forge-orkes 0.75.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/bin/create-forge.js +27 -0
  2. package/experimental/m10/README.md +4 -4
  3. package/experimental/m10/install.sh +10 -9
  4. package/experimental/m10/source/hooks/protect-primary-checkout.sh +149 -0
  5. package/package.json +1 -1
  6. package/template/.claude/hooks/README.md +121 -0
  7. package/template/.claude/hooks/forge-state-rollup.sh +431 -0
  8. package/template/.claude/hooks/protect-primary-checkout.sh +149 -0
  9. package/template/.claude/hooks/tests/README.md +20 -0
  10. package/template/.claude/hooks/tests/forge-state-rollup.test.sh +282 -0
  11. package/template/.claude/hooks/tests/protect-primary-checkout.test.sh +147 -0
  12. package/template/.claude/settings.json +20 -0
  13. package/template/.claude/skills/chief-of-staff/SKILL.md +16 -11
  14. package/template/.claude/skills/forge/SKILL.md +11 -7
  15. package/template/.claude/skills/initializing/SKILL.md +20 -1
  16. package/template/.claude/skills/reviewing/SKILL.md +17 -0
  17. package/template/.claude/skills/upgrading/SKILL.md +22 -2
  18. package/template/.forge/FORGE.md +8 -5
  19. package/template/.forge/bin/new-worktree.sh +61 -0
  20. package/template/.forge/bin/tests/new-worktree.test.sh +70 -0
  21. package/template/.forge/git-hooks/pre-commit +57 -0
  22. package/template/.forge/git-hooks/tests/pre-commit.test.sh +97 -0
  23. package/template/.forge/migrations/0.77.0-state-rollup-executable.md +120 -0
  24. package/template/.forge/migrations/0.78.0-weld-primary-checkout.md +66 -0
  25. package/template/.forge/templates/state/desire-path.yml +3 -0
  26. package/experimental/m10/source/hooks/forge-branch-guard.sh +0 -61
