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.
@@ -80,16 +80,45 @@ mkdir -p "$wt_root"
80
80
  git worktree prune
81
81
  git worktree add -b forge/${anchor} --lock --reason "forge session m-${milestone_id}" "$wt_root/${anchor}" main
82
82
  wt="$wt_root/${anchor}"
83
- # Submodules don't auto-populate in a new worktree — init them if present. Reuses the
83
+
84
+ # --- Hydrate the worktree ---
85
+ # A git worktree is a clean checkout of *tracked* files only. Everything required but
86
+ # untracked — submodule working trees, gitignored config/secrets — is absent until we
87
+ # put it there. Hydration is that ordered step; submodule init is one instance of it.
88
+
89
+ # (1) Submodules don't auto-populate in a new worktree — init them if present. Reuses the
84
90
  # superproject's shared .git/modules object store (no re-clone, just a working-tree
85
91
  # checkout); ccache absorbs the subsequent rebuild cost across worktrees.
86
92
  if [ -n "$(git -C "$wt" submodule status 2>/dev/null)" ]; then
87
93
  ( cd "$wt" && git submodule update --init --recursive )
88
94
  fi
95
+
96
+ # (2) Required-but-gitignored artifacts (e.g. .mcp.json — MCP config, often secret-bearing;
97
+ # .env.local; experimental hook configs) named in orchestration.worktree_hydrate. A clean
98
+ # checkout lacks them, and the failure is SILENT: MCP servers that never appear, an env-
99
+ # dependent command that misbehaves. Copy each from the superproject, guarded source-exists
100
+ # && dest-absent → never overwrites, and we NEVER `git add` a hydrated path: it stays
101
+ # gitignored/untracked in both trees, so a secret like .mcp.json never enters git history.
102
+ sed -n '/^orchestration:/,/^[^[:space:]#]/p' "$repo_root/.forge/project.yml" 2>/dev/null \
103
+ | sed -n '/^[[:space:]]*worktree_hydrate:/,/^[[:space:]]*[a-z_]*:/p' \
104
+ | sed -n 's/^[[:space:]]*-[[:space:]]*//p' | tr -d "\"'" \
105
+ | while IFS= read -r rel; do # line-based: a path may contain spaces; robust across sh/bash/zsh
106
+ [ -n "$rel" ] || continue
107
+ [ -e "$repo_root/$rel" ] || continue # source-exists guard (no-op when nothing applies)
108
+ [ -e "$wt/$rel" ] && continue # dest-absent guard (idempotent — never clobbers)
109
+ mkdir -p "$(dirname "$wt/$rel")"
110
+ cp -p "$repo_root/$rel" "$wt/$rel"
111
+ done
112
+
113
+ # (3) Dependency install (node_modules etc.) is DEFERRED to the executing/verifying preflight
114
+ # on purpose — Forge core owns no package-manager concept, and the node_modules desire path is
115
+ # already handled there. That is where the false-green `tsc` class (a no-op tsc against a missing
116
+ # node_modules reporting green) is closed — not here.
117
+
89
118
  ( cd "$wt" && git hook run pre-commit || true )
90
119
  ```
91
120
 
92
- Verify worktree dir exists, branch locked, and (if applicable) submodule dirs are non-empty. On failure → cleanup partial state, refuse mode.
121
+ Verify worktree dir exists, branch locked, and (if applicable) submodule dirs are non-empty and each configured `worktree_hydrate` artifact that exists in the superproject is present in the worktree. On failure → cleanup partial state, refuse mode.
93
122
 
94
123
  `anchor` (= `m-{milestone_id}-{session_id}`) names the worktree dir **and** branch from here on; every reference below that used to be `{session_id}` is now `{anchor}`. `session_id` (the bare uuid) is retained only as the collision key + audit id in state.
95
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.74.0",
3
+ "version": "0.77.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -113,3 +113,124 @@ primary coordination point.
113
113
  - "internal error at line N" on every edit → corrupt DB or missing tool. Run doctor. Common: `jq` not on PATH.
114
114
  - No collisions detected → confirm `CLAUDE_SESSION_ID` set and `claims.db` exists; otherwise hook fail-opens.
115
115
  - macOS `timeout: command not found` → `brew install coreutils` for `gtimeout`, or skip (DB busy_timeout still applies).
116
+
117
+ ## `forge-state-rollup.sh` — SessionStart state rollup
118
+
119
+ Makes the boot rollup mechanical (issue #22). Ports **State Rollup 1.0** and the
120
+ **Stream Rollup** (ADR-015 addendum) from advisory skill prose into an
121
+ executable that a SessionStart hook runs on every boot, so a session can no
122
+ longer skip the render and work from a stale cache or hand-reconstruct the
123
+ registries — the failure mode filed against #22.
124
+
125
+ ### Behavior
126
+
127
+ Renders two git-ignored render-on-read caches (0.53.0) and never commits
128
+ either:
129
+
130
+ | Renders | From | Notes |
131
+ |---|---|---|
132
+ | `.forge/state/index.yml` | `.forge/state/milestone-*.yml` | Derived registry status (`deferred`/`complete`/`not_started`/`active`); `last_updated` falls back to legacy `progress.last_update`; per-row `release_state` is fetched by shelling to `forge-release-fold.sh` — the fold is the **only** source, never re-derived here |
133
+ | `.forge/streams/active.yml` | `.forge/streams/*.yml` + active milestones | Stream Rollup join: per-stream coordination rows, milestone-derived `phase`, `implicit` coverage rows for active milestones with no stream file, derived `merge_queue` (only rendered if `.forge/streams/` exists) |
134
+
135
+ A **step-0 completed-stream auto-close sweep** runs before the stream join and
136
+ edits committed stream files (`streams/{stream}.yml`) — a milestone-backed
137
+ stream whose milestone is `complete`, not already `closed`, with no live
138
+ worktree gets auto-closed. This step is **main-checkout only**
139
+ (`git rev-parse --git-dir` == `--git-common-dir`); a linked worktree skips the
140
+ sweep but still renders both caches locally.
141
+
142
+ Deterministic and idempotent — two runs against the same sources produce
143
+ byte-identical output.
144
+
145
+ **Fold dependency and degrade.** `release_state` comes exclusively from
146
+ `.claude/hooks/forge-release-fold.sh`. If the fold script is missing or exits
147
+ non-zero for a unit, that row gets `release_state: unknown` plus one advisory
148
+ stderr line (`forge-state-rollup: release fold unavailable for m-N —
149
+ release_state: unknown`) — a broken fold degrades a single row, it never
150
+ breaks boot.
151
+
152
+ **Non-blocking contract.** Exit `0` **always** — SessionStart hooks are
153
+ non-blocking, so every failure path (not a git repo, no `.forge/`, no
154
+ `.forge/state/`) degrades to a silent no-op rather than surfacing an error.
155
+ stdout carries one terse summary line (unless `--quiet`) that is injected into
156
+ the model's context — e.g. `[forge-state-rollup] rendered index.yml (3
157
+ milestones) + active.yml (2 stream rows). Registries are fresh — READ them,
158
+ do not regenerate by hand.` stderr carries advisories (auto-close, fold
159
+ degrade) and is visible in the transcript but not injected into context.
160
+
161
+ ### Why `cwd`, not `$CLAUDE_PROJECT_DIR`
162
+
163
+ `$CLAUDE_PROJECT_DIR` resolves to the repo **root** (main checkout) even when
164
+ the session started inside a linked worktree. Rendering against it from a
165
+ worktree session would write the caches into the wrong tree. The hook instead
166
+ resolves the target repo from the SessionStart payload's `cwd` (read from
167
+ stdin JSON), falling back to `$PWD` if `--project` isn't passed and stdin/`jq`
168
+ aren't available. Each worktree therefore renders **its own** gitignored
169
+ caches; a worktree's boot never touches main's.
170
+
171
+ ### Prerequisites
172
+
173
+ - POSIX `sh` + `git` + `awk`/`sed` (mirrors `forge-release-fold.sh`) — no other
174
+ runtime dependency.
175
+ - `jq` — used only to parse the SessionStart stdin JSON (`cwd`). Absent `jq`
176
+ degrades to a no-stdin path (falls back to `--project` or `$PWD`); it does
177
+ not disable the hook.
178
+ - No network calls.
179
+
180
+ ### CLI usage
181
+
182
+ ```bash
183
+ .claude/hooks/forge-state-rollup.sh [--project <path>] [--quiet]
184
+ ```
185
+
186
+ - `--project <path>` — operate on the repo containing `<path>` instead of the
187
+ SessionStart payload's `cwd`. Useful for manually re-rendering a specific
188
+ worktree or repo outside a session.
189
+ - `--quiet` — suppress the stdout summary line; caches are still written.
190
+
191
+ Run it manually any time the caches look stale — it is safe to re-run and
192
+ always produces the same output for the same sources.
193
+
194
+ ### Registration
195
+
196
+ Wired as a `SessionStart` hook in `.claude/settings.json` (and the
197
+ `create-forge` template copy), matcher `startup|resume|clear`:
198
+
199
+ ```json
200
+ {
201
+ "hooks": {
202
+ "SessionStart": [
203
+ {
204
+ "matcher": "startup|resume|clear",
205
+ "hooks": [
206
+ {
207
+ "type": "command",
208
+ "command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" || true"
209
+ }
210
+ ]
211
+ }
212
+ ]
213
+ }
214
+ }
215
+ ```
216
+
217
+ The `[ -x ... ] && ... || true` guard means a repo without the hook file (or
218
+ with it non-executable) boots exactly as before — the hook is additive, not a
219
+ new hard dependency.
220
+
221
+ ### Disabling
222
+
223
+ Remove the `SessionStart` block above from `.claude/settings.json`, or make
224
+ the script non-executable: `chmod -x .claude/hooks/forge-state-rollup.sh`.
225
+ Without it, boot falls back to the advisory skill-prose rollup (`forge` /
226
+ `chief-of-staff` render-then-read) — correct but no longer mechanically
227
+ enforced.
228
+
229
+ ### Tests
230
+
231
+ `.claude/hooks/tests/forge-state-rollup.test.sh` — a standalone git-fixture
232
+ suite (36 cases) run directly (not through `tests/run.sh`), building throwaway
233
+ repos in `mktemp` dirs. Covers the #22 acceptance shape (a stale cache is
234
+ regenerated regardless), fold-degrade → `release_state: unknown`, the
235
+ main-only auto-close sweep, the linked-worktree guard (sweep skipped, caches
236
+ still rendered), and the SessionStart stdin path. See `tests/README.md`.
@@ -249,10 +249,27 @@ compute_id() {
249
249
  # Hyphen is OPTIONAL in the token ([A-Z]+-?[0-9]+): refactor ids (R100) carry none
250
250
  # (ADR-023), so requiring a hyphen would hide them and drop the floor to 0. Split the
251
251
  # alpha prefix from the digits in awk so R does not match e.g. FR (anchor ^prefix$).
252
- main_max="$(git show main:.forge/reservations.yml 2>/dev/null \
253
- | grep -oE '[A-Z]+-?[0-9]+' \
254
- | awk -v p="$prefix" '{ a=$0; sub(/[-0-9].*$/,"",a); d=$0; gsub(/[^0-9]/,"",d);
255
- if(a==p){ n=d+0; if(n>m)m=n } } END{print m+0}')"
252
+ #
253
+ # READ BOTH main AND origin/main (#26): in the local-main-first workflow (ADR-010 —
254
+ # primary checkout parked on main, all work in worktrees reaching main via PRs), local
255
+ # `main` routinely lags origin/main by whatever merged since the last fast-forward, so a
256
+ # local-only floor hands out ids origin/main already claimed — #19's collision class,
257
+ # relocated to the one input that still isn't machine-shared. Best-effort-fetch origin
258
+ # first, then take the max across whichever of {main, origin/main} resolve. Every leg
259
+ # degrades to nothing offline / with no remote (the same posture the rest of the script
260
+ # takes) — a fetch failure or a missing ref is never fatal, it just contributes 0.
261
+ # FORGE_RESERVE_NO_FETCH=1 skips the fetch (fixtures with a local file:// origin want the
262
+ # deterministic ref state they set up, not a network round-trip).
263
+ ref_reservations_max() { # $1 = git ref → max reserved id for $prefix on that ref (0 if absent)
264
+ git show "$1:.forge/reservations.yml" 2>/dev/null \
265
+ | grep -oE '[A-Z]+-?[0-9]+' \
266
+ | awk -v p="$prefix" '{ a=$0; sub(/[-0-9].*$/,"",a); d=$0; gsub(/[^0-9]/,"",d);
267
+ if(a==p){ n=d+0; if(n>m)m=n } } END{print m+0}'
268
+ }
269
+ [ "${FORGE_RESERVE_NO_FETCH:-0}" = 1 ] || git fetch --quiet origin main 2>/dev/null || true
270
+ main_local_max="$(ref_reservations_max main)"; [ -n "$main_local_max" ] || main_local_max=0
271
+ main_origin_max="$(ref_reservations_max origin/main)"; [ -n "$main_origin_max" ] || main_origin_max=0
272
+ main_max="$(awk -v a="$main_local_max" -v b="$main_origin_max" 'BEGIN{print (a>b)?a:b}')"
256
273
  [ -n "$main_max" ] || main_max=0
257
274
 
258
275
  next="$(awk -v a="$ledger_max" -v b="$intree_max" -v c="$main_max" \
@@ -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