forge-orkes 0.75.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/package.json +1 -1
- package/template/.claude/hooks/README.md +121 -0
- 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-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/forge/SKILL.md +11 -7
- package/template/.forge/FORGE.md +3 -3
- package/template/.forge/migrations/0.77.0-state-rollup-executable.md +120 -0
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`.
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
`
|
|
86
|
-
|
|
87
|
-
|
|
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:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
|
@@ -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
|
|
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 join — so 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
|
|
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
|
-
|
|
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. **
|
|
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
|
-
**
|
|
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.
|
|
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}%"*
|
package/template/.forge/FORGE.md
CHANGED
|
@@ -195,12 +195,12 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
|
|
|
195
195
|
- `design-system.md` — component mapping table
|
|
196
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`.
|
|
197
197
|
- `roadmap.yml` — phases, milestones, dependencies
|
|
198
|
-
- `state/index.yml` — DERIVED milestone registry, **git-ignored render-on-read cache** (0.53.0): regenerated at every boot by
|
|
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`.
|
|
199
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.
|
|
200
200
|
- `state/milestone-{id}.yml` — per-milestone cursor (**single source of truth**): position, decisions, blockers. One owner at a time.
|
|
201
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.
|
|
202
202
|
- `context.md` — active locked decisions + deferred ideas (≤12 KB); history archives to `context-archive.md`
|
|
203
|
-
- `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))
|
|
204
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)
|
|
205
205
|
- `research/milestone-{id}.md` — dated immutable findings snapshot
|
|
206
206
|
- `phases/milestone-{id}/{phase}-{name}/plan-{NN}.md` — task plans with must_haves frontmatter (`{id}`=milestone, `{phase}`=phase# verbatim, `{name}`=kebab, `{NN}`=seq)
|
|
@@ -216,7 +216,7 @@ State lives in `.forge/`. One line per artifact; protocol detail lives in the na
|
|
|
216
216
|
|
|
217
217
|
### Stream Rollup
|
|
218
218
|
|
|
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.
|
|
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.
|
|
220
220
|
|
|
221
221
|
### State Commit Protocol
|
|
222
222
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Migration Guide: State rollup made mechanical (Forge 0.77.0)
|
|
2
|
+
|
|
3
|
+
Applies to projects upgrading to 0.77.0 or later. State Rollup 1.0 and the
|
|
4
|
+
Stream Rollup (ADR-015 addendum) — previously advisory skill prose that an
|
|
5
|
+
agent had to remember to run at boot — are now an executable,
|
|
6
|
+
`.claude/hooks/forge-state-rollup.sh`, invoked mechanically by a `SessionStart`
|
|
7
|
+
hook. A boot session can no longer skip the render and fall back to reading a
|
|
8
|
+
stale `.forge/state/index.yml` / `.forge/streams/active.yml` cache, or
|
|
9
|
+
hand-reconstruct the registries from memory — the exact failure mode filed as
|
|
10
|
+
issue #22.
|
|
11
|
+
|
|
12
|
+
`/upgrading` ships the new hook file (`.claude/hooks/forge-state-rollup.sh`)
|
|
13
|
+
like any other framework file. It does **not** wire the `SessionStart`
|
|
14
|
+
registration, because `.claude/settings.json` is a **user-owned file** —
|
|
15
|
+
upgrades never overwrite it (FORGE.md → State Ownership, "Stable shared"
|
|
16
|
+
class notes settings.json is gitignored-carve-out territory, propagated only
|
|
17
|
+
via explicit installer steps, never blanket overwrite). Without this
|
|
18
|
+
migration's one manual step, the hook ships but is never invoked: the
|
|
19
|
+
executable exists, nothing mechanically triggers it.
|
|
20
|
+
|
|
21
|
+
Projects that ran `initializing` fresh on 0.77.0+ (or already carry the
|
|
22
|
+
`SessionStart` block from an earlier partial adoption) need nothing further.
|
|
23
|
+
|
|
24
|
+
## Prerequisites
|
|
25
|
+
|
|
26
|
+
1. On the new version's framework files (`npx forge-orkes upgrade`, or the
|
|
27
|
+
`upgrading` skill) — so `.claude/hooks/forge-state-rollup.sh` and
|
|
28
|
+
`.claude/hooks/forge-release-fold.sh` are present and executable.
|
|
29
|
+
2. Working tree clean or changes committed — this migration edits
|
|
30
|
+
`.claude/settings.json`, a tracked file.
|
|
31
|
+
|
|
32
|
+
## Detection
|
|
33
|
+
|
|
34
|
+
Keyed on **the executable existing (or the version already at/above 0.77.0)
|
|
35
|
+
while the SessionStart wiring is absent** — the shipped-but-unwired gap. Uses
|
|
36
|
+
`jq` to inspect `.claude/settings.json`'s `hooks.SessionStart` array for an
|
|
37
|
+
entry referencing `forge-state-rollup`; degrades to a plain-text grep if `jq`
|
|
38
|
+
is not installed. Silent in a non-Forge / non-git tree, silent once the hook
|
|
39
|
+
is wired:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
|
43
|
+
[ -x .claude/hooks/forge-state-rollup.sh ] || exit 0
|
|
44
|
+
|
|
45
|
+
settings=".claude/settings.json"
|
|
46
|
+
[ -f "$settings" ] || {
|
|
47
|
+
echo "MIGRATE — forge-state-rollup.sh is present but $settings does not exist; add it with the SessionStart hook below"
|
|
48
|
+
exit 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if command -v jq >/dev/null 2>&1; then
|
|
52
|
+
wired="$(jq -r '
|
|
53
|
+
(.hooks.SessionStart // []) as $entries
|
|
54
|
+
| [$entries[].hooks[]?.command // "" | select(contains("forge-state-rollup"))]
|
|
55
|
+
| length > 0
|
|
56
|
+
' "$settings" 2>/dev/null)"
|
|
57
|
+
if [ "$wired" != "true" ]; then
|
|
58
|
+
echo "MIGRATE — $settings has no SessionStart hook referencing forge-state-rollup.sh; the executable shipped but nothing mechanically triggers it"
|
|
59
|
+
fi
|
|
60
|
+
else
|
|
61
|
+
if ! grep -q "forge-state-rollup" "$settings" 2>/dev/null; then
|
|
62
|
+
echo "MIGRATE — $settings has no reference to forge-state-rollup.sh (jq unavailable, ran plain-text check); add the SessionStart hook below"
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Migration steps
|
|
68
|
+
|
|
69
|
+
### 1. Add the SessionStart hook block
|
|
70
|
+
|
|
71
|
+
Open `.claude/settings.json` and add (or merge into) a `hooks.SessionStart`
|
|
72
|
+
array entry:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"hooks": {
|
|
77
|
+
"SessionStart": [
|
|
78
|
+
{
|
|
79
|
+
"matcher": "startup|resume|clear",
|
|
80
|
+
"hooks": [
|
|
81
|
+
{
|
|
82
|
+
"type": "command",
|
|
83
|
+
"command": "[ -x \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" ] && \"$CLAUDE_PROJECT_DIR/.claude/hooks/forge-state-rollup.sh\" || true"
|
|
84
|
+
}
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
If `hooks.SessionStart` already has other entries (e.g. from a project-local
|
|
93
|
+
customization), append this object to the existing array rather than
|
|
94
|
+
replacing it. If `hooks` exists but has no `SessionStart` key yet, add the key
|
|
95
|
+
alongside the existing `PreToolUse`/`PostToolUse` blocks.
|
|
96
|
+
|
|
97
|
+
The `[ -x ... ] && ... || true` guard means the hook is a no-op — never a
|
|
98
|
+
boot failure — on any checkout where the script is absent or not executable
|
|
99
|
+
(SessionStart hooks are non-blocking; `forge-state-rollup.sh` itself also
|
|
100
|
+
always exits 0).
|
|
101
|
+
|
|
102
|
+
### 2. Commit the settings change
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
git add .claude/settings.json
|
|
106
|
+
git commit -m "chore(forge): wire forge-state-rollup SessionStart hook (0.77.0)"
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Validation
|
|
110
|
+
|
|
111
|
+
- Start a new session, or run `/clear` — the boot summary line should include
|
|
112
|
+
`[forge-state-rollup] rendered index.yml (…) …` (plus an `active.yml` clause
|
|
113
|
+
if `.forge/streams/` exists), confirming the hook actually ran.
|
|
114
|
+
- `jq '.hooks.SessionStart' .claude/settings.json` prints a non-null array
|
|
115
|
+
containing the `forge-state-rollup` command, not `null`.
|
|
116
|
+
- Re-running the Detection block above now prints nothing (exits silently) —
|
|
117
|
+
the gap is closed.
|
|
118
|
+
- `.forge/state/index.yml` and `.forge/streams/active.yml` (if applicable)
|
|
119
|
+
exist on disk and are fresh — their mtimes update on every boot, since the
|
|
120
|
+
hook re-renders unconditionally.
|