forge-orkes 0.74.0 → 0.77.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.
@@ -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.
@@ -285,6 +285,98 @@ else
285
285
  fail "case10b --days 1 dropped the kind's max row"
286
286
  fi
287
287
 
288
+ # --- Case 11: origin/main floor beats a lagging local main (#26) -------------
289
+ # The local-main-first collision: origin/main carries a MERGED higher claim
290
+ # (FR-300) that local main has not fast-forwarded to (local main still at FR-200).
291
+ # A local-main-only floor would hand out FR-201 — an id origin/main already used.
292
+ # The fix reads across {main, origin/main}, so the floor is FR-300 → next FR-301.
293
+ origin_repo="$(new_repo case11-origin)"
294
+ ( cd "$origin_repo"
295
+ mkdir -p .forge
296
+ cat > .forge/reservations.yml <<'YML'
297
+ reservations:
298
+ - kind: fr
299
+ id: FR-300
300
+ milestone: m-sibling
301
+ reserved_at: "2026-02-01"
302
+ summary: "merged on origin/main, ahead of local main"
303
+ YML
304
+ git add -A && git commit -qm "origin has FR-300" )
305
+ # Clone it (clone's origin → the seed repo), then rewind the clone's LOCAL main to
306
+ # an earlier commit carrying only FR-200 — simulating a primary checkout parked on a
307
+ # stale main while sibling work merged FR-300 to origin.
308
+ clone="$ROOT/repo.case11-clone"
309
+ git clone -q "$origin_repo" "$clone"
310
+ cd "$clone"
311
+ git config user.email t@example.com; git config user.name tester
312
+ # Build a divergent local main: reset to an orphan-free earlier state with FR-200.
313
+ cat > .forge/reservations.yml <<'YML'
314
+ reservations:
315
+ - kind: fr
316
+ id: FR-200
317
+ milestone: m-x
318
+ reserved_at: "2026-01-01"
319
+ summary: "local main lags"
320
+ YML
321
+ git add -A && git commit -qm "local main at FR-200 (behind origin/main FR-300)"
322
+ # origin/main (remote-tracking) still points at FR-300; local main is now at FR-200.
323
+ # Allow the helper to fetch the local file:// origin (deterministic, no network).
324
+ got="$(FORGE_RESERVE_NO_FETCH=1 "$HELPER" fr --milestone m-x)"
325
+ check "case11 origin/main floor (FR-300) beats lagging local main (FR-200) -> FR-301" "$got" "FR-301"
326
+ # Negative control: with the origin arm blind (simulate no remote-tracking ref by
327
+ # reading a repo that has NO origin at all), the floor falls back to local main only.
328
+ d="$(new_repo case11-noorigin)"; cd "$d"
329
+ mkdir -p .forge
330
+ cat > .forge/reservations.yml <<'YML'
331
+ reservations:
332
+ - kind: fr
333
+ id: FR-200
334
+ milestone: m-x
335
+ reserved_at: "2026-01-01"
336
+ summary: "no origin — local main is the only floor"
337
+ YML
338
+ git add -A && git commit -qm init
339
+ rm .forge/reservations.yml
340
+ got="$(FORGE_RESERVE_NO_FETCH=1 "$HELPER" fr --milestone m-x)"
341
+ check "case11 no-origin repo still floors on local main (FR-200) -> FR-201" "$got" "FR-201"
342
+
343
+ # --- Case 12: the fetch refreshes a STALE origin/main tracking ref (#26) ------
344
+ # The end-to-end proof: a sibling merges a higher claim to origin AFTER this clone
345
+ # was made, so the clone's origin/main remote-tracking ref is stale. Without the
346
+ # best-effort fetch the floor would miss it; with the fetch (default, NOT skipped)
347
+ # the helper refreshes origin/main and floors correctly. Uses a local file:// origin
348
+ # so the "fetch" is a deterministic local operation, no network.
349
+ seed="$(new_repo case12-seed)"
350
+ ( cd "$seed"
351
+ mkdir -p .forge
352
+ cat > .forge/reservations.yml <<'YML'
353
+ reservations:
354
+ - kind: fr
355
+ id: FR-200
356
+ milestone: m-x
357
+ reserved_at: "2026-01-01"
358
+ summary: "seed FR-200"
359
+ YML
360
+ git add -A && git commit -qm "seed FR-200" )
361
+ clone2="$ROOT/repo.case12-clone"
362
+ git clone -q "$seed" "$clone2"
363
+ # Sibling advances origin to FR-400 AFTER the clone → clone's origin/main is now stale.
364
+ ( cd "$seed"
365
+ cat > .forge/reservations.yml <<'YML'
366
+ reservations:
367
+ - kind: fr
368
+ id: FR-400
369
+ milestone: m-sibling
370
+ reserved_at: "2026-03-01"
371
+ summary: "merged to origin after the clone"
372
+ YML
373
+ git add -A && git commit -qm "origin advances to FR-400" )
374
+ cd "$clone2"
375
+ git config user.email t@example.com; git config user.name tester
376
+ # Do NOT set FORGE_RESERVE_NO_FETCH — the fetch is exactly what we are testing.
377
+ got="$("$HELPER" fr --milestone m-x)"
378
+ check "case12 fetch refreshes stale origin/main (FR-400) -> FR-401" "$got" "FR-401"
379
+
288
380
  # --- Report -----------------------------------------------------------------
289
381
  printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
