chore(memory): BDR-114 + LRN-200..202 + journal — feat manual-push-mode run D

This commit is contained in:
bchanot
2026-10-07 17:24:31 +02:00
parent 1203a9a735
commit 3721cf522a
10 changed files with 260 additions and 2 deletions
@@ -0,0 +1,41 @@
# CONTRACT — manual-push-failclosed-d1 (run D1 of manual-push mode)
- date: 2026-10-07 | flow: feat | branch: feature/manual-push-mode (runs A, B, C landed)
- status: active
## REQUEST (verbatim — IMMUTABLE)
User (fr): "ok enchaine sur le run D"
Run D as consolidated in `.claude/tasks/TODO.md`: "fail-CLOSED on an unparseable `gitflow.autopush` in every reader at once (lib `_gitflow_push_off`, the two emitted push hooks + githooks regen, unpushed-guard) so the \"invalid → lib/hooks still push\" caveat in CHANGELOG/SETTINGS/skills can be removed; push-guard residuals (inner-quote/backslash tokens, lone-surrogate payload, `*) deny` default, up-front tool check, T42 base, literal `true` test); verb stderr: `LC_ALL=C` done, truncation marker + sanitizer test; client-handover: stale \"COMMIT + PUSH\" headings, `<abs project>` quoting in tour hints; release-executor own version regex".
## CLARIFICATIONS
Q: scope split / A: D1 (this contract) = fail-closed readers: lib `_gitflow_push_off`, the emitted push hooks (+ regenerated `.githooks/` and `githooks/`), `hooks/unpushed-guard.sh`, their tests. D2 = push-guard residuals + session-start banner on an invalid value + tour hint quoting. D3 = skill/agent prose (remove the "until run D" caveats, stale headings, release-executor version regex) + doc-sync. [orchestrator — scope]
Q: semantics / A: for every reader, `gitflow.autopush` unset or `true` → auto (push); `false` → manual (no push); anything else (unparseable, corrupt config, git failure) → NO push, reported as "invalid, treated as manual push mode". The lib verb `push-mode` already prints `invalid`; `_gitflow_push_off` reuses it (`!= auto` → off). The emitted hooks stay standalone `#!/bin/sh` (foreign repos, no lib): same rule inline. [orchestrator — the user chose fail-closed for the guard in run B; this extends it to every reader]
Q: regeneration of installed hooks / A: files only, with no config read or write: `bash lib/gitflow.sh emit-hook <name> > <dir>/<name>` for post-commit and post-merge under `.githooks/` and `githooks/`, in the same step as the emitter edit. NOT `install-hook` (writes a local hooks-path entry) and NOT `global-hooks` (writes the GLOBAL config when `~/.gitconfig` lacks the value — true since a dotfiles installer overwrote it at 15:39 today). `.git/config` hash unchanged and `~/.gitconfig` untouched are part of the evidence. [orchestrator — revised twice]
Q: run precondition / A: the user's `~/.gitconfig` must be restored first (identity + hooksPath); the executor checks `name =` is not `@USER@` by reading, else BLOCKED. [orchestrator]
Q: silence vs naming / A: a stopped push is NAMED on stderr by every reader (hook: one line per commit; lib: the verb's line passes through `_gitflow_push_off`): D1 must not remove the terminal user's only signal. [orchestrator — revised after challenge]
Q: unpushed-guard mode source / A: the guard reads the mode through the lib verb (`$(dirname "${BASH_SOURCE[0]}")/../lib/gitflow.sh push-mode`, the same relative path session-start uses), one reader for hooks that live next to the lib; its invalid line becomes "treated as manual push mode (nothing pushes); fix the value by hand". [orchestrator — internal]
## ACCEPTANCE CRITERIA
1. Lib: with `gitflow.autopush` set to an unparseable value, `gitflow start` creates the branch locally without pushing and prints the verb's `not a boolean` line, a commit on it is not pushed by the post-commit hook which prints a `NOT pushed` line, `gitflow finish` merges locally and origin/develop is unchanged; with `true` the post-commit hook pushes (tips equal, positive control). `_gitflow_push_off` reads the mode through `gitflow_push_mode`. Locked by the new isolated gitflow-test block T18q1–T18q5 (q5 skipped with `ok` when shellcheck is absent).
CHECK: out=$(make test suite=lib/gitflow-test.sh 2>&1); printf '%s' "$out" | grep -q ' FAIL ' && exit 1; for t in "T18q1" "T18q2" "T18q3" "T18q4" "T18q5"; do grep -qF "ok $t" <<<"$out" || exit 1; done; echo FAILCLOSED-LIB-OK
EXPECT: FAILCLOSED-LIB-OK
EVIDENCE: MET exit=0 marker-found :: FAILCLOSED-LIB-OK
2. Emitted hooks (`_gitflow_emit_push_hook`): `#!/bin/sh`-portable rule — push only when `git config --bool gitflow.autopush` returns rc 0 `true` or rc 1 (unset); `false` exits 0 silently; any other result prints one stderr line (`NOT pushed, treated as manual push mode`) and exits 0. `.githooks/` and `githooks/` regenerated (files only) and identical to the emitters (T19a–e green); no `--default true gitflow.autopush` left in the four regenerated files; auto-mode T18a–h and manual T18m green.
CHECK: out=$(make test suite=lib/gitflow-test.sh 2>&1); printf '%s' "$out" | grep -q ' FAIL ' && exit 1; for t in T19a T19b T19c T19e T19d T18a T18b T18h T18i T18j T18k; do grep -q "ok $t" <<<"$out" || exit 1; done; ! grep -q -- '--default true gitflow.autopush' .githooks/post-commit .githooks/post-merge githooks/post-commit githooks/post-merge && grep -q 'NOT pushed' .githooks/post-commit && echo HOOKS-OK
EXPECT: HOOKS-OK
EVIDENCE: MET exit=0 marker-found :: HOOKS-OK
3. `hooks/unpushed-guard.sh`: mode read through the lib verb (absolute lib path resolved before any `cd`; no temp file); anything other than `auto` behaves as manual (silent at Stop; SessionStart `ℹ manual push mode:` line); `invalid` names the value and says "treated as manual push mode (nothing pushes)"; an unreadable verb result says so. Auto and manual behaviour unchanged (T1–T13, T15–T16 green); T14 rewritten for the new semantics.
CHECK: out=$(make test suite=lib/tests/unpushed-guard.test.sh 2>&1); printf '%s' "$out" | grep -q '^FAIL' && exit 1; grep -qE 'PASS=(2[8-9]|[3-9][0-9]) FAIL=0' <<<"$out" && grep -q 'treated as manual push mode' hooks/unpushed-guard.sh && grep -q 'gitflow.sh" push-mode\|gitflow.sh push-mode\|push-mode' hooks/unpushed-guard.sh && ! grep -q -- '--default true' hooks/unpushed-guard.sh && echo GUARD-OK
EXPECT: GUARD-OK
EVIDENCE: MET exit=0 marker-found :: GUARD-OK
4. No `--default true gitflow.autopush` read remains in lib/gitflow.sh or hooks/unpushed-guard.sh (the `gitflow.protect` reads keep `--default true`; `hooks/session-start.sh` is D2). shellcheck clean on lib/gitflow.sh, lib/gitflow-test.sh, hooks/unpushed-guard.sh; no new suppression; floor guard clean.
CHECK: ! grep -q -- '--default true gitflow.autopush' lib/gitflow.sh hooks/unpushed-guard.sh && shellcheck lib/gitflow.sh lib/gitflow-test.sh hooks/unpushed-guard.sh && [ "$(git diff -- lib/gitflow.sh lib/gitflow-test.sh hooks/unpushed-guard.sh lib/tests/unpushed-guard.test.sh | grep -c '^+.*shellcheck disable')" -eq 0 ] && echo SHELLCHECK-OK
EXPECT: SHELLCHECK-OK
EVIDENCE: MET exit=0 marker-found :: SHELLCHECK-OK
5. Every hermetic suite green except the declared environmental red `lib/tests/design-tool-gate.test.sh`.
CHECK: fail=0; for t in $(ls lib/tests/*.test.sh lib/seo-data/*.test.sh lib/gitflow-test.sh lib/tests/run-*.sh | grep -v design-tool-gate.test.sh); do make test suite="$t" >/dev/null 2>&1 || { fail=1; echo "RED $t"; }; done; [ $fail -eq 0 ] && echo SUITES-OK
EXPECT: SUITES-OK
EVIDENCE: MET exit=0 marker-found :: SUITES-OK
6. Judged by reading: `GITFLOW_NO_PUSH=1` semantics unchanged; `gitflow_push_mode` stdout contract unchanged; the emitted hooks remain standalone POSIX sh (no bash-isms, no lib dependency); no file outside FILE SCOPE changed except the regenerated `.githooks/{post-commit,post-merge}` and `githooks/{post-commit,post-merge}`; `pre-commit` and `reference-transaction` emitted files unchanged byte for byte; `.git/config` unchanged (hash before/after in the executor report); the `left in place` note text unchanged.
## FILE SCOPE
lib/gitflow.sh · lib/gitflow-test.sh · hooks/unpushed-guard.sh · lib/tests/unpushed-guard.test.sh · generated: .githooks/post-commit, .githooks/post-merge, githooks/post-commit, githooks/post-merge
@@ -0,0 +1,40 @@
# CONTRACT — manual-push-guard-residuals-d2 (run D2 of manual-push mode)
- date: 2026-10-07 | flow: feat | branch: feature/manual-push-mode (after D1)
- status: active
## REQUEST (verbatim — IMMUTABLE)
User (fr): "ok enchaine sur le run D"
Run D as consolidated in `.claude/tasks/TODO.md`; D2 slice: "push-guard residuals (inner-quote/backslash tokens, lone-surrogate payload, `*) deny` default, up-front tool check, T42 base, literal `true` test)", session-start banner on an invalid value, tour `<abs project>` quoting in hints.
## CLARIFICATIONS
Q: single reader / A: push-guard SOURCES the lib once (absolute path resolved at the top) and calls `gitflow_push_mode` per candidate (no bash spawn per candidate); lib missing → deny with its own reason. The banner calls the verb through the existing `_gf_lib`; lib missing → no lock line. [orchestrator — revised]
Q: inner-quote/backslash tokens / A: challenge r1 — the extraction regex must capture the whole shell word (adjacent quoted and unquoted segments). A token fully enclosed in one quote pair is stripped and resolved (an inner apostrophe inside `"…"` is fine); a token that MIXES quoted and unquoted parts is DENIED (fail closed, user-gated rule for pathological commands); a backslash-escaped space in an unquoted token is unescaped deterministically (no eval) and resolved, not denied. [orchestrator — revised]
Q: unparseable payload / A: `field` fails → the raw payload is the text to scan, with the two-character JSON escapes folded to spaces, through the unchanged `is_push`; a match → static deny via the EXIT trap (mode-blind); no match → allow. Accepted limit: a `description` mentioning a push also denies on a broken payload. [orchestrator — revised]
Q: missing core tools (grep, sed, sort, head) / A: same policy as jq: one stderr warning, allow — a guard that denies every Bash call when PATH is broken makes the session unusable; PATH is not command-controlled. Documented. [orchestrator]
Q: T42 base / A: compare the deny list against `main:settings.json` (the last release; `git describe --tags` fails here because v2.0.0 is not an ancestor of the branch) instead of HEAD: "no deny entry present on main was removed" stays meaningful after the branch merges into develop. Fallback `origin/main:settings.json`; neither readable → the check prints SKIP, never FAIL. [orchestrator]
## ACCEPTANCE CRITERIA
1. push-guard: `mode_in` reads the mode through the sourced lib verb (manual/auto/invalid + its stderr line), lib missing → deny with its own reason; the `case "$mode"` has a `*)` deny default; a dir token mixing quoted and unquoted parts → deny naming it, a fully-quoted token with an inner apostrophe or a backslash-escaped space → resolved normally; unparseable payload with push-looking raw text (JSON escapes folded) → static deny, without → allow; missing core tools → stderr warning + allow; a repo with `gitflow.autopush = true` → allow. All existing cases stay green. Locked by the suite (new cases T51–T57, incl. T52b/c, T54b/c).
CHECK: out=$(make test suite=lib/tests/push-guard.test.sh 2>&1); printf '%s' "$out" | grep -q '^FAIL' && exit 1; grep -qE 'PASS=(8[0-9]|9[0-9]|[1-9][0-9]{2}) FAIL=0' <<<"$out" && grep -q 'push-mode' hooks/push-guard.sh && ! grep -q -- '--default true' hooks/push-guard.sh && echo PUSH-GUARD-OK
EXPECT: PUSH-GUARD-OK
EVIDENCE: MET exit=0 marker-found :: PUSH-GUARD-OK
2. T42 compares the current deny list against the fresher of `origin/main` and `main` (SKIP if neither resolves; FAIL if the base deny list is empty): every entry present there is still present; the test prints `T42 base: <ref>`.
CHECK: grep -q 'T42 base' lib/tests/push-guard.test.sh && ! grep -q 'base=HEAD' lib/tests/push-guard.test.sh && grep -q 'SKIP T42' lib/tests/push-guard.test.sh && echo T42-OK
EXPECT: T42-OK
EVIDENCE: MET exit=0 marker-found :: T42-OK
3. session-start banner reads the mode through the lib verb: `manual` → existing line; `invalid` → `🔒 push : manual (autopush bad) — ! git push` (41 chars, fits the box); `auto` or lib missing → nothing. No `--default true gitflow.autopush` read left in hooks/session-start.sh or hooks/push-guard.sh (hooks/unpushed-guard.sh is D1's; the whole-hooks grep is run after D1's commit). Locked by push-guard.test.sh banner cases (T44–T46 + new T57).
CHECK: grep -q 'push-mode' hooks/session-start.sh && grep -q 'autopush bad' hooks/session-start.sh && ! grep -q -- '--default true gitflow.autopush' hooks/session-start.sh hooks/push-guard.sh && out=$(make test suite=lib/tests/push-guard.test.sh 2>&1) && ! grep -qE '^FAIL T(4[4-6]|57)' <<<"$out" && echo BANNER-OK
EXPECT: BANNER-OK
EVIDENCE: MET exit=0 marker-found :: BANNER-OK
4. skills/tour/SKILL.md: every `<abs project>` inside a command or hint is double-quoted (`git -C "<abs project>"`).
CHECK: [ "$(grep -c 'git -C <abs project>' skills/tour/SKILL.md)" = 0 ] && [ "$(grep -c 'git -C "<abs project>"' skills/tour/SKILL.md)" -ge 3 ] && echo TOUR-OK
EXPECT: TOUR-OK
EVIDENCE: MET exit=0 marker-found :: TOUR-OK
5. shellcheck clean on hooks/push-guard.sh, hooks/session-start.sh, lib/tests/push-guard.test.sh; no new suppression; floor guard clean; doctrine citers green; every hermetic suite green except the declared environmental red.
CHECK: shellcheck hooks/push-guard.sh hooks/session-start.sh lib/tests/push-guard.test.sh && [ "$(git diff -- hooks/push-guard.sh hooks/session-start.sh lib/tests/push-guard.test.sh | grep -c '^+.*shellcheck disable')" -eq 0 ] && make test suite=lib/tests/doctrine-citers.test.sh >/dev/null 2>&1 && fail=0 && for t in $(ls lib/tests/*.test.sh lib/seo-data/*.test.sh lib/gitflow-test.sh lib/tests/run-*.sh | grep -v design-tool-gate.test.sh); do make test suite="$t" >/dev/null 2>&1 || fail=1; done && [ $fail -eq 0 ] && echo SUITES-OK
EXPECT: SUITES-OK
EVIDENCE: MET exit=0 marker-found :: SUITES-OK
6. Judged by reading: no `eval`; the strict/loose/alias regexes unchanged; the 20-token cap unchanged; `bash -n` clean; sourcing the lib brings no `set -e`/`set -o pipefail` into the hook; the header DENIED/MISSES/LIMITS list updated; no file outside FILE SCOPE; the tour example row with `~/proj/site` stays unquoted.
## FILE SCOPE
hooks/push-guard.sh · lib/tests/push-guard.test.sh · hooks/session-start.sh · skills/tour/SKILL.md
@@ -0,0 +1,36 @@
# CONTRACT — manual-push-prose-d3 (run D3 of manual-push mode)
- date: 2026-10-07 | flow: feat | branch: feature/manual-push-mode (after D1 + D2: every reader fails closed)
- status: active
## REQUEST (verbatim — IMMUTABLE)
User (fr): "ok enchaine sur le run D"
Run D as consolidated in `.claude/tasks/TODO.md`; D3 slice: skill/agent prose — drop the "until run D" caveats now that every reader fails closed, stale "COMMIT + PUSH" headings in client-handover, release-executor own version regex; then doc-sync (CHANGELOG, SETTINGS "still push on an invalid value" sentences).
## CLARIFICATIONS
Q: heading rename "COMMIT + PUSH" / A: agents/client-handover-writer.md `## STEP 5 — COMMIT + PUSH (only if files changed)` → `## STEP 5 — COMMIT + PUSH STATE READ (only if files changed)`; skills/client-handover/SKILL.md step 4 bold label `**COMMIT + PUSH**` → `**COMMIT + PUSH STATE READ**` (the agent's own term for the sub-step; "PUSH STATE" alone could read as "push the state"). Repo-wide grep shows no other citer of either string (checked 2026-10-07: only those two lines). [orchestrator — public-name-ish label, surfaced in the final report]
Q: invalid-value wording after D1 / A: challenge r1 — the ahead count DECIDES the wording: `ahead` > 0 or unknown → "treated as manual push mode by every reader, nothing pushed"; `ahead` = 0 → "pushed anyway: a hook in this repo still fails open (stale .githooks/, refreshed next session)". The verb's stderr line is quoted verbatim (never a templated value). [orchestrator]
Q: executor version check / A: prep span only (finish's branch precondition already depends on prep), checked by reading the string, never inside a Bash command. [orchestrator — revised]
Q: D1/D2 precondition / A: the executor's first step greps for any surviving `--default true gitflow.autopush` reader; a hit → BLOCKED. [orchestrator]
Q: release-executor version check / A: SUPERSEDED by the "revised" entry below (prep span only, by reading). [orchestrator]
## ACCEPTANCE CRITERIA
1. skills/capitalize/SKILL.md: no "until run D" text; the invalid-mode outcome is split on the ahead count in 5C and in STEP 6 (`> 0`/unknown → treated as manual by every reader, nothing pushed, user command with the `once a remote exists` qualifier when unknown; `= 0` → pushed anyway, stale fail-open hook named); the verb's stderr line is quoted verbatim; the `--no-push` ahead-0 line carries the invalid qualifier.
CHECK: [ "$(grep -c 'until run D' skills/capitalize/SKILL.md)" = 0 ] && [ "$(grep -c 'treated as manual push mode by every reader' skills/capitalize/SKILL.md)" -ge 2 ] && [ "$(grep -c 'still fails open' skills/capitalize/SKILL.md)" -ge 2 ] && [ "$(grep -c 'verb stderr line verbatim' skills/capitalize/SKILL.md)" -ge 5 ] && grep -q 'pushed anyway' skills/capitalize/SKILL.md && grep -q 'push mode `auto`, finish rc 0' skills/capitalize/SKILL.md && echo CAPITALIZE-OK
EXPECT: CAPITALIZE-OK
EVIDENCE: MET exit=0 marker-found :: CAPITALIZE-OK
2. client-handover: both labels renamed to "COMMIT + PUSH STATE READ"; the STEP 5 residual lines ("Before any commit or push", "do NOT commit, do NOT push", "Commit/push skipped") no longer imply the pipeline pushes; the skill's step 4 names the invalid value.
CHECK: grep -q 'COMMIT + PUSH STATE READ' agents/client-handover-writer.md && grep -q 'COMMIT + PUSH STATE READ' skills/client-handover/SKILL.md && [ "$(grep -h 'COMMIT + PUSH' agents/client-handover-writer.md skills/client-handover/SKILL.md | grep -vc 'COMMIT + PUSH STATE READ')" = 0 ] && ! grep -q 'Commit/push skipped' agents/client-handover-writer.md && grep -q 'invalid gitflow.autopush' skills/client-handover/SKILL.md && echo HANDOVER-OK
EXPECT: HANDOVER-OK
EVIDENCE: MET exit=0 marker-found :: HANDOVER-OK
3. agents/release-executor.md: the prep span's Input carries the format-check sentence (by reading, never in a Bash command) with the literal regex; the manual-mode line also names an invalid value.
CHECK: grep -qF '^[0-9]+\.[0-9]+\.[0-9]+$' agents/release-executor.md && grep -q 'by reading the string' agents/release-executor.md && grep -q 'invalid gitflow.autopush' agents/release-executor.md && echo EXECUTOR-OK
EXPECT: EXECUTOR-OK
EVIDENCE: MET exit=0 marker-found :: EXECUTOR-OK
4. Doctrine citers census green; floor guard clean; every hermetic suite green except the declared environmental red.
CHECK: make test suite=lib/tests/doctrine-citers.test.sh >/dev/null 2>&1 && bash ~/.claude/lib/floor-guard.sh develop -- skills/capitalize/SKILL.md skills/client-handover/SKILL.md agents/client-handover-writer.md agents/release-executor.md >/dev/null 2>&1 && fail=0 && for t in $(ls lib/tests/*.test.sh lib/seo-data/*.test.sh lib/gitflow-test.sh lib/tests/run-*.sh | grep -v design-tool-gate.test.sh); do make test suite="$t" >/dev/null 2>&1 || fail=1; done && [ $fail -eq 0 ] && echo SUITES-OK
EXPECT: SUITES-OK
EVIDENCE: MET exit=0 marker-found :: SUITES-OK
5. Judged by reading: no `git push` entered any Bash call; frontmatter and agent pins unchanged; no file outside FILE SCOPE (doc-sync handles CHANGELOG/SETTINGS afterwards, through its own gate).
## FILE SCOPE
skills/capitalize/SKILL.md · skills/client-handover/SKILL.md · agents/client-handover-writer.md · agents/release-executor.md