25 changed files with 46 additions and 989 deletions
-14
View File
@@ -1364,17 +1364,3 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate).
- **Alternatives rejected**: prepend coreutils/gnu-sed to PATH in Makefile+hooks; `grep X >/dev/null` (GNU grep treats /dev/null stdout like `-q`, race stays); broad `| grep -q` census (119 hits, fixtures, false confidence).
- **Gates**: 3 lenses (FATAL 6 / CONCERNS 4 / FATAL 2) + confirmation CONCERNS(2), all closed r3; GATE 0 MET 8/8 ×2; verifier CONFORME 9/9; security PASS (1 MEDIUM hardened). Linux run `[deferred]`.
- **Refs**: contract `.claude/tasks/contracts/2026-10-06-macos-portability-1105.md`, plan `.claude/tasks/plans/2026-10-06-macos-portability-1030.md`, commit 0efdff0 (bugfix/macos-portability), [[BLK-026]], [[LRN-189]], [[LRN-190]].
## BDR-111 — Manual-push mode = `gitflow.autopush false` end to end, no new key [accepted] (2026-10-06)
- **Decision**: user need (work machine): same flow, branches + commits + local merges, nothing pushed, push only by hand. Reuse existing human-set `gitflow.autopush` (static deny on `git config gitflow.*`), no `gitflow.mode`. Run A: lib `_gitflow_push_off` single reader for `start`/`finish`/`delete_remote`; `gitflow_delete` checks out CONTAINING base + `--unset-upstream` before `-d`; `_gitflow_sync_base` warns "behind origin, cannot fast-forward" instead of silent `|| true`; `unpushed-guard` silent at Stop, one `ℹ manual push mode:` SessionStart line counting ALL local branches; doctrine line CLAUDE.global.md. Run B (queued): PreToolUse `hooks/push-guard.sh` denies `git push` when autopush=false (user: block, `! git push` only), widen `gitflow.*` deny (`git config * gitflow.*`, `git -c`, `GIT_CONFIG_COUNT=`), settings prose, banner, fail-CLOSED on unparseable value in every reader at once. Run C (queued): skills that push alone (`/capitalize` STEP 5C `git push origin develop`, client-handover, release-candidate/tour claims). ORDER: no autopush=false at work before B+C.
- **Why**: `autopush false` already silenced hooks + remote delete; lib push sites ignored it (bug). Merge is NOT the user's concern (local merge wanted), push is. Fail-open on invalid value kept in A for consistency with untouched hook emitters (AC7).
- **Alternatives rejected**: new `gitflow.mode auto|manual` (duplicates autopush); tty-only lock on `finish` (user wants local merges); `-D` after ancestor gate (statically denied form, reviewers' red flag); fail-closed in lib only (hooks would still push → inconsistent).
- **Gates**: 3 lenses CONCERNS(1/2/2) + confirmation FATAL(4) → r2 fixes (T22j containing base, `-u` fixture, T18n before T18l); feater ×2; GATE 0 7/8 (AC6 = env red); verifier ECARTS(1) = AC6 only; security PASS ×2 (1 MEDIUM fail-open → run B).
- **Refs**: contract `.claude/tasks/contracts/2026-10-06-manual-push-mode-1632.md`, plan `.claude/tasks/plans/2026-10-06-manual-push-mode-1632.md`, commit 2fc8830 (feature/manual-push-mode, UNMERGED). Extends [[BDR-095]] (c); [[LRN-161]], [[LRN-191]], [[LRN-192]], [[LRN-193]], [[BLK-022]].
## BDR-112 — push-guard: text-only PreToolUse deny of Claude's `git push` in manual-push mode, fail closed [accepted] (2026-10-07)
- **Decision**: run B of [[BDR-111]]. `hooks/push-guard.sh` (own PreToolUse group `Bash|Monitor`, timeout 10) reads `tool_input.command` + `cwd`; folds `\`-newline in bash (BSD sed unsafe, [[LRN-195]]); three ERE matches on `/usr/bin/grep`: STRICT (git + `-opt [arg]`* + push|send-pack, boundaries `[^[:alnum:]_.-]` / `[^[:alnum:]_-]`), LOOSE on quote-stripped text (any later ` push` word in the same simple command), ALIAS (`alias.x=…push`). Candidate dirs = cwd + literal `-C`/`cd`/`pushd` tokens (quotes stripped, never eval/expand), dedup `sort -u`, >20 distinct → deny before any fork. Mode per dir via `git config --bool gitflow.autopush` rc: 0 → value, 1 → auto, else → deny; NO work-tree gate (global key = work-machine deployment). Deny = JSON `permissionDecision=deny` exit 0, reason carries `! <cmd>`; EXIT trap emits static deny + `exit 0` once a push is detected and nothing decided; jq missing → stderr + allow (sibling policy). User gated: fail-closed cases (cap, unenterable dir, git failure) fire in auto mode too. settings.json: 18 deny entries on WRITE forms of `gitflow.*` (any `git … config` spelling, remove/rename-section, `-c`, config env overrides, Edit/Write of git config files); soft_deny "pushing in manual-push mode, any form, no per-turn clearance"; routing-around hard_deny names hook refusals; banner `🔒 push : manual (autopush=false) — ! git push` (`%-46s`, bytes).
- **Why**: `ask` inert under auto ([[LRN-155]]); `hooks/guard-bash.sh` withheld ([[BLK-022]]) → one narrow rule instead. Trailing ` *` glob matches end-of-string ([[LRN-194]]) → no infix rule spares the bare read → Claude loses `git config … gitflow.autopush`; hooks/lib keep it; run C gets `gitflow.sh push-mode`. Text-only guard cannot see scripts/aliases → soft_deny is the declared backstop.
- **Alternatives rejected**: no-jq fallback (greps whole payload, denies in auto, untestable without shim; jq hard dep); `-C` dir unresolvable → allow (fail-open; now skipped, cwd still checked); `rev-parse` work-tree gate (drops the global key); `--default true` read (hides git failure); narrowing deny to spare the read (impossible with end-matching globs).
- **Gates**: 3 lenses CONCERNS(2)/CONCERNS(5)/FATAL(7) + confirmation FATAL(8) → r2 (BSD sed, oracle naming denied tokens, read loss); feater ×3 (58 → 61 → 71 checks); GATE 0 MET ×3; verifier CONFORME, then ECARTS(1) on hardening closed by gated clarification; security PASS ×2 (3 MEDIUM closed: 20k-token flood denied in 0.15 s, git absent → deny, `bash -c 'cd … && git push'` extracted; residuals → run D).
- **Refs**: contract `.claude/tasks/contracts/2026-10-07-manual-push-guard-1003.md`, plan `.claude/tasks/plans/2026-10-07-manual-push-guard-1003.md`, commits a2ac018 + 6468eda (feature/manual-push-mode, UNMERGED). Links [[BDR-095]], [[BDR-100]], [[LRN-069]], [[LRN-196]]. Open: user probe `! git push --dry-run` under autopush=false (bang commands assumed hook-free).
-6
View File
@@ -567,9 +567,3 @@ rules:
- Reconcile 2026-10-06: TODO:15 + TODO:10 closed (npm soft_deny covers), TODO:892 re-verified open; 6 BLK external/open unchanged; BLK-018 due at the running release.
- /prune-memory 2026-10-06: A none, D none; B BLK-019+020 → BLK-028 (merge); C bounded 37 entries ≥9% filler → 37 edited, 1 untouched (LRN-075 all-negation), cuts 1-11% only (negation guard protects "X not Y" lessons); fidelity + index OK. 110 bloated entries left for a later run.
- /doc global audit 2026-10-06 (opus): 45 items, 6 docs. Applied 34 (24 AUTO + 9 HUMAN drafts + clone URL → Gitea): README components/slash/flow, Makefile help (11 profiles), USAGE /health→make doctor + GSD 3.0.0, ARCHITECTURE layout, MIGRATION retitled + "Upgrading to 2.0.0", SETTINGS package-install guard, CHANGELOG SemVer + default model + upgrade pointer → c6fb2e4. 10 deferred logged in TODO (LICENSE, Known-residual vs release, README restructure, USAGE narrative, templates/settings.json ask inert).
- Release 2.0.0 cut (user go x3: release, tag push, MIT): bugfix merged 370f35a; develop merged into release/2.0.0 (b47bba7, CHANGELOG conflict resolved: upgrade pointer under [2.0.0]); suite 46/46 green on release; 9ef66e2 MIT LICENSE + README License + Linux residual reworded; gitflow finish by release-executor (BLK-018 did not fire) -> main 4093cca, tag v2.0.0 pushed on user go. Open after release: Linux make test (TODO), make plugin + .env on this machine (BLK-027).
- /feat manual-push-mode run A (user: work machine, same flow, never push alone): `gitflow.autopush false` = manual-push mode end to end. Plan challenged 3 lenses + 1 confirm → 2 MAJOR (`-d` re-arms on lagging upstream LRN-161; /close STEP 5C pushes develop) + 3 BLOCKER in r2 (T22j regress, develop untracked in fixture, T18l/T18n order) all closed by named changes. feater ×2 (gaps: pipefail flake `git log | grep -q`, 9 SC2034 suppressions removed), GATE 0 7/8, verifier ECARTS(1) = AC6 env red only (design-tool-gate, 21st CLI present, same on develop fa67664), security PASS ×2. Commit 2fc8830 on feature/manual-push-mode, UNMERGED. Runs B (push-guard hook, settings deny widening, banner) + C (skills that push) queued in TODO; do NOT enable manual mode at work before B+C.
## 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?
-24
View File
@@ -1700,27 +1700,3 @@ Rule: when editing a doctrine file under structure locks, grep the test's lock s
## LRN-190 — Oracle hygiene: wrapped lines, baselines, no rm -rf via variable
- **Context**: GATE 0 criterion 4 NOT-MET while code correct: executor wrapped `grep -q … \` + `<<<"$(…)"` at 80 cols (my own style rule), single-line regex missed it. Criterion 7 `shellcheck` bare would fail on pre-existing info notes outside Health Stack scope. Criterion 2 CHECK held `rm -rf "$d"` (destructive-tools rule), executor's copy refused by permission system.
- **Apply**: join continuations first (`sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n[[:space:]]*/ /g'`); lint criteria compare counts against base ref (`git show base:file | shellcheck -`); planted fixtures cleaned with `rm -f file; rmdir dir`. Oracle edits after a red floor logged in CLARIFICATIONS as "oracle maintenance", criterion text never loosened. Extends [[LRN-188]].
## LRN-191 — `cmd | grep -q` under pipefail reintroduced one commit after BDR-110 banned it
- **Context**: feater wrote T18j as `git log develop --format=%s | grep -q …` in a `set -uo pipefail` suite. Green alone ×3, red once under load (3 suites + agents in parallel): `grep -q` exits early → SIGPIPE on `git log` → rc 141 → `&&` chain fails. Demo: `seq 1 200000 | grep -q 1` fails 300/300 under pipefail, `grep -q 1 < <(seq …)` 0/300.
- **Apply**: [[BDR-110]] form `grep -q PAT < <(cmd)` in tests, `<<<"$(cmd)"` in prod. Census can't catch it by text (BDR-110 chose no rule) → executor brief + verifier lens must name it: "no multi-line producer piped into `grep -q`". A flake seen ONCE under load is a bug, not noise: reproduce the mechanism before calling it flaky. Single-write `printf '%s' "$v" | grep -q` is safe.
## LRN-192 — Turning auto-push off re-arms `git branch -d`'s upstream check (LRN-161 inverted)
- **Context**: [[LRN-161]]: auto-push kept upstream in sync → `-d` a no-op guard. Manual-push mode: upstream lags → `-d` REFUSES a branch merged into HEAD ("not yet merged to origin/<br>") → `finish` merges then rc 5 false "unmerged". First fix `--unset-upstream` then `-d` regressed T22j (hotfix merged into main only, HEAD=develop → `-d` refuses).
- **Apply**: after the explicit ancestor gate, checkout the base that CONTAINS the branch (`merge-base --is-ancestor br develop` ? develop : main), `--unset-upstream`, then `-d`. Any change to push/upstream config → re-read every `-d`, `--ff-only`, `@{u}` site AND the tests that assume upstream in sync (T22j class). Tests: gitflow-test T18k, T22j.
## LRN-193 — A revised plan gets a fresh challenger, not a re-read: r2 found 3 BLOCKERs inside r1's fixes
- **Context**: manual-push-mode plan. r1 (3 lenses) → 2 MAJOR, I rewrote 5 checklist items. Confirmation pass (1 fresh correctness challenger on the REVISED file) → FATAL(4): my `--unset-upstream` fix broke T22j; my T18l fixture never set develop's upstream (`push` without `-u`, init creates develop untracked); my T18n/T18l order made offline silence vacuous. All three were in text I had just written and re-read.
- **Apply**: `challenge-plan.md` "re-challenge once if materially changed" is load-bearing, never skip it to save a dispatch. Brief the confirmation challenger on the NEW mechanics explicitly (state machine of new tests, fixture preconditions, ordering). Fixes to tests need the same fixture trace as the code (`-u`, upstream, what an earlier test leaves behind).
## LRN-194 — Permission globs: trailing ` *` matches end-of-string; a denied token poisons every command that names it
- **Context**: push-guard deny widening. Planned `Bash(git *config *gitflow.* *)` to deny writes (key + value) and spare the bare read for run C. Evidence: `git config --local core.hooksPath` (no value) is denied by `Bash(git config --local core.hooksPath *)` → ` *` also matches end. Second effect: once `Bash(*GIT_CONFIG_COUNT*)` style rules landed (settings.json symlinked = live), a contract CHECK, a grep and a commit message naming the tokens would all be denied — including the oracle meant to verify the rules.
- **Apply**: (a) an infix/suffix glob cannot carve out a read of a denied key → give consumers a sanctioned reader (lib verb) instead; (b) a leading-`*` deny on a token makes the token unspeakable in command text → assertions about it live in test FILES (`make test`), never in CHECK commands, grep one-liners or commit subjects; (c) simplify: `Bash(git *config *gitflow.*)` already covers value writes, `--unset`, `--bool` forms. Links [[BDR-112]], [[BDR-100]].
## LRN-195 — BSD sed: `N` on the last line quits without printing → the `:a;N;$!ba` fold returns EMPTY on single-line input
- **Context**: push-guard plan folded `\`-newline with `sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n[[:space:]]*/ /g'` (the [[LRN-190]] idiom, written on GNU). `/usr/bin/sed` on macOS is BSD: `printf 'git push' | sed …` prints NOTHING. Every single-line command would have read as empty → guard dead in production, while a 2-line test passed. Caught by the confirmation challenger, not by tests.
- **Apply**: fold in bash (`one=${cmd//$'\\\n'/ }; one=${one//$'\n'/ }`) or `sed -e ':a' -e '$!N' -e '$!ba'`. Add a single-line positive control to any multi-line normaliser test. [[BDR-110]] census can't catch it (structural, not textual). Links [[LRN-190]], [[BDR-112]].
## LRN-196 — A fail-closed Claude Code hook: trap must `exit 0`, cap attacker-sized loops, read git's rc not its value
- **Context**: push-guard hardening (security gate, 3 MEDIUM). (1) EXIT trap printed the static deny but kept the non-zero rc → Claude Code parses hook JSON only on exit 0 → deny ignored = allow. (2) Each literal `cd`/`-C` token cost a subshell + 3 git execs: 600 tokens = 12 s > 10 s hook timeout → timeout = non-blocking = allow. (3) `git config --bool --default true` returns empty on git absent / old git / unreadable dir → read as "auto" → allow.
- **Apply**: `trap '… ; exit 0' EXIT`; deny path `out=$(jq …) || out=$STATIC; printf '%s' "$out"`; dedup (`sort -u`) + hard cap on command-controlled token counts, deny above the cap BEFORE any fork; distinguish `git config` rc 0/1/other (value / unset / failure → deny); record "decided" only after ≥1 clean evaluation. Lock each with a test (shim PATH without a tool, 25-token flood, chmod 000 dir with SKIP path). Measure the flood after the fix (20 000 tokens → 0.15 s). Links [[BDR-112]], [[BDR-087]], [[LRN-160]].
-11
View File
@@ -2055,14 +2055,3 @@ dans un runner; capitalize reste main-loop.
- [ ] P33 USAGE token figures ("Budget Pro ~11k tokens/5h", per-pattern) have no source in code — verify or drop
- [ ] P34 USAGE + agents/plugin-advisor.md "gstack ON/OFF", "context7 ON" vocabulary — gstack is per-profile, ctx7 is a CLI; move both together
- [ ] P41 templates/settings/settings.json: `permissions.ask` entries (npx, docker rm, make deploy, psql…) inert under defaultMode auto → config fix, not doc
## 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`
- [ ] run C — 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)
- [ ] ORDER: do not set `gitflow.autopush false` on the work machine before B + C are merged (until then `/close` still pushes develop)
## 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.
@@ -1,51 +0,0 @@
# CONTRACT — manual-push-mode
- date: 2026-10-06 | flow: feat | branch: feature/manual-push-mode (run A of 2; run B = push-guard hook + banner)
- status: active
## REQUEST (verbatim — IMMUTABLE)
User (fr): "est-ce qu'on a un moyen de regler le flow automatique de git. Activer / desactiver le fait que ca pousse tout seul, que ca ne merge pas tout seul etc. Q`'il y ai forcement la demande ou l'authorisation humaine pour cela ? Il faut pouvoir le toggle on ou toggle off"
User (fr): "ok donc ou sera la cle gitflow.mode ? Pour expliaquer, c'est pour pouvoir utiliser la config au taff. Il faut tout faire pareil, juste rien push seul. Mais faire les branches locale,ment, faire les commits localements etc. Juste il faut pas push. seulement manuel"
/feat args: Manual-push mode via the existing `gitflow.autopush` git-config key (no new key). Scope: (1) lib/gitflow.sh `_gitflow_push_branch` must honour `gitflow.autopush=false` like the hooks and `_gitflow_delete_remote` do (today `start`/`finish` push regardless, bug); (2) guard-bash: when `git config --bool --default true gitflow.autopush` is false in the cwd repo, deny any `git push` from Claude with a message pointing to `! git push` (human runs it); (3) hooks/unpushed-guard.sh: in manual mode, SessionStart emits "push manuel : N commit(s) à pousser" info only, Stop emits nothing; (4) hooks/session-start.sh banner shows push mode (auto/manual); (5) CLAUDE.global.md: one line in the gitflow section, autopush=false → unpushed work is expected, never push unless the user asks; (6) tests updated (guard-bash.test.sh, unpushed-guard.test.sh, gitflow-test.sh). User decisions already taken: mechanical block of git push (chosen), guard info at SessionStart only (chosen).
## CLARIFICATIONS
Q: Mechanical block of `git push` when autopush=false? / A: yes, block (user, pre-flow) [gated 2026-10-06]
Q: unpushed-guard behaviour in manual mode? / A: info at SessionStart only, silent at Stop (user, pre-flow) [gated 2026-10-06]
Q: scope split — request spans ~10 files (> /feat max 5) / A: run A (this contract) = items 1, 3, 5 + their tests; run B = items 2, 4 as `hooks/push-guard.sh` + test + settings.json wiring + banner. `hooks/guard-bash.sh` does not exist (BLK-022), so item 2 lands in a new dedicated hook, and `guard-bash.test.sh` (spec of an absent hook) is left untouched. [gated 2026-10-06, orchestrator — scope class, surfaced to user in pass B]
Q: manual-mode SessionStart message language / A: English, consistent with the hook family. Exact line: `ℹ manual push mode: <N> commit(s) on '<branch>' to push by hand (git push)`; no-upstream variant: `ℹ manual push mode: '<branch>' has no upstream (<N> commit(s) on this disk only), push by hand: git push -u origin <branch>`; the existing `; <d> uncommitted change(s) in <cwd>` clause follows when the tree is dirty. [gated 2026-10-06]
Q: run B hook name / A: `hooks/push-guard.sh` + `lib/tests/push-guard.test.sh` [gated 2026-10-06]
Q: challenge r1 — skills push on their own (`skills/capitalize/SKILL.md:338` `git push origin develop` after the BDR-068 auto-finish; `skills/client-handover/SKILL.md:48` + `agents/client-handover-writer.md:586` `git push`; `skills/release-candidate/SKILL.md:96` and `skills/tour/SKILL.md:273` claim the branch is already on origin) and `settings.json` environment prose (lines ~480, ~499) says unpushed = defect / A: out of run A's 5-file scope. Run B (settings.json: hook wiring + widen the `gitflow.*` deny to `git config * gitflow.*`, `git -c gitflow.*`, `GIT_CONFIG_COUNT=*` + prose) and run C (the 5 skill/agent files: gate each push on `git config --bool --default true gitflow.autopush`, report `manual push mode: <ref> not pushed`). DEPLOYMENT ORDER: `gitflow.autopush false` is not to be set on the work machine before B and C are merged. [gated 2026-10-06, orchestrator — scope class, surfaced to the user]
Q: challenge r1 — manual-mode count scope / A: all local branches (`--branches --not --remotes`), listing the ahead branches; the gated sentence shape stays (`ℹ manual push mode: <n> commit(s) not on origin (<b1>, <b2>), push by hand: git push -u origin <branch>`). Auto mode unchanged. [gated 2026-10-06, orchestrator — refinement of the chosen wording, surfaced to the user]
## ACCEPTANCE CRITERIA
1. `_gitflow_push_branch` returns without pushing when `gitflow.autopush` is false: `gitflow start` under autopush=false creates the branch locally and origin has no copy; `gitflow finish` under autopush=false merges locally and origin's develop tip is unchanged. A branch whose upstream lags (pushed once by hand, then committed to) is still deleted by `finish` (rc 0): `--unset-upstream` before `-d` (LRN-161). Skipped remote delete says `left in place`. A base that cannot fast-forward from origin warns `behind origin/<base>`; offline stays silent. Locked by the new isolated gitflow-test block T18i–T18n.
CHECK: out=$(make test suite=lib/gitflow-test.sh 2>&1); printf '%s' "$out" | grep -q ' FAIL ' && exit 1; for t in T18m0 T18i T18j T18k T18o T18n T18l; do printf '%s' "$out" | grep -q "ok $t" || exit 1; done; echo GITFLOW-MANUAL-OK
EXPECT: GITFLOW-MANUAL-OK
EVIDENCE: MET exit=0 marker-found :: GITFLOW-MANUAL-OK
2. Auto mode unchanged: existing T18a–T18h, T24a–T24f and the T19 installed==emitted drift gate stay green (no hook emitter touched).
CHECK: out=$(make test suite=lib/gitflow-test.sh 2>&1); printf '%s' "$out" | grep -q ' FAIL ' && exit 1; for t in T18a T18b T18c T18h T18f T19a T19b T19c T22i T22j T24b T24f; do printf '%s' "$out" | grep -q "ok $t" || exit 1; done; echo GITFLOW-AUTO-OK
EXPECT: GITFLOW-AUTO-OK
EVIDENCE: MET exit=0 marker-found :: GITFLOW-AUTO-OK
3. `hooks/unpushed-guard.sh` in manual mode (`gitflow.autopush=false` in the cwd repo): Stop emits nothing even with unpushed commits; SessionStart emits the manual-mode info line (`ℹ manual push mode: <n> commit(s) not on origin (<branches>), push by hand: …`) counting every local branch, silent at n=0 with a clean tree, plus the existing uncommitted-changes clause; an invalid `gitflow.autopush` value is named at SessionStart and treated as auto; no "⚠ unpushed work" wording in manual mode. Auto mode output unchanged (T1–T9). Locked by new test cases T10–T16.
CHECK: out=$(make test suite=lib/tests/unpushed-guard.test.sh 2>&1); printf '%s' "$out" | grep -q '^FAIL' && exit 1; printf '%s' "$out" | grep -qE 'PASS=(1[6-9]|[2-9][0-9]) FAIL=0' && echo GUARD-OK
EXPECT: GUARD-OK
EVIDENCE: MET exit=0 marker-found :: GUARD-OK
4. `CLAUDE.global.md` gitflow section gains one statement: `gitflow.autopush false` = manual-push mode, unpushed work is expected there, Claude never pushes unless the user asks; the "ahead of its upstream is a defect" sentence is scoped to auto mode. File stays within the 320-line density budget.
CHECK: grep -q 'autopush false' CLAUDE.global.md && grep -qi 'manual' CLAUDE.global.md && [ "$(wc -l < CLAUDE.global.md)" -le 320 ] && echo DOCTRINE-OK
EXPECT: DOCTRINE-OK
EVIDENCE: MET exit=0 marker-found :: DOCTRINE-OK
5. shellcheck clean on the two touched scripts.
CHECK: shellcheck lib/gitflow.sh hooks/unpushed-guard.sh && echo SHELLCHECK-OK
EXPECT: SHELLCHECK-OK
EVIDENCE: MET exit=0 marker-found :: SHELLCHECK-OK
6. Full hermetic suite green.
CHECK: make test >/dev/null 2>&1 && echo SUITE-GREEN
EXPECT: SUITE-GREEN
EVIDENCE: NOT-MET exit=2 (nonzero) ::
7. No new git-config key, no new env var, no change to `GITFLOW_NO_PUSH` semantics, no edit to hook emitters (`_gitflow_emit_*`) or to `githooks/`/`.githooks/`.
8. shellcheck stays clean on `lib/gitflow-test.sh` and `lib/tests/unpushed-guard.test.sh` too (Health Stack `shellcheck lib/*.sh`).
CHECK: shellcheck lib/gitflow-test.sh lib/tests/unpushed-guard.test.sh && echo SHELLCHECK-TESTS-OK
EXPECT: SHELLCHECK-TESTS-OK
EVIDENCE: MET exit=0 marker-found :: SHELLCHECK-TESTS-OK
## FILE SCOPE
lib/gitflow.sh · hooks/unpushed-guard.sh · CLAUDE.global.md · lib/gitflow-test.sh · lib/tests/unpushed-guard.test.sh
@@ -1,49 +0,0 @@
# CONTRACT — manual-push-guard (run B of manual-push mode)
- date: 2026-10-07 | flow: feat | branch: feature/manual-push-mode (run A landed as 2fc8830; run C = skills that push, separate)
- status: active
## REQUEST (verbatim — IMMUTABLE)
User (fr): "ok enchaine sur le run B"
Run B as scoped in `.claude/tasks/contracts/2026-10-06-manual-push-mode-1632.md` CLARIFICATIONS and `.claude/tasks/TODO.md` "manual-push-mode": `hooks/push-guard.sh` PreToolUse (deny `git push` in manual mode) + test + settings.json (hook wiring, widen `gitflow.*` deny: `git config * gitflow.*`, `git -c gitflow.*`, `GIT_CONFIG_COUNT=*`; environment prose ~480/~499) + session-start banner push mode. User decisions (2026-10-06): mechanical block of `git push` chosen; only `! git push` (the user, in the terminal) passes; hook name `hooks/push-guard.sh` + `lib/tests/push-guard.test.sh`.
## CLARIFICATIONS
Q: hook deny form / A: documented JSON on stdout, exit 0: `hookSpecificOutput.permissionDecision = "deny"` + `permissionDecisionReason` (code.claude.com/docs/en/hooks.md). Reason reaches Claude as the tool error. [orchestrator, internal]
Q: middle wildcards in `permissions.deny` Bash patterns / A: supported (`Bash(git * main)` documented), `*` matches any text incl. spaces, literal match on the whole command string. [orchestrator, verified via docs]
Q: fail-CLOSED on an unparseable `gitflow.autopush` value / A: user: refuse the push. In the GUARD only (deny, reason names the invalid value); lib and emitted hooks stay fail-open until run D (every reader at once, emitters included). [gated 2026-10-07]
Q: banner wording / A: user picked `push : manual`; final line (43 chars, fits the 44-char box): `🔒 push : manual (autopush=false) — ! git push`. [gated 2026-10-07]
Q: `git push --dry-run` / `-n` in manual mode / A: denied like any push (one rule, no carve-out; the user runs it). [orchestrator — simplest, stated]
Q: challenge r1 — deny widening vs run C's read / A: widen WRITE forms only (`git *config *gitflow.* *`, `*unset*`, `-c`, `--config-env`, `GIT_CONFIG_PARAMETERS`, `GIT_CONFIG_COUNT`, Edit/Write of `.git/config` and `.gitconfig`); the read `git config --bool --default true gitflow.autopush` stays reachable for run C. `Bash(env GIT_CONFIG_COUNT*)` dropped (covered by the existing `env GIT_CONFIG*`). [gated 2026-10-07, orchestrator — scope]
Q: challenge r1 — no-jq fallback / A: dropped; jq is a hard dependency (install-plugins.sh); the guard warns on stderr and allows, like every sibling hook. Fail-closed EXIT trap kept for internal errors once a push is detected. [orchestrator — internal]
Q: challenge r1 — mode read outside a repo / A: no work-tree gate; `git config` reads global/system there (work-machine `--global` deployment). Candidate dirs = cwd + literal `-C`/`cd` tokens; unresolvable → skipped, never an allow. [orchestrator — internal, fail-closed]
Q: challenge r1 — classifier coverage / A: one soft_deny entry added for pushes in manual mode in any form (scripts, aliases, subshells, sub-agents); env prose no longer names the hook as the whole defence. Matcher `Bash|Monitor` in its own hook group, timeout 10 s. [orchestrator]
Q: confirmation r2 — bare read / A: a trailing ` *` in a permission glob also matches end-of-string (evidence in plan Context), so the bare read `git config … gitflow.autopush` is denied for Claude after run B; hooks and lib keep it (not tool calls). Run C reads the mode through a lib verb (`gitflow.sh push-mode`), recorded in TODO. The deny list is simplified to `Bash(git *config *gitflow.*)` + section-level and env/edit forms (18 entries). [gated 2026-10-07, orchestrator — scope, surfaced to the user]
Q: confirmation r2 — oracles / A: settings.json assertions live in the test file (T40–T43), never in a CHECK command or a commit message: the new tokens would deny the command that names them. [orchestrator]
Q: hardening gate — two cases where the mode cannot be read safely (more than 20 distinct `cd`/`-C` dir tokens in one command; a named dir that exists but cannot be entered) deny the push even when the cwd is in auto mode; the verifier flagged this against criterion 2's "zero noise outside manual mode" / A: user: refuse the push (fail closed). Criterion 2 is read with this exception: auto-mode silence holds for every command whose named dirs can all be evaluated and number at most 20. [gated 2026-10-07]
Q: full-suite criterion / A: every suite except `lib/tests/design-tool-gate.test.sh`, a pre-existing environmental red on this machine (21st CLI present; reproduced on develop fa67664 without run A; TODO "test hermeticity"). Declared upfront, not loosened after a red. [orchestrator]
## ACCEPTANCE CRITERIA
1. `hooks/push-guard.sh` (PreToolUse) denies any Bash command that runs `git push` — plain, `git -C <dir> push`, `git -c k=v push`, `--no-pager`, `--dry-run`/`-n`, inside `cd x && git push`, `(…)`, `bash -c '…'`, after `;`/`&&`/`|`, absolute `/usr/bin/git`, backslash-newline split — when `gitflow.autopush` reads false (or unparseable) in the payload cwd or in any literal `-C`/`cd` dir the command names (global config counts outside a repo). JSON deny form; the reason names manual push mode and tells the user to run it with `! <command>`.
CHECK: out=$(make test suite=lib/tests/push-guard.test.sh 2>&1); printf '%s' "$out" | grep -q '^FAIL' && exit 1; printf '%s' "$out" | grep -qE 'PASS=(4[0-9]|[5-9][0-9]) FAIL=0' && echo PUSH-GUARD-OK
EXPECT: PUSH-GUARD-OK
EVIDENCE: MET exit=0 marker-found :: PUSH-GUARD-OK
2. Zero noise outside manual mode: auto mode (key unset or true, no global key) → the hook prints nothing and exits 0 for every command, `git push` included, except the two fail-closed cases gated in CLARIFICATIONS (more than 20 distinct dir tokens; a named dir that exists but cannot be entered) [gated 2026-10-07]; in manual mode every non-push command (`git status`, `git commit -m "fix push guard"`, `gitflow.sh finish`, `git pushd`, `git stash`, `echo pushed`) → nothing, exit 0. An unparseable value (e.g. `flase`) → deny, reason says the value is not a boolean. No jq → stderr warning, allow (sibling-hook behaviour, jq is a hard dependency). Locked by the same test file.
3. `settings.json`: (a) `hooks.PreToolUse` gains its own group `matcher "Bash|Monitor"` running `bash ~/.claude/hooks/push-guard.sh` with `timeout` 10; (b) `permissions.deny` gains the 18 entries listed in the plan (key writes in any `git … config` spelling, section removal/rename, `-c`/env overrides, direct edits of git config files); (c) one new soft_deny entry on pushing in manual-push mode in any form with the no-clearance clause, and the routing-around hard_deny names PreToolUse hook refusals; (d) prose: "Branch deletion by hand" stays unconditional with a manual-mode parenthetical, "**Push discipline**" gains the exception. Valid JSON; no existing entry removed, reworded or weakened. Locked by push-guard.test.sh T40–T43 (file-content assertions).
CHECK: jq . settings.json >/dev/null && out=$(make test suite=lib/tests/push-guard.test.sh 2>&1) && ! grep -qE '^FAIL T4[0-3]' <<<"$out" && grep -qE 'PASS=[0-9]+ FAIL=0' <<<"$out" && echo SETTINGS-OK
EXPECT: SETTINGS-OK
EVIDENCE: MET exit=0 marker-found :: SETTINGS-OK
4. `hooks/session-start.sh` banner: when `gitflow.autopush` reads false from the session cwd (local or global), one extra line `🔒 push : manual (autopush=false) — ! git push` inside the box, right border aligned (`%-46s`: bash pads by bytes, `—` is 3); nothing otherwise. Locked by push-guard.test.sh T44–T46 (fixture in the suite, `SESSION_START_OFFLINE=1`, positive control before the absence check).
CHECK: grep -q 'gitflow.autopush' hooks/session-start.sh && grep -q 'push : manual (autopush=false)' hooks/session-start.sh && grep -q '%-46s' hooks/session-start.sh && out=$(make test suite=lib/tests/push-guard.test.sh 2>&1) && ! grep -qE '^FAIL T4[4-6]' <<<"$out" && echo BANNER-OK
EXPECT: BANNER-OK
EVIDENCE: MET exit=0 marker-found :: BANNER-OK
5. shellcheck clean on `hooks/push-guard.sh`, `hooks/session-start.sh`, `lib/tests/push-guard.test.sh`; `bash -n` on all three.
CHECK: shellcheck hooks/push-guard.sh hooks/session-start.sh lib/tests/push-guard.test.sh && bash -n hooks/push-guard.sh hooks/session-start.sh lib/tests/push-guard.test.sh && echo SHELLCHECK-OK
EXPECT: SHELLCHECK-OK
EVIDENCE: MET exit=0 marker-found :: SHELLCHECK-OK
6. Every hermetic suite green except the declared environmental red `lib/tests/design-tool-gate.test.sh` (CLARIFICATIONS).
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
7. No change to lib/gitflow.sh, hook emitters, githooks/, .githooks/, hooks/unpushed-guard.sh, skills/, CLAUDE.global.md; no new config key or env var; `hooks/rtk-rewrite.sh` untouched (integrity pin); no `eval` in the guard. Floor guard clean (no new suppression).
## FILE SCOPE
hooks/push-guard.sh (new) · lib/tests/push-guard.test.sh (new) · settings.json · hooks/session-start.sh
@@ -1,48 +0,0 @@
# PLAN — manual-push-mode (run A) — REVISED after challenge r1 + confirmation r2
Contract: .claude/tasks/contracts/2026-10-06-manual-push-mode-1632.md
## Context
`gitflow.autopush` (git config, default true) already silences the post-commit/post-merge push hooks and `_gitflow_delete_remote`. Gap: `_gitflow_push_branch` (lib/gitflow.sh:78-88) only reads `GITFLOW_NO_PUSH`, so `start`/`finish` push even in manual mode. `hooks/unpushed-guard.sh` nags at every Stop regardless of mode. Doctrine says unpushed = defect, which would drive Claude to push by hand.
Challenge r1 added: (a) in manual mode a branch's upstream lags, and `git branch -d` checks the UPSTREAM when one is set (LRN-161), so `gitflow_delete` would refuse after a successful merge (rc 5, false "unmerged"); (b) `git pull --ff-only … || true` swallows a diverged base silently, which only auto-push used to surface; (c) `_gitflow_delete_remote` skipping leaves `origin/<br>` behind with no word; (d) skills push on their own (`/capitalize` STEP 5C `git push origin develop`, client-handover, release-candidate/tour "already on origin" claims) and settings.json prose says unpushed = defect → run C (skills) and run B (settings), see contract.
## Checklist
- [ ] lib/gitflow.sh — add `_gitflow_push_off()` right above `_gitflow_push_branch`: rc 0 when `GITFLOW_NO_PUSH=1` OR `git config --bool --default true gitflow.autopush` is `false`. Comment: "GITFLOW_NO_PUSH=1 (throwaway test repos) or gitflow.autopush=false (manual-push mode, human-set: work machine, foreign clone)". Call it as the first line of `_gitflow_push_branch`. In `_gitflow_delete_remote` KEEP `[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0` as the first line (test repos stay silent), then replace the inline autopush line with `_gitflow_push_off && { <left-in-place note, item 2>; return 0; }`. Grep claim, scoped: outside the hook-emitter heredocs (`_gitflow_emit_push_hook`, untouched per AC7) and that one documented NO_PUSH line, no inline reader of the two flags remains in lib/gitflow.sh.
- [ ] lib/gitflow.sh — `_gitflow_delete_remote`: when `_gitflow_push_off` fires (NO_PUSH already returned above, so this is autopush=false), origin exists, and `git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null` succeeds (no network), print to stderr `gitflow: origin/<br> left in place (manual push mode) — by hand: git push origin --delete <br>`; return 0 either way. Every `rev-parse --verify` probe added by this plan ends in `>/dev/null`: `gitflow_start`'s stdout is the branch name only (T11).
- [ ] lib/gitflow.sh — `gitflow_delete`: after `gitflow_merged_into_base` passes, check out the base that CONTAINS the branch: `if git merge-base --is-ancestor "$br" "$GITFLOW_DEVELOP" 2>/dev/null; then git checkout -q "$GITFLOW_DEVELOP"; else git checkout -q "$GITFLOW_MAIN"; fi` (replaces the current develop-else-main fallback at line ~195; T22j = merged into main only must stay deletable). Then `git branch -q --unset-upstream "$br" 2>/dev/null || true` BEFORE `git branch -q -d "$br"`. Comment citing LRN-161: `-d` judges against the upstream when one is set, against HEAD otherwise; the ancestor check is the real gate, so HEAD must be the containing base and the upstream must be out of the way. Keep the ≤25-logic-line budget: extract `_gitflow_checkout_containing_base <br>` if needed.
- [ ] lib/gitflow.sh — add `_gitflow_sync_base()` (≤10 lines) replacing the two `git pull --ff-only -q 2>/dev/null || true` lines (gitflow_start, _gitflow_merge_into): `_gitflow_timeout git pull --ff-only -q >/dev/null 2>&1 && return 0`; then if `git rev-parse -q --verify '@{u}'` succeeds and `git rev-list --count HEAD..@{u}` > 0 → stderr `gitflow: <branch> is behind origin/<branch> by <n> and cannot fast-forward — reconcile by hand (git pull, then push)`; always return 0 (never blocks). Silent when: no upstream (`@{u}` unresolvable), or offline with no RECORDED divergence (HEAD..@{u} = 0). Offline after an earlier fetch recorded the base as behind → still warns (the recorded fact is true). The `@{u}` probe ends in `>/dev/null`.
- [ ] hooks/unpushed-guard.sh — mode detection after `br=`: `raw=$(git config gitflow.autopush)`; `manual=0`; `[ "$(git config --bool --default true gitflow.autopush 2>/dev/null)" = false ] && manual=1`; `invalid=0`; `[ -n "$raw" ] && ! git config --bool gitflow.autopush >/dev/null 2>&1 && invalid=1`. Stop + manual → `exit 0` immediately (BDR-087: message only, and the user chose silence at Stop). ONE clause function kept (`unpushed_clause`), mode-aware: auto path unchanged byte for byte (T1–T9). Manual path: `n=$(git rev-list --count --branches --not --remotes)` (ALL local branches, not just HEAD — a session usually starts on develop after a local finish); `n -eq 0` → empty (so a fresh `start` branch with 0 commits is silent, LRN-091); else list the ahead branches via `git for-each-ref --format='%(refname:short)' refs/heads` filtered on `git rev-list --count <b> --not --remotes` > 0, joined by `, ` → clause `<n> commit(s) not on origin (<b1>, <b2>), push by hand: git push -u origin <first listed ahead branch>` (never HEAD's name: HEAD may hold no unique commit); no origin remote → `no 'origin' remote, <n> commit(s) on this disk only`. Prefix chosen at the single emit site: auto `⚠ unpushed work:`, manual `ℹ manual push mode:`. SessionStart keeps the `; <d> uncommitted change(s) in <cwd>` clause in both modes (dirty-only manual → `ℹ manual push mode: <d> uncommitted change(s) in <cwd>`). `invalid=1` → SessionStart appends `; gitflow.autopush='<raw>' is not a boolean, treated as auto (pushes run)`. Header comment: +3 lines on manual mode. Functions ≤25 logic lines: extract `ahead_branches()`.
- [ ] CLAUDE.global.md — gitflow section: replace the two sentences `Foreign clone: \`git config gitflow.protect false\` / \`gitflow.autopush false\`; \`GITFLOW_NO_PUSH=1\` only for throwaway test repos. A branch ahead of its upstream is a defect, not a state.` (lines 186-188) with ONE statement: `Human-set opt-outs: \`git config gitflow.protect false\` (foreign clone) and \`gitflow.autopush false\` = manual-push mode (work machine): branches, commits and local merges run as usual, nothing is pushed, Claude never pushes (\`/close\` included) unless the user asks; \`GITFLOW_NO_PUSH=1\` only for throwaway test repos. Outside manual mode a branch ahead of its upstream is a defect, not a state.` Line 229 bullet: append ` Manual-push mode (above) is the one exception.` Net +3 to +4 lines (312 → ≤316, budget 320). No heading or bold label changes (doctrine-citers census unaffected).
- [ ] lib/gitflow-test.sh — NEW isolated block after T18g, before T19: `echo "T18m — manual-push mode: gitflow.autopush=false (human-set) → nothing pushed, finish still deletes"`; `newrepo manual; echo a>a; hookon; gitflow_init`; bare origin; `git push -q -u origin main develop` (`-u`: develop MUST track origin/develop for T18l/T18n — gitflow_init creates develop untracked, and manual mode never sets it); precondition chk `T18m0 develop tracks origin/develop`: `git rev-parse -q --verify 'develop@{u}' >/dev/null`; `git config gitflow.autopush false`. ORDER inside the block: T18i, T18j, T18k, T18o, T18n, T18l (T18l fetches `o` into refs/remotes/origin/develop and nothing reconciles it, so an offline test after it would warn — T18n runs first, while develop is ahead-only).
T18i: `gitflow_start feature manual` → `git rev-parse --verify -q refs/heads/feature/manual` AND `! git ls-remote --exit-code --heads origin feature/manual`.
T18j: `echo m>m.txt; git add m.txt; git commit -q -m m`; `# shellcheck disable=SC2034` + `dev_remote_before=$(git -C "$bare" rev-parse develop)`; `fin_rc=0; gitflow_finish >/dev/null 2>&1 || fin_rc=$?` → rc 0, `Merge feature/manual into develop` in local develop log, origin develop == dev_remote_before, branch deleted.
T18k (lagging upstream): `git config gitflow.autopush true; gitflow_start feature lag` (pushed -u); `git config gitflow.autopush false; echo l>l.txt; git add l.txt; git commit -q -m l`; `lag_out=$(gitflow_finish 2>&1); lag_rc=$?` → rc 0, `! git rev-parse --verify -q refs/heads/feature/lag`, origin/develop still == dev_remote_before, `lag_out` contains `left in place`, `git ls-remote --exit-code --heads origin feature/lag` still exists.
T18o (NO_PUSH stays silent on the remote copy): `git config gitflow.autopush true; gitflow_start feature np` (pushed -u); `git config gitflow.autopush false; echo n>n.txt; git add n.txt; git commit -q -m n`; `np_out=$(GITFLOW_NO_PUSH=1 gitflow_finish 2>&1); np_rc=$?` → rc 0, branch deleted, `np_out` does NOT contain `left in place`, origin/feature/np still exists.
T18n (offline, no recorded divergence → silent): `git remote set-url origin /nonexistent/x.git; off2_out=$(gitflow_start feature off2 2>&1)` → does NOT contain `behind`, `git rev-parse --verify -q refs/heads/feature/off2`; `git remote set-url origin "$bare"; git checkout -q develop`.
T18l (diverged base warning): `other="$WORK/manual-other"; git clone -q "$bare" "$other"`; in other: hooks off, identity, `git checkout -q develop; echo o>o.txt; git add o.txt; git commit -q -m o; git push -q origin develop`; local (on develop, ahead by the local merges): `div_err="$WORK/div.err"; div_out=$(gitflow_start feature div 2>"$div_err")` → stdout `[ "$div_out" = feature/div ]` (no SHA leak), stderr `grep -q 'behind origin/develop' "$div_err"`, branch exists.
Every `*_out`/`*_rc`/`dev_remote_before` read only inside chk evals gets `# shellcheck disable=SC2034` on the line above (lib/gitflow-test.sh idiom, lines 296/344/353).
- [ ] lib/tests/unpushed-guard.test.sh — append before the PASS line (repo has origin, upstream on main/master, in sync after T8's push; tree dirty from T7/T8 → `git checkout -q -- a` first):
`git config gitflow.autopush false`
T10 manual + clean + in sync: SessionStart → `silent`; Stop → `silent`.
T11 one local commit on HEAD, plus `git branch side HEAD; git checkout -q side; echo s>s; git add s; git commit -q -m s; git checkout -q -` (second ahead branch): Stop → `silent`; SessionStart → contains `manual push mode`, `2 commit(s)`, `side`, and NOT `unpushed work`.
T12 fresh branch with no upstream and 0 extra commits (`git checkout -q -b fresh`): SessionStart → still reports the 2 commits (they are reachable from other branches; count is repo-wide) — assert `2 commit(s)`; then `git checkout -q -` .
T13 dirty tree only (push the two commits by hand in the test: `git push -q origin HEAD side`, then `echo d>>a`): SessionStart → contains `manual push mode` and `uncommitted`, NOT `commit(s) not on origin`; Stop → silent. `git checkout -q -- a`.
T14 invalid value: `git config gitflow.autopush flase`; one more local commit; SessionStart → contains `not a boolean` AND `unpushed work` (treated as auto); Stop → contains `1 commit(s)` (auto behaviour).
T15 toggle back: `git config --unset gitflow.autopush`; Stop → contains `1 commit(s)` (positive control, auto path intact).
T16 no-origin manual (LAST, nothing restored after): `git config gitflow.autopush false; git remote remove origin`; SessionStart → contains `manual push mode` and `no 'origin' remote`; Stop → silent.
## Edge cases
- `gitflow.autopush` set `--global` on the work machine: `git config --bool --default true` reads the merged value → every repo, no code difference. Toggle is human-set (static deny on `git config gitflow.*`, BDR-095 c); the deny is prefix-based and run B widens it (`git config * gitflow.*`, `git -c gitflow.*`, `GIT_CONFIG_COUNT=*`).
- Garbage value: `--bool` fails → auto mode (fail-open toward pushing, pre-existing in the emitted hooks, which run A may not edit — AC7); the guard now SAYS so at SessionStart. Fail-closed is a run B question (hook emitters).
- Count scope: manual mode counts every local branch (`--branches --not --remotes`); auto mode keeps the current-branch count (unchanged contract, T5/T6).
- Diverged base: warning only, never blocks `start`/`finish`; the user reconciles by hand. No upstream → silent; offline with no recorded divergence → silent; offline after a fetch already recorded the base as behind → warns (true fact).
- `gitflow_delete` now ends on the base that contains the branch (main for a main-only merge, develop otherwise) instead of always develop; no test asserts HEAD after a delete.
- No emitter (`_gitflow_emit_*`) touched → T19 drift gate needs no regeneration.
- Deployment order (contract): `gitflow.autopush false` must not be set on the work machine before runs B (push-guard, settings) and C (skills that push) are merged; until then `/close` STEP 5C still pushes develop.
## Disposition (STEP 0.6 + challenge r1)
- honors BDR-095 by extending the existing `gitflow.autopush` opt-out (amendment c), not a new key.
- honors BDR-100 / LRN-113 by (1) one shared predicate `_gitflow_push_off` for every lib push site, (2) surface grep widened to `grep -rn "git push\|autopush\|GITFLOW_NO_PUSH" lib hooks githooks skills agents settings.json CLAUDE.global.md` — the skill/agent/settings hits are assigned to runs B and C in the contract, not silently dropped.
- honors LRN-161 by `--unset-upstream` before `-d` (the ancestor check is the gate; `-d` must judge against HEAD) and by re-reading the `--ff-only` pulls (now warn on divergence, wrapped in `_gitflow_timeout`).
- honors LRN-104 by locking every new output string in a test: manual line (T11), dirty-only (T13), invalid value (T14), no-origin (T16), "left in place" (T18k) and its NO_PUSH silence (T18o), "behind origin" (T18l) and its offline silence (T18n), stdout purity of `start` (T18l).
- honors LRN-091 / LRN-047 by silence at Stop and at `n=0` in manual mode.
- BDR-087: Stop hook stays systemMessage-only; no control flow.
@@ -1,57 +0,0 @@
# PLAN — manual-push-guard (run B) — REVISED r2 (3 lenses + robustness confirmation)
Contract: .claude/tasks/contracts/2026-10-07-manual-push-guard-1003.md
## Context
Run A made `gitflow.autopush false` stop every lib push. Nothing yet stops Claude from typing `git push` itself: `Bash(git push *)` is on `ask`, inert under auto mode (BDR-095, LRN-155). `hooks/guard-bash.sh` does not exist (BLK-022); this guard is ONE narrow rule. The human-only toggle (`git config gitflow.*` deny) is prefix-only; run A widened its reach to the lib, so the bypass forms close now. A trailing ` *` in a permission glob also matches end-of-string (evidence: `git config --local core.hooksPath` with no value is denied by `Bash(git config --local core.hooksPath *)`), so NO infix rule can spare the bare read `git config … gitflow.autopush`: Claude loses the read, hooks and lib (not tool calls) keep it, and run C reads the mode through a lib verb (recorded in TODO). jq is a hard dependency (install-plugins.sh); sibling hooks fail open without it. `/usr/bin/sed` is BSD sed: no `N`-on-last-line idiom (an unconditional `N` on the last line quits WITHOUT printing → empty string on single-line input).
## Checklist
- [ ] hooks/push-guard.sh (new, ≤100 lines, functions ≤25 logic lines, `set -u`, `unset CDPATH`):
Header: purpose, BDR-111, deny form (JSON `hookSpecificOutput.permissionDecision=deny`, exit 0), what it sees (command TEXT only), candidate dirs, fail-closed policy (unparseable value = manual; once a push is detected an EXIT trap emits the static deny with exit 0 unless a decision was recorded), limits: OVER-BLOCKS in manual mode (any command whose text carries a later ` push` word after a `git` token: `git subtree push`, `git stash push`, `git log -S "git push"`, `grep -rn "git push" skills/`, `git config --get push.default`, `git add push.sh`, `git help push`, a commit message containing "git push") and MISSES (`"git" push`, `git "push"`, `git send-pack` caught, `git -c alias.p=push p` caught by the alias pattern; expansions `~`/`$VAR`/`$(…)` in `-C`/`cd` never resolved; `--git-dir`/`GIT_DIR`; a push inside a script, Makefile target or user alias it runs → soft_deny rule). jq missing → one stderr warning, allow (sibling-hook behaviour).
Parse: `payload=$(cat 2>/dev/null)`; jq check; `field() { printf '%s' "$payload" | jq -r "$1 // empty" 2>/dev/null; }`; `cmd=$(field '.tool_input.command')`; `cwd=$(field '.cwd')`; `[ -n "$cmd" ] || exit 0`; `[ -d "$cwd" ] || cwd=$PWD`.
Normalize IN BASH, no sed: `one=${cmd//$'\\\n'/ }; one=${one//$'\n'/ }` (backslash-newline, then bare newlines → spaces); `bare=$(printf '%s' "$one" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")` (quoted spans removed; unbalanced quotes → documented limit).
`is_push()` (any of three, `grep -qE` on a single-write `printf '%s'`):
STRICT on `one`: `(^|[^[:alnum:]_.-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(push|send-pack)([^[:alnum:]_-]|$)`
LOOSE on `bare`: `(^|[^[:alnum:]_.-])git[[:space:]]+([^|;&()]*[[:space:]])?(push|send-pack)([^[:alnum:]_-]|$)`
ALIAS on `bare`: `alias\.[^=[:space:]]+=[^[:space:]]*push`
Not a push → `exit 0` silently (nothing armed yet).
Arm: `STATIC_DENY` = compact literal JSON (reason "push-guard: internal error while checking manual push mode — push refused (fail closed). Run it yourself in the terminal with !"); `decided=0; trap '[ "$decided" = 1 ] || printf "%s" "$STATIC_DENY"; exit 0' EXIT` (the trap forces exit 0 so Claude Code parses the JSON).
`candidates()`: start with `cwd`; `grep -oE` on `one` for `(^|[[:space:];&|()])(cd|pushd)[[:space:]]+(--[[:space:]]+)?("[^"]*"|'[^']*'|[^[:space:];&|()]+)` and `(^|[[:space:]])-C[[:space:]]+("[^"]*"|'[^']*'|[^[:space:];&|()]+)`; take the LAST field of each match, strip one pair of surrounding quotes, skip `-` and empty; resolve `( cd -- "$cwd" && cd -- "$tok" 2>/dev/null && pwd -P )`; unresolvable → skipped (never expands `~`, `$`, backticks; no eval). Over-inclusion (`rg -C 3`, `tar -C /tmp`) only adds dirs. Empty list is impossible (cwd always present).
`mode_in <dir>` → prints `manual` / `invalid:<raw>` / nothing: `( cd -- "$dir" || exit 0; raw=$(git config gitflow.autopush 2>/dev/null); val=$(git config --bool --default true gitflow.autopush 2>/dev/null); [ "$val" = false ] && echo manual; [ -n "$raw" ] && ! git config --bool gitflow.autopush >/dev/null 2>&1 && echo "invalid:$raw" )`. NO work-tree gate: outside a repo `git config` reads global/system (work-machine `--global` deployment).
Decide: loop candidates; first `manual` → deny reason `push-guard: manual push mode (gitflow.autopush=false in <dir>) — Claude never pushes. Run it yourself in the terminal: ! <cmd>`; first `invalid:<raw>` → deny reason `push-guard: gitflow.autopush='<raw>' is not a boolean in <dir> — treated as manual push mode (fail closed). Fix the value by hand, or run it yourself: ! <cmd>`; none → `decided=1; exit 0`. Deny: `out=$(jq -cn --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}') || out=$STATIC_DENY; printf '%s' "$out"; decided=1; exit 0`. `<cmd>` = original command (jq --arg escapes it).
- [ ] lib/tests/push-guard.test.sh (new) — top: `set -u; export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null` (hermetic even when run directly; file content, not a command line), `ROOT`, `H="$ROOT/hooks/push-guard.sh"`, `WORK=$(mktemp -d)`, trap cleanup. Harness like rtk-rewrite.test.sh: `run(cmd, cwd)` pipes `jq -n '{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c},cwd:$d}'` into `bash "$H"`, records stdout AND rc; `fire()` → `deny` iff rc=0 and stdout parses with `.hookSpecificOutput.permissionDecision=="deny"`, `allow` iff rc=0 and stdout empty, else `error:<rc>`; `reason()`. Multi-line producers never piped into `grep -q` (LRN-191): use `grep -q … <<<"$out"`. Fixtures: `plain/` (dir, not a repo), `auto/` (git init, no key), `manual/` (key false; `sub/`, `my dir/` inside), `bad/` (key `flase`), `manual2/` (toggle), `gconf` (file `[gitflow]` / `autopush = false`), `shim/` (dir with a `jq` script: `[ "$1" = -cn ] && exit 1; exec /usr/bin/jq "$@"`, resolved via `command -v jq` at test time). Cases (≥40):
auto/none: T1 plain `git push` allow; T2 auto `git push` allow; T3 auto `git push -u origin feature/x` allow.
manual deny (cwd manual unless stated): T4 `git push` (also asserts stdout is ONE JSON line); T5 `git push -u origin feature/x`; T6 (cwd plain) `git -C "$WORK/manual" push`; T7 `cd sub && git push`; T8 `git push --dry-run`; T9 `git -c a=b push origin HEAD`; T10 `(cd sub && git push)`; T11 `bash -c 'git push'`; T12 `git push; echo done`; T13 `/usr/bin/git push`; T14 `git --no-pager push`; T15 (cwd plain) `cd "$WORK/manual/my dir"; git push`; T16 `git push&&echo ok`; T17 (cwd plain) `cd -- $WORK/manual && git push` (literal expanded path, written by the test); T18 two-line `git \` + newline + ` push`; T19 `git push|tee /dev/null`; T20 `git subtree push --prefix=x origin main` (documented over-block); T21 (cwd plain) `(cd $WORK/manual&&git push)`; T22 `git -c alias.p=push p`; T23 `git send-pack origin`; T24 `grep -rn "git push" skills/` (documented over-block, locked); T25 `git config --get push.default` (documented over-block, locked).
manual allow: T26 `git status && git commit -m "fix push guard"`; T27 `bash ~/.claude/lib/gitflow.sh finish`; T28 `git pushd`; T29 `git stash`; T30 `echo pushed`; T31 `rg -C 3 push src/`; T32 `git branch --show-current`.
invalid: T33 bad `git push` → deny, reason contains `not a boolean` and `flase`.
global: T34a cwd auto, `GIT_CONFIG_GLOBAL=$WORK/gconf` for that one `run` (set inline inside the test function), `git push` → deny; T34b cwd plain, same env, `cd "$WORK/auto" && git push` → deny; T34c cwd auto, default env → allow (control).
control: T35 manual2 `git push` deny, then `git config --unset gitflow.autopush` in manual2 → allow.
fail-closed: T36 cwd manual, `PATH="$WORK/shim:$PATH"` for that run, `git push` → deny with rc 0 and reason contains `internal error` (jq -cn fails → static deny). T37 cwd manual `git push` under default PATH → reason contains `! git push` and `manual push mode`.
payload: T38 `{}` → allow, empty stdout, rc 0; T39 payload with `tool_input.command` but no `cwd` → uses PWD (run from manual/) → deny.
wiring (file-content assertions, never typed as a command): T40 `jq -e '.hooks.PreToolUse[] | select(any(.hooks[]; .command=="bash ~/.claude/hooks/push-guard.sh")) | .matcher=="Bash|Monitor" and .hooks[0].timeout==10' "$ROOT/settings.json"`; T41 every deny entry of settings (b) below present (loop over a literal list in the test file); T42 every deny entry of `git show HEAD:settings.json` still present (nothing removed); T43 soft_deny contains `manual-push mode` and the clearance clause `! git push`.
banner: `out=$(cd "$WORK/manual" && SESSION_START_OFFLINE=1 bash "$ROOT/hooks/session-start.sh" </dev/null 2>/dev/null)`; T44 positive control `grep -q 'Claude Code config' <<<"$out"`; T45 `grep -q 'push : manual (autopush=false)' <<<"$out"`; T46 same from `auto/`: positive control present AND no `push : manual`.
- [ ] settings.json (hand-formatted; text edits; `jq . settings.json >/dev/null`; `git diff settings.json` shows only these hunks; comma discipline: previous last element gains `,`, new last has none). NOTE for the executor and the orchestrator: once this lands, ~/.claude/settings.json (symlink) is live — never type the new tokens (`GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS`, `--config-env`) in a Bash command or a commit message; they live in files only.
(a) `.hooks.PreToolUse` += NEW group `{"matcher": "Bash|Monitor", "hooks": [{"type": "command", "command": "bash ~/.claude/hooks/push-guard.sh", "timeout": 10}]}`.
(b) `.permissions.deny`, after `"Bash(git config --local gitflow.*)"`, 18 entries: `"Bash(git *config *gitflow.*)"`, `"Bash(git *config *remove-section*gitflow*)"`, `"Bash(git *config *rename-section*gitflow*)"`, `"Bash(git -c gitflow.*)"`, `"Bash(git * -c gitflow.*)"`, `"Bash(*--config-env*gitflow*)"`, `"Bash(*GIT_CONFIG_PARAMETERS*)"`, `"Bash(*GIT_CONFIG_COUNT*)"`, `"Bash(* GIT_CONFIG_GLOBAL=*)"`, `"Bash(* GIT_CONFIG_SYSTEM=*)"`, `"Edit(**/.git/config)"`, `"Write(**/.git/config)"`, `"Edit(**/.gitconfig)"`, `"Write(**/.gitconfig)"`, `"Edit(~/.gitconfig)"`, `"Write(~/.gitconfig)"`, `"Edit(~/.config/git/config)"`, `"Write(~/.config/git/config)"`.
(c) `.permissions.autoMode.soft_deny` += `"Pushing in manual-push mode (\`gitflow.autopush false\`, set by the user): any git push by Claude — direct, scripted, aliased, inside a subshell, a Makefile target, a sub-agent, or after a HOME/GIT_CONFIG override that hides the key. The push-guard hook catches the direct forms; this rule covers the rest. A request to push in this turn does not clear it: the user types \`! git push\` in the terminal."`; hard_deny "Routing around a guardrail": insert `a PreToolUse hook,` into the list of refusers (`a command the deny rules, a PreToolUse hook or this classifier refused`). Adding restrictions only.
(d) prose: hard_deny "Branch deletion by hand": keep `which every branch has since BDR-095` and append ` (manual-push mode: the lib unsets the upstream itself before \`-d\`; the hand form stays banned)`; environment **Push discipline**: append ` Exception, manual-push mode (\`gitflow.autopush false\`, set by the user, work machine): nothing is pushed by Claude, in any form; the user pushes by hand with \`! git push\`.`
- [ ] hooks/session-start.sh — after the 🪝 `GF_REFRESHED` block:
```
# ── manual-push mode (BDR-111): one lock line when this repo never auto-pushes ──
# %-46s, not 44: bash printf pads by BYTES and "—" is 3 bytes (2 extra).
if [ "$(git config --bool --default true gitflow.autopush 2>/dev/null)" = false ]; then
printf "│ 🔒 %-46s│\n" "push : manual (autopush=false) — ! git push"
fi
```
## Edge cases
- Global key: shows the banner and denies everywhere, repo or not (truth on a work machine).
- Over-blocking in manual mode (loose match): listed in the header, two cases locked (T24, T25); never in auto mode.
- `Bash(*GIT_CONFIG_COUNT*)` ends the LRN-069 token-header idiom (`git -c http.extraHeader=…` stays). `Bash(* GIT_CONFIG_GLOBAL=*)` leaves `make test` untouched (the export lives inside the Makefile).
- Hook timeout 10 s → Claude Code treats a timeout as non-blocking (allow); the soft_deny and `ask` remain.
- `!` bang commands run in the user's terminal, outside the Bash tool — not hook-gated (belief): final report asks the user to probe once with `! git push --dry-run` in a scratch repo under `autopush=false`.
- Run C: the bare read is denied for Claude after (b); run C adds a lib verb (`gitflow.sh push-mode`, prints `auto|manual|invalid`) and gates skills on it — TODO updated by the orchestrator.
## Disposition
- honors BDR-111 / BDR-095 (static deny first, prose second, `ask` entrusted with nothing; restrictions only added, nothing reworded or removed).
- honors BLK-022 (one narrow guard), LRN-069/LRN-155 (hook = gate under auto), LRN-047/LRN-091 (silent in auto and on non-push), LRN-104 (every reason, the wiring, matcher and timeout locked), LRN-191 (no multi-line producer into `grep -q`), BDR-110 (BSD sed/grep: bash folding, `/usr/bin/grep -E` semantics verified by the challengers), LRN-193 (fresh confirmation pass done: FATAL(8) → this revision).
- honors BDR-100 / LRN-113: surface grep after the change (file-content tokens only, via `make test` assertions T41/T42); readers outside this run → run D.
+1 -1
View File
@@ -20,7 +20,7 @@ claude-config/
├── update-all.sh # One-command update for all components
├── Makefile # Unified entry point: make install / doctor / update / test (make help)
├── plugins.lock.json # Version pinning for non-marketplace dependencies and vendored skills
├── hooks/ # Claude Code hooks: session start, statusline, RTK rewrite, ctx7 + design-toolchain reminders, attention notify, unpushed-work guard, manual-mode push guard
├── hooks/ # Claude Code hooks: session start, statusline, RTK rewrite, ctx7 + design-toolchain reminders, attention notify, unpushed-work guard
├── githooks/ # Generated git hooks (pre-commit, post-commit, post-merge, reference-transaction), git's global core.hooksPath
├── .githooks/ # This repo's own copy of the same hooks
├── rules/ # Rule files deployed to ~/.claude/rules (path-scoped or always-on)
-10
View File
@@ -6,16 +6,6 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and this project
## [Unreleased]
### Added
- **Manual-push mode**: `git config gitflow.autopush false` (human-set) now stops every push the gitflow lib makes, not only the post-commit / post-merge hooks. `gitflow start` and `finish` branch, commit and merge locally and push nothing; `gitflow delete` leaves the `origin/` copy in place and prints `git push origin --delete <br>` for the user to run. `hooks/unpushed-guard.sh` stays silent at turn end in this mode and opens each session with one `ℹ manual push mode:` line counting the commits no remote holds across every local branch; an unparseable `gitflow.autopush` value is named and treated as auto. `hooks/push-guard.sh` (PreToolUse, `Bash|Monitor`) refuses any `git push` Claude types while `gitflow.autopush` reads false in the session cwd or in a literal `-C`/`cd` directory the command names (global config counts outside a repo); the refusal tells the user to run it with `! git push`. It fails closed: for this hook a non-boolean value reads as manual, and a git failure while reading the key, an internal error or more than 20 distinct directory tokens in one command refuse the push (these pathological cases fire in auto mode too). In manual mode it over-blocks any command where a `push` word follows a `git` token; the misses listed in its header fall to a new `autoMode.soft_deny` rule that no request in the turn clears. The session banner adds `🔒 push : manual (autopush=false) — ! git push` when the key reads false. Skills that push on their own do not honour the mode yet. Tests: `lib/gitflow-test.sh` T18m block, `lib/tests/unpushed-guard.test.sh` T10-T16, `lib/tests/push-guard.test.sh` (71 checks).
### Changed
- `settings.json` denies every write form of the human-only `gitflow.*` keys (18 entries): any `git … config` spelling, section remove/rename, `git -c`, the git config env overrides, and Edit/Write of `.git/config`, `.gitconfig` and `~/.config/git/config`. Side effect: Claude can no longer read `gitflow.autopush` through `git config` either; hooks and `lib/gitflow.sh` still read it. The `hard_deny` rule on routing around a guardrail now names PreToolUse hook refusals.
- `gitflow start` and `finish` warn on stderr when a base is behind origin and cannot fast-forward, instead of a silent `git pull --ff-only || true` (T18l, T18n).
### Fixed
- `gitflow delete` (and `finish`) land on the base that contains the branch and drop the branch's upstream before `git branch -d`, so a branch whose upstream lags (manual-push mode) is deleted instead of refused by git (T18k).
## [2.0.0] — 2026-10-06
Upgrading from 1.x: see [MIGRATION.md](./MIGRATION.md#upgrading-an-existing-machine-to-200).
+4 -8
View File
@@ -183,12 +183,9 @@ auto-pushed upstream). The reference-transaction hook vetoes any deletion
or rename of `main`/`develop`. The four hooks run in every repo: `make
link` generates `githooks/` and sets the global `core.hooksPath`; a repo
that ran `gitflow init` (new/onboarded projects) keeps its own `.githooks/`,
refreshed at session start. Human-set opt-outs: `git config
gitflow.protect false` (foreign clone) and `gitflow.autopush false` =
manual-push mode (work machine): branches, commits and local merges run as
usual, nothing is pushed, Claude never pushes (`/close` included) unless
the user asks; `GITFLOW_NO_PUSH=1` only for throwaway test repos. Outside
manual mode a branch ahead of its upstream is a defect, not a state.
refreshed at session start. Foreign clone: `git config gitflow.protect
false` / `gitflow.autopush false`; `GITFLOW_NO_PUSH=1` only for throwaway
test repos. A branch ahead of its upstream is a defect, not a state.
## Security — non-negotiable defaults
Apply at every step: design, scaffolding, implementation, review.
@@ -230,8 +227,7 @@ days of work never pushed.
- A brief, plan step or test recipe never authorizes a sub-agent to do any
of this; a reviewer reads the script it reviews, it does not run it.
- Everything is pushed as it lands (gitflow hooks): unpushed work is a
defect to fix now, not a state to keep. Manual-push mode (above) is the
one exception.
defect to fix now, not a state to keep.
# Communication mode: radical honesty
- TRUTH OVER COMFORT: point out flaws immediately, no sugarcoating, no "not
+1 -3
View File
@@ -18,9 +18,7 @@ Not a collection of prompts — an operating layer on top of Claude Code:
- **Hooks and permissions** are deterministic guardrails: gitflow enforced
by a pre-commit hook in every repo (`make link` points git's global
`core.hooksPath` at `~/.claude/githooks`), every commit pushed by
post-commit and post-merge hooks (nothing pushed in a repo the user puts
in manual-push mode, where a PreToolUse hook also refuses Claude's own
`git push`), `main`/`develop` undeletable by a reference-transaction hook,
post-commit and post-merge hooks, `main`/`develop` undeletable by a reference-transaction hook,
deny-first permission rules, secrets kept in `~/.claude/.env` and
never in config files.
- **Templates and memory** seed every project with persistent registries
-157
View File
@@ -1,157 +0,0 @@
#!/usr/bin/env bash
# push-guard.sh — PreToolUse (Bash|Monitor): refuse `git push` in manual
# push mode (BDR-111). Manual mode = `gitflow.autopush` reads false (or is
# unparseable or unreadable: fail closed) in the payload cwd or in any literal -C / cd dir
# the command names; outside a repo `git config` reads global/system.
#
# Deny form: JSON on stdout, exit 0 (hookSpecificOutput.permissionDecision
# = "deny"). Silent in auto mode and on every non-push command. The guard
# sees the command TEXT only. Once a push is detected an EXIT trap emits a
# static deny (exit 0) unless a decision was recorded: internal error =
# push refused. jq missing: one stderr warning, allow (sibling hooks).
#
# OVER-BLOCKS in manual mode: any text carrying a later ` push` word after
# a `git` token (git subtree push, git stash push, git log -S "git push",
# grep -rn "git push" skills/, git config --get push.default, git add
# push.sh, git help push, a commit message quoting "git push").
# MISSES: "git" push, git "push", git pu\sh, git $'push', $g push; ~ / $VAR /
# $(...) in -C or cd (never resolved, never eval'd); --git-dir / GIT_DIR; a
# push hidden in a script, Makefile target, user alias or an alias planted
# by a redirect into .git/config; cumulative relative `cd a && cd b` (each
# dir is resolved from cwd, not from the previous cd). LIMITS: more than 20
# distinct cd/-C dir tokens in one command is refused outright. The
# soft_deny rule covers every miss above.
set -u
unset CDPATH
if ! command -v jq >/dev/null 2>&1; then
echo "push-guard: jq missing, guard inactive" >&2
exit 0
fi
payload=$(cat 2>/dev/null)
field() { printf '%s' "$payload" | jq -r "$1 // empty" 2>/dev/null; }
cmd=$(field '.tool_input.command')
cwd=$(field '.cwd')
[ -n "$cmd" ] || exit 0
[ -d "$cwd" ] || cwd=$PWD
# Fold line breaks (backslash-newline first), then drop quoted spans.
one=${cmd//$'\\\n'/ }
one=${one//$'\n'/ }
bare=$(printf '%s' "$one" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")
# is_push: strict (full text), loose (quotes removed), alias definition.
is_push() {
local strict loose alias_re
strict='(^|[^[:alnum:]_.-])git([[:space:]]+-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?)*[[:space:]]+(push|send-pack)([^[:alnum:]_-]|$)'
loose='(^|[^[:alnum:]_.-])git[[:space:]]+([^|;&()]*[[:space:]])?(push|send-pack)([^[:alnum:]_-]|$)'
alias_re='alias\.[^=[:space:]]+=[^[:space:]]*push'
printf '%s' "$one" | grep -qE "$strict" && return 0
printf '%s' "$bare" | grep -qE "$loose" && return 0
printf '%s' "$bare" | grep -qE "$alias_re"
}
is_push || exit 0
# static_deny: the fixed fail-closed answer (no jq needed to build it).
static_deny() {
printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"push-guard: internal error while checking manual push mode — push refused (fail closed). Run it yourself in the terminal with !"}}'
}
decided=0
trap '[ "$decided" = 1 ] || static_deny; exit 0' EXIT
# unquote <tok>: strip one pair of surrounding quotes.
unquote() {
local t=$1
case "$t" in
\"*\") t=${t#\"}; t=${t%\"} ;;
\'*\') t=${t#\'}; t=${t%\'} ;;
esac
printf '%s' "$t"
}
# arg_tokens: the directory argument of every `cd`/`pushd`/`-C` in the text.
# A quote or backtick may precede the command word (bash -c 'cd d && ...').
arg_tokens() {
local pre='[[:space:];&|()"'"'"'`]'
local arg='(--[[:space:]]+)?("[^"]*"|'"'[^']*'"'|[^[:space:];&|()"'"'"'`]+)'
{
printf '%s' "$one" | grep -oE "(^|$pre)(cd|pushd)[[:space:]]+$arg"
printf '%s' "$one" | grep -oE "(^|$pre)-C[[:space:]]+$arg"
} | sed -E "s/^$pre*(cd|pushd|-C)[[:space:]]+(--[[:space:]]+)?//"
}
# resolve_dir <tok>: absolute dir for a literal token, from cwd. A missing
# dir yields nothing (skipped); an existing but unenterable one yields its
# path so mode_in fails closed on it.
resolve_dir() {
(
cd -- "$cwd" 2>/dev/null || exit 1
[ -d "$1" ] || exit 1
if cd -- "$1" 2>/dev/null; then pwd -P; exit 0; fi
case "$1" in /*) printf '%s\n' "$1" ;; *) printf '%s/%s\n' "$PWD" "$1" ;; esac
)
}
# candidates: cwd, then each distinct literal dir of $tokens, deduplicated
# after resolution (unresolvable ones are skipped, never an allow).
candidates() {
local tok
printf '%s\n' "$cwd"
printf '%s\n' "$tokens" | while IFS= read -r tok; do
tok=$(unquote "$tok")
case "$tok" in ''|-) continue ;; esac
resolve_dir "$tok"
done | sort -u
}
# mode_in <dir>: prints `manual`, `auto` (key unset or true),
# `invalid:<raw>` (not a boolean) or `failed:<what>` (git or cd failed).
mode_in() {
(
cd -- "$1" 2>/dev/null || { echo "failed:cannot enter the directory"; exit 0; }
val=$(git config --bool gitflow.autopush 2>/dev/null); rc=$?
case "$rc" in
0) if [ "$val" = false ]; then echo manual; else echo auto; fi ;;
1) echo auto ;;
*) raw=$(git config gitflow.autopush 2>/dev/null)
if [ -n "$raw" ]; then echo "invalid:$raw"
else echo "failed:git exited $rc"; fi ;;
esac
)
}
# deny <reason>: emit the deny JSON, record the decision.
deny() {
local out
out=$(jq -cn --arg r "$1" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}') || out=$(static_deny)
printf '%s' "$out"
decided=1
exit 0
}
# Cap the distinct dir tokens before resolving any (hook timeout is 10 s).
tokens=$(arg_tokens | sort -u)
ntok=$(printf '%s\n' "$tokens" | grep -c .)
if [ "$ntok" -gt 20 ]; then
deny "push-guard: too many directory tokens in one command ($ntok > 20) — push refused (fail closed). Split the command, or run it yourself: ! $cmd"
fi
evaluated=0
while IFS= read -r dir; do
mode=$(mode_in "$dir" | head -n 1)
case "$mode" in
manual)
deny "push-guard: manual push mode (gitflow.autopush=false in $dir) — Claude never pushes. Run it yourself in the terminal: ! $cmd" ;;
invalid:*)
deny "push-guard: gitflow.autopush='${mode#invalid:}' is not a boolean in $dir — treated as manual push mode (fail closed). Fix the value by hand, or run it yourself: ! $cmd" ;;
failed:*)
deny "push-guard: could not read gitflow.autopush in $dir (${mode#failed:}) — git failed, push refused (fail closed). Run it yourself in the terminal: ! $cmd" ;;
auto) evaluated=$((evaluated + 1)) ;;
esac
done < <(candidates)
# Zero cleanly evaluated candidates: leave decided=0, the EXIT trap denies.
[ "$evaluated" -gt 0 ] && decided=1
exit 0
-5
View File
@@ -230,11 +230,6 @@ if [ -n "$GF_REFRESHED" ]; then
printf "│ 🪝 %-44s│\n" "${_gf_line:0:44}"
unset _gf_line
fi
# ── manual-push mode (BDR-111): one lock line when this repo never auto-pushes ──
# %-46s, not 44: bash printf pads by BYTES and "—" is 3 bytes (2 extra).
if [ "$(git config --bool --default true gitflow.autopush 2>/dev/null)" = false ]; then
printf "│ 🔒 %-46s│\n" "push : manual (autopush=false) — ! git push"
fi
if [ -n "$GRAPHIFY_HINT" ]; then
printf "│ 🕸️ %-44s│\n" "${GRAPHIFY_HINT:0:44}"
printf "│ %-40s│\n" "→ /graphify (AST, seconds) — you decide"
+1 -36
View File
@@ -9,10 +9,6 @@
# SessionStart also reports uncommitted changes (a dead session leaves some
# behind); Stop reports unpushed commits only, since a dirty tree mid-work is
# the normal state at a turn end.
#
# Manual-push mode (git config gitflow.autopush false, human-set): unpushed
# work is expected, so Stop stays silent; SessionStart gives one info line
# counting every local branch, with the branches to push by hand.
set -u
payload=$(cat 2>/dev/null)
@@ -23,37 +19,9 @@ cd "$cwd" 2>/dev/null || exit 0
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0
raw=$(git config gitflow.autopush 2>/dev/null)
manual=0; invalid=0
[ "$(git config --bool --default true gitflow.autopush 2>/dev/null)" = false ] && manual=1
[ -n "$raw" ] && ! git config --bool gitflow.autopush >/dev/null 2>&1 && invalid=1
[ "$manual" = 1 ] && [ "$event" != SessionStart ] && exit 0 # BDR-087: info at start only
# Local branches holding commits no remote has, one per line.
ahead_branches() {
local b
while IFS= read -r b; do
[ "$(git rev-list --count "$b" --not --remotes 2>/dev/null)" -gt 0 ] && echo "$b"
done < <(git for-each-ref --format='%(refname:short)' refs/heads)
}
# Manual mode: commits on every local branch that no remote holds.
manual_clause() {
local n list first
n=$(git rev-list --count --branches --not --remotes 2>/dev/null || echo 0)
[ "$n" -gt 0 ] || return 0
if ! git remote get-url origin >/dev/null 2>&1; then
echo "no 'origin' remote, $n commit(s) on this disk only"
return
fi
list=$(ahead_branches); first=$(printf '%s\n' "$list" | head -n 1)
echo "$n commit(s) not on origin ($(printf '%s' "$list" | paste -sd, - | sed 's/,/, /g')), push by hand: git push -u origin $first"
}
# Commits that no remote holds, as one clause; empty when everything is pushed.
unpushed_clause() {
local up n
[ "$manual" = 1 ] && { manual_clause; return; }
if ! git remote get-url origin >/dev/null 2>&1; then
echo "no 'origin' remote, every commit lives on this disk only"
return
@@ -73,12 +41,9 @@ if [ "$event" = "SessionStart" ]; then
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
[ "$dirty" -gt 0 ] && msg="${msg:+$msg; }$dirty uncommitted change(s) in $cwd"
fi
if [ "$invalid" = 1 ] && [ "$event" = "SessionStart" ]; then
msg="${msg:+$msg; }gitflow.autopush='$raw' is not a boolean, treated as auto (pushes run)"
fi
[ -n "$msg" ] || exit 0
if [ "$manual" = 1 ]; then msg="ℹ manual push mode: $msg"; else msg="⚠ unpushed work: $msg"; fi
msg="⚠ unpushed work: $msg"
if [ "$event" = "SessionStart" ]; then
jq -cn --arg m "$msg" \
'{systemMessage: $m, hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $m}}'
+2 -2
View File
@@ -37,8 +37,8 @@ exemption still lets a *manual* memory commit through on a protected base, but a
skill-driven one now branches to `chore/*` first.
**Integration is human-gated by default** — these flows commit, they do not merge.
EXCEPTION: `/capitalize` + `/close` auto-persist their memory-only commit (finish → develop; the lib pushes develop in auto-push mode only)
when THEY branched a `chore/*` off develop this run (BDR-068 — a
EXCEPTION: `/capitalize` + `/close` auto-persist their memory-only commit (finish →
develop + push) when THEY branched a `chore/*` off develop this run (BDR-068 — a
scoped [[LRN-069]] exception; see the capitalize skill's STEP 5C). `/prune-memory`
+ `/reconcile` stay fully human-gated: never run `gitflow finish` from them.
-51
View File
@@ -172,22 +172,6 @@ if bash "$HERE/gitflow.sh" protected-base main; then ok "cli protected-bas
if bash "$HERE/gitflow.sh" protected-base feature/x; then no "cli protected-base feature (rc0?)"; else ok "cli protected-base feature → rc1"; fi
chk "cli base-for hotfix=main" '[ "$(bash "$HERE/gitflow.sh" base-for hotfix)" = main ]'
echo "T11b — push-mode verb (the sanctioned reader for skills, BDR-112)"
newrepo pm; echo a>a
bash "$HERE/gitflow.sh" init >/dev/null 2>&1
chk "cli push-mode default auto" '[ "$(bash "$HERE/gitflow.sh" push-mode)" = auto ]'
git config gitflow.autopush true
chk "cli push-mode true auto" '[ "$(bash "$HERE/gitflow.sh" push-mode)" = auto ]'
git config gitflow.autopush false
chk "cli push-mode manual" '[ "$(bash "$HERE/gitflow.sh" push-mode)" = manual ]'
git config gitflow.autopush flase
pm_out=$(bash "$HERE/gitflow.sh" push-mode 2>"$WORK/pm.err"); pm_rc=$?
chk "cli push-mode invalid, rc 0, value on stderr" "[ $pm_rc -eq 0 ] && [ \"$pm_out\" = invalid ] && grep -q flase \"$WORK/pm.err\""
printf '[gitflow\n' >> .git/config
pm2_out=$(bash "$HERE/gitflow.sh" push-mode 2>/dev/null); pm2_rc=$?
chk "cli push-mode corrupt config → invalid, rc 0" "[ $pm2_rc -eq 0 ] && [ \"$pm2_out\" = invalid ]"
chk "cli usage lists push-mode" 'grep -q push-mode <<<"$(bash "$HERE/gitflow.sh" nope 2>&1)"'
echo "T12 — finish arg-guard (named branch must equal current, else refuse)"
newrepo finargs; echo a>a; hookon; gitflow_init >/dev/null 2>&1
gitflow_start feature standon >/dev/null 2>&1; echo w>w.txt; git add w.txt; git commit -q -m w
@@ -370,41 +354,6 @@ gitflow_start feature nr >/dev/null 2>&1; echo w>w; git add w
nr_out="$(git commit -q -m w 2>&1)"; nr_rc=$?
chk "T18g no origin → silent, commit ok" "[ $nr_rc -eq 0 ] && ! printf '%s' \"\$nr_out\" | grep -q FAILED"
echo "T18m — manual-push mode: gitflow.autopush=false (human-set) → nothing pushed, finish still deletes"
newrepo manual; echo a>a; hookon; gitflow_init >/dev/null 2>&1
bare="$WORK/manual.git"; git init -q --bare "$bare"; git remote add origin "$bare"
git push -q -u origin main develop 2>/dev/null
chk "T18m0 develop tracks origin/develop" "git rev-parse -q --verify 'develop@{u}' >/dev/null"
git config gitflow.autopush false
gitflow_start feature manual >/dev/null 2>&1
chk "T18i start → branch local, no copy on origin" 'git rev-parse --verify -q refs/heads/feature/manual >/dev/null && ! git ls-remote --exit-code --heads origin feature/manual >/dev/null 2>&1'
echo m>m.txt; git add m.txt; git commit -q -m m
dev_remote_before=$(git -C "$bare" rev-parse develop)
gitflow_finish >/dev/null 2>&1; fin_rc=$?
chk "T18j finish → merged locally, origin develop unchanged, branch deleted" "[ $fin_rc -eq 0 ] && grep -q 'Merge feature/manual into develop' < <(git log develop --format=%s) && [ \"\$(git -C \"$bare\" rev-parse develop)\" = \"$dev_remote_before\" ] && ! git rev-parse --verify -q refs/heads/feature/manual >/dev/null"
git config gitflow.autopush true; gitflow_start feature lag >/dev/null 2>&1
git config gitflow.autopush false
echo l>l.txt; git add l.txt; git commit -q -m l
gitflow_finish >"$WORK/lag.out" 2>&1; lag_rc=$?
chk "T18k lagging upstream → finish deletes, remote copy left in place" "[ $lag_rc -eq 0 ] && ! git rev-parse --verify -q refs/heads/feature/lag >/dev/null && [ \"\$(git -C \"$bare\" rev-parse develop)\" = \"$dev_remote_before\" ] && grep -q 'left in place' \"$WORK/lag.out\" && git ls-remote --exit-code --heads origin feature/lag >/dev/null 2>&1"
git config gitflow.autopush true; gitflow_start feature np >/dev/null 2>&1
git config gitflow.autopush false
echo n>n.txt; git add n.txt; git commit -q -m n
GITFLOW_NO_PUSH=1 gitflow_finish >"$WORK/np.out" 2>&1; np_rc=$?
chk "T18o NO_PUSH → silent on the remote copy" "[ $np_rc -eq 0 ] && ! git rev-parse --verify -q refs/heads/feature/np >/dev/null && ! grep -q 'left in place' \"$WORK/np.out\" && git ls-remote --exit-code --heads origin feature/np >/dev/null 2>&1"
git remote set-url origin /nonexistent/x.git
gitflow_start feature off2 >"$WORK/off2.out" 2>&1
chk "T18n offline, nothing recorded → silent, branch created" "! grep -q behind \"$WORK/off2.out\" && git rev-parse --verify -q refs/heads/feature/off2 >/dev/null"
git remote set-url origin "$bare"; git checkout -q develop
other="$WORK/manual-other"; git clone -q "$bare" "$other" 2>/dev/null
( cd "$other" && git config user.email t@t && git config user.name t \
&& git config core.hooksPath /dev/null && git checkout -q develop \
&& echo o>o.txt && git add o.txt && git commit -q -m o \
&& git push -q origin develop ) >/dev/null 2>&1
div_err="$WORK/div.err"
div_out=$(gitflow_start feature div 2>"$div_err")
chk "T18l diverged base → warns on stderr, stdout stays the branch name" "[ \"$div_out\" = feature/div ] && grep -q 'behind origin/develop' \"$div_err\" && git rev-parse --verify -q refs/heads/feature/div >/dev/null"
echo "T19 — installed hooks == emitted hooks in the config repo (LRN-114 drift gate)"
if [ -d "$HERE/../.githooks" ]; then
chk "T19a pre-commit installed == emitted" 'diff -q <(_gitflow_emit_pre_commit) "$HERE/../.githooks/pre-commit" >/dev/null'
+7 -83
View File
@@ -68,51 +68,17 @@ gitflow_release_open() {
[ -n "$(git for-each-ref --format='%(refname:short)' 'refs/heads/release/*')" ]
}
# gitflow_push_mode -> stdout auto | manual | invalid, rc 0 always. The ONE
# reader skills may call: `git config ... gitflow.*` is statically denied to
# Claude (BDR-112). manual = key reads false; auto = true or unset; invalid =
# anything else (unparseable value, git failure); the raw value goes to
# stderr so the caller can name it. Reads only. Ignores GITFLOW_NO_PUSH (a
# test-repo switch, not a mode): a caller that pushes must not rely on this
# verb alone, the lib's own push sites use _gitflow_push_off.
gitflow_push_mode() {
local val rc raw
val=$(git config --bool gitflow.autopush 2>/dev/null); rc=$?
case "$rc:$val" in
0:false) echo manual ;;
0:true|1:*) echo auto ;;
*) raw=$(git config gitflow.autopush 2>/dev/null)
if [ -n "$raw" ]; then
echo "gitflow.sh push-mode: gitflow.autopush='$raw'" \
"is not a boolean (git rc $rc)" >&2
else
echo "gitflow.sh push-mode: could not read" \
"gitflow.autopush (git rc $rc)" >&2
fi
echo invalid ;;
esac
return 0
}
# ── start ────────────────────────────────────────────────────────────────────
# rc 0 when pushing is off: GITFLOW_NO_PUSH=1 (throwaway test repos) or
# gitflow.autopush=false (manual-push mode, human-set: work machine, foreign
# clone). The single reader of both flags for the lib's own push sites.
_gitflow_push_off() {
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
[ "$(git config --bool --default true gitflow.autopush)" = false ]
}
# gitflow_start <type> <name> → checkout -b <type>/<name> from the correct base.
# _gitflow_push_branch <br> → push + set upstream on origin (BDR-095: a remote
# only backs up what it holds, so a branch is pushed the moment it exists).
# Best effort BY CONTRACT: no origin, offline, or refused → loud warning, rc 0.
# A failed push must never block the work, only make the gap visible.
# Opt-outs: see _gitflow_push_off.
# GITFLOW_NO_PUSH=1 opts out (throwaway test repos).
_gitflow_push_branch() {
local br="$1"
_gitflow_push_off && return 0
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
git remote get-url origin >/dev/null 2>&1 || return 0
if _gitflow_timeout git push -q -u --follow-tags origin "$br" >/dev/null 2>&1; then
return 0
@@ -130,20 +96,6 @@ _gitflow_timeout() {
fi
}
# _gitflow_sync_base → fast-forward the checked-out base from its upstream.
# Never blocks. A base that cannot fast-forward while the remote is ahead (a
# recorded divergence) is warned about: auto-push used to be the only thing
# that surfaced it. No upstream, or offline with nothing recorded → silent.
_gitflow_sync_base() {
local behind
_gitflow_timeout git pull --ff-only -q >/dev/null 2>&1 && return 0
git rev-parse -q --verify '@{u}' >/dev/null 2>&1 || return 0
behind=$(git rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
[ "$behind" -gt 0 ] || return 0
echo "gitflow: $(git symbolic-ref --short -q HEAD) is behind origin/$(git symbolic-ref --short -q HEAD) by $behind and cannot fast-forward — reconcile by hand (git pull, then push)" >&2
return 0
}
gitflow_start() {
local type="${1:-}" name="${2:-}" base
base="$(gitflow_base_for "$type")" || return 2
@@ -151,7 +103,7 @@ gitflow_start() {
git rev-parse --verify -q "$base" >/dev/null \
|| { echo "gitflow_start: base '$base' missing — run 'gitflow init' first" >&2; return 3; }
git checkout -q "$base" || return 1
_gitflow_sync_base # best-effort sync; warns on divergence, never blocks
git pull --ff-only -q 2>/dev/null || true # best-effort sync; offline / no-upstream ok
git checkout -q -b "$type/$name" || return 1
_gitflow_push_branch "$type/$name"
echo "$type/$name"
@@ -162,7 +114,7 @@ gitflow_start() {
_gitflow_merge_into() { # _gitflow_merge_into <target> <source>
local target="$1" source="$2"
git checkout -q "$target" || return 1
_gitflow_sync_base
git pull --ff-only -q 2>/dev/null || true
git merge --no-ff -q -m "Merge $source into $target" "$source" \
|| { echo "gitflow: conflict merging $source → $target — resolve, commit, re-run finish" >&2; return 4; }
_gitflow_push_branch "$target" # git merge fires post-merge, not post-commit; push here too
@@ -191,15 +143,6 @@ gitflow_merged_into_base() {
return 1
}
# _gitflow_note_remote_left <br> → manual mode never deletes origin/<br>; say
# so when a remote-tracking ref shows a copy exists (no network call).
_gitflow_note_remote_left() {
local br="$1"
gitflow_protected_base "$br" && return 0
git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null || return 0
echo "gitflow: origin/$br left in place (manual push mode) — by hand: git push origin --delete $br" >&2
}
# _gitflow_delete_remote <br> → remove origin/<br> once the LOCAL copy is gone.
# Same contract as the pushes (BDR-095): best effort, warn never fail; skipped
# under GITFLOW_NO_PUSH=1, gitflow.autopush=false or no origin. The REMOTE tip
@@ -210,11 +153,8 @@ _gitflow_note_remote_left() {
_gitflow_delete_remote() {
local br="$1" out rc tip
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
[ "$(git config --bool --default true gitflow.autopush)" = false ] && return 0
git remote get-url origin >/dev/null 2>&1 || return 0
if _gitflow_push_off; then
_gitflow_note_remote_left "$br"
return 0
fi
gitflow_protected_base "$br" && return 0
out="$(_gitflow_timeout git ls-remote --exit-code --heads origin "refs/heads/$br" 2>/dev/null)"; rc=$?
[ "$rc" -eq 2 ] && return 0 # no remote copy — nothing to remove
@@ -235,17 +175,6 @@ _gitflow_delete_remote() {
return 0
}
# _gitflow_checkout_containing_base <br> → leave <br>, landing on the base that
# contains it (develop first, main for a branch merged into main only).
_gitflow_checkout_containing_base() {
local br="$1"
if git merge-base --is-ancestor "$br" "$GITFLOW_DEVELOP" 2>/dev/null; then
git checkout -q "$GITFLOW_DEVELOP"
else
git checkout -q "$GITFLOW_MAIN"
fi
}
# gitflow_delete <branch> → the one sanctioned way to delete a branch, local
# copy then origin copy. finish calls it after its merges; the CLI exposes it
# for a branch merged elsewhere (a Gitea PR, a hand merge). Refuses, branch
@@ -263,11 +192,7 @@ gitflow_delete() {
echo "gitflow: REFUSED — '$br' is not merged into $GITFLOW_DEVELOP or $GITFLOW_MAIN — branch kept" >&2
return 5
fi
_gitflow_checkout_containing_base "$br"
# LRN-161: `-d` judges against the upstream when one is set, against HEAD
# otherwise. The ancestor check above is the real gate, so HEAD must be the
# base that contains <br> and a lagging upstream (manual mode) must go.
git branch -q --unset-upstream "$br" 2>/dev/null || true
git checkout -q "$GITFLOW_DEVELOP" 2>/dev/null || git checkout -q "$GITFLOW_MAIN" 2>/dev/null
git branch -q -d "$br" || { echo "gitflow: git refused to delete '$br' — branch kept" >&2; return 5; }
_gitflow_delete_remote "$br"
}
@@ -623,7 +548,6 @@ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
delete) gitflow_delete "$@" ;;
merged) [ -n "${1:-}" ] || { echo "usage: gitflow.sh merged <branch>" >&2; exit 2; }
gitflow_merged_into_base "$1" ;;
push-mode) gitflow_push_mode ;;
hooks) printf '%s\n' "${GITFLOW_HOOKS[@]}" ;;
init) gitflow_init "$@" ;;
reconcile) gitflow_reconcile_gitignore "$@" ;;
@@ -633,6 +557,6 @@ if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
global-hooks) gitflow_global_hooks "$@" ;;
emit-hook) _gitflow_emit_hook "${1:-pre-commit}" \
|| { echo "gitflow.sh emit-hook {$(IFS='|'; echo "${GITFLOW_HOOKS[*]}")}" >&2; exit 2; } ;;
*) echo "usage: gitflow.sh {type|protected-base|base-for|release-open|start|finish|delete <br>|merged <br>|push-mode|init|reconcile|purge-transient|install-hook|reconcile-hooks|global-hooks <dir> [value]|hooks|emit-hook <name>}" >&2; exit 2 ;;
*) echo "usage: gitflow.sh {type|protected-base|base-for|release-open|start|finish|delete <br>|merged <br>|init|reconcile|purge-transient|install-hook|reconcile-hooks|global-hooks <dir> [value]|hooks|emit-hook <name>}" >&2; exit 2 ;;
esac
fi
-239
View File
@@ -1,239 +0,0 @@
#!/usr/bin/env bash
# lib/tests/push-guard.test.sh
# hooks/push-guard.sh (BDR-111): denies `git push` in manual push mode,
# silent otherwise. Also locks the settings.json wiring and the banner line.
set -u
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
H="$ROOT/hooks/push-guard.sh"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
pass=0; fail=0
check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1));
printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; }
# ── fixtures ──
mkrepo() { mkdir -p "$1" && git init -q "$1"; }
mkdir -p "$WORK/plain"
mkrepo "$WORK/auto"
mkrepo "$WORK/manual"; git -C "$WORK/manual" config gitflow.autopush false
mkdir -p "$WORK/manual/sub" "$WORK/manual/my dir"
mkrepo "$WORK/bad"; git -C "$WORK/bad" config gitflow.autopush flase
mkrepo "$WORK/manual2"; git -C "$WORK/manual2" config gitflow.autopush false
printf '[gitflow]\n\tautopush = false\n' > "$WORK/gconf"
mkdir -p "$WORK/shim"
cat > "$WORK/shim/jq" <<EOF
#!/bin/sh
[ "\$1" = -cn ] && exit 1
exec $(command -v jq) "\$@"
EOF
chmod +x "$WORK/shim/jq"
# ── harness ──
OUT=""; RC=0
run() { # run <cmd> <cwd>
local payload
payload=$(jq -n --arg c "$1" --arg d "$2" \
'{hook_event_name:"PreToolUse",tool_name:"Bash",tool_input:{command:$c},cwd:$d}')
OUT=$(printf '%s' "$payload" | bash "$H" 2>/dev/null); RC=$?
}
verdict() {
if [ "$RC" -ne 0 ]; then echo "error:$RC"; return; fi
if [ -z "$OUT" ]; then echo allow; return; fi
if [ "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$OUT" 2>/dev/null)" = deny ]
then echo deny; else echo "error:badjson"; fi
}
fire() { run "$1" "$2"; verdict; }
reason() { jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$OUT"; }
M="$WORK/manual"
# ── auto / none: silent ──
check T1-plain-allow "$(fire 'git push' "$WORK/plain")" allow
check T2-auto-allow "$(fire 'git push' "$WORK/auto")" allow
check T3-auto-upstream "$(fire 'git push -u origin feature/x' "$WORK/auto")" allow
# ── manual: deny ──
run 'git push' "$M"
check T4-push "$(verdict)" deny
check T4b-one-line "$(printf '%s' "$OUT" | wc -l | tr -d ' ')" 0
check T5-push-u "$(fire 'git push -u origin feature/x' "$M")" deny
check T6-dash-C "$(fire "git -C \"$M\" push" "$WORK/plain")" deny
check T7-cd-sub "$(fire 'cd sub && git push' "$M")" deny
check T8-dry-run "$(fire 'git push --dry-run' "$M")" deny
check T9-dash-c "$(fire 'git -c a=b push origin HEAD' "$M")" deny
check T10-subshell "$(fire '(cd sub && git push)' "$M")" deny
check T11-bash-c "$(fire "bash -c 'git push'" "$M")" deny
check T12-semicolon "$(fire 'git push; echo done' "$M")" deny
check T13-abs-git "$(fire '/usr/bin/git push' "$M")" deny
check T14-no-pager "$(fire 'git --no-pager push' "$M")" deny
check T15-quoted-dir "$(fire "cd \"$M/my dir\"; git push" "$WORK/plain")" deny
check T16-amp "$(fire 'git push&&echo ok' "$M")" deny
check T17-cd-dashdash "$(fire "cd -- $M && git push" "$WORK/plain")" deny
check T18-backslash-nl "$(fire $'git \\\n push' "$M")" deny
check T19-pipe "$(fire 'git push|tee /dev/null' "$M")" deny
check T20-subtree "$(fire 'git subtree push --prefix=x origin main' "$M")" deny
check T21-cd-amp "$(fire "(cd $M&&git push)" "$WORK/plain")" deny
check T22-alias "$(fire 'git -c alias.p=push p' "$M")" deny
check T23-send-pack "$(fire 'git send-pack origin' "$M")" deny
check T24-grep-overblock "$(fire 'grep -rn "git push" skills/' "$M")" deny
check T25-config-overblock "$(fire 'git config --get push.default' "$M")" deny
# ── manual: allow ──
check T26-commit-msg "$(fire 'git status && git commit -m "fix push guard"' "$M")" allow
check T27-gitflow "$(fire 'bash ~/.claude/lib/gitflow.sh finish' "$M")" allow
check T28-pushd "$(fire 'git pushd' "$M")" allow
check T29-stash "$(fire 'git stash' "$M")" allow
check T30-echo "$(fire 'echo pushed' "$M")" allow
check T31-rg-C "$(fire 'rg -C 3 push src/' "$M")" allow
check T32-branch "$(fire 'git branch --show-current' "$M")" allow
# ── invalid value: fail closed ──
run 'git push' "$WORK/bad"
check T33-invalid "$(verdict)" deny
R=$(reason)
check T33b-not-boolean "$(grep -c 'not a boolean' <<<"$R")" 1
check T33c-raw-value "$(grep -c 'flase' <<<"$R")" 1
# ── global key ──
g() { # g <cmd> <cwd>: run with the global config pointing at gconf
local saved=$GIT_CONFIG_GLOBAL
GIT_CONFIG_GLOBAL="$WORK/gconf"; fire "$1" "$2"
GIT_CONFIG_GLOBAL=$saved
}
check T34a-global-auto-cwd "$(g 'git push' "$WORK/auto")" deny
check T34b-global-cd "$(g "cd \"$WORK/auto\" && git push" "$WORK/plain")" deny
check T34c-control "$(fire 'git push' "$WORK/auto")" allow
# ── toggle control ──
check T35a-manual2 "$(fire 'git push' "$WORK/manual2")" deny
git -C "$WORK/manual2" config --unset gitflow.autopush
check T35b-unset "$(fire 'git push' "$WORK/manual2")" allow
# ── fail closed on internal error ──
# T36: a jq shim that fails on `jq -cn` makes the guard's deny path error.
saved_path=$PATH; PATH="$WORK/shim:$PATH"
run 'git push' "$M"
PATH=$saved_path
check T36-static-deny "$(verdict)" deny
check T36b-internal "$(grep -c 'internal error' <<<"$(reason)")" 1
check T36c-rc "$RC" 0
run 'git push' "$M"
R=$(reason)
check T37a-bang "$(grep -c '! git push' <<<"$R")" 1
check T37b-mode "$(grep -c 'manual push mode' <<<"$R")" 1
# T47: jq absent from PATH: guard warns on stderr and stays inactive.
mkdir -p "$WORK/nojq"
for tool in bash cat git grep sed tr dirname basename mktemp; do
real=$(command -v "$tool") || continue
case "$real" in /*) ln -sf "$real" "$WORK/nojq/$tool" ;; esac
done
payload47=$(jq -n --arg c 'git push' --arg d "$M" \
'{tool_input:{command:$c},cwd:$d}')
out47=$(cd "$M" && printf '%s' "$payload47" \
| PATH="$WORK/nojq" "$(command -v bash)" "$H" 2>"$WORK/nojq.err"); rc47=$?
check T47a-rc "$rc47" 0
check T47b-stdout-empty "$out47" ""
check T47c-warn "$(grep -c 'jq missing' "$WORK/nojq.err")" 1
# ── payload edge cases ──
run_empty=$(printf '{}' | bash "$H" 2>/dev/null); rc=$?
check T38-empty-stdout "$run_empty" ""
check T38b-rc "$rc" 0
nocwd=$(jq -n '{tool_input:{command:"git push"}}')
out=$(cd "$M" && printf '%s' "$nocwd" | bash "$H" 2>/dev/null)
check T39-no-cwd "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$out")" deny
# ── hardening: candidate cap, git failure, quote-prefixed cd ──
cmd48=""; for i in $(seq 1 25); do cmd48="${cmd48}cd /x$i;"; done
run "$cmd48 git push" "$WORK/auto"
check T48-cap-deny "$(verdict)" deny
check T48-cap-reason "$(grep -c 'too many directory tokens' <<<"$(reason)")" 1
cmd48b=""; for i in 1 2 3 4 5; do cmd48b="${cmd48b}cd \"$M\";"; done
run "$cmd48b git push" "$WORK/plain"
check T48b-dedup-detect "$(verdict)" deny
check T48b-manual-reason "$(grep -c 'manual push mode' <<<"$(reason)")" 1
# T49: git absent from PATH: the mode cannot be read, so deny (fail closed).
mkdir -p "$WORK/nogit"
for tool in bash cat grep sed tr jq dirname basename mktemp head sort wc; do
real=$(command -v "$tool") || continue
case "$real" in /*) ln -sf "$real" "$WORK/nogit/$tool" ;; esac
done
payload49=$(jq -n --arg c 'git push' --arg d "$M" \
'{tool_input:{command:$c},cwd:$d}')
out49=$(printf '%s' "$payload49" | PATH="$WORK/nogit" "$(command -v bash)" "$H" 2>/dev/null); rc49=$?
check T49-rc "$rc49" 0
check T49-deny "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$out49")" deny
check T49-reason "$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$out49" | grep -cE 'git|internal error')" 1
# T49b: an existing but unenterable candidate dir fails closed.
mkdir -p "$WORK/locked"; chmod 000 "$WORK/locked"
if [ -r "$WORK/locked" ] || (cd "$WORK/locked" 2>/dev/null); then
echo "SKIP T49b-unreadable (chmod 000 ineffective for this user)"
else
check T49b-unreadable "$(fire "cd \"$WORK/locked\" && git push" "$WORK/plain")" deny
fi
chmod 755 "$WORK/locked"
# T50: a cd that follows a quote is still extracted.
check T50-bash-c-cd "$(fire "bash -c 'cd \"$M\" && git push'" "$WORK/plain")" deny
check T50b-unquoted-arg "$(fire "bash -c 'cd $M && git push'" "$WORK/plain")" deny
# ── settings.json wiring (file content only) ──
S="$ROOT/settings.json"
check T40-wiring "$(jq -e '.hooks.PreToolUse[]
| select(any(.hooks[]; .command=="bash ~/.claude/hooks/push-guard.sh"))
| .matcher=="Bash|Monitor" and .hooks[0].timeout==10' "$S" 2>&1)" true
has_deny() { jq -e --arg e "$1" '.permissions.deny | index($e)' "$S" >/dev/null; }
missing=""
while IFS= read -r e; do
has_deny "$e" || missing="$missing [$e]"
done <<'EOF'
Bash(git *config *gitflow.*)
Bash(git *config *remove-section*gitflow*)
Bash(git *config *rename-section*gitflow*)
Bash(git -c gitflow.*)
Bash(git * -c gitflow.*)
Bash(*--config-env*gitflow*)
Bash(*GIT_CONFIG_PARAMETERS*)
Bash(*GIT_CONFIG_COUNT*)
Bash(* GIT_CONFIG_GLOBAL=*)
Bash(* GIT_CONFIG_SYSTEM=*)
Edit(**/.git/config)
Write(**/.git/config)
Edit(**/.gitconfig)
Write(**/.gitconfig)
Edit(~/.gitconfig)
Write(~/.gitconfig)
Edit(~/.config/git/config)
Write(~/.config/git/config)
EOF
check T41-new-deny-present "$missing" ""
# Nothing removed: every deny entry of the pre-run-B settings is still there.
base=HEAD
lost=$(git -C "$ROOT" show "$base:settings.json" 2>/dev/null \
| jq -r --slurpfile now "$S" \
'.permissions.deny[] | select(. as $e | ($now[0].permissions.deny | index($e)) == null)')
check T42-nothing-removed "$lost" ""
soft=$(jq -r '.autoMode.soft_deny[]' "$S")
check T43a-soft-rule "$(grep -c 'manual-push mode' <<<"$soft" | tr -d ' ')" 1
check T43b-clearance "$(grep -c "does not clear it: the user types \`! git push\`" <<<"$soft")" 1
# ── session banner ──
banner() { # banner <dir>
(cd "$1" && SESSION_START_OFFLINE=1 bash "$ROOT/hooks/session-start.sh" \
</dev/null 2>/dev/null)
}
out=$(banner "$M")
check T44-banner-control "$(grep -c 'Claude Code config' <<<"$out")" 1
check T45-banner-manual "$(grep -c 'push : manual (autopush=false)' <<<"$out")" 1
out=$(banner "$WORK/auto")
check T46a-auto-control "$(grep -c 'Claude Code config' <<<"$out")" 1
check T46b-auto-silent "$(grep -c 'push : manual' <<<"$out")" 0
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
-38
View File
@@ -38,42 +38,4 @@ check T8-dirty-start-reported "$(has "$(fire SessionStart "$PWD")" "uncommitted"
out=$(jq -n --arg d "$PWD" '{hook_event_name:"SessionStart", cwd:$d}' | bash "$H" 2>/dev/null)
check T9-start-adds-context "$(printf '%s' "$out" | jq -r '.hookSpecificOutput.hookEventName')" SessionStart
# ── manual-push mode (gitflow.autopush=false) ──
git checkout -q -- a
git config gitflow.autopush false
check T10-manual-clean-start "$(fire SessionStart "$PWD")" silent
check T10-manual-clean-stop "$(fire Stop "$PWD")" silent
echo m>m; git add m; git commit -q -m m
git branch side HEAD; git checkout -q side; echo s>s; git add s; git commit -q -m s
git checkout -q -
check T11-manual-stop-silent "$(fire Stop "$PWD")" silent
out=$(fire SessionStart "$PWD")
check T11-manual-info "$(has "$out" "manual push mode")" yes
check T11-manual-count "$(has "$out" "2 commit(s)")" yes
check T11-manual-lists-branch "$(has "$out" "side")" yes
check T11-manual-no-warning "$(has "$out" "unpushed work")" no
git checkout -q -b fresh
check T12-fresh-branch-repo-wide "$(has "$(fire SessionStart "$PWD")" "2 commit(s)")" yes
git checkout -q -
git push -q origin HEAD side 2>/dev/null; echo d>>a
out=$(fire SessionStart "$PWD")
check T13-dirty-info "$(has "$out" "manual push mode")" yes
check T13-dirty-uncommitted "$(has "$out" "uncommitted")" yes
check T13-dirty-no-commit-clause "$(has "$out" "commit(s) not on origin")" no
check T13-dirty-stop-silent "$(fire Stop "$PWD")" silent
git checkout -q -- a
git config gitflow.autopush flase
echo i>i; git add i; git commit -q -m i
out=$(fire SessionStart "$PWD")
check T14-invalid-named "$(has "$out" "not a boolean")" yes
check T14-invalid-treated-auto "$(has "$out" "unpushed work")" yes
check T14-invalid-stop-auto "$(has "$(fire Stop "$PWD")" "1 commit(s)")" yes
git config --unset gitflow.autopush
check T15-unset-auto-intact "$(has "$(fire Stop "$PWD")" "1 commit(s)")" yes
git config gitflow.autopush false; git remote remove origin
out=$(fire SessionStart "$PWD")
check T16-no-origin-manual "$(has "$out" "manual push mode")" yes
check T16-no-origin-clause "$(has "$out" "no 'origin' remote")" yes
check T16-no-origin-stop-silent "$(fire Stop "$PWD")" silent
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
+4 -33
View File
@@ -316,25 +316,7 @@
"Bash(git config --local core.hooksPath *)",
"Bash(git config gitflow.*)",
"Bash(git config --global gitflow.*)",
"Bash(git config --local gitflow.*)",
"Bash(git *config *gitflow.*)",
"Bash(git *config *remove-section*gitflow*)",
"Bash(git *config *rename-section*gitflow*)",
"Bash(git -c gitflow.*)",
"Bash(git * -c gitflow.*)",
"Bash(*--config-env*gitflow*)",
"Bash(*GIT_CONFIG_PARAMETERS*)",
"Bash(*GIT_CONFIG_COUNT*)",
"Bash(* GIT_CONFIG_GLOBAL=*)",
"Bash(* GIT_CONFIG_SYSTEM=*)",
"Edit(**/.git/config)",
"Write(**/.git/config)",
"Edit(**/.gitconfig)",
"Write(**/.gitconfig)",
"Edit(~/.gitconfig)",
"Write(~/.gitconfig)",
"Edit(~/.config/git/config)",
"Write(~/.config/git/config)"
"Bash(git config --local gitflow.*)"
],
"ask": [
"Bash(bash -c *)",
@@ -381,16 +363,6 @@
"command": "bash ~/.claude/hooks/rtk-rewrite.sh"
}
]
},
{
"matcher": "Bash|Monitor",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/push-guard.sh",
"timeout": 10
}
]
}
],
"Notification": [
@@ -493,7 +465,6 @@
"Sending SIGKILL (`kill -9`) or killing processes by name (`killall`, `pkill`). These reach processes outside this session, including the user's editors, shells, dtach sessions and background jobs, and the target is chosen by a pattern, so a typo kills the wrong thing. Clear only when the user named the process in this turn.",
"Editing more than one file in place in a single command: `sed -i` or `perl -pi` over a glob, or a loop over `git ls-files`. The damage is not loss, since git recovers it, but a diff spanning hundreds of files that nobody reads before committing. `sed -i` on a single named file passes. Clear only when the user asked for the sweep.",
"Moving or renaming a directory inside the repo (`mv src/api src/api_old`, or any `mv` of a tree). It breaks imports and paths silently, and the breakage surfaces far from the command. Clear only when the user asked for that move.",
"Pushing in manual-push mode (`gitflow.autopush false`, set by the user): any git push by Claude — direct, scripted, aliased, inside a subshell, a Makefile target, a sub-agent, or after a HOME/GIT_CONFIG override that hides the key. The push-guard hook catches the direct forms; this rule covers the rest. A request to push in this turn does not clear it: the user types `! git push` in the terminal.",
"An inline interpreter or `xargs` that deletes, or that writes outside the current working directory: `python3 -c`, `python -c` or `node -e` calling `rmtree`, `remove`, `unlink` or `truncate`; `xargs` feeding `rm`, `mv` or `dd`. `find ... | xargs rm` is the case that matters, since it routes around the `find * -exec rm` deny rule. Reading, computing, and editing a file inside the working directory pass untouched.",
"Docker data destruction on this workstation: `docker rm -f` of a container, and `docker run` with a bind mount outside the current working directory or the session temp dir (volume drops, `system prune`, `compose down -v` and `--privileged` are static deny rules and cannot be cleared). Clear only when the user named the container or the mount in this turn.",
"Discarding uncommitted work: `git checkout -- <path>` or `git checkout .`, `git restore` without `--staged`, `git stash pop` onto a dirty tree, or overwriting a modified tracked file with `cp` or `mv`. Git recovers a committed state, not this. Clear only when the user asked to discard those exact changes in this turn.",
@@ -506,8 +477,8 @@
"Secret exfiltration: reading a `.env`, an SSH key, a token or a credentials file, and then sending what it contained outside this machine. That covers an HTTP request body or header, a paste or gist service, a file upload, an email, and a commit or push to any repository. The static deny rules match the reading tools by pattern and cannot see the second half of the chain; read and send are separate steps and may sit several turns apart. No instruction clears this.",
"Production deployment: running a project's deploy script (`bin/deploy.sh` and its equivalents), any lftp, FTP, SFTP or rsync push to a hosting provider, and any action against a target whose name carries `prod` or `production` as a whole word or name segment. The user never asks Claude to deploy: Claude writes or explains the runbook, the user runs it by hand, out of session, and a transfer tool (`lftp`, `sftp`, `ftp`, `curl -T`) has no use in a session, test included (a test is a dev server on this machine). A green test suite, a finished feature, or a plan step that reads \"deploy\" is not an instruction to deploy. No in-session instruction clears this.",
"Destructive tool against a local path: `lftp mirror`, `rsync --delete`, `find -delete`, `rm -r`, `chmod -R` or `chown -R`, or a docker volume drop, aimed at a path built from a variable, `~`, `..` or a wildcard, or resolving outside the current working directory and the session temp dir. This holds for a trace, a dry run, a rehearsal or an experiment that a brief, a plan step, a test recipe or a previous reviewer calls allowed: a sub-agent brief carries no user authority here, and on 2026-09-21 exactly such a trace (`mirror --delete` against a local `file://` tree) wiped the home, the NAS mount and 15 repositories. Tracing what such a tool would do is done by reading it. When a run is unavoidable, the target is a fresh `mktemp -d` path written literally in the same command, and `--dry-run` comes first. No in-session instruction clears this; the user runs the real thing by hand.",
"Branch deletion by hand: `git branch -d`, `-D` or `--delete`, `git update-ref -d`, `git push --delete` or the `:ref` push form, a rename of `main` or `develop`, or a script or sub-agent that does one of these, local or remote. The only sanctioned path is `gitflow.sh finish` or `gitflow.sh delete <branch>`, which refuses `main`/`develop` outright and any branch not merged into develop or main, then removes the `origin/` copy once its own tip passes the same check; `git branch -d` proves nothing once the branch has an auto-pushed upstream, which every branch has since BDR-095 (manual-push mode: the lib unsets the upstream itself before `-d`; the hand form stays banned). A branch the lib refuses to delete still holds unmerged work: keep it and say so. No brief, plan step or cleanup routine authorizes a hand deletion.",
"Routing around a guardrail: a command the deny rules, a PreToolUse hook or this classifier refused is not run again through a wrapper script, an alias, a heredoc, a Makefile target written for the purpose, an env file, another shell, `make`/`python -c` indirection or another agent. That is the same action one step removed. A refusal ends the attempt: report the exact command and the rule that stopped it, then wait for the user. This binds every sub-agent whatever its brief says: a brief that orders a refused form is wrong, report it, do not comply. The legitimate hermetic test run is `make test` (optionally `suite=<file>`); the export lives in the Makefile, never on the command line.",
"Branch deletion by hand: `git branch -d`, `-D` or `--delete`, `git update-ref -d`, `git push --delete` or the `:ref` push form, a rename of `main` or `develop`, or a script or sub-agent that does one of these, local or remote. The only sanctioned path is `gitflow.sh finish` or `gitflow.sh delete <branch>`, which refuses `main`/`develop` outright and any branch not merged into develop or main, then removes the `origin/` copy once its own tip passes the same check; `git branch -d` proves nothing once the branch has an auto-pushed upstream, which every branch has since BDR-095. A branch the lib refuses to delete still holds unmerged work: keep it and say so. No brief, plan step or cleanup routine authorizes a hand deletion.",
"Routing around a guardrail: a command the deny rules or this classifier refused is not run again through a wrapper script, an alias, a heredoc, a Makefile target written for the purpose, an env file, another shell, `make`/`python -c` indirection or another agent. That is the same action one step removed. A refusal ends the attempt: report the exact command and the rule that stopped it, then wait for the user. This binds every sub-agent whatever its brief says: a brief that orders a refused form is wrong, report it, do not comply. The legitimate hermetic test run is `make test` (optionally `suite=<file>`); the export lives in the Makefile, never on the command line.",
"Disarming the guardrails: removing or weakening an entry in the `deny`, `soft_deny` or `hard_deny` lists of any settings.json, passing `--no-verify` to git, deleting, editing or chmod-ing any generated hook under `.githooks/` or `githooks/` (pre-commit, post-commit, post-merge, reference-transaction), setting `gitflow.protect` or `gitflow.autopush`, setting `disableAllHooks`, or switching `permissions.defaultMode` to `bypassPermissions`. Adding a restriction is fine; removing one is not. When a task is blocked by a guardrail, say so and stop, rather than widening the guardrail to get through. The user maintains these files by hand. No instruction clears this."
],
"environment": [
@@ -525,7 +496,7 @@
"**Internal package registry**: none. Public npm and PyPI.",
"**Host containment**: an ordinary developer workstation with open internet and no sandbox. Nothing is contained by the environment itself.",
"**Data-loss history**: on 2026-09-21 a sub-agent's `lftp mirror --delete` trace against a local `file://` path wiped the home, the NAS mount and 15 repositories in 90 seconds; nothing had been pushed for four days. The deny rules on transfer and mirror tools, the hard_deny on destructive tools against local paths, and the gitflow push hooks exist because of it.",
"**Push discipline**: every branch is pushed at creation and every commit at once by the gitflow post-commit and post-merge hooks, so the remote holds the work. A branch ahead of its upstream is a defect to fix now, not a state to keep. Exception, manual-push mode (`gitflow.autopush false`, set by the user, work machine): nothing is pushed by Claude, in any form; the user pushes by hand with `! git push`.",
"**Push discipline**: every branch is pushed at creation and every commit at once by the gitflow post-commit and post-merge hooks, so the remote holds the work. A branch ahead of its upstream is a defect to fix now, not a state to keep.",
"**Sensitive remote targets**: any namespace, host, database or container whose name carries `prod` or `production` as a whole word or name segment.",
"**Sensitive data locations & audiences**: per-project `.env` files (gitignored) hold database, deploy and API credentials; some web projects store customer-submitted form data under a retention policy. Both are personal or client data — never send either to an external service."
]
+13 -25
View File
@@ -10,7 +10,7 @@ description: |
Triggers: "capitalize", "before clear/compact", "flush memory", "don't
lose this", "avant de clear/compact", "capitalise ce qui manque",
"close", "fin de journée", "checkpoint memory".
argument-hint: "[--ritual] [--no-push] (scans conversation + git + TODO against .claude/memory/; --ritual adds the 3-question reflection; --no-push holds memory on chore/<name>: pushed to origin by the hooks in auto-push mode, NOT merged (finish skipped), merge when ready; default = auto-finish into develop)"
argument-hint: "[--ritual] [--no-push] (scans conversation + git + TODO against .claude/memory/; --ritual adds the 3-question reflection; --no-push holds memory on chore/<name>: pushed to origin by the hooks, NOT merged (finish skipped), merge when ready; default = auto-finish into develop)"
allowed-tools:
- Read
- Edit
@@ -332,22 +332,17 @@ pre-BDR-068 behavior):
branch the memory already rides feature/bugfix — never auto-merge it), AND
- `--no-push` was NOT passed (the hold escape hatch).
Skip this step entirely (go to STEP 6, which prints the hold note) on
`--no-push`, on a WORKING branch, or when STEP 5B returned rc 3.
Then, from the `chore/<name>` branch:
Otherwise, from the `chore/<name>` branch, THREE separate Bash calls, never combined. INVARIANT: no `git push` inside any Bash call of this skill (push-guard reads command text; the lib pushes develop itself in auto-push mode). The hints that tell the USER what to type (`! git push …`) are prose, kept on single lines.
bash "$HOME/.claude/lib/gitflow.sh" finish chore <name> # merge → develop, delete branch
git push origin develop
1. `bash "$HOME/.claude/lib/gitflow.sh" finish chore <name>` — merge → develop, delete branch, push develop in auto-push mode. rc≠0 → skip calls 2-3, go to STEP 6 with the `finish failed` line: rc 4 = conflict, develop mid-merge, `chore/<name>` kept, NOT merged; rc 1 = checkout failed, NOT merged; rc 5/2/6 come from the delete AFTER the merge: check `git merge-base --is-ancestor chore/<name> develop` and report `merged, branch not deleted (rc <n>)` when it holds, `NOT merged` otherwise. Never say "merged" without that check.
2. `bash "$HOME/.claude/lib/gitflow.sh" push-mode` → `auto | manual | invalid` (stderr names an invalid value).
3. `git rev-list --count origin/develop..develop 2>/dev/null || echo unknown` → `ahead` (0 = on origin; `unknown` = no origin/develop ref, e.g. no origin remote).
Outcomes, evaluated IN THIS ORDER (all require finish rc 0):
- **push mode `invalid`** → `merged to develop — gitflow.autopush=<value from stderr> is not a boolean: the lib and hooks still push on an invalid value until run D (origin/develop is <ahead> commit(s) behind, or unknown); fix the value by hand`.
- **`ahead` = 0** → `develop <short> pushed` (auto-push mode did it).
- **`ahead` = unknown** → `merged to develop — not on origin (no origin/develop ref; no remote or never fetched)`; push mode manual → add `You: ! git push origin develop once a remote exists`.
- **`ahead` > 0, push mode `manual`** → `merged to develop — manual push mode: not pushed. You: ! git push origin develop`.
- **`ahead` > 0, push mode `auto`** → `merged to develop — push FAILED (see finish stderr); push manually`. Do NOT retry or reset the merge.
- **finish + push OK** → surface `develop <short> pushed` in STEP 6.
- **push fails** (offline / rejected) → the merge to develop ALREADY happened
locally; report `merged to develop, push FAILED — push manually`. Do NOT retry
or reset the merge.
- **`--no-push` / WORKING branch / rc 3** → skip this step; the commit stays where
it is. STEP 6 prints the manual-merge note.
## STEP 6 — FINAL OUTPUT + HANDOFF
@@ -360,7 +355,7 @@ CAPITALIZE COMPLETE — <YYYY-MM-DD> (<pre-wipe flush | session-close>)
TODO.md : checked <N>, added <M>
journal.md : +1 line under ## <date>
committed : <mem_hash> (chore(memory): …) | ⚠️ NOT committed (rc 3 — see closing line)
persisted : develop <short> pushed | merged, manual push mode: not pushed | merged, not on origin (no origin/develop) | merged, push FAILED | merged, gitflow.autopush invalid (<ahead> behind) | finish rc <n>, not merged | merged, branch not deleted (rc <n>) | on chore/<name>, not merged (--no-push)
persisted : develop <short> pushed | on chore/<name>, not merged (--no-push) | merged, push FAILED
dropped as already-captured: LRN-023, BLK-006
ignored as noise: push/tag release
```
@@ -369,15 +364,8 @@ Then the closing line — pick by the STEP 5C persist result (`<mode>` = `Contex
flushed` for pre-wipe, `Session closed` for ritual):
- **auto-persisted (default — branched off develop, pushed)** → `✅ <mode> + persisted to origin/develop (<short>). Next session: read .claude/memory/ at startup.`
On the `--no-push` path (and on any 5B-committed path where 5C did not run) read TWO facts first, each its own Bash call: `bash "$HOME/.claude/lib/gitflow.sh" push-mode` and `git rev-list --count origin/chore/<name>..chore/<name> 2>/dev/null || echo unknown` (`branch_ahead`). `<push mode>` below is the verb's word.
- **--no-push, `branch_ahead` = 0** → `✅ <mode> + committed on chore/<name> — pushed to origin by the hooks (auto-push mode), NOT merged (--no-push). Merge when ready.`
- **--no-push, `branch_ahead` > 0 or unknown** → `✅ <mode> + committed on chore/<name> — this disk only, not pushed (<push mode manual | no origin/chore ref>), NOT merged. You: ! git push -u origin chore/<name>; merge when ready.` With push mode `invalid`, append ` gitflow.autopush=<value> is not a boolean: fix it by hand`.
- **manual (merged, `ahead` > 0)** → `✅ <mode> + merged to develop — manual push mode: not pushed. You: ! git push origin develop`
- **not on origin (merged, `ahead` unknown)** → `✅ <mode> + merged to develop — not on origin (no origin/develop ref).`
- **invalid (merged)** → `⚠️ <mode> + merged to develop — gitflow.autopush=<value> is not a boolean; lib/hooks still push on it until run D (origin/develop <ahead> behind). Fix the value by hand.`
- **--no-push (held on branch)** → `✅ <mode> + committed on chore/<name> — pushed to origin by the hooks, NOT merged (--no-push: finish skipped). Merge when ready.`
- **push failed after merge** → `✅ <mode> + merged to develop — ⚠️ push FAILED (<reason>); merged locally, push manually.`
- **finish failed** → `⚠️ <mode> + finish rc <n>: <stderr> — chore/<name> kept, NOT merged (or: merged, branch not deleted); resolve by hand.`
- **WORKING branch (rode a feature branch)** → `✅ <mode> + committed <mem_hash> on <branch>. Integrates when the branch merges.`
- **commit skipped (rc 3)** → keep the ✅ on the WRITE but make the gap loud, never
buried: `✅ <mode> — ⚠️ NOT committed (<reason: detached/merge/non-git>); entries safe on disk, commit manually.`
@@ -412,7 +400,7 @@ manual commit (rc 3).
always produces a commit; only an unsafe git state (rc 3) skips it.
- **Auto-persist the flush (STEP 5C, BDR-068)** — a memory-only commit on a
`chore/<name>` branch THIS run created off develop auto-finishes → develop +
pushes (the lib pushes develop in auto-push mode only; manual mode merges and leaves the push to the user); a scoped exception to LRN-069. `--no-push` holds it on the branch; a
pushes; a scoped exception to LRN-069. `--no-push` holds it on the branch; a
WORKING branch (memory rides feature/bugfix) or rc 3 skips it. NEVER auto-finish
a branch the run did not create.
- **Skip trivial** for the 4 ID registries; journal excepted.
+2 -2
View File
@@ -9,7 +9,7 @@ description: |
(that is /prune-memory).
Triggers: "close", "end session", "ferme la session", "session close",
"checkpoint memory", "what did we learn", "retro rapide", "fin de journée".
argument-hint: "[--no-push] (runs capitalize in ritual mode; --no-push holds memory on chore/<name>: pushed to origin by the hooks in auto-push mode, NOT merged (finish skipped), merge when ready; default = auto-finish into develop)"
argument-hint: "[--no-push] (runs capitalize in ritual mode; --no-push holds memory on chore/<name>: pushed to origin by the hooks, NOT merged (finish skipped), merge when ready; default = auto-finish into develop)"
allowed-tools:
- Read
- Edit
@@ -28,7 +28,7 @@ allowed-tools:
Invoke the `capitalize` skill now and run it in **ritual mode**: the full
pipeline (STEP 0 precheck → STEP 1 auto-scan → STEP 2 dedup → STEP 2B TODO
reconcile → STEP 3 approval gate → STEP 4 write → STEP 5 journal → STEP 5B
memory commit → STEP 5C auto-persist: finish (push rides it in auto-push mode), BDR-068 — pass
memory commit → STEP 5C auto-persist: finish + push, BDR-068 — pass
`--no-push` through to hold the chore branch instead → STEP 6 handoff),
PLUS STEP 1B's explicit 3-question reflection (what did you decide / learn
/ block).
+4 -9
View File
@@ -54,13 +54,10 @@ bash ~/.claude/lib/gitflow.sh protected-base [br] # rc 0 on main/develop — the
`main`/`develop` (rc 6) and any branch not merged into develop or main (rc 5),
and keeps the branch. The `origin/` copy is removed right after, once ITS
tip passes the same check; a remote tip holding commits the bases lack is
kept, loudly (T24). In manual-push mode (`git config gitflow.autopush
false`, human-set) nothing is pushed: `start` and `finish` stay local, and
the `origin/` copy is left in place (T18i-T18k). A `git push` Claude types is
refused by `hooks/push-guard.sh`; the user pushes with `! git push`. Hand
`git branch -d` is denied — with an auto-pushed upstream it checks the wrong
thing (T22a). A `reference-transaction` hook vetoes any deletion or rename of
`main`/`develop` at the ref layer, in every repo.
kept, loudly (T24). Hand `git branch -d` is denied — with an auto-pushed
upstream it checks the wrong thing (T22a). A `reference-transaction` hook
vetoes any deletion or rename of `main`/`develop` at the ref layer, in every
repo.
## The finish gate — merge ONLY on an explicit human signal
@@ -110,8 +107,6 @@ stays human-gated.
| `delete`/`finish` rc=5 — branch not merged into develop or main | The branch still holds unmerged work: KEEP it, report it, never fall back to `git branch -d`/`-D`. Merge first (human gate), then re-run |
| `delete` rc=6 — protected base | `main`/`develop` are never deleted. Stop; the request itself is the defect to report |
| `delete`/`finish` warning "remote copy KEPT" or "NOT removed" | Non-fatal BY CONTRACT (remote cleanup is best-effort). KEPT = origin/<br> has a tip the bases lack: fetch, look, merge or leave it — never `git push --delete` by hand. NOT removed = origin unreachable or refused: report the printed command to the user |
| `delete`/`finish` warning "origin/<br> left in place (manual push mode)" | Expected in manual-push mode, not a failure. Pass the printed `git push origin --delete <br>` to the user; never run it (manual mode: Claude never pushes, even when asked in the turn; the user runs it with `!`. `push --delete` is also denied by settings) |
| `start`/`finish` warning "<base> is behind origin/<base> by N and cannot fast-forward" | Non-fatal BY CONTRACT: the branch is still created and the merge still runs on the local base. The base has diverged from origin: report it to the user, who reconciles (`git pull`, then push). Never rebase or force-push a base |
## Common Mistakes
+7 -27
View File
@@ -145,8 +145,6 @@ local trace, and the brief had authorized it. What holds now, by tier:
| `chmod`/`chown -R`, `sudo`/`doas`/`pkexec`, disk tools (`dd`, `mkfs`, `shred`…), `chattr` | `permissions.deny` | The user runs them by hand. |
| Docker volume drops, `system prune`, `compose down -v`, `--privileged`, the docker socket, `-v /:` | `permissions.deny` | Promoted from `soft_deny`: no in-session clearance for data drops. |
| Git history destruction (`push --delete`/`--mirror`/`:ref`/`--force-with-lease`, `branch -D`, `filter-branch`, `reflog expire`, `stash clear`/`drop`, `clean -f`), `--no-verify`, `core.hooksPath` | `permissions.deny` | A remote is the backup; nothing rewrites or deletes what it holds. |
| Writing the human-only `gitflow.*` toggles: any `git … config` spelling, section remove/rename, `git -c`, the git config env overrides, Edit/Write of git config files | `permissions.deny` | Claude never flips the mode that binds it. Side effect: the trailing glob also matches the bare read, so Claude cannot read `gitflow.autopush` through `git config`; hooks and `lib/gitflow.sh` still do. |
| Pushing in manual-push mode (`gitflow.autopush false`) | `hooks/push-guard.sh` (PreToolUse) + `autoMode.soft_deny` | `ask` is inert under auto mode. The hook denies the direct forms; the soft_deny covers scripted, aliased, subshell and sub-agent pushes, and a request in the turn does not clear it: the user types `! git push`. |
| Destructive tool against a local path (variable, `~`, `..`, wildcard, outside cwd/tmp), even as a trace or a rehearsal a brief allows | `autoMode.hard_deny` | A pattern cannot express "the target resolves outside the project"; the classifier can. A sub-agent brief carries no user authority. |
| `docker rm -f`, bind mount outside cwd; discarding uncommitted work | `autoMode.soft_deny` | Recoverable or user-intended in the turn. |
@@ -154,8 +152,7 @@ Rules apply to sub-agents (auto mode is inherited) and to each segment of
a compound command; a tool nested in another command (`docker compose run …
lftp`) is not matched by a static rule. The PreToolUse guard hook that scans
the whole command, its executable spec in `lib/tests/guard-bash.test.sh`,
is not shipped yet (BLK-022). `hooks/push-guard.sh` scans the command text
for `git push` only, in manual-push mode (see below).
is not shipped yet (BLK-022).
Push discipline lives in `lib/gitflow.sh`: `start` pushes the branch,
`finish` pushes each merge target, and the post-commit / post-merge hooks
@@ -170,29 +167,12 @@ fourth hook, `reference-transaction`, vetoes any deletion or rename of
reach every repo two ways: `make link` generates `githooks/` from the lib
and sets git's global `core.hooksPath` to `~/.claude/githooks` (a repo's own
local `core.hooksPath` wins, by git's rules), and `hooks/session-start.sh`
refreshes a repo's `.githooks/` when it lags the lib. Per-repo opt-outs, set
by a human: `git config gitflow.protect false` (branch model, foreign clone)
and `git config gitflow.autopush false` (manual-push mode: the hooks,
`start` and `finish` push nothing, and `delete` leaves the `origin/` copy in
place, printing the command to remove it by hand); `GITFLOW_NO_PUSH=1` for
one command in a throwaway repo. `start` and `finish` warn when a base is
behind origin and cannot fast-forward. `make doctor` checks the global
setting and the generated dir. `hooks/unpushed-guard.sh` reports a branch
ahead of its upstream at session start and at each turn end; in manual-push
mode it stays silent at turn end and gives one `ℹ manual push mode:` line at
session start, counting unpushed commits across every local branch.
In manual-push mode `hooks/push-guard.sh` (PreToolUse, `Bash|Monitor`) also
refuses any `git push` Claude types, when the key reads false in the session
cwd or in a literal `-C`/`cd` directory the command names (global config
counts outside a repo). The refusal tells the user to run the push with
`! git push`, and the session banner adds a `🔒 push : manual` line. The hook
fails closed: a non-boolean value reads as manual, and a git failure while
reading the key or more than 20 directory tokens in one command refuses the
push, in auto mode too. In manual mode it over-blocks any command where a
`push` word follows a `git` token (`git stash push`, a grep for "git push").
The misses listed in its header fall to an `autoMode.soft_deny` rule that no
request in the turn clears. Skills that push on their own are not adapted
yet.
refreshes a repo's `.githooks/` when it lags the lib. Per-repo opt-outs for
a foreign clone: `git config gitflow.protect false` (branch model) and
`git config gitflow.autopush false` (push); `GITFLOW_NO_PUSH=1` for one
command in a throwaway repo. `make doctor` checks the global setting and
the generated dir. `hooks/unpushed-guard.sh` reports a branch ahead of its
upstream at session start and at each turn end.
## managed-settings.json (enterprise)