@@ -0,0 +1,431 @@
1
+ #!/usr/bin/env sh
2
+ # forge-state-rollup — the boot rollup made mechanical (issue #22). Ports
3
+ # State Rollup 1.0 (index.yml ← state/milestone-*.yml) and the Stream Rollup
4
+ # (active.yml ← streams/*.yml ⋈ active milestones, ADR-015 addendum) from
5
+ # advisory skill prose into an executable, so a boot regenerates the derived
6
+ # registries deterministically instead of trusting an agent to run the prose
7
+ # (which a "the index is stale, work around it" project-memory habit can
8
+ # out-compete — the exact ptnrkit failure #22 was filed against).
9
+ #
10
+ # forge-state-rollup.sh [--project <path>] [--quiet]
11
+ # (SessionStart hook) reads {cwd, hook_event_name, source} from stdin JSON.
12
+ #
13
+ # --project <path> operate on the repo containing <path>. Default: the
14
+ # SessionStart payload's `cwd`, else the CWD. NEVER
15
+ # $CLAUDE_PROJECT_DIR — that resolves to the repo ROOT
16
+ # (main checkout) even in a worktree, which would
17
+ # render this worktree's caches into the wrong tree.
18
+ # --quiet suppress the stdout summary line (still writes caches).
19
+ #
20
+ # What it writes (both are git-ignored render-on-read caches since 0.53.0):
21
+ # .forge/state/index.yml — derived milestone registry
22
+ # .forge/streams/active.yml — derived stream registry (if streams/ exists)
23
+ # It NEVER commits them and NEVER runs git add/commit. Rendering a gitignored
24
+ # cache is a local write that never lands on main, so any checkout (main OR
25
+ # worktree) may render its own copy (FORGE.md → State Ownership, Derived class).
26
+ #
27
+ # The ONE write that is committed-source and therefore main-checkout-only is
28
+ # the Stream Rollup's step-0 completed-stream auto-close sweep (it edits
29
+ # streams/{stream}.yml). In a linked worktree that step is skipped; the caches
30
+ # still render. Detection: `git rev-parse --git-dir` == `--git-common-dir`.
31
+ #
32
+ # release_state is NOT re-derived here — the fold binary
33
+ # (forge-release-fold.sh) is its only source (FORGE.md → derived release_state).
34
+ # Fold missing / exit 2 for a unit → that row gets release_state: unknown and
35
+ # one advisory stderr line; a broken fold must never break boot.
36
+ #
37
+ # Contract (SessionStart, verified against Claude Code hooks docs):
38
+ # - Exit 0 ALWAYS. SessionStart is non-blocking; this hook must never abort
39
+ # a session. Every failure degrades to a no-op + an advisory stderr line.
40
+ # - stdout is injected into the model's context. We emit ONE terse summary
41
+ # line (unless --quiet) so the boot session sees the caches were freshly
42
+ # rendered by machinery — it then READS them, never regenerates by hand.
43
+ # - stderr carries advisories/diagnostics (shown in transcript, not context).
44
+ #
45
+ # Portable: POSIX sh + git + awk/sed (mirrors forge-release-fold.sh). jq is
46
+ # used only to parse the SessionStart stdin JSON and degrades to a no-stdin
47
+ # path when absent. No network calls (NFR-030).
48
+
49
+ set -u
50
+
51
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
52
+ FOLD="$SCRIPT_DIR/forge-release-fold.sh"
53
+
54
+ # --- argument / stdin parsing ------------------------------------------------
55
+
56
+ project=""
57
+ quiet=0
58
+
59
+ while [ $# -gt 0 ]; do
60
+ case "$1" in
61
+ --project) shift; project="${1:-}"; [ $# -gt 0 ] && shift || true ;;
62
+ --project=*) project="${1#*=}"; shift ;;
63
+ --quiet) quiet=1; shift ;;
64
+ -*) shift ;; # unknown flag — ignore, never fail a boot hook
65
+ *) shift ;;
66
+ esac
67
+ done
68
+
69
+ # SessionStart delivers {cwd, source, hook_event_name, ...} on stdin. When no
70
+ # --project was given, prefer the payload's cwd (the actual worktree the
71
+ # session started in) over the process CWD. Read stdin only if it is not a tty
72
+ # and jq is present; never block waiting for input that will not arrive.
73
+ stdin_cwd=""
74
+ if [ -z "$project" ] && [ ! -t 0 ] && command -v jq >/dev/null 2>&1; then
75
+ payload="$(cat 2>/dev/null || true)"
76
+ if [ -n "$payload" ]; then
77
+ stdin_cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty' 2>/dev/null || true)"
78
+ fi
79
+ fi
80
+ [ -z "$project" ] && project="${stdin_cwd:-$PWD}"
81
+
82
+ # --- repo resolution ---------------------------------------------------------
83
+ # Resolve from `project` (cwd), NOT $CLAUDE_PROJECT_DIR. Silent no-op when not
84
+ # in a git repo or the repo has no .forge/ — the common case for a non-Forge
85
+ # session, and the hook must be invisible there.
86
+
87
+ root="$(git -C "$project" rev-parse --show-toplevel 2>/dev/null)" || exit 0
88
+ [ -d "$root/.forge" ] || exit 0
89
+ [ -d "$root/.forge/state" ] || exit 0
90
+
91
+ # Main checkout vs linked worktree: --git-dir == --git-common-dir → main.
92
+ this_gitdir="$(git -C "$root" rev-parse --git-dir 2>/dev/null || true)"
93
+ common_gitdir="$(git -C "$root" rev-parse --git-common-dir 2>/dev/null || true)"
94
+ in_main_checkout=0
95
+ [ -n "$this_gitdir" ] && [ "$this_gitdir" = "$common_gitdir" ] && in_main_checkout=1
96
+
97
+ # --- YAML helpers (ported from forge-release-fold.sh, identical contract) -----
98
+
99
+ # yaml_get <content> <block> <key> — scalar value of a 2-space-indented key
100
+ # inside a top-level block. Strips trailing `# comments`. Prints raw value.
101
+ yaml_get() {
102
+ printf '%s\n' "$1" | awk -v blk="$2" -v key="$3" '
103
+ /^[^ \t]/ { inblk = (index($0, blk ":") == 1) }
104
+ inblk && index($0, " " key ":") == 1 {
105
+ val = substr($0, length(key) + 4)
106
+ sub(/[ \t]#.*$/, "", val)
107
+ if (val ~ /^[ \t]*#/) val = ""
108
+ gsub(/^[ \t]+/, "", val); gsub(/[ \t]+$/, "", val)
109
+ print val
110
+ exit
111
+ }
112
+ '
113
+ }
114
+
115
+ unquote() {
116
+ uq="$1"
117
+ uq="${uq#\"}"; uq="${uq%\"}"
118
+ uq="${uq#"'"}"; uq="${uq%"'"}"
119
+ printf '%s\n' "$uq"
120
+ }
121
+
122
+ yaml_scalar() { unquote "$(yaml_get "$1" "$2" "$3")"; }
123
+
124
+ # is_nullish <val> — true when empty, "null", or "~".
125
+ is_nullish() {
126
+ case "$1" in ''|null|'~') return 0 ;; *) return 1 ;; esac
127
+ }
128
+
129
+ # --- milestone Rollup 1.0 ----------------------------------------------------
130
+ # Deterministic + idempotent: output is a pure function of the milestone files,
131
+ # so two sessions rendering it produce identical bytes.
132
+
133
+ fold_unavailable=0 # count of rows that fell back to release_state: unknown
134
+
135
+ render_index() {
136
+ index_path="$root/.forge/state/index.yml"
137
+ rows="" # accumulated "id\tname\tstatus\tlast_updated\trelease_state" lines
138
+ n=0
139
+
140
+ for mfile in "$root"/.forge/state/milestone-*.yml; do
141
+ [ -f "$mfile" ] || continue
142
+ content="$(cat "$mfile")"
143
+
144
+ m_id="$(yaml_scalar "$content" milestone id)"
145
+ [ -z "$m_id" ] && continue # a file with no milestone.id is not a registry row
146
+ m_name="$(yaml_scalar "$content" milestone name)"
147
+ c_status="$(yaml_scalar "$content" current status)"
148
+
149
+ # last_updated: current.last_updated → legacy progress.last_update → null.
150
+ last_updated="$(yaml_scalar "$content" current last_updated)"
151
+ if is_nullish "$last_updated"; then
152
+ last_updated="$(yaml_scalar "$content" progress last_update)"
153
+ fi
154
+ is_nullish "$last_updated" && last_updated="null"
155
+
156
+ deferred_at="$(yaml_scalar "$content" lifecycle deferred_at)"
157
+ resumed_at="$(yaml_scalar "$content" lifecycle resumed_at)"
158
+
159
+ # Derive registry status (order matters — deferred wins unless resumed later).
160
+ reg_status="active"
161
+ if [ "$c_status" = "complete" ]; then
162
+ reg_status="complete"
163
+ elif [ "$c_status" = "not_started" ]; then
164
+ reg_status="not_started"
165
+ fi
166
+ if ! is_nullish "$deferred_at"; then
167
+ # deferred unless a later resumed_at supersedes it (string compare works
168
+ # for ISO 8601 / date strings; a resumed_at >= deferred_at un-defers).
169
+ if is_nullish "$resumed_at" || [ "$resumed_at" \< "$deferred_at" ]; then
170
+ reg_status="deferred"
171
+ fi
172
+ fi
173
+
174
+ # release_state — the fold is the ONLY source. Degrade to unknown, never
175
+ # re-derive. Fold unit id is the milestone id normalized to m-{N}.
176
+ fold_unit="$m_id"
177
+ case "$fold_unit" in m-*) ;; *) fold_unit="m-$m_id" ;; esac
178
+ rel="unknown"
179
+ if [ -x "$FOLD" ]; then
180
+ fold_out="$("$FOLD" "$fold_unit" --project "$root" 2>/dev/null)"; fold_rc=$?
181
+ if [ "$fold_rc" -eq 0 ]; then
182
+ rel="$(printf '%s\n' "$fold_out" | sed -n 's/^release_state=\([a-z]*\).*/\1/p' | head -1)"
183
+ [ -z "$rel" ] && rel="unknown"
184
+ fi
185
+ fi
186
+ if [ "$rel" = "unknown" ]; then
187
+ fold_unavailable=$((fold_unavailable + 1))
188
+ printf 'forge-state-rollup: release fold unavailable for %s — release_state: unknown\n' "$fold_unit" >&2
189
+ fi
190
+
191
+ rows="$rows$m_id $m_name $reg_status $last_updated $rel
192
+ "
193
+ n=$((n + 1))
194
+ done
195
+
196
+ # Sort rows by id (stable, deterministic). Then emit YAML.
197
+ sorted="$(printf '%s' "$rows" | LC_ALL=C sort -t' ' -k1,1)"
198
+
199
+ {
200
+ printf '# Forge Global State — Cross-Milestone Index (DERIVED — do not hand-edit)\n'
201
+ printf '# Regenerated at boot by .claude/hooks/forge-state-rollup.sh from\n'
202
+ printf '# state/milestone-*.yml (+ per-row release_state from forge-release-fold.sh).\n'
203
+ printf '# Git-ignored render-on-read cache (0.53.0) — never committed.\n'
204
+ printf 'milestones:\n'
205
+ if [ "$n" -eq 0 ]; then
206
+ printf ' []\n'
207
+ else
208
+ printf '%s\n' "$sorted" | while IFS=' ' read -r r_id r_name r_status r_lu r_rel; do
209
+ [ -z "$r_id" ] && continue
210
+ printf ' - id: %s\n' "$r_id"
211
+ printf ' name: %s\n' "$(yaml_quote "$r_name")"
212
+ printf ' status: %s\n' "$r_status"
213
+ if [ "$r_lu" = "null" ]; then
214
+ printf ' last_updated: null\n'
215
+ else
216
+ printf ' last_updated: %s\n' "$(yaml_quote "$r_lu")"
217
+ fi
218
+ printf ' release_state: %s\n' "$r_rel"
219
+ done
220
+ fi
221
+ } > "$index_path.tmp.$$" && mv -f "$index_path.tmp.$$" "$index_path"
222
+
223
+ INDEX_ROW_COUNT="$n"
224
+ }
225
+
226
+ # yaml_quote <val> — always double-quote a scalar for the rendered cache,
227
+ # escaping embedded backslashes and double quotes. Keeps names with `:`/`#`/
228
+ # unicode safe. Empty → "".
229
+ yaml_quote() {
230
+ qq="$1"
231
+ qq="$(printf '%s' "$qq" | sed 's/\\/\\\\/g; s/"/\\"/g')"
232
+ printf '"%s"' "$qq"
233
+ }
234
+
235
+ # --- Stream Rollup (ADR-015 addendum) ----------------------------------------
236
+
237
+ # norm_id <id> — canonical unit key: strip a single leading `m-` so a stream's
238
+ # `milestone: "m-27"` and a numeric milestone file id `27` compare equal (a
239
+ # real repo mixes both spellings). A labeled id (m-AUTO01) has no numeric twin,
240
+ # so stripping is harmless there — the key is just used for equality.
241
+ norm_id() { printf '%s' "${1#m-}"; }
242
+
243
+ # derived_milestone_status <id> — read the just-rendered index.yml row's status
244
+ # for a milestone id, matched on the normalized key. Empty when no index row.
245
+ derived_milestone_status() {
246
+ awk -v want="$(norm_id "$1")" '
247
+ /^ - id:/ { id=$3; gsub(/["\x27]/,"",id); sub(/^m-/,"",id) }
248
+ /^ status:/ { if (id==want) { print $2; exit } }
249
+ ' "$root/.forge/state/index.yml" 2>/dev/null
250
+ }
251
+
252
+ # stream_field <file> <block> <key> — thin wrapper over yaml_scalar on a file.
253
+ stream_field() { yaml_scalar "$(cat "$1")" "$2" "$3"; }
254
+
255
+ # worktree_is_live <path> — true when <path> resolves to a registered live
256
+ # git worktree of this repo. Empty/nullish path → not live.
257
+ worktree_is_live() {
258
+ wt="$1"
259
+ is_nullish "$wt" && return 1
260
+ git -C "$root" worktree list --porcelain 2>/dev/null \
261
+ | awk -v p="$wt" '/^worktree / { if ($2 == p) { found=1 } } END { exit found?0:1 }'
262
+ }
263
+
264
+ SWEEP_CLOSED="" # ids auto-closed this run (for the summary)
265
+
266
+ # step 0 — completed-stream auto-close sweep. Writes stream files (committed
267
+ # source) → MAIN CHECKOUT ONLY. A milestone-backed stream whose milestone is
268
+ # `complete`, that is not already `closed`, with no live worktree → auto-close.
269
+ stream_autoclose_sweep() {
270
+ [ "$in_main_checkout" -eq 1 ] || return 0
271
+ for sfile in "$root"/.forge/streams/*.yml; do
272
+ [ -f "$sfile" ] || continue
273
+ case "$(basename "$sfile")" in active.yml) continue ;; esac
274
+ s_status="$(stream_field "$sfile" stream status)"
275
+ [ "$s_status" = "closed" ] && continue
276
+ s_ms="$(stream_field "$sfile" stream milestone)"
277
+ is_nullish "$s_ms" && continue
278
+ m_status="$(derived_milestone_status "$s_ms")"
279
+ [ "$m_status" = "complete" ] || continue
280
+ # Guard: a deferred milestone is never swept (handled above by status
281
+ # check). A complete milestone WITH a live worktree is left for the
282
+ # forge-boot finalize path — do not sweep it here.
283
+ s_wt="$(stream_field "$sfile" runtime worktree)"
284
+ if worktree_is_live "$s_wt"; then continue; fi
285
+
286
+ # Auto-close: status → closed, merge.readiness → merged, append a note.
287
+ # Edit in place with awk (2-space-indented keys under their blocks).
288
+ awk '
289
+ /^[^ \t]/ { blk=$0; sub(/:.*/,"",blk) }
290
+ blk=="stream" && /^ status:/ { print " status: closed # auto-closed: milestone complete + on main (rollup sweep)"; next }
291
+ blk=="merge" && /^ readiness:/ { print " readiness: merged # auto-closed by rollup sweep"; next }
292
+ { print }
293
+ ' "$sfile" > "$sfile.tmp.$$" && mv -f "$sfile.tmp.$$" "$sfile"
294
+ SWEEP_CLOSED="$SWEEP_CLOSED $s_ms"
295
+ printf 'forge-state-rollup: auto-closed stale stream %s — milestone complete, already on main\n' "$s_ms" >&2
296
+ done
297
+ }
298
+
299
+ STREAM_ROW_COUNT=0
300
+
301
+ render_active() {
302
+ [ -d "$root/.forge/streams" ] || return 0
303
+ active_path="$root/.forge/streams/active.yml"
304
+
305
+ stream_rows="" # "id\tcoordination\tphase\tmilestone\tmerge_readiness"
306
+ merge_rows="" # stream ids with merge.readiness: ready
307
+ seen_ms=" " # milestones covered by a stream file (for implicit rows)
308
+ sn=0
309
+
310
+ for sfile in "$root"/.forge/streams/*.yml; do
311
+ [ -f "$sfile" ] || continue
312
+ case "$(basename "$sfile")" in active.yml) continue ;; esac
313
+ s_id="$(stream_field "$sfile" stream id)"
314
+ [ -z "$s_id" ] && s_id="$(basename "$sfile" .yml)"
315
+ s_coord="$(stream_field "$sfile" stream status)"
316
+ [ -z "$s_coord" ] && s_coord="unknown"
317
+ s_ms="$(stream_field "$sfile" stream milestone)"
318
+ s_merge="$(stream_field "$sfile" merge readiness)"
319
+ [ -z "$s_merge" ] && s_merge="not_ready"
320
+
321
+ # phase from the milestone (backed streams only) — never stored in stream.
322
+ s_phase="-"
323
+ if ! is_nullish "$s_ms"; then
324
+ seen_ms="$seen_ms$(norm_id "$s_ms") " # normalized key: m-27 and 27 collapse
325
+ mp="$root/.forge/state/milestone-${s_ms#m-}.yml"
326
+ [ -f "$mp" ] || mp="$root/.forge/state/milestone-${s_ms}.yml"
327
+ if [ -f "$mp" ]; then
328
+ s_phase="$(yaml_scalar "$(cat "$mp")" current status)"
329
+ [ -z "$s_phase" ] && s_phase="-"
330
+ fi
331
+ else
332
+ s_ms="null"
333
+ fi
334
+
335
+ stream_rows="$stream_rows$s_id $s_coord $s_phase $s_ms $s_merge
336
+ "
337
+ [ "$s_merge" = "ready" ] && merge_rows="$merge_rows$s_id
338
+ "
339
+ sn=$((sn + 1))
340
+ done
341
+
342
+ # implicit rows: every ACTIVE milestone (from the fresh index.yml) with no
343
+ # stream file → a coverage row so it can never be silently missing.
344
+ if [ -f "$root/.forge/state/index.yml" ]; then
345
+ active_ms="$(awk '
346
+ /^ - id:/ { id=$3; gsub(/["\x27]/,"",id) }
347
+ /^ status:/ { if ($2=="active") print id }
348
+ ' "$root/.forge/state/index.yml" 2>/dev/null)"
349
+ for am in $active_ms; do
350
+ case "$seen_ms" in *" $(norm_id "$am") "*) continue ;; esac
351
+ mp="$root/.forge/state/milestone-${am#m-}.yml"
352
+ [ -f "$mp" ] || mp="$root/.forge/state/milestone-${am}.yml"
353
+ ph="-"
354
+ [ -f "$mp" ] && ph="$(yaml_scalar "$(cat "$mp")" current status)"
355
+ [ -z "$ph" ] && ph="-"
356
+ stream_rows="$stream_rows$am implicit $ph $am not_ready
357
+ "
358
+ sn=$((sn + 1))
359
+ done
360
+ fi
361
+
362
+ sorted="$(printf '%s' "$stream_rows" | LC_ALL=C sort -t' ' -k1,1)"
363
+ msorted="$(printf '%s' "$merge_rows" | LC_ALL=C sort)"
364
+
365
+ {
366
+ printf '# Forge Stream Registry — active work (DERIVED — do not hand-edit)\n'
367
+ printf '# Regenerated at boot by .claude/hooks/forge-state-rollup.sh: streams/*.yml\n'
368
+ printf '# joined with active milestones (ADR-015). Git-ignored cache — never committed.\n'
369
+ printf 'version: 1\n'
370
+ printf 'streams:\n'
371
+ if [ "$sn" -eq 0 ]; then
372
+ printf ' []\n'
373
+ else
374
+ printf '%s\n' "$sorted" | while IFS=' ' read -r r_id r_coord r_phase r_ms r_merge; do
375
+ [ -z "$r_id" ] && continue
376
+ printf ' - id: %s\n' "$(yaml_quote "$r_id")"
377
+ printf ' coordination: %s\n' "$r_coord"
378
+ printf ' phase: %s\n' "$r_phase"
379
+ if [ "$r_ms" = "null" ]; then
380
+ printf ' milestone: null\n'
381
+ else
382
+ printf ' milestone: %s\n' "$(yaml_quote "$r_ms")"
383
+ fi
384
+ printf ' merge_readiness: %s\n' "$r_merge"
385
+ done
386
+ fi
387
+ printf 'merge_queue:\n'
388
+ if [ -z "$msorted" ]; then
389
+ printf ' []\n'
390
+ else
391
+ printf '%s\n' "$msorted" | while IFS= read -r mq; do
392
+ [ -z "$mq" ] && continue
393
+ printf ' - %s\n' "$(yaml_quote "$mq")"
394
+ done
395
+ fi
396
+ } > "$active_path.tmp.$$" && mv -f "$active_path.tmp.$$" "$active_path"
397
+
398
+ STREAM_ROW_COUNT="$sn"
399
+ }
400
+
401
+ # --- run ---------------------------------------------------------------------
402
+
403
+ render_index # index.yml first — stream rollup reads it
404
+
405
+ if [ -d "$root/.forge/streams" ]; then
406
+ stream_autoclose_sweep # step 0 (main checkout only)
407
+ render_active # then the join
408
+ fi
409
+
410
+ # --- summary (stdout → injected into boot context) ---------------------------
411
+
412
+ if [ "$quiet" -eq 0 ]; then
413
+ summary="[forge-state-rollup] rendered index.yml (${INDEX_ROW_COUNT:-0} milestone"
414
+ [ "${INDEX_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
415
+ summary="$summary)"
416
+ if [ -d "$root/.forge/streams" ]; then
417
+ summary="$summary + active.yml (${STREAM_ROW_COUNT:-0} stream row"
418
+ [ "${STREAM_ROW_COUNT:-0}" = "1" ] || summary="${summary}s"
419
+ summary="$summary)"
420
+ fi
421
+ [ -n "$SWEEP_CLOSED" ] && summary="$summary; auto-closed:${SWEEP_CLOSED}"
422
+ [ "${fold_unavailable:-0}" -gt 0 ] && summary="$summary; ${fold_unavailable} release_state unknown (fold unavailable)"
423
+ if [ "$in_main_checkout" -eq 1 ]; then
424
+ summary="$summary. Registries are fresh — READ them, do not regenerate by hand."
425
+ else
426
+ summary="$summary (worktree: caches rendered locally; auto-close sweep skipped). READ the caches, do not regenerate by hand."
427
+ fi
428
+ printf '%s\n' "$summary"
429
+ fi
430
+
431
+ exit 0
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env bash
2
+ # Weld the primary checkout to the default branch — deny branch create/switch there.
3
+ #
4
+ # PreToolUse(Bash) hook. Exit 2 (+ JSON permissionDecision:deny) = block. Exit 0 = allow.
5
+ #
6
+ # WHY: the primary checkout's HEAD is shared mutable state across every concurrent
7
+ # Claude session running in that directory. One session running `git checkout -b`
8
+ # silently redirects every OTHER session's subsequent commits onto its branch — an
9
+ # invisible clobber that surfaces later as wrong-branch commits, contaminated PRs,
10
+ # stranded milestone state, or dropped history (issue #21: 3 confirmed incidents —
11
+ # m85's 12-commit drop, m62's stranded phase commits, a Quick-tier checkout -b that
12
+ # contaminated a concurrent PR). Branch work belongs in worktrees, which each own
13
+ # their own HEAD.
14
+ #
15
+ # Scope: fires ONLY in the primary checkout (git-dir == git-common-dir). Linked
16
+ # worktrees are unaffected (their git-dir differs). Unlike the dormant
17
+ # forge-branch-guard.sh this supersedes, the branch weld is UNCONDITIONAL — it fires
18
+ # even when no sibling worktree exists yet, because the second session's worktree is
19
+ # typically created AFTER the offending `checkout -b`. This hook also folds in the
20
+ # one distinct protection the old guard provided: denying a hard-reset in the primary
21
+ # checkout while sibling worktrees are branched off it (the history-rewrite class).
22
+ #
23
+ # Default branch is resolved dynamically (main/master + init.defaultBranch), never
24
+ # hardcoded — Forge ships to repos on any default branch.
25
+ #
26
+ # Fails OPEN (exit 0 / allow) on any malfunction (no jq, empty command, non-git cwd,
27
+ # git probe error) — a guard must never wedge the agent on its own failure.
28
+ #
29
+ # Escape hatch (deliberate, human use): ALLOW_MAIN_BRANCHING=1
30
+
31
+ set -uo pipefail
32
+
33
+ emit_deny() {
34
+ local reason="${1//\"/\\\"}"
35
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$reason"
36
+ exit 2
37
+ }
38
+
39
+ [ "${ALLOW_MAIN_BRANCHING:-0}" = "1" ] && exit 0
40
+
41
+ command -v jq >/dev/null 2>&1 || exit 0
42
+
43
+ INPUT=$(cat)
44
+ COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)
45
+ [ -z "$COMMAND" ] && exit 0
46
+
47
+ # Normalize the cheapest whitespace-substitution evasion (`git checkout$IFS-b`) to a
48
+ # real space before matching. A string matcher on an un-expanded shell command can
49
+ # never be complete against expansion — the pre-commit backstop is the sound layer —
50
+ # but closing the trivial $IFS trick keeps the PreToolUse layer honest.
51
+ COMMAND=$(printf '%s' "$COMMAND" | sed -E 's/\$\{?IFS\}?/ /g')
52
+
53
+ # Git allows global options BEFORE the subcommand: `git -c k=v checkout`,
54
+ # `git --no-pager switch`, `git -C <path> -p checkout`, etc. The guard must tolerate
55
+ # ANY run of them, not just a single `-C <path>` — otherwise `git -c x=y checkout -b`
56
+ # sails past (issue #21 regression). GITPRE matches `git` + zero-or-more global options
57
+ # (a `-x`/`--long`/`--long=val`/`-C <path>`/`-c <kv>` token, each optionally taking the
58
+ # next word as its value) up to the subcommand.
59
+ GITPRE='git[[:space:]]+((-[A-Za-z]|--[A-Za-z-]+)(=[^[:space:]]+)?[[:space:]]+([^-][^[:space:]]*[[:space:]]+)?)*'
60
+
61
+ # Fast path: only inspect commands that touch checkout/switch/reset at all. Anything
62
+ # else exits immediately WITHOUT any git probe (keeps the hook cheap on every Bash call).
63
+ printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch|reset)([[:space:]]|\$)" || exit 0
64
+
65
+ # Resolve which repo dir the command targets: honor `git -C <path>`, else the hook cwd.
66
+ CWD=$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
67
+ [ -z "$CWD" ] && CWD=$(pwd)
68
+ # Honor a GLOBAL `git -C <path>` (before the subcommand) only. Isolate the pre-subcommand
69
+ # segment first so `switch -C`/`checkout -C` (create-force, AFTER the subcommand) is never
70
+ # mistaken for a target-dir option.
71
+ PRE=$(printf '%s' "$COMMAND" | sed -E 's/[[:space:]](checkout|switch|reset)[[:space:]].*$//')
72
+ GC_PATH=$(printf '%s' "$PRE" | grep -oE '(^|[[:space:]])-C[[:space:]]+[^[:space:]]+' | head -1 | awk '{print $2}' || true)
73
+ # A flag-shaped -C value (e.g. `-C --foo`) is not a real target dir → ignore it.
74
+ case "$GC_PATH" in -*) GC_PATH="" ;; esac
75
+ TARGET_DIR="${GC_PATH:-$CWD}"
76
+
77
+ GIT_DIR=$(git -C "$TARGET_DIR" rev-parse --git-dir 2>/dev/null) || exit 0
78
+ COMMON_DIR=$(git -C "$TARGET_DIR" rev-parse --git-common-dir 2>/dev/null) || exit 0
79
+ GIT_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$GIT_DIR" 2>/dev/null && pwd) || exit 0
80
+ COMMON_DIR_ABS=$(cd "$TARGET_DIR" 2>/dev/null && cd "$COMMON_DIR" 2>/dev/null && pwd) || exit 0
81
+
82
+ # In a linked worktree git-dir != git-common-dir → branch ops are fine there.
83
+ [ "$GIT_DIR_ABS" != "$COMMON_DIR_ABS" ] && exit 0
84
+
85
+ # ── Default-branch set (dynamic — mirrors block-dangerous-commands.sh) ───────────
86
+ # Seed main+master, add the configured init.defaultBranch. A switch/checkout TO any
87
+ # of these names is "returning home" and always allowed.
88
+ DEFAULT_BRANCHES="main master"
89
+ GIT_DEFAULT=$(git -C "$TARGET_DIR" config --get init.defaultBranch 2>/dev/null || true)
90
+ [ -n "$GIT_DEFAULT" ] && DEFAULT_BRANCHES="$DEFAULT_BRANCHES $GIT_DEFAULT"
91
+ is_default_branch() {
92
+ for b in $DEFAULT_BRANCHES; do [ "$1" = "$b" ] && return 0; done
93
+ return 1
94
+ }
95
+
96
+ DENY_MSG="Blocked: the primary checkout's HEAD stays on the default branch — branch create/switch here silently redirects every other concurrent session's commits onto your branch (recurring clobber, issue #21). Do branch work in a worktree: bash .forge/bin/new-worktree.sh <slug> (or git worktree add). To operate on an existing worktree from here, use git -C <worktree-path>. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
97
+
98
+ # ── 1) Branch creation / orphan / detach / track: always deny in primary checkout ──
99
+ if printf '%s' "$COMMAND" | grep -qE "${GITPRE}checkout[[:space:]]+((-[a-zA-Z]*[bB][a-zA-Z]*|--orphan|--detach|--track)([[:space:]=]|\$))"; then
100
+ emit_deny "$DENY_MSG"
101
+ fi
102
+ if printf '%s' "$COMMAND" | grep -qE "${GITPRE}switch[[:space:]]+((-[cC]|--create|--force-create|--orphan|--detach|--track)([[:space:]=]|\$))"; then
103
+ emit_deny "$DENY_MSG"
104
+ fi
105
+
106
+ # ── 2) hard/keep/merge reset in the primary checkout while worktrees are branched ──
107
+ # Folded from the superseded forge-branch-guard.sh: a reset that rewrites HEAD +
108
+ # working tree here can drop verified work committed under sibling worktrees. Gated
109
+ # on >1 worktree (unlike the branch weld, which is unconditional).
110
+ if printf '%s' "$COMMAND" | grep -qE "${GITPRE}reset[[:space:]]+(--hard|--keep|--merge)([[:space:]]|\$)"; then
111
+ wt_count=$(git -C "$TARGET_DIR" worktree list 2>/dev/null | wc -l | tr -d ' ')
112
+ if [ "${wt_count:-1}" -gt 1 ]; then
113
+ emit_deny "Refusing to move the shared primary HEAD via reset — $wt_count worktrees are branched off it (the 'burned twice' history-rewrite failure, issue #21). Reset inside a worktree, or stop the other sessions first. Human escape hatch: ALLOW_MAIN_BRANCHING=1."
114
+ fi
115
+ exit 0 # single-worktree hard-reset is the operator's own business — allow.
116
+ fi
117
+
118
+ # ── 3) checkout/switch to a ref other than the default branch ───────────────────
119
+ # Only checkout/switch move HEAD to a ref. A reset that reached this far (--soft /
120
+ # --mixed, or a hard/keep/merge reset in a single-worktree repo) is not a branch move
121
+ # off the default → allow it and stop.
122
+ printf '%s' "$COMMAND" | grep -qE "${GITPRE}(checkout|switch)([[:space:]]|\$)" || exit 0
123
+
124
+ # `git checkout <ref> -- <paths>` and `git checkout -- <paths>` never move HEAD →
125
+ # allow anything containing a ` -- ` pathspec separator.
126
+ printf '%s' "$COMMAND" | grep -qE '[[:space:]]--([[:space:]]|$)' && exit 0
127
+
128
+ # Extract the first non-flag argument after checkout/switch. Strip everything up to and
129
+ # including the subcommand keyword — whatever git global options preceded it (`-c`,
130
+ # `--no-pager`, `-C <path>`, …) are consumed by the greedy prefix. The trailing separator
131
+ # is optional so a BARE `git checkout` (no target) yields empty ARGS → allowed below.
132
+ ARGS=$(printf '%s' "$COMMAND" | sed -E 's/.*[[:space:]](checkout|switch)([[:space:]]+|$)/ /' | head -1)
133
+ FIRST_REF=""
134
+ for word in $ARGS; do
135
+ case "$word" in
136
+ -*) continue ;; # flags (creation flags already denied above)
137
+ \;|\&\&|\|\||\||\&) break ;; # end of this git command
138
+ *) FIRST_REF="$word"; break ;;
139
+ esac
140
+ done
141
+
142
+ # Bare `git checkout`/`git switch` (no target) is a no-op/error → allow.
143
+ [ -z "$FIRST_REF" ] && exit 0
144
+
145
+ # Returning home (default branch) or file-restore forms → allow. Any other ref → deny.
146
+ case "$FIRST_REF" in
147
+ HEAD|.) exit 0 ;; # file restore forms
148
+ *) is_default_branch "$FIRST_REF" && exit 0 || emit_deny "$DENY_MSG" ;;
149
+ esac
@@ -71,6 +71,26 @@ For a `deny`/`allow` style hook, every case file should include:
71
71
  - At least one **allow** that sits right next to a deny path (e.g. `git push origin main` denies, `git push origin feature/foo` allows) — proves the pattern is specific, not blanket.
72
72
  - One **no-op** case where the hook input does not match the shape the hook expects (e.g. `file_path` missing, `command` empty) — proves the hook fails open for irrelevant events.
73
73
 
74
+ ## Standalone git-fixture tests
75
+
76
+ Some hooks need a real git repository (tracked files, merges, worktrees) rather
77
+ than a single stdin payload, so they ship self-contained `*.test.sh` scripts run
78
+ directly instead of through `run.sh`:
79
+
80
+ ```bash
81
+ .claude/hooks/tests/forge-state-rollup.test.sh # boot rollup (index.yml + active.yml)
82
+ .claude/hooks/tests/forge-release-fold.test.sh # release_state fold
83
+ .claude/hooks/tests/forge-reserve.test.sh # id reservation
84
+ .claude/hooks/tests/verify-signoff.test.sh # signed-tag verification
85
+ .claude/hooks/tests/wu-done.test.sh # done predicate
86
+ ```
87
+
88
+ Each builds throwaway repos in `mktemp` dirs with no remote and isolated git
89
+ config. `forge-state-rollup.test.sh` covers the #22 acceptance shape (a stale
90
+ cache is regenerated regardless), fold-degrade → `release_state: unknown`, the
91
+ main-only auto-close sweep, the linked-worktree guard (sweep skipped, caches
92
+ still rendered), and the SessionStart stdin path.
93
+
74
94
  ## Why this lives here
75
95
 
76
96
  Hooks run on every tool call the agent makes. If they regress silently, either the agent starts bypassing real protections, or innocent edits start getting blocked and the hooks get disabled in frustration. A small test harness catches both before they land.