chore(memory): BDR-114 + LRN-200..202 + journal — feat manual-push-mode run D
This commit is contained in:
@@ -1385,3 +1385,10 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate).
|
||||
- **Alternatives rejected**: gate the existing push on the verb (denied/stateless); keep the GO question (gated nothing); word invalid as manual (false in the lib); per-skill `git config` read (denied).
|
||||
- **Gates**: C1: 3 lenses (1 BLOCKER: shell gate) + confirm CONCERNS(3); feater; GATE 0 MET; verifier CONFORME; security PASS. C2: 3 lenses (BLOCKER: deploy brief said "push done") + confirm CONCERNS(4); feater; verifier CONFORME; security BLOCK(1) branch-name injection ([[LRN-198]]) → fixed → CONFORME + PASS. Polish pass: CONFORME + PASS.
|
||||
- **Refs**: contracts `.claude/tasks/contracts/2026-10-07-manual-push-skills-c1-1304.md`, `…-c2-1325.md`; plans same slugs; commits 5cf049d, 6104545, 3881f46 (feature/manual-push-mode, UNMERGED). Links [[BDR-068]], [[BDR-095]], [[BDR-042]], [[LRN-069]].
|
||||
|
||||
## BDR-114 — Every `gitflow.autopush` reader fails closed and names the stop; the lib verb is the single reader [accepted] (2026-10-07)
|
||||
- **Decision**: run D of [[BDR-111]]. Rule for every reader: unset or `true` → auto (push); `false` → manual (no push, silent); anything else (unparseable value, corrupt config, git failure) → NO push and one named line. Readers: lib `_gitflow_push_off` = `[ "$(gitflow_push_mode)" != auto ]` (verb stderr passes through); emitted post-commit/post-merge hooks (standalone POSIX sh, mirror of the verb: `case "$rc:$v" in 0:true|1:*) ;; 0:false) exit 0 ;; *) echo "… NOT pushed, treated as manual push mode" >&2; exit 0 ;; esac`); unpushed-guard, session-start banner (`autopush bad` lock line) and push-guard (sources the lib once, `gitflow_push_mode` per candidate) all read the verb. push-guard residuals: whole-word dir tokens (mixed quoting → deny; fully quoted + inner other-quote and `\ ` escapes resolved), payload jq cannot parse → raw scan with JSON escapes folded → static deny, cap BEFORE any per-token fork, T42 base = main. Skill prose: invalid outcome split on the ahead count (0 → "pushed anyway, likely a stale fail-open hook or a manual push"), labels COMMIT + PUSH STATE READ, release-executor version format check by reading. Doctrine: invalid = manual; "never pushes, even when asked".
|
||||
- **Why**: `--bool --default true` covers a MISSING key only; a typo re-enabled every push silently (the 21-09 hazard class on a work machine). The user chose fail-closed for the guard in run B; D extends it everywhere and keeps a terminal signal (git's own "fatal: bad boolean" used to be the only one; the hooks now print theirs).
|
||||
- **Alternatives rejected**: regen via `install-hook` (writes a LOCAL hooks-path entry) or `global-hooks` (writes the GLOBAL config when the value is missing — it was, [[LRN-200]]) → `emit-hook > file` only; temp file for the verb's stderr in a hook (fail-open on a full TMPDIR, predictable path) → `2>&1` capture ([[LRN-202]]); classification before the token cap (13 s flood) → cap first ([[LRN-201]]); shell check of the release version string (interpolation sink) → by reading.
|
||||
- **Gates**: D1 3 lenses + confirm FATAL(1) (global-config write) → emit-hook; feater; GATE 0 MET; verifier CONFORME; security PASS. D2 3 lenses + confirm FATAL(3) (escape alternative, same-quote-inside, `bare=$one` when unparsed); feater; GATE 0 MET; verifier CONFORME; security BLOCK(1) cap-after-fork → fixed (20k tokens 0.13 s) → CONFORME + PASS. D3 3 lenses + confirm CONCERNS(3); feater; CONFORME + PASS.
|
||||
- **Refs**: contracts/plans `2026-10-07-manual-push-failclosed-d1-1522`, `…-guard-residuals-d2-1526`, `…-prose-d3-1530`; commits 472cccb, 3c59333, 64ca0f8 (feature/manual-push-mode, UNMERGED). Supersedes the "fail-open on invalid value" line of [[BDR-111]]. Links [[BDR-112]], [[BDR-113]], [[LRN-114]], [[LRN-196]]. Residuals: TODO "post-run-D residuals" (soft_deny names only `false`; stale `.githooks/` in onboarded repos until reconcile).
|
||||
|
||||
@@ -573,4 +573,5 @@ rules:
|
||||
## 2026-10-07
|
||||
- /feat manual-push-mode run B (user: "enchaine"): `hooks/push-guard.sh` PreToolUse denies Claude's `git push` when autopush false/unparseable/unreadable in cwd or literal -C/cd dirs (global config counts). Challenge: 3 lenses + robustness confirm FATAL(8) → BSD sed `N` fold empty on 1 line (hook dead), AC3 oracle naming denied tokens, glob trailing ` *` matches end → bare read lost, run C needs lib verb. feater ×3 (impl 58, no-jq test 61, hardening 71: cap 20 dirs, git rc → deny, quoted cd). GATE 0 MET ×3; verifier CONFORME then ECARTS(1) → user gated fail-closed also in auto for pathological commands; security PASS ×2 (3 MEDIUM closed, 2 residual → run D). Commits a2ac018 + 6468eda on feature/manual-push-mode, UNMERGED. settings.json live: 18 deny entries, soft_deny, Bash|Monitor hook group. User probe pending: `! git push --dry-run` bypasses hooks?
|
||||
- /feat manual-push-mode run C (user: "enchaine"), split C1+C2. C1: lib verb `gitflow.sh push-mode` (auto|manual|invalid, value on stderr, rc 0) + T11b; /close STEP 5C `git push origin develop` REMOVED (finish has pushed develop since BDR-095; shell gate would be denied whole by push-guard, `$mode` dies between Bash calls) → finish, verb, `rev-list --count origin/develop..develop`, prose outcomes incl. finish-failure; `--no-push` line from the branch's own count. Challenge 3 lenses (1 BLOCKER) + confirm CONCERNS(3); verifier CONFORME; security PASS. C2: client-handover GO question + push block removed (hooks pushed already in auto; guard denies in manual) → PUSH STATE READ re-run before every claim, branch-name allowlist (security BLOCK(1) → fixed, PASS); release-candidate: two counts + verb, `! git push --atomic origin main develop vX`, tag gate kept for auto/0/0; tour: `-C` fact per project, suffix-aware branch, `--remotes=origin`. Verifier CONFORME ×2. Commits 5cf049d + 6104545, UNMERGED. Polish pass in flight.
|
||||
- /feat manual-push-mode run D (user: "enchaine"), split D1/D2/D3, 9 lenses + 3 confirmations. D1: every autopush reader fails closed AND names an invalid value (lib `_gitflow_push_off` via the verb; emitted hooks POSIX `case "$rc:$v"` + stderr line; unpushed-guard via the verb, no temp file); regen files-only via `emit-hook >` after the confirmation showed `install-hook` writes a local hooks-path and `global-hooks` writes the GLOBAL config when it lacks the value — which it did: user's dotfiles installer had overwritten `~/.gitconfig` (@USER@ placeholders, no hooksPath) + `~/.zshrc` at 15:39; user confirmed (own machine setup) and restored from the installer's backup before execution. D2: push-guard sources the lib, whole-word tokens (mixed quoting → deny), payload fallback, cap BEFORE classification (security BLOCK(1) caught the reorder: 1,600 tokens = 13 s > 10 s timeout → fixed, 20k tokens 0.13 s), T42 base main, banner `autopush bad`. D3: prose aligned (invalid outcome split on the ahead count, labels COMMIT + PUSH STATE READ, executor version check by reading). Commits 472cccb, 3c59333, 64ca0f8 on feature/manual-push-mode, UNMERGED. Residuals in TODO.
|
||||
|
||||
|
||||
@@ -1736,3 +1736,15 @@ Rule: when editing a doctrine file under structure locks, grep the test's lock s
|
||||
## LRN-199 — Agent-level branching is prose on a printed word: shell state dies between Bash calls, and a text guard denies the whole call
|
||||
- **Context**: plan wrote `mode=$(gitflow.sh push-mode)` then `[ "$mode" = auto ] && git push origin develop`. Two failures: (a) separate calls → `$mode` empty → silent skip, rc 1 misread as "push FAILED"; (b) one call → push-guard's STRICT regex matches `&& git push origin` in the TEXT and denies the WHOLE call, so even `finish` never runs. Three lenses hit it independently.
|
||||
- **Apply**: a skill reads a word from a command's visible stdout, then branches in PROSE ("printed `auto` → run X as its own call; anything else → never issue X"). Never a shell variable across calls, never a conditional that contains a guarded token. Any text-only PreToolUse guard turns `cmd-you-wanted-to-avoid` inside a conditional into a denial of the surrounding command. Links [[BDR-112]], [[BDR-113]], [[LRN-191]].
|
||||
|
||||
## LRN-200 — `install-hook` and `global-hooks` write git config as a side effect; only `emit-hook > file` regenerates hooks config-free
|
||||
- **Context**: D1 planned `install-hook` (= write hooks + a LOCAL hooks-path entry this repo must not gain: it runs on the global hooks) then `global-hooks <dir> <value>` ("returns before any write because the global value already matches"). The confirmation challenger read `~/.gitconfig`: rewritten 2 min earlier by the user's dotfiles installer (`@USER@` placeholders, no hooks path) → `global-hooks` WOULD have written the global config through a lib call, and `md5 .git/config` could not see it. User confirmed (own machine setup) and restored from the installer's backup; commits waited.
|
||||
- **Apply**: regenerate generated hooks with `bash lib/gitflow.sh emit-hook <name> > <dir>/<name>` (no config read/write, mode preserved); never `install-hook`/`global-hooks` from a flow. Evidence = md5 of `.git/config` AND a Read of `~/.gitconfig` before/after. Before any commit, check `git var GIT_COMMITTER_IDENT` is not a placeholder: an external installer can rewrite dotfiles mid-session. Links [[BDR-114]], [[LRN-114]], [[BLK-027]].
|
||||
|
||||
## LRN-201 — A flood cap must run before ANY per-token fork; a reorder reopened the timeout fail-open and only a timed 2 000-token test catches it
|
||||
- **Context**: run B put the 20-token cap before resolution (20k tokens 0.15 s). D2 added `classify_tok` (one subshell + sed per token) and the executor ran it BEFORE the cap: 1,600 tokens = 13 s > the 10 s hook timeout = allow. Verifier CONFORME (suite max 21 tokens), security re-scan BLOCK(1). Fix: `arg_tokens | sort -u | grep -c .` then `> 20` deny, then classification on ≤20 survivors; T58 = 2,000 tokens, deny, `$SECONDS` < 5.
|
||||
- **Apply**: in any guard, order = count → cap → everything else; a cap without a timed flood test in the suite is a comment, not a guard. Re-measure the flood after every change to the token pipeline (security gate did: 0.13 s at 20k). Links [[BDR-114]], [[LRN-196]], [[LRN-104]].
|
||||
|
||||
## LRN-202 — Reading a stderr-then-stdout verb from a hook: `out=$(cmd 2>&1)`, last line = word, prefix line = reason; no temp file; lib path absolute before any cd
|
||||
- **Context**: unpushed-guard plan used `2>"${TMPDIR:-/tmp}/x.$$"` + cat + rm: fail-OPEN when TMPDIR is full (`|| mode=auto`), predictable path, symlink-followable on shared /tmp, leaked on kill; `mode=$(cmd 2>&1 >/dev/null)` captures ONLY stderr. push-guard's `mktemp` variant added `set -u` trap hazards. The verb writes its stderr line BEFORE its stdout word in one process, so `${out##*$'\n'}` is the word and the `gitflow.sh push-mode:` line is the reason (select by prefix, not `head -1`: a bash startup warning could precede it). Resolve the lib path to an absolute one BEFORE the hook's `cd "$cwd"` (a relative invocation otherwise resolves into the target repo).
|
||||
- **Apply**: hooks never touch temp files for a one-line capture; anything but the expected word is treated as the fail-closed state, never as the default. Links [[BDR-114]], [[LRN-196]], [[LRN-199]].
|
||||
|
||||
@@ -2059,12 +2059,13 @@ dans un runner; capitalize reste main-loop.
|
||||
## manual-push-mode (2026-10-06, /feat × 3)
|
||||
- [x] run A — `gitflow.autopush=false` honoured by `_gitflow_push_branch`, quiet unpushed-guard, doctrine line; plan `.claude/tasks/plans/2026-10-06-manual-push-mode-1632.md` → commit 2fc8830 on feature/manual-push-mode; verifier ECARTS(1) = AC6 only (design-tool-gate env red, pre-existing on develop) → human waiver; merge human-gated
|
||||
- [x] run B — `hooks/push-guard.sh` PreToolUse (deny `git push` in manual mode) + 71-check test + settings.json (own hook group Bash|Monitor timeout 10; 18 write-form deny entries on the toggle; soft_deny on manual-mode pushes with no per-turn clearance; prose) + banner → a2ac018 + hardening commit; verifier CONFORME then ECARTS(1) closed by gated clarification (fail-closed cap/unenterable dir also in auto mode); security PASS ×2
|
||||
- [ ] run D also (push-guard residuals, security gate 2026-10-07): tokens with inner quotes/backslashes (`cd /m/'a b'`) resolve to the wrong dir → treat as unresolvable + deny or document; unparseable payload (lone surrogate) → jq fails → silent allow → grep raw payload for `push` and deny; `case "$mode"` default `*) deny`; up-front `command -v grep sed sort head jq` check; header line > 80 cols; T42 compares against HEAD (vacuous once committed) → compare against a pinned base or drop; no test sets the key to literal `true`
|
||||
- [x] run D also (closed in D2 3c59333 — push-guard residuals, security gate 2026-10-07): tokens with inner quotes/backslashes (`cd /m/'a b'`) resolve to the wrong dir → treat as unresolvable + deny or document; unparseable payload (lone surrogate) → jq fails → silent allow → grep raw payload for `push` and deny; `case "$mode"` default `*) deny`; up-front `command -v grep sed sort head jq` check; header line > 80 cols; T42 compares against HEAD (vacuous once committed) → compare against a pinned base or drop; no test sets the key to literal `true`
|
||||
- [x] run C (C1 5cf049d, C2 6104545; split C1: lib verb `push-mode` + T11 tests + capitalize STEP 5C + close hint, plan `.claude/tasks/plans/2026-10-07-manual-push-skills-c1-1304.md`; C2: client-handover skill+agent, release-candidate STEP 6, tour rule) — skills that push on their own, gate on the mode through a NEW lib verb `bash ~/.claude/lib/gitflow.sh push-mode` (prints auto|manual|invalid; the bare `git config … gitflow.autopush` read is denied for Claude after run B — a trailing ` *` glob also matches end-of-string): capitalize STEP 5C (`git push origin develop`), client-handover SKILL:48 + agents/client-handover-writer.md:586, release-candidate:96 + tour:273 "already on origin" claims
|
||||
- [ ] run B also: fail-CLOSED on an unparseable `gitflow.autopush` value in every reader at once (lib `_gitflow_push_off`, the two emitted push hooks, unpushed-guard) — run A keeps fail-open for consistency with the untouched emitters (security gate MEDIUM, 2026-10-06); `--end-of-options`/`--` on refname args and `printf %q` in copy-paste hints (LOW); `gitflow_delete`: check `_gitflow_checkout_containing_base` rc before `--unset-upstream` (LOW, 2nd gate)
|
||||
- [x] C1/C2 polish pass → 3881f46 (verifier CONFORME, security PASS; items were: capitalize STEP 5C heading still says "(finish + push)"; :372 paragraph glued to the :371 bullet and tells the WORKING-branch path to read `origin/chore/<name>..` (no such ref there; that path never uses the mode); on finish rc 5/2/6 calls 2-3 are skipped so a manual-mode user gets no `! git push origin develop` hint; the "unknown + manual" closing line (:377) lacks the `once a remote exists` hint present in 5C (:348); STEP 6 "auto-persisted … pushed" bullet (:371) not tied to `ahead = 0` (security MEDIUM); verb stderr: cap `raw` to 64 printable chars; T11b "default auto" relies on the Makefile's hermetic env (fine under `make test`, spurious when run bare on a global-manual machine) → export in the suite header like line 257. C2 polish (verifier non-gaps): client-handover-writer PUSH STATE READ states need explicit precedence (uncommitted/no-commits first, then no-origin, then ahead); tour STEP 3 item 5 only when a branch exists (report-only / dirty-tree rows have none); 9.7 `STATUS: BLOCKED` branch lacks the `- Push:` bullet; release-executor:85-86 line > 80 cols
|
||||
- [x] ORDER constraint lifted: A + B + C all on feature/manual-push-mode; merge (human gate) then the work machine may set `gitflow.autopush false`. Still pending before relying on it at work: user probe `! git push --dry-run` in a scratch repo under autopush=false (bang commands assumed hook-free); run D below.
|
||||
- [ ] run D — 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
|
||||
- [x] run D (D1 472cccb, D2 3c59333, D3 64ca0f8 — all verifier CONFORME + security PASS; split 2026-10-07: D1 fail-closed readers — lib `_gitflow_push_off` via the verb, emitted push hooks POSIX rule + regen, unpushed-guard via the verb, plan `.claude/tasks/plans/2026-10-07-manual-push-failclosed-d1-1522.md`; D2 push-guard residuals + session-start banner on invalid + tour `<abs project>` quoting; D3 skill/agent prose: drop the "until run D" caveats, stale COMMIT + PUSH headings, release-executor version regex, + doc-sync) — 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
|
||||
|
||||
## test hermeticity (2026-10-06, found during manual-push-mode run A)
|
||||
- [ ] `lib/tests/design-tool-gate.test.sh` reds on any machine with the 21st CLI installed ("FAIL precondition: system-wide 21st present, CLI_ABSENT case not hermetic") — pre-existing on develop (fa67664), independent of the diff. Make the CLI_ABSENT case hermetic (PATH shim / stubbed probe) so `make test` is green on a design-profile machine. Until then full-suite oracles (`make test` exit 0) cannot be MET here.
|
||||
- [ ] post-run-D residuals (gates, 2026-10-07): settings.json soft_deny names only `gitflow.autopush false` → add "or an unparseable value" (restriction only, settings run); capitalize STEP 6: manual mode with `ahead` = 0 (user pushed by hand between merge and read) matches no closing line, and the 5C `ahead` = 0 bullet still says "(auto-push mode did it)"; release-executor:86 line > 80 cols; push-guard header line 4 > 80 cols (pre-existing); unpushed-guard Stop silence now also covers a missing lib (SessionStart names it) — accepted
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,61 @@
|
||||
# PLAN — manual-push-failclosed-d1 — REVISED r2 (3 lenses + correctness confirmation)
|
||||
Contract: .claude/tasks/contracts/2026-10-07-manual-push-failclosed-d1-1522.md
|
||||
|
||||
## Context
|
||||
Every lib/hook reader of `gitflow.autopush` uses `git config --bool --default true … = false`. `--default` only covers a MISSING key: an unparseable value makes git die with empty output, `[ "" = false ]` is false, and the push runs — a typo silently re-enables every push on a work machine. push-guard (run B) already fails closed; the lib verb `push-mode` (run C) already prints `invalid`. D1 makes the lib, the two emitted push hooks and unpushed-guard agree: unset/true → auto, false → manual, anything else → NO push AND one stderr line naming it (the terminal user keeps a signal: today git's own `fatal: bad boolean` is that signal, and D1 must not remove it silently). `~/.claude/lib` and `~/.claude/githooks` are symlinks into this checkout: lib edits are live machine-wide at once, so tests run against the SOURCED emitter before the installed copies are regenerated, and regeneration comes last.
|
||||
|
||||
## Checklist (in this order)
|
||||
- [ ] lib/gitflow-test.sh FIRST — NEW isolated block after T18m (before T19): `echo "T18q — fail closed: unparseable gitflow.autopush → nothing pushes, named (BDR-114)"`; `newrepo badval; echo a>a; hookon; gitflow_init`; bare origin; `git push -q -u origin main develop`; `git config gitflow.autopush flase`.
|
||||
T18q1 `gitflow_start feature bad >/dev/null 2>"$WORK/q1.err"` → local branch exists AND `! git ls-remote --exit-code --heads origin feature/bad` AND `grep -q 'not a boolean' "$WORK/q1.err"` (the verb's stderr passes through `_gitflow_push_off`).
|
||||
T18q2 `echo b>b.txt; git add b.txt; git commit -q -m b 2>"$WORK/q2.err"` → `! git ls-remote --exit-code --heads origin feature/bad` AND `grep -q 'NOT pushed' "$WORK/q2.err"` (the hook names it; hooks ON via hookon — note: the installed `.githooks/` in the throwaway repo is written by `gitflow_init` from the SOURCED emitter, so this tests the new text before any regen).
|
||||
T18q3 `dev_before=$(git -C "$bare" rev-parse develop)`; `gitflow_finish >/dev/null 2>&1; q_rc=$?` → `[ $q_rc -eq 0 ] && [ "$(git -C "$bare" rev-parse develop)" = "$dev_before" ] && ! git rev-parse --verify -q refs/heads/feature/bad`.
|
||||
T18q4 positive control for the hook's `0:true` arm: `git config gitflow.autopush true; gitflow_start feature good; echo g>g.txt; git add g.txt; git commit -q -m g` → `[ "$(git rev-parse HEAD)" = "$(git -C "$bare" rev-parse feature/good)" ]` (tips equal: the post-commit hook pushed; `ls-remote` alone would pass from the start's push).
|
||||
T18q5 POSIX-clean emitted hook: `_gitflow_emit_push_hook post-commit > "$WORK/pc.sh"` (SOURCED function, never a relative lib path from a fixture cwd); `[ -s "$WORK/pc.sh" ] && grep -qF 'case "$rc:$v"' "$WORK/pc.sh"`; then `if command -v shellcheck >/dev/null 2>&1; then chk "T18q5 emitted hook is POSIX-clean" 'shellcheck -s sh "$WORK/pc.sh"'; else ok "T18q5 skipped (no shellcheck)"; fi` (first shellcheck use in a hermetic suite → guarded).
|
||||
Variables read in double-quoted assertions (no SC2034 suppression). Config writes live in the test FILE. No T18q0 (duplicate of T11b).
|
||||
- [ ] lib/gitflow.sh — `_gitflow_push_off`:
|
||||
```
|
||||
# rc 0 when pushing is off: GITFLOW_NO_PUSH=1 (throwaway test repos), or
|
||||
# gitflow.autopush not readable as `true`/unset — manual-push mode (false,
|
||||
# human-set) AND fail closed on an unparseable value or a config read
|
||||
# failure (BDR-114). The verb's stderr passes through: it names an invalid
|
||||
# value and is silent for auto/manual. Single reader for the lib's push sites.
|
||||
_gitflow_push_off() {
|
||||
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
|
||||
[ "$(gitflow_push_mode)" != auto ]
|
||||
}
|
||||
```
|
||||
Comments: line ~195 ("manual mode never deletes origin/<br>") → "push off (manual mode or invalid value) never deletes origin/<br>"; line ~206 ("skipped under GITFLOW_NO_PUSH=1, gitflow.autopush=false or no origin") → "skipped when push is off (see _gitflow_push_off) or no origin". `_gitflow_note_remote_left`'s message UNCHANGED ("manual push mode" is the doctrine name for the off state; skills/gitflow/SKILL.md:114 quotes it).
|
||||
- [ ] lib/gitflow.sh — `_gitflow_emit_push_hook` heredoc: replace the two opt-out lines (`# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false` / `[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0`) with:
|
||||
```
|
||||
# Manual-push mode (human-set): git config gitflow.autopush false. Fail closed:
|
||||
# an unparseable value or a config read failure also means "no push", named.
|
||||
# Mirrors gitflow_push_mode (lib/gitflow.sh); arms pinned by T18b/T18h/T18q2/T18q4.
|
||||
v=$(git config --bool gitflow.autopush 2>/dev/null); rc=$?
|
||||
case "$rc:$v" in
|
||||
0:true|1:*) ;;
|
||||
0:false) exit 0 ;;
|
||||
*) echo "gitflow $hook: gitflow.autopush unreadable (git rc $rc) — NOT pushed, treated as manual push mode; fix the value by hand" >&2; exit 0 ;;
|
||||
esac
|
||||
```
|
||||
POSIX sh only. pre-commit and reference-transaction emitters untouched (byte for byte).
|
||||
- [ ] hooks/unpushed-guard.sh — at the TOP (before `payload=$(cat …)` and before `cd "$cwd"`): `_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd)/gitflow.sh"`. Replace lines 26-29 (the four lines `raw=`, `manual=0; invalid=0`, the `manual=` test, the `invalid=` test) AND lines 76-78 (the old invalid clause) — no `invalid` variable survives (SC2034 otherwise) — with:
|
||||
```
|
||||
out=$(bash "$_lib" push-mode 2>&1); mode=${out##*$'\n'}; mode_err=${out%"$mode"}
|
||||
manual=0; [ "$mode" = auto ] || manual=1 # fail closed: anything but auto
|
||||
```
|
||||
(the verb writes its stderr line BEFORE its stdout word, so the last line is the mode; no temp file, no delete). Invalid/unreadable clause (SessionStart only): `case "$mode" in invalid) msg="${msg:+$msg; }${mode_err#gitflow.sh push-mode: } — treated as manual push mode (nothing pushes); fix the value by hand" ;; manual|auto) ;; *) msg="${msg:+$msg; }push mode unreadable (lib verb printed '${mode:-nothing}') — treated as manual push mode" ;; esac` (strip the trailing newline of `mode_err`). Stop + manual=1 → silent (unchanged early exit). Header comment: "+ an unparseable value is treated as manual (fail closed, BDR-114); the mode comes from the lib verb". Functions ≤25 logic lines.
|
||||
- [ ] lib/tests/unpushed-guard.test.sh — T14 rewrite (same fixture, key `flase`, one unpushed commit): T14-invalid-named → SessionStart contains `not a boolean`; T14-invalid-prefix → contains `ℹ manual push mode:`; T14-invalid-treated → contains `treated as manual`; T14-invalid-no-warn → NOT `unpushed work`; T14-invalid-stop-silent → Stop → `silent`. T15 unchanged.
|
||||
- [ ] regenerate in the SAME step as the emitter edit (a SessionStart `reconcile-hooks` between the two would run `install-hook` and write a local hooks-path entry), files only, with NO config read or write of any kind: `bash lib/gitflow.sh emit-hook post-commit > .githooks/post-commit`, `… emit-hook post-merge > .githooks/post-merge`, `… emit-hook post-commit > githooks/post-commit`, `… emit-hook post-merge > githooks/post-merge` (writing into the existing files keeps mode 755). NOT `install-hook` (local config write) and NOT `global-hooks` (writes the GLOBAL config when `~/.gitconfig` lacks the hooksPath — which is the case right now: the user's gitconfig was overwritten by a dotfiles installer at 15:39 and must be restored by the user first). Evidence: `md5 -q .git/config` identical before/after; `~/.gitconfig` untouched (the executor never reads it); `git diff --stat` shows only the four hook files. If a command is refused, STOP and report; never hand-edit generated hooks.
|
||||
- [ ] then run `make test suite=lib/gitflow-test.sh` again: T19a–e green = installed == emitted.
|
||||
|
||||
## Edge cases
|
||||
- `GITFLOW_NO_PUSH=1` still short-circuits before any mode read (T18o).
|
||||
- Terminal user with a typo: every commit prints the hook's one-line stderr and pushes nothing; `gitflow start`/`finish` print the verb's line. Inside Claude: unpushed-guard names it at SessionStart; the banner shows the lock line after D2.
|
||||
- Lib path for the guard resolved before any `cd` (relative invocation safe).
|
||||
- Onboarded projects keep their committed fail-open `.githooks/` until a session-start `reconcile-hooks` runs there and the user commits the refresh: documented at doc-sync (CHANGELOG scope note), not solvable from this repo.
|
||||
- Skills/docs still carrying "until run D" (capitalize :346/:379, CHANGELOG, SETTINGS) become stale the moment D1 lands: D3 + doc-sync follow on the same branch before merge.
|
||||
- Hooks in throwaway test repos are written by `gitflow_init` from the sourced emitter → T18q runs against the NEW hook text before regeneration.
|
||||
|
||||
## Disposition
|
||||
- honors BDR-111/BDR-112 (fail-closed semantics chosen by the user, now every reader), BDR-095 (push every commit — unchanged in auto mode; T18b/T18q4 positive controls; a stopped push is always NAMED on stderr), LRN-114 (edit the generator → regenerate installed copies through the lib → T19 drift gate), LRN-113 (grep `--default true gitflow.autopush` across lib/ hooks/ githooks/ .githooks/ ends at zero after D1+D2), LRN-191, LRN-194, LRN-196 (read git's rc), BDR-087 (Stop stays message-only), LRN-193 (fresh confirmation after this revision).
|
||||
- New BDR-114 proposed at capitalize: "every autopush reader fails closed and names the value; unset/true auto, false manual, else no push".
|
||||
@@ -0,0 +1,35 @@
|
||||
# PLAN — manual-push-guard-residuals-d2 — REVISED r2 (3 lenses + correctness confirmation)
|
||||
Contract: .claude/tasks/contracts/2026-10-07-manual-push-guard-residuals-d2-1526.md
|
||||
|
||||
## Context
|
||||
push-guard (run B, hardened) is live on every Bash|Monitor call (`~/.claude/hooks` is a symlink into this tree: every edit above `is_push || exit 0` runs machine-wide at once → `bash -n` after each edit). Residuals: `arg_tokens`' unquoted alternative stops at the first quote, so `cd /m/'a b'` yields `/m/` and the wrong dir is checked (fail-open toward the parent); an unparseable payload allows silently; the mode `case` has no default; missing core tools allow silently; T42 is vacuous once committed; no literal-`true` test; the banner shows nothing on an invalid value. After D1 the lib verb is the single reader: push-guard sources the lib once and calls `gitflow_push_mode` per candidate; the banner calls the verb.
|
||||
|
||||
## Checklist (bash -n hooks/push-guard.sh after every edit)
|
||||
- [ ] hooks/push-guard.sh
|
||||
a. Top: `LIB="$(cd -P "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd)/gitflow.sh"` (absolute, before anything else). Tools: after the jq check, `for t in cat grep sed sort head; do command -v "$t" >/dev/null 2>&1 || { echo "push-guard: $t missing, guard inactive" >&2; exit 0; }; done` (jq policy; documented).
|
||||
b. Payload fallback (no regex hoist, no early `static_deny` call): `unparsed=0; cmd=$(field '.tool_input.command') || { cmd=$payload; unparsed=1; }` (jq's rc is the rc of `field`; a parse failure → the raw payload becomes the text to scan). In the fold step, when `unparsed=1`, also replace the two-character JSON escapes `\n`, `\r`, `\t`, `\\` by spaces: `one=${one//\\n/ }; one=${one//\\r/ }; one=${one//\\t/ }; one=${one//\\\\/ }`. `is_push` runs unchanged on it. When `unparsed=1`, also set `bare=$one` (JSON quotes are syntax, not shell quoting: deleting every `"…"` span would blind the loose and alias regexes). Right after the EXIT trap is installed: `[ "$unparsed" = 1 ] && exit 0` → the trap emits the static deny (mode-blind, fail closed). Accepted limit (header): on a broken payload the whole JSON text is scanned, so a `description` mentioning a push also denies.
|
||||
c. Source the lib once: after LIB: `# shellcheck source=/dev/null` then `if [ -r "$LIB" ]; then . "$LIB"; LIB_OK=1; else LIB_OK=0; fi` (the `source=/dev/null` directive is the only clean way to source a path variable; it is not a `disable`) (sourcing defines functions only; the CLI dispatcher runs only when executed). `mode_in <dir>`: `( cd -- "$1" 2>/dev/null || { echo "failed:cannot enter the directory"; exit 0; }; [ "$LIB_OK" = 1 ] || { echo "failed:gitflow lib missing"; exit 0; }; out=$(gitflow_push_mode 2>&1); m=${out##*$'\n'}; why=$(printf '%s\n' "$out" | grep -m1 '^gitflow.sh push-mode: ' | sed 's/^gitflow.sh push-mode: //'); case "$m" in manual|auto) echo "$m" ;; invalid) echo "invalid:${why:-unreadable}" ;; *) echo "$m" ;; esac )`. No temp file. Reason for `invalid:*`: `push-guard: ${mode#invalid:} in $dir — treated as manual push mode (fail closed). Fix the value by hand, or run it yourself: ! $cmd` (the verb's line already says "gitflow.autopush='x' is not a boolean (git rc N)" or "could not read …"). Reason for `failed:*`: `push-guard: push mode unreadable in $dir (${mode#failed:}) — push refused (fail closed). Run it yourself: ! $cmd`.
|
||||
d. `case "$mode"` gains `*) deny "push-guard: unexpected push mode '$mode' in $dir — push refused (fail closed). Run it yourself: ! $cmd" ;;`.
|
||||
e. Tokens: `arg_tokens`' argument alternative becomes a REPETITION of segments so adjacent quoted and unquoted parts form one shell word: `arg='(--[[:space:]]+)?((\\.|"[^"]*"|'"'"'[^'"'"']*'"'"'|[^[:space:];&|()"'"'"'`\\]+)+)'` — an escape alternative `\\.` and the backslash removed from the unquoted class, so `my\ dir` is one word (verified on BSD grep: `/W/manual/my\ dir`, `a\ b\ c`, `/a'/../b'`, `"Bob's"`). Keep the `--` optional prefix and the same `pre` class. Then a `classify_tok` step in the MAIN shell, before the 20-token cap: for each raw token — if it starts and ends with the same quote → strip that pair, then if the inner text still contains THAT same quote character → deny (mixed: `"/m"/x"/y"`, `'/u/Bob'\''s repo'`); inner quotes of the OTHER kind are fine (`"Bob's repo"`); a token not enclosed that contains any quote → deny: `push-guard: directory token $tok mixes quoted and unquoted parts — this guard refuses to interpolate it (fail closed). Quote the whole path, or run it yourself: ! $cmd`; else unescape backslashes in the unquoted token with ONE mechanism: `tok=$(printf '%s' "$tok" | sed -E 's/\\(.)/\1/g')` (BSD sed verified: `a\\b` → `a\b`, `my\ dir` → `my dir`; deterministic, no eval). Resolution and dedup unchanged after that. Drop the old `unquote` helper if `classify_tok` replaces it.
|
||||
f. Header: DENIED now lists mixed-quote tokens and the case where a `cd` argument touches a closing quote followed by another quote on the line (`bash -c 'cd /x' && bash -c 'git push'` → one mixed token → denied; accepted, fail closed); MISSES drops inner-quote tokens, keeps `~`/`$VAR`/`$(…)`; add "payload jq cannot parse → raw text scanned (JSON escapes folded), static deny on a push match; grep/sed/sort/head/cat or jq missing → stderr warning, allow"; LIMITS unchanged (20 tokens).
|
||||
- [ ] lib/tests/push-guard.test.sh
|
||||
T51 `truerepo` fixture (key `true`) `git push` → allow.
|
||||
T52 (cwd plain) `cd $WORK/auto'/../manual' && git push` (test writes the expanded `$WORK`) → deny, reason contains `mixes quoted and unquoted`. T52b (cwd plain) `cd "$WORK/manual/my dir" && git push` → deny, reason `manual push mode` (fully quoted, resolved). T52c (cwd auto) fixture dir `$WORK/auto/bob's` → `cd "$WORK/auto/bob's" && git status` → allow (no push) and `cd "$WORK/auto/bob's" && git push` → allow (auto; inner apostrophe inside a fully-quoted token is fine).
|
||||
T53 (cwd plain) `cd $WORK/manual/my\ dir && git push` → deny, reason `manual push mode` (backslash unescaped, resolved). T53b (cwd plain) `cd "$WORK/manual"/sub"" && git push` → deny, reason `mixes quoted and unquoted`.
|
||||
T54 unparseable payload: fixture written with `printf '%s'` holding the 6-char escape `\ud800` in `cwd`; precondition `! jq -e . <"$WORK/bad.json"` (FAIL the test if jq parses it); run from `$WORK/auto` with command `git push` → stdout contains `"permissionDecision":"deny"`, reason contains `internal error`, rc 0; T54b same broken payload with `git status` → empty stdout; T54c broken payload whose command is `git add -A\ngit push` (two-char escape) → deny. T54d broken payload whose command is `git subtree push --prefix=x origin main` → deny (loose regex must still see it: `bare=$one` when unparsed).
|
||||
T55 missing core tool: shim with bash, cat, jq, git, sed, sort, head but NO grep → rc 0, empty stdout, stderr `grep missing`.
|
||||
T56 lib missing: `mkdir -p "$WORK/alone/hooks" && cp "$ROOT/hooks/push-guard.sh" "$WORK/alone/hooks/"` (copy; no `$WORK/alone/lib`), run with a temporary `H="$WORK/alone/hooks/push-guard.sh"` then restore `H`, cwd manual → deny, reason `gitflow lib missing`.
|
||||
T57 banner invalid: reuse the existing `banner` helper → `banner "$WORK/bad"` contains `push : manual (autopush bad)`.
|
||||
T42 → base: whichever of `origin/main` / `main` resolves (`git -C "$ROOT" rev-parse -q --verify`); both → the fresher by ancestry (`merge-base --is-ancestor main origin/main` → origin/main, else main); neither → print `SKIP T42 (no main ref)` and count nothing; assert the base deny count > 0 (FAIL otherwise); print `T42 base: <ref>`.
|
||||
- [ ] hooks/session-start.sh — replace the `--default true` test with: `_pm=$( [ -r "$_gf_lib" ] && bash "$_gf_lib" push-mode 2>/dev/null )` (reuse `_gf_lib`, computed at line ~52 — move its `unset` after this block); `case "$_pm" in manual) printf "│ 🔒 %-46s│\n" "push : manual (autopush=false) — ! git push" ;; invalid) printf "│ 🔒 %-46s│\n" "push : manual (autopush bad) — ! git push" ;; esac` (41 chars + 2 bytes for `—` → fits the box); `unset _pm`. Keep the byte-padding comment.
|
||||
- [ ] skills/tour/SKILL.md — `git -C <abs project>` → `git -C "<abs project>"` at every placeholder site (~243, 245, 248, 285); the `~/proj/site` example row stays unquoted (a quoted `~` would not expand).
|
||||
|
||||
## Edge cases
|
||||
- Sourcing `lib/gitflow.sh` inside the hook: functions only; `set -uo pipefail` is NOT set by sourcing (the lib sets it only in its CLI branch) — verify by reading the lib's last block; the hook keeps its own `set -u`.
|
||||
- `gitflow_push_mode` inside `$(…)` in a subshell: one git call per candidate, no bash spawn (BASH_ENV irrelevant).
|
||||
- Broken payload: the static deny is mode-blind by design (the mode cannot be read without a command); documented.
|
||||
- Mixed-quote tokens are denied in auto mode too (user-gated fail-closed for pathological commands, run B); fully-quoted tokens with inner apostrophes and backslash-escaped spaces are resolved, not denied.
|
||||
- D1 owns hooks/unpushed-guard.sh; D2 is verified after D1's commit, so AC3's `--default true` grep over hooks/ is run then (contract says so).
|
||||
|
||||
## Disposition
|
||||
- honors BDR-112 (fail-closed guard; user-gated pathological cases), BDR-113/BDR-114 (single reader = the verb, sourced), LRN-196 (trap exit 0 unchanged; caps; read rc), LRN-198 (quoted paths; no eval, deterministic unescape), LRN-104 (every new string locked), LRN-191, LRN-194, LRN-193 (fresh confirmation after this revision), BDR-100 (`--default true gitflow.autopush` grep across hooks/ ends at zero after D1+D2).
|
||||
@@ -0,0 +1,24 @@
|
||||
# PLAN — manual-push-prose-d3 — REVISED r2 (3 lenses + correctness confirmation)
|
||||
Contract: .claude/tasks/contracts/2026-10-07-manual-push-prose-d3-1530.md
|
||||
|
||||
## Context
|
||||
After D1 and D2 every reader of `gitflow.autopush` in THIS checkout (lib, emitted hooks, unpushed-guard, push-guard, banner) treats an unparseable value as manual: nothing pushes, and the stop is named. Two caveats remain: (a) onboarded projects keep their committed `.githooks/` until a session-start reconcile refreshes them, so a stale per-repo hook can still push on an invalid value — the ahead count tells; (b) the soft_deny backstop names only `gitflow.autopush false` (settings.json is outside D3; TODO). The skill prose written during run C still says "the lib and hooks still push on an invalid value until run D". Two labels say "COMMIT + PUSH" although the step reads the push state. The release executor trusts the dispatcher's version regex alone.
|
||||
|
||||
## Checklist
|
||||
- [ ] PRECONDITION (executor's first step): `grep -l -- '--default true gitflow.autopush' lib/gitflow.sh hooks/unpushed-guard.sh hooks/session-start.sh githooks/post-commit githooks/post-merge .githooks/post-commit .githooks/post-merge` must print nothing (D1 + D2 landed); any hit → STATUS: BLOCKED, nothing edited.
|
||||
- [ ] skills/capitalize/SKILL.md — line ~346 (5C invalid outcome) becomes TWO lines, both single-line bullets, placed where the one line is (still evaluated first):
|
||||
`- **push mode \`invalid\`, \`ahead\` > 0 or unknown** → \`merged to develop — <verb stderr line verbatim>: treated as manual push mode by every reader, nothing pushed (origin/develop is <ahead> commit(s) behind, or unknown). Fix the value by hand, then: ! git push origin develop\` (append \` once a remote exists\` when \`ahead\` is unknown).`
|
||||
`- **push mode \`invalid\`, \`ahead\` = 0** → \`merged to develop — <verb stderr line verbatim>: pushed anyway, likely a stale fail-open hook (a session-start reconcile refreshes a stale .githooks/; commit the refresh) or a manual push. Fix the value by hand.\``
|
||||
Line ~379 (STEP 6 invalid closing line) becomes two matching lines: `- **invalid (merged, ahead > 0 or unknown)** → \`⚠️ <mode> + merged to develop — <verb stderr line verbatim>: treated as manual push mode by every reader, nothing pushed (origin/develop <ahead> behind). Fix the value by hand, then: ! git push origin develop\` (+ \` once a remote exists\` when unknown)` and `- **invalid (merged, ahead = 0)** → \`⚠️ <mode> + merged to develop — <verb stderr line verbatim>: pushed anyway, likely a stale fail-open hook (refreshed by the next session-start reconcile; commit the refresh) or a manual push. Fix the value by hand.\``. STEP 6 is picked by the 5C result, not evaluated in order, so disambiguate the neighbours: line ~371 `**auto-persisted (5C: finish rc 0 AND \`ahead\` = 0)**` → `**auto-persisted (push mode \`auto\`, finish rc 0 AND \`ahead\` = 0)**`; line ~378 `**not on origin (merged, \`ahead\` unknown)**` → `**not on origin (push mode not \`invalid\`, merged, \`ahead\` unknown)**`. Recap line ~363: `merged, gitflow.autopush invalid (<ahead> behind)` → `merged, autopush invalid, nothing pushed (<ahead> behind) | merged, autopush invalid, pushed anyway (stale hook or manual push)`. Line ~375 (`--no-push`, `branch_ahead` = 0): add after the template, as an instruction like the :376 precedent: `With push mode \`invalid\`, replace \`(auto-push mode)\` with \`(<verb stderr line verbatim>: pushed anyway, likely a stale fail-open hook or a manual push; fix the value by hand, commit the .githooks refresh)\`.` Line ~376 (`--no-push`, `branch_ahead` > 0 or unknown): its existing `With push mode \`invalid\`, append \` gitflow.autopush=<value> is not a boolean: fix it by hand\`` → `With push mode \`invalid\`, append \` <verb stderr line verbatim>: treated as manual push mode, nothing pushed; fix the value by hand\``. No "until run D" text remains; no templated `<value>`: the verb's stderr line is quoted verbatim (it may say "could not read"). No period right after a `! git …` command.
|
||||
- [ ] agents/client-handover-writer.md — heading `## STEP 5 — COMMIT + PUSH (only if files changed)` → `## STEP 5 — COMMIT + PUSH STATE READ (only if files changed)`. Residual push claims in the same step — the current text WRAPS across lines and carries bold markers; use the Read tool and multi-line old_strings: ~545-546 `Before any commit or push,⏎confirm this is a gitflow repo:` → `Before any commit, confirm this is a gitflow repo (the pipeline never pushes):`; ~553-554 `**do NOT commit,⏎do NOT push.**` → `**do NOT commit (and never push).**`; ~555-556 `Commit/push skipped — no gitflow model in this repo; publish the⏎listed changes manually before deploy.` → `Commit skipped — no gitflow model in this repo; publish the listed changes by hand before deploy.` (re-wrap as the file does). Line ~700 (C2-qualified "mini-commit; push state read, never assumed") stays. Verify with the Grep tool (pattern `push`), never a Bash grep carrying the push word.
|
||||
- [ ] skills/client-handover/SKILL.md — step 4 label `**COMMIT + PUSH**` → `**COMMIT + PUSH STATE READ**`; in the same sentence `otherwise (manual push mode, or a hook push that failed)` → `otherwise (manual push mode, an invalid gitflow.autopush, or a hook push that failed)`.
|
||||
- [ ] agents/release-executor.md — prep span, `### Input` paragraph: after "never bump it." add on its own line: `Format check only, by reading the string (never inside a Bash command): <X.Y.Z> must match ^[0-9]+\.[0-9]+\.[0-9]+$ (literal regex text, single backslashes); anything else → STATUS: BLOCKED, nothing created.` (prep only: finish's existing `### Preconditions` already requires the `release/<X.Y.Z>` branch that only prep creates). Lines ~80-83: `in manual push mode they stay local` → `in manual push mode, or with an invalid gitflow.autopush, they stay local` — keep `invalid gitflow.autopush` and `by reading the string` unbroken on one physical line each (line-based greps).
|
||||
|
||||
## Edge cases
|
||||
- Label rename: repo-wide grep for "COMMIT + PUSH" (skills, agents, lib, hooks, rules, README, USAGE, templates, CLAUDE.global.md, CHANGELOG, docs) → only the two renamed lines; in-file references are by step number.
|
||||
- "Pushed anyway" diagnosis: after D1, an invalid value with `ahead` = 0 can only come from a stale per-repo hook (or a human push); the line says so instead of asserting "nothing pushes".
|
||||
- Doc-sync afterwards owns: CHANGELOG `[Unreleased]` three sites ("treated as auto", "for this hook a non-boolean value reads as manual", "still push on it as auto"), SETTINGS Push discipline "still push on it as auto; only push-guard fails closed", with the stale-`.githooks/` scope note.
|
||||
- TODO (outside D3): settings.json soft_deny names only `gitflow.autopush false` — add "or an unparseable value" (restriction only) in a later settings run.
|
||||
|
||||
## Disposition
|
||||
- honors BDR-114 (fail-closed everywhere → prose must stop saying otherwise, but never claims more than the facts: the ahead count decides), BDR-100/LRN-113 (label rename with citer grep; precondition grep for D1/D2 before any prose change), LRN-104, LRN-198 (version string checked by reading, never interpolated into a check command), BDR-042 (dispatcher decides the number; the executor only formats-checks).
|
||||
Reference in New Issue
Block a user