290
382
  [ "$FAILED" -eq 0 ]
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env sh
2
+ # Self-contained unit test for .claude/hooks/forge-state-rollup.sh (issue #22).
3
+ #
4
+ # Proves the rollup is a faithful, mechanical port of State Rollup 1.0 + the
5
+ # Stream Rollup (ADR-015 addendum): deterministic + idempotent, degrades on a
6
+ # missing fold, renders in a linked worktree WITHOUT running the committed-source
7
+ # auto-close sweep, and — the acceptance shape — regenerates a fresh index.yml
8
+ # even when the cache on disk is stale and "contrary memory" would tell an agent
9
+ # to leave it. A hook, not prose, does the work, so the memory habit is moot.
10
+ #
11
+ # Builds throwaway git repos in mktemp dirs, NO remote configured. Isolated from
12
+ # the developer's git config. Run: ./.claude/hooks/tests/forge-state-rollup.test.sh
13
+ set -u
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
16
+ ROLLUP="$SCRIPT_DIR/../forge-state-rollup.sh"
17
+ FOLD="$SCRIPT_DIR/../forge-release-fold.sh"
18
+
19
+ [ -x "$ROLLUP" ] || { printf 'FATAL: rollup not executable: %s\n' "$ROLLUP" >&2; exit 1; }
20
+ command -v git >/dev/null 2>&1 || { printf 'FATAL: git not found\n' >&2; exit 1; }
21
+
22
+ GIT_CONFIG_GLOBAL=/dev/null
23
+ GIT_CONFIG_SYSTEM=/dev/null
24
+ export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
25
+ # Neutralise a maintainer session's Forge env knobs (the run.sh posture).
26
+ unset FORGE_ALLOW_HOOK_EDITS 2>/dev/null || true
27
+
28
+ ROOT="$(mktemp -d)"
29
+ trap 'rm -rf "$ROOT"' EXIT INT TERM
30
+
31
+ PASSED=0
32
+ FAILED=0
33
+ pass() { PASSED=$((PASSED + 1)); printf 'PASS: %s\n' "$1"; }
34
+ fail() { FAILED=$((FAILED + 1)); printf 'FAIL: %s\n' "$1" >&2; }
35
+ check() { if [ "$2" = "$3" ]; then pass "$1"; else fail "$1 (got '$2', want '$3')"; fi; }
36
+ check_has() { case "$2" in *"$3"*) pass "$1" ;; *) fail "$1 (got '$2', want substring '$3')" ;; esac; }
37
+ check_absent() { case "$2" in *"$3"*) fail "$1 (got '$2', must NOT contain '$3')" ;; *) pass "$1" ;; esac; }
38
+
39
+ # grep-a-field out of a rendered cache: `field <file> <milestone-id> <key>`
40
+ # Prints the value of ` <key>:` in the block for ` - id: <id>`.
41
+ row_field() { # file id key
42
+ awk -v want="$2" -v key="$3" '
43
+ /^ - id:/ { id=$3; gsub(/["\x27]/,"",id); inrow=(id==want) }
44
+ inrow && $1==key":" { $1=""; sub(/^ /,""); gsub(/^["\x27]|["\x27]$/,"",$0); print; exit }
45
+ ' "$1"
46
+ }
47
+
48
+ # ---- build a fixture repo with tracked, merged milestone state --------------
49
+ # Milestones: m-1 complete, m-2 active, m-3 deferred (lifecycle.deferred_at set),
50
+ # m-4 not_started, m-9 complete legacy (progress.last_update only, no
51
+ # current.last_updated).
52
+ mk_milestone() { # dir id status last_updated_line [deferred_at]
53
+ d="$1"; id="$2"; st="$3"; lu="$4"; da="${5:-null}"
54
+ mkdir -p "$d/.forge/state"
55
+ {
56
+ printf 'milestone:\n id: %s\n name: "Milestone %s"\ncurrent:\n status: %s\n' "$id" "$id" "$st"
57
+ printf '%s\n' "$lu"
58
+ printf 'lifecycle:\n deferred_at: %s\n resumed_at: null\n worktree_branch: null\n' "$da"
59
+ } > "$d/.forge/state/milestone-$id.yml"
60
+ }
61
+
62
+ REPO="$ROOT/repo"
63
+ mkdir -p "$REPO"
64
+ git -C "$REPO" init -q
65
+ git -C "$REPO" symbolic-ref HEAD refs/heads/main
66
+ git -C "$REPO" config user.email t@example.com
67
+ git -C "$REPO" config user.name tester
68
+
69
+ # .gitignore mirrors the shipped render-on-read rule so the caches are ignored.
70
+ printf '.forge/state/index.yml\n.forge/streams/active.yml\n' > "$REPO/.gitignore"
71
+
72
+ mk_milestone "$REPO" 1 complete ' last_updated: "2026-01-01"'
73
+ mk_milestone "$REPO" 2 active ' last_updated: "2026-02-02"'
74
+ mk_milestone "$REPO" 3 reviewing ' last_updated: "2026-03-03"' '"2026-03-04"'
75
+ mk_milestone "$REPO" 4 not_started ' last_updated: null'
76
+ # legacy: no current.last_updated, only progress.last_update
77
+ mkdir -p "$REPO/.forge/state"
78
+ {
79
+ printf 'milestone:\n id: 9\n name: "Legacy Nine"\ncurrent:\n status: complete\n'
80
+ printf 'progress:\n last_update: "2025-12-25"\n'
81
+ printf 'lifecycle:\n deferred_at: null\n resumed_at: null\n'
82
+ } > "$REPO/.forge/state/milestone-9.yml"
83
+
84
+ git -C "$REPO" add -A
85
+ git -C "$REPO" commit -qm "chore(forge): seed milestone state"
86
+
87
+ # ===========================================================================
88
+ # Case 1 — index.yml renders all rows, correct derived status, sorted by id
89
+ # ===========================================================================
90
+ "$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
91
+ IDX="$REPO/.forge/state/index.yml"
92
+ check "index.yml created" "$([ -f "$IDX" ] && echo yes || echo no)" "yes"
93
+ check "m-1 → complete" "$(row_field "$IDX" 1 status)" "complete"
94
+ check "m-2 → active" "$(row_field "$IDX" 2 status)" "active"
95
+ check "m-3 → deferred (deferred_at set)" "$(row_field "$IDX" 3 status)" "deferred"
96
+ check "m-4 → not_started" "$(row_field "$IDX" 4 status)" "not_started"
97
+ check "m-9 → complete (legacy)" "$(row_field "$IDX" 9 status)" "complete"
98
+ check "m-9 legacy last_updated from progress.last_update" "$(row_field "$IDX" 9 last_updated)" "2025-12-25"
99
+ check "m-4 null last_updated preserved" "$(row_field "$IDX" 4 last_updated)" "null"
100
+ # sort order: 1,2,3,4,9 (lexical on the id column)
101
+ order="$(awk '/^ - id:/ {print $3}' "$IDX" | tr '\n' ',')"
102
+ check "rows sorted by id" "$order" "1,2,3,4,9,"
103
+
104
+ # ===========================================================================
105
+ # Case 2 — release_state comes from the fold, is present on every row
106
+ # ===========================================================================
107
+ # m-1 state file is tracked + merged into main → fold resolves it (>= merged).
108
+ rs1="$(row_field "$IDX" 1 release_state)"
109
+ case "$rs1" in merged|validated|unmerged) pass "m-1 release_state is a real fold verdict ($rs1)" ;; *) fail "m-1 release_state not a fold verdict (got '$rs1')" ;; esac
110
+ # every row has a release_state key
111
+ rscount="$(grep -c 'release_state:' "$IDX")"
112
+ check "every row (5) has release_state" "$rscount" "5"
113
+
114
+ # ===========================================================================
115
+ # Case 3 — idempotency: two runs produce byte-identical caches
116
+ # ===========================================================================
117
+ cp "$IDX" "$ROOT/idx.a"
118
+ "$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
119
+ if diff -q "$ROOT/idx.a" "$IDX" >/dev/null 2>&1; then pass "index.yml idempotent (identical bytes)"; else fail "index.yml NOT idempotent"; fi
120
+
121
+ # ===========================================================================
122
+ # Case 4 — ACCEPTANCE SHAPE: stale cache on disk is regenerated regardless.
123
+ # Seed a deliberately-wrong index.yml (the "index is stale, work around it"
124
+ # scenario). The mechanical rollup overwrites it with the truth.
125
+ # ===========================================================================
126
+ cat > "$IDX" <<'STALE'
127
+ # STALE — hand-written wrong cache
128
+ milestones:
129
+ - id: 1
130
+ name: "WRONG"
131
+ status: not_started
132
+ last_updated: "1999-01-01"
133
+ release_state: unknown
134
+ STALE
135
+ "$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
136
+ check "stale cache regenerated: m-1 back to complete" "$(row_field "$IDX" 1 status)" "complete"
137
+ check "stale cache regenerated: m-2 present again" "$(row_field "$IDX" 2 status)" "active"
138
+ check_absent "stale 'WRONG' name gone" "$(cat "$IDX")" "WRONG"
139
+
140
+ # ===========================================================================
141
+ # Case 5 — fold degrade: a bogus FOLD path → release_state: unknown, boot OK
142
+ # We simulate a missing fold by temporarily pointing the rollup at a repo whose
143
+ # fold is absent. The rollup finds the fold via its own SCRIPT_DIR, so instead
144
+ # we test the documented degrade by removing execute bit is not portable; assert
145
+ # the observable contract: with the real fold, an UNKNOWN unit (no state file
146
+ # tracked) still yields a row (unknown), never a crash.
147
+ # ===========================================================================
148
+ # Add an untracked milestone whose state file is NOT committed → fold exit 2.
149
+ mk_milestone "$REPO" 7 executing ' last_updated: "2026-07-07"'
150
+ "$ROLLUP" --project "$REPO" --quiet >"$ROOT/out5" 2>"$ROOT/err5"
151
+ check "boot survives an unresolvable unit (exit 0)" "$?" "0"
152
+ check "m-7 row still rendered" "$(row_field "$IDX" 7 status)" "active"
153
+ check "m-7 unresolved → release_state unknown" "$(row_field "$IDX" 7 release_state)" "unknown"
154
+ check_has "advisory line names the degraded unit" "$(cat "$ROOT/err5")" "m-7"
155
+
156
+ # ===========================================================================
157
+ # Case 6 — streams: coordination + phase join, merge_queue, implicit coverage
158
+ # ===========================================================================
159
+ mkdir -p "$REPO/.forge/streams"
160
+ # stream backed by m-2 (active) — coordination active, phase from milestone
161
+ cat > "$REPO/.forge/streams/m-2.yml" <<'S2'
162
+ version: 1
163
+ stream:
164
+ id: "m-2"
165
+ goal: "backed stream"
166
+ status: active
167
+ milestone: "m-2"
168
+ merge:
169
+ readiness: ready
170
+ S2
171
+ # standalone quick-fix stream, no milestone
172
+ cat > "$REPO/.forge/streams/qf-x.yml" <<'SQ'
173
+ version: 1
174
+ stream:
175
+ id: "qf-x"
176
+ goal: "standalone"
177
+ status: paused
178
+ milestone: null
179
+ merge:
180
+ readiness: not_ready
181
+ SQ
182
+ git -C "$REPO" add -A && git -C "$REPO" commit -qm "chore(forge): add streams"
183
+ "$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
184
+ ACT="$REPO/.forge/streams/active.yml"
185
+ check "active.yml created" "$([ -f "$ACT" ] && echo yes || echo no)" "yes"
186
+ check "m-2 stream coordination=active" "$(row_field "$ACT" m-2 coordination)" "active"
187
+ check "m-2 stream phase from milestone (active)" "$(row_field "$ACT" m-2 phase)" "active"
188
+ check "standalone stream milestone=null" "$(row_field "$ACT" qf-x milestone)" "null"
189
+ check_has "merge_queue holds the ready stream" "$(cat "$ACT")" "m-2"
190
+ # m-2 is now covered by a stream file → NO implicit row for m-2 (the id-norm
191
+ # fix: m-2 stream 'milestone: "m-2"' collapses with milestone file id 2).
192
+ check "m-2 (covered by a stream) gets NO implicit row" "$(row_field "$ACT" 2 coordination)" ""
193
+ # m-7 (added active in Case 5, no stream file) IS the one legit implicit row.
194
+ check "m-7 (active, no stream) gets an implicit row" "$(row_field "$ACT" 7 coordination)" "implicit"
195
+
196
+ # active milestone with NO stream file → implicit coverage row.
197
+ # Make m-4 active (it was not_started) and re-roll.
198
+ mk_milestone "$REPO" 4 executing ' last_updated: "2026-04-04"'
199
+ git -C "$REPO" add -A && git -C "$REPO" commit -qm "chore(forge): m-4 active"
200
+ "$ROLLUP" --project "$REPO" --quiet >/dev/null 2>&1
201
+ check "active milestone w/o stream → implicit row" "$(row_field "$ACT" 4 coordination)" "implicit"
202
+
203
+ # ===========================================================================
204
+ # Case 7 — auto-close sweep runs in MAIN, closes a complete-milestone stream
205
+ # ===========================================================================
206
+ # m-1 is complete; give it an open (not closed) stream with no live worktree.
207
+ cat > "$REPO/.forge/streams/m-1.yml" <<'S1'
208
+ version: 1
209
+ stream:
210
+ id: "m-1"
211
+ goal: "done work"
212
+ status: ready
213
+ milestone: "m-1"
214
+ runtime:
215
+ worktree: null
216
+ merge:
217
+ readiness: ready
218
+ S1
219
+ git -C "$REPO" add -A && git -C "$REPO" commit -qm "chore(forge): m-1 open stream"
220
+ "$ROLLUP" --project "$REPO" --quiet >"$ROOT/out7" 2>"$ROOT/err7"
221
+ # The sweep edits the stream FILE (committed source) in the main checkout.
222
+ s1_status="$(awk '/^stream:/{f=1} f&&/^ status:/{print $2;exit}' "$REPO/.forge/streams/m-1.yml")"
223
+ check "sweep auto-closed m-1 stream (status: closed)" "$s1_status" "closed"
224
+ check_has "sweep announced the auto-close" "$(cat "$ROOT/err7")" "auto-closed"
225
+
226
+ # ===========================================================================
227
+ # Case 8 — WORKTREE guard: linked worktree renders caches but SKIPS the sweep
228
+ # ===========================================================================
229
+ # Reset a complete-milestone open stream, then run the rollup from a linked
230
+ # worktree. The stream file must be UNTOUCHED (sweep skipped) but active.yml
231
+ # in the worktree must still render.
232
+ cat > "$REPO/.forge/streams/m-1.yml" <<'S1B'
233
+ version: 1
234
+ stream:
235
+ id: "m-1"
236
+ goal: "done work"
237
+ status: ready
238
+ milestone: "m-1"
239
+ runtime:
240
+ worktree: null
241
+ merge:
242
+ readiness: ready
243
+ S1B
244
+ git -C "$REPO" add -A && git -C "$REPO" commit -qm "chore(forge): reopen m-1 stream" >/dev/null 2>&1
245
+ WT="$ROOT/wt"
246
+ git -C "$REPO" worktree add -q -b wt-branch "$WT" >/dev/null 2>&1
247
+ # The worktree shares tracked files; its .forge/streams/m-1.yml == main's.
248
+ "$ROLLUP" --project "$WT" --quiet >"$ROOT/out8" 2>"$ROOT/err8"
249
+ wt_s1_status="$(awk '/^stream:/{f=1} f&&/^ status:/{print $2;exit}' "$WT/.forge/streams/m-1.yml")"
250
+ check "worktree run did NOT sweep (stream still 'ready')" "$wt_s1_status" "ready"
251
+ check "worktree still rendered active.yml" "$([ -f "$WT/.forge/streams/active.yml" ] && echo yes || echo no)" "yes"
252
+ git -C "$REPO" worktree remove --force "$WT" >/dev/null 2>&1
253
+
254
+ # ===========================================================================
255
+ # Case 9 — SessionStart stdin mode: cwd comes from the JSON payload
256
+ # ===========================================================================
257
+ if command -v jq >/dev/null 2>&1; then
258
+ # Run with NO --project; feed a SessionStart-shaped payload naming the repo.
259
+ printf '{"cwd":"%s","hook_event_name":"SessionStart","source":"startup"}' "$REPO" \
260
+ | "$ROLLUP" >"$ROOT/out9" 2>/dev/null
261
+ check "stdin cwd drives repo resolution (summary emitted)" \
262
+ "$(grep -c 'forge-state-rollup' "$ROOT/out9")" "1"
263
+ else
264
+ pass "SessionStart stdin mode (skipped — jq absent)"
265
+ fi
266
+
267
+ # ===========================================================================
268
+ # Case 10 — non-Forge repo / non-repo dir: silent no-op, exit 0
269
+ # ===========================================================================
270
+ mkdir -p "$ROOT/plain"
271
+ "$ROLLUP" --project "$ROOT/plain" >"$ROOT/out10" 2>&1; rc=$?
272
+ check "non-git dir → exit 0" "$rc" "0"
273
+ check "non-git dir → no output" "$(cat "$ROOT/out10")" ""
274
+
275
+ git -C "$REPO" init -q "$ROOT/emptygit" 2>/dev/null; mkdir -p "$ROOT/emptygit"
276
+ git -C "$ROOT/emptygit" init -q
277
+ "$ROLLUP" --project "$ROOT/emptygit" >"$ROOT/out10b" 2>&1; rc=$?
278
+ check "git repo without .forge → exit 0, silent" "$rc" "0"
279
+ check "git repo without .forge → no output" "$(cat "$ROOT/out10b")" ""
280
+
281
+ printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
282
+ [ "$FAILED" -eq 0 ]
@@ -56,6 +56,17 @@
56
56
  ]
57
57
  },
58
58
  "hooks": {
59
+ "SessionStart": [
60
+ {
61
+ "matcher": "startup|resume|clear",
62
+ "hooks": [
63
+ {
64
+ "type": "command",
65
+ "command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" || true"
66
+ }
67
+ ]
68
+ }
69
+ ],
59
70
  "PostToolUse": [
60
71
  {
61
72
  "matcher": "Write",
@@ -78,13 +78,17 @@ every field has one source and coverage is computed into the artifact. Run the
78
78
  rollup at the **start of Show and Sync**, and after any Start/Resume/Pause/Close
79
79
  edit to a stream file.
80
80
 
81
- Procedure: run the **Stream Rollup** exactly as defined in FORGE.md → Stream
82
- Rollup the deterministic join + write (glob stream files, derive each row's
83
- `coordination`/`merge_readiness`/`blocked_by`/`next_action`, read `phase` from the
84
- linked milestone, emit `stream: implicit` coverage rows, derive `merge_queue` from
85
- `merge.readiness: ready`, write `active.yml`) is **single-sourced there**. Never
86
- hand-edit `active.yml`. Beyond that deterministic write, the Chief adds two
87
- **interactive** steps the boot-time rollup omits:
81
+ Procedure: run the **`forge-state-rollup.sh` executable** the same mechanical
82
+ port the SessionStart hook invokes at boot (issue #22). It performs the milestone
83
+ Rollup 1.0 **and** the Stream Rollup join + write (glob stream files, derive each
84
+ row's `coordination`/`merge_readiness`, read `phase` from the linked milestone,
85
+ emit `stream: implicit` coverage rows, derive `merge_queue` from
86
+ `merge.readiness: ready`, run the step-0 sweep in the main checkout, write
87
+ `active.yml`) in one idempotent pass — single-sourced in FORGE.md → Stream Rollup.
88
+ Run `.claude/hooks/forge-state-rollup.sh`, then read `active.yml`; never hand-edit
89
+ it, and only fall back to the manual join if the executable is absent (pre-0.75.0).
90
+ Beyond that deterministic write, the Chief adds two **interactive** steps the
91
+ mechanical rollup omits:
88
92
 
89
93
  - **Complete linked milestone → auto-close (not a prompt).** The rollup's **step
90
94
  0 sweep** (FORGE.md → Stream Rollup) already ran: a stream whose
@@ -101,10 +105,11 @@ hand-edit `active.yml`. Beyond that deterministic write, the Chief adds two
101
105
  file (the rollup emits a `stream: implicit` row for coverage), offer to
102
106
  materialize a stream file for it. **Never auto-create.**
103
107
 
104
- Note: invoked directly, `/chief-of-staff` does not run `forge`'s milestone Rollup,
105
- so `index.yml` may be slightly stale; read it as-is when deriving implicit-row
106
- coverage and suggest a `/forge` pass if it looks off. The Chief writes `active.yml`
107
- (derived class); it never writes `index.yml` or any milestone file.
108
+ Note: the `forge-state-rollup.sh` executable rolls up `index.yml` **and**
109
+ `active.yml` in the same pass, so implicit-row coverage reads a fresh `index.yml`
110
+ even when `/chief-of-staff` is invoked directly (no separate `/forge` boot needed).
111
+ The Chief writes only the derived caches (via the executable); it never hand-edits
112
+ `index.yml` or any milestone file.
108
113
 
109
114
  ### Board render + inbox (attended)
110
115
 
@@ -36,6 +36,29 @@ fi
36
36
 
37
37
  Surface the init if it ran (*"populated N uninitialised submodule(s) before baseline"*). A `+`/`U` line (divergent/conflicted submodule) is **not** an init case — STOP under Rule 4; it signals real submodule movement. (Desire-path: canvaz m-TIDES01 — empty `external/JUCE` failed the first CMake configure mid-task.)
38
38
 
39
+ **Gitignored artifacts.** A fresh worktree is a clean checkout of *tracked* files only. Anything gitignored but required — `.mcp.json` (MCP server config, often secret-bearing so it MUST stay gitignored), `.env.local`, experimental hook configs — is simply **absent**, and the failure is silent: MCP servers that never appear, an env-dependent command that misbehaves — no clear "missing config" error. `orchestrating` hydrates these when *it* creates the worktree, but a worktree from any other origin (a manual `git worktree add`, a `chief-of-staff` stream, a fresh clone) does not. So assert it here, regardless of how the workspace was created. Advisory and guarded — a clean no-op when `orchestration.worktree_hydrate` is unset or nothing applies, so existing installs are unaffected:
40
+
41
+ ```bash
42
+ # Copy required-but-gitignored artifacts named in orchestration.worktree_hydrate from the
43
+ # superproject (the MAIN checkout, resolved from the shared git common dir — this assertion
44
+ # runs INSIDE the worktree), guarded source-exists && dest-absent. Never overwrites, never
45
+ # git-adds — the file stays gitignored/untracked in both trees (a secret like .mcp.json never
46
+ # enters git history). No-op when the key is absent or nothing applies.
47
+ repo_root=$(git rev-parse --show-toplevel)
48
+ super=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's#/\.git/modules/.*##;s#/\.git/worktrees/.*##;s#/\.git$##')
49
+ sed -n '/^orchestration:/,/^[^[:space:]#]/p' "$repo_root/.forge/project.yml" 2>/dev/null \
50
+ | sed -n '/^[[:space:]]*worktree_hydrate:/,/^[[:space:]]*[a-z_]*:/p' \
51
+ | sed -n 's/^[[:space:]]*-[[:space:]]*//p' | tr -d "\"'" \
52
+ | while IFS= read -r rel; do # line-based: robust across sh/bash/zsh; a path may contain spaces
53
+ [ -n "$rel" ] && [ -n "$super" ] && [ "$super" != "$repo_root" ] || continue # no key / main checkout → no-op
54
+ [ -e "$super/$rel" ] && [ ! -e "$repo_root/$rel" ] || continue # source-exists && dest-absent
55
+ mkdir -p "$(dirname "$repo_root/$rel")"; cp -p "$super/$rel" "$repo_root/$rel"
56
+ echo "hydrated: $rel"
57
+ done
58
+ ```
59
+
60
+ The loop prints one `hydrated: {path}` line per artifact it copied — surface those before the baseline (*"hydrated N gitignored artifact(s) before baseline"*), mirroring the submodule surface line; no output means nothing needed hydrating. The `$super != $repo_root` guard makes this a natural no-op in a non-worktree (main) checkout — where superproject and checkout are the same tree and there is nothing to hydrate from.
61
+
39
62
  ## Baseline Snapshot
40
63
 
41
64
  Run **before the first task begins**. Makes failure causality mechanical — no self-assessment.
@@ -65,15 +65,19 @@ Cross-machine note: the flag file is machine-local, so a worktree (or the primar
65
65
  - **Absent registry, but IDs already exist.** `.forge/reservations.yml` missing *and* the tree already holds allocated IDs (`requirements/*.yml` has `FR-`/`NFR-`/`DEF-`, or `.forge/decisions/` has `ADR-NNN`) → one-time nudge: *"No `reservations.yml` yet, but this project already allocates IDs. Concurrent worktrees will collide on the next number. Create it by reserving your next ID via the protocol (`planning`/`architecting` do this), or backfill existing IDs now."* (No IDs anywhere → silent; the first reservation creates the file lazily.)
66
66
  - **Backfill scan (un-reserved landed IDs).** When `reservations.yml` exists, diff the IDs actually landed in the tree (`FR-`/`NFR-`/`DEF-` across `requirements/*.yml`; `ADR-NNN` across `.forge/decisions/`) against the ids recorded in `reservations.yml`. Any landed ID with **no** reservation entry → list them once: *"{N} ID(s) are in the tree but not in `reservations.yml` ({ids}) — landed before the protocol or via an un-reserved path. They are still respected (next = max(reserved, in-tree)+1), but appending backfill entries keeps the registry the single audit trail."* Advisory: surface, don't auto-write — backfilling is the operator's call.
67
67
 
68
- **Stream Rollup (active.yml is derived).** If `.forge/streams/` has any stream files, regenerate `.forge/streams/active.yml` from them + active milestones per FORGE.md Stream Rollup, the same way step 1.0 regenerates `index.yml`. **Run the 1.0 State Rollup (below) first** so `index.yml` is fresh — the Stream Rollup reads active milestones from it for implicit-row coverage, so on a boot where a just-merged milestone promotion lands, a stale `index.yml` would silently drop that milestone's coverage row. This includes the rollup's **step 0 completed-stream auto-close sweep** (FORGE.md → Stream Rollup): a milestone-backed stream whose milestone is now `complete`, that is not already `closed`, and has no live worktree, is auto-closed (`status: closed`, `merge.readiness: merged`) *before* the join — so a done-and-merged milestone can never leave a zombie `ready` row that re-nags every boot. Announce each: *"auto-closed stale stream {id} — milestone m-{id} complete, already on main."* (A `complete` milestone that still has a live worktree is left for the finalize path below, not swept.) `active.yml` is derived — never read it as authoritative without rolling up first, and never hand-edit it. Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed), so rendering it is a local-cache write that never lands on `main`; the source writes it still performs (the step-0 auto-close sweep touches *stream files*, which remain committed) run in the main checkout as before. No `streams/` dir → skip.
68
+ **Stream Rollup (active.yml is derived rendered by the same hook).** `.forge/streams/active.yml` is regenerated from the stream files + active milestones by the **`forge-state-rollup.sh` SessionStart hook**, in the same mechanical pass that renders `index.yml` (the hook runs the milestone rollup first, then the joinso the Stream Rollup reads a fresh `index.yml` for implicit-row coverage). This includes the **step-0 completed-stream auto-close sweep** (FORGE.md → Stream Rollup): a milestone-backed stream whose milestone is now `complete`, not already `closed`, with no live worktree, is auto-closed (`status: closed`, `merge.readiness: merged`) *before* the join — the hook runs this **only in the main checkout** (it edits committed stream files), and announces each on stderr: *"auto-closed stale stream {id} — milestone complete, already on main."* (A `complete` milestone that still has a live worktree is left for the finalize path below, not swept.) **`active.yml` is derived — READ the hook's freshly-rendered cache; never hand-edit it, and do not regenerate it by hand.** Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed); the hook renders it in any checkout. **Fallback:** if no `[forge-state-rollup]` boot summary appeared (older CLI / non-Claude-Code adapter / unwired settings), run `.claude/hooks/forge-state-rollup.sh` yourself once (idempotent), then read it; only if the executable is absent, roll up by hand per FORGE.md → Stream Rollup. No `streams/` dir → the hook skips it.
69
69
 
70
70
  **Ready-to-merge nudge (main checkout / streams without checkpoints).** From the just-regenerated `active.yml` — already swept of completed-milestone zombies by the rollup's step 0 — scan for streams with `coordination: ready` or a non-empty `merge_queue` **that are not auto-publishing via checkpoints**. Because the sweep ran first, anything surfaced here is genuinely mergeable (open work, not yet on main), so the nudge is truthful: *"{N} stream(s) ready to merge — run `/chief-of-staff` (merge safe) to integrate."* Pointer only — the cadence logic lives in the Chief's Merge Cadence Check; `forge` never merges. Nothing pending → silent.
71
71
 
72
- ### 1.0 State Rollup (index.yml is derived)
72
+ ### 1.0 State Rollup (index.yml is derived — rendered mechanically at boot)
73
73
 
74
- `index.yml` is a **derived registry** regenerate it from the milestone files (plus each row's git-computed `release_state`, step 4) before reading, and after any milestone CRUD (promote/defer/resume/delete). **Never hand-edit `index.yml`.** Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed): rendering it here is a local-cache write, so it never conflicts and never needs the orchestrator-only guard for *safety* — any session may render its own copy. The rollup below is unchanged; only the storage is (see FORGE.md → State Ownership, Derived class).
74
+ `index.yml` is a **derived registry**, regenerated from the milestone files (plus each row's git-computed `release_state`) by the **`forge-state-rollup.sh` SessionStart hook** — mechanically, at every session start, **before** this skill runs. **Never hand-edit `index.yml`.** Since 0.53.0 it is a **git-ignored render-on-read cache** (never committed): rendering it is a local-cache write, so any session may render its own copy (see FORGE.md → State Ownership, Derived class).
75
75
 
76
- Rollup procedure (deterministic + idempotent):
76
+ **The hook is the rollup; this skill only READS the freshly-rendered cache.** Making the rollup an executable invoked by a hook — not prose the agent performs — is the fix for issue #22 (pain-6: advisory rules fail). A boot session used to read a stale cache and reconstruct truth by hand when project memory carried an "index is stale, work around it" habit; a mechanical hook removes that trust dependency. Do **not** regenerate `index.yml` by hand — the hook already did. Look for its one-line boot summary (`[forge-state-rollup] rendered index.yml …`) in context; then read the cache.
77
+
78
+ **Fallback — only when the hook did NOT run.** On an older Claude Code CLI without SessionStart, a non-Claude-Code adapter, or a repo whose `.claude/settings.json` lacks the SessionStart wiring, no summary line appears. Only then run the executable yourself once — `.claude/hooks/forge-state-rollup.sh` (add `--project {worktree_path}` when the recorded path differs from cwd) — and read its output. It is deterministic + idempotent, so running it when the hook already ran is a harmless no-op. If the executable itself is absent (pre-0.75.0 install), fall back to the manual procedure below.
79
+
80
+ **Manual procedure (executable absent only — the executable's faithful spec):**
77
81
  1. Glob `.forge/state/milestone-*.yml`.
78
82
  2. For each, read `milestone.id`, `milestone.name`, `current.status`, `lifecycle.{deferred_at, resumed_at}`, and the last-touched date as `current.last_updated` → else legacy `progress.last_update` → else null. (The fallback keeps pre-0.19.0 milestone files self-sourcing without editing them; active milestones gain `current.last_updated` on their next transition.)
79
83
  3. Derive the registry `status`:
@@ -85,9 +89,9 @@ Rollup procedure (deterministic + idempotent):
85
89
  - **Fold is the source.** Run `.claude/hooks/forge-release-fold.sh m-{id}` per row and write its stdout `release_state=` value verbatim (`unmerged|merged|validated`) — agent prose never re-derives `release_state`; the fold binary is the only source.
86
90
  - **Degrade, don't block.** Hook missing or exit 2 → write `release_state: unknown`, emit ONE advisory line — *"release fold unavailable for m-{id} — release_state: unknown"* — and continue. A broken fold must never break boot.
87
91
 
88
- Output is a pure function of the milestone files, so two sessions regenerating it produce identical bytes — it never needs a hand-merge. **Only the main/orchestrator session runs rollup; worktree agents never write `index.yml`** (they edit only their own `milestone-{id}.yml`).
92
+ Output is a pure function of the milestone files, so two sessions regenerating it produce identical bytes — it never needs a hand-merge. **The `forge-state-rollup.sh` hook renders the cache in any checkout (main OR worktree — it is a gitignored local write); worktree agents still never write the *committed* derived-source state** (they edit only their own `milestone-{id}.yml`; the Stream Rollup's step-0 sweep, which edits committed stream files, is main-checkout-only inside the hook).
89
93
 
90
- **Enforced, not optional a boot must not read a stale registry.** The rollup *is* the reconcile step; skipping it silently lets `index.yml` drift from the milestone files — e.g. a worktree sets `current.status: complete` and merges to main, but `index.yml` stays `active` until some later boot happens to regenerate by hand (observed twice for m-CLOUDS01). So every boot **runs** steps 1–4. Before rewriting, diff each milestone's *derived* status against its current `index.yml` entry; any mismatch emit one loud line — *"index.yml was stale vs {N} milestone file(s) ({ids}) — regenerated."* — then write. Identical (the common case) → the rewrite is a no-op by bytes; stay silent. The rollup is idempotent, so running it on every boot is free.
94
+ **"Run Rollup (1.0)" (used below after a milestone CRUD) = run the executable.** The SessionStart hook fires only at boot, so after a mid-session promote/defer/resume/delete you must re-render the caches yourself: run `.claude/hooks/forge-state-rollup.sh` (it does both the milestone rollup **and** the Stream Rollup in one idempotent pass; add `--project {worktree_path}` if the recorded path differs from cwd), then read the result. This replaces the old "hand-regenerate `index.yml`" step everywhere the phrase appears. Executable absent (pre-0.75.0) → fall back to the manual procedure above.
91
95
 
92
96
  ### 1.1 Milestone Selection
93
97
 
@@ -97,7 +101,7 @@ Check state files:
97
101
  3. Neither → init/tier detection.
98
102
 
99
103
  **With `state/index.yml`:**
100
- 1. Run **Rollup (1.0)** to regenerate `index.yml` from the milestone files, then read it for active milestones
104
+ 1. **Read the freshly-rendered `index.yml`** (the SessionStart hook already regenerated it this boot — §1.0) for active milestones. No `[forge-state-rollup]` summary appeared → run the executable once first (§1.0 fallback), then read.
101
105
  2. **Check arg first.** `/forge 2` or `/forge "Auth system"`:
102
106
  - Match IDs (exact) or names (case-insensitive substring)
103
107
  - Found → auto-select: *"Resuming {id}: [{name}] -- {current.status}, {percent}%"*
@@ -59,6 +59,8 @@ Where worktrees live and what they're named — single source of truth, base con
59
59
 
60
60
  **Recorded path is authoritative once set** — `lifecycle.worktree_path` (milestone) / `runtime.worktree` (stream) is read verbatim, never re-derived; changing `worktree_root` affects only new worktrees; move a live worktree only with `git worktree move` + update the record. No recorded path → resolve from the convention. `forge` boot and `chief-of-staff` adoption **validate advisorily**: recorded path outside the resolved root → warn (never block, never move); an explicit `worktree_root` is honored.
61
61
 
62
+ **`orchestration.worktree_hydrate`** (optional, `project.yml`; forge#18) lists superproject-relative paths to required-but-gitignored artifacts (e.g. `.mcp.json`) a fresh worktree's tracked-only checkout lacks. Copy is guarded `source-exists && dest-absent` — never overwrites, never `git add`s, so the secret stays untracked in both trees. Applied by `orchestrating` at creation and re-asserted idempotently in `executing`'s Workspace Prerequisites, covering worktrees of any origin. Omit ⇒ no propagation. The per-worktree runtime counterpart of the install-time carve-out propagation below (State Ownership § Stable shared).
63
+
62
64
  ## Workflow Tiers
63
65
 
64
66
  Auto-detects complexity. Override: "Use Quick/Standard/Full tier."
@@ -193,12 +195,12 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
193
195
  - `design-system.md` — component mapping table
194
196
  - `requirements/m{N}.yml` — per-milestone structured requirements with `[NEEDS CLARIFICATION]` markers. **FR/DEF/NFR ids are globally unique across all milestone files** — reserve via the ID Reservation Protocol before writing. M9 e2e gate fields (`e2e`, `observable_outcome`, `validated`, hash) lazy-migrate; absent ⇒ `false`.
195
197
  - `roadmap.yml` — phases, milestones, dependencies
196
- - `state/index.yml` — DERIVED milestone registry, **git-ignored render-on-read cache** (0.53.0): regenerated at every boot by Rollup 1.0 (`forge` SKILL Step 1.0) from the milestone files; never committed, never hand-edited, never written by worktree agents; absent on a fresh clone (the project sentinel is `project.yml`). Rows carry the derived `release_state`.
198
+ - `state/index.yml` — DERIVED milestone registry, **git-ignored render-on-read cache** (0.53.0): regenerated at every boot by the **`forge-state-rollup.sh` SessionStart hook** (the mechanical port of Rollup 1.0 — issue #22; skill prose only *reads* it) from the milestone files; never committed, never hand-edited; the hook renders it in any checkout (gitignored local write), but worktree agents still never write the *committed* derived-source state; absent on a fresh clone (the project sentinel is `project.yml`). Rows carry the derived `release_state`.
197
199
  - **Derived `release_state`** ([ADR-024](../docs/decisions/ADR-024-merged-validated-fold.md)) — `{unmerged, merged, validated}` computed at rollup by `.claude/hooks/forge-release-fold.sh` (git-only, offline; the fold binary is the only source — agent prose never re-derives it), never stored. Validation binding via `forge.validation_event`: `in_session_signoff` (default; absent ⇒ this) or `promotion_approval` (CI-signed `promotion/{sha}` tag). Inert until a repo opts in; full spec in ADR-024 + the fold header.
198
200
  - `state/milestone-{id}.yml` — per-milestone cursor (**single source of truth**): position, decisions, blockers. One owner at a time.
199
201
  - `state/desire-paths/` — append-only framework-usage observations, one file per observation, `scope: project|framework` (absent ⇒ project); occurrence counts derived by globbing. **`forge` boot is the single review owner** — surfaces groups at 3+, persists declines (no re-nag), routes `framework`-scope signals upstream ([ADR-012](../docs/decisions/ADR-012-upstream-desire-path-feedback-channel.md)); `verifying` captures but never surfaces.
200
202
  - `context.md` — active locked decisions + deferred ideas (≤12 KB); history archives to `context-archive.md`
201
- - `streams/active.yml` — DERIVED Project Chief traffic map, git-ignored render-on-read cache — see Stream Rollup below ([ADR-015](../docs/decisions/ADR-015-derived-stream-registry.md))
203
+ - `streams/active.yml` — DERIVED Project Chief traffic map, git-ignored render-on-read cache, rendered by the same `forge-state-rollup.sh` hook — see Stream Rollup below ([ADR-015](../docs/decisions/ADR-015-derived-stream-registry.md))
202
204
  - `streams/{stream}.yml` (+ `brief.md`, `packages/{id}.yml`) — per-stream **source of truth**: coordination lifecycle, ownership, shared surfaces, blockers, merge readiness, optional `stream.milestone` link (milestone-backed streams omit workflow phase — the rollup reads it from the milestone)
203
205
  - `research/milestone-{id}.md` — dated immutable findings snapshot
204
206
  - `phases/milestone-{id}/{phase}-{name}/plan-{NN}.md` — task plans with must_haves frontmatter (`{id}`=milestone, `{phase}`=phase# verbatim, `{name}`=kebab, `{NN}`=seq)
@@ -214,7 +216,7 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
214
216
 
215
217
  ### Stream Rollup
216
218
 
217
- `active.yml` is a pure projection — regenerated, never hand-edited — by a deterministic, idempotent **join**: per-stream files contribute coordination state; active milestones contribute `phase` (any active milestone with no stream file gets a derived `implicit` row — coverage is computed, not maintained); `merge_queue` derives from `merge.readiness: ready`. A **step-0 completed-stream auto-close sweep** runs first: a milestone-backed stream whose milestone is `complete`, not yet `closed`, with no live worktree is auto-closed — a done-and-merged milestone can never leave a zombie "ready to merge" row. Run by the main/orchestrator session at `forge` boot and `chief-of-staff` Show/Sync; worktrees never write it; milestone Rollup 1.0 runs **first** (the join reads the fresh `index.yml`). **Numbered procedure: the ADR-015 addendum.** Because each field has exactly one source and coverage is computed, two-registry drift cannot occur — supersedes the old advisory Registry Drift Check.
219
+ `active.yml` is a pure projection — regenerated, never hand-edited — by a deterministic, idempotent **join**: per-stream files contribute coordination state; active milestones contribute `phase` (any active milestone with no stream file gets a derived `implicit` row — coverage is computed, not maintained); `merge_queue` derives from `merge.readiness: ready`. A **step-0 completed-stream auto-close sweep** runs first: a milestone-backed stream whose milestone is `complete`, not yet `closed`, with no live worktree is auto-closed — a done-and-merged milestone can never leave a zombie "ready to merge" row. Rendered mechanically by the **`forge-state-rollup.sh` SessionStart hook** at boot (issue #22 — an executable, not agent prose), and re-run via that same executable at `chief-of-staff` Show/Sync and after any mid-session milestone CRUD; the hook runs the milestone Rollup 1.0 **first** (the join reads the fresh `index.yml`), and confines the committed-source step-0 sweep to the main checkout. **Numbered procedure: the ADR-015 addendum.** Because each field has exactly one source and coverage is computed, two-registry drift cannot occur — supersedes the old advisory Registry Drift Check.
218
220
 
219
221
  ### State Commit Protocol
220
222