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.
- package/experimental/m10/source/skills/orchestrating/SKILL.md +31 -2
- package/package.json +1 -1
- package/template/.claude/hooks/README.md +121 -0
- package/template/.claude/hooks/forge-reserve.sh +21 -4
- package/template/.claude/hooks/forge-state-rollup.sh +431 -0
- package/template/.claude/hooks/tests/README.md +20 -0
- package/template/.claude/hooks/tests/forge-reserve.test.sh +92 -0
- package/template/.claude/hooks/tests/forge-state-rollup.test.sh +282 -0
- package/template/.claude/settings.json +11 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +16 -11
- package/template/.claude/skills/executing/SKILL.md +23 -0
- package/template/.claude/skills/forge/SKILL.md +11 -7
- package/template/.forge/FORGE.md +5 -3
- package/template/.forge/migrations/0.77.0-state-rollup-executable.md +120 -0
- package/template/.forge/templates/project.yml +14 -0
|
@@ -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
|
-
|
|
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
|
@@ -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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|