forge-orkes 0.50.1 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/template/.claude/hooks/forge-reserve.sh +51 -15
  3. package/template/.claude/hooks/forge-slice-notify.sh +66 -0
  4. package/template/.claude/hooks/forge-slice-runner.sh +593 -0
  5. package/template/.claude/hooks/tests/verify-signoff.test.sh +122 -0
  6. package/template/.claude/hooks/tests/wu-done.test.sh +68 -0
  7. package/template/.claude/hooks/verify-signoff.sh +139 -0
  8. package/template/.claude/hooks/wu-done.sh +48 -0
  9. package/template/.claude/skills/chief-of-staff/SKILL.md +11 -3
  10. package/template/.claude/skills/deferred/SKILL.md +8 -4
  11. package/template/.claude/skills/discussing/SKILL.md +4 -2
  12. package/template/.claude/skills/executing/SKILL.md +21 -17
  13. package/template/.claude/skills/forge/SKILL.md +13 -10
  14. package/template/.claude/skills/planning/SKILL.md +3 -1
  15. package/template/.claude/skills/prototyping/SKILL.md +10 -7
  16. package/template/.claude/skills/quick-tasking/SKILL.md +3 -1
  17. package/template/.claude/skills/researching/SKILL.md +2 -0
  18. package/template/.claude/skills/reviewing/SKILL.md +7 -7
  19. package/template/.claude/skills/verifying/SKILL.md +8 -7
  20. package/template/.forge/FORGE.md +14 -7
  21. package/template/.forge/gitignore +8 -0
  22. package/template/.forge/migrations/0.52.0-id-reservation-milestone-refactor.md +45 -0
  23. package/template/.forge/templates/deferred-issue.md +22 -0
  24. package/template/.forge/templates/slice-runner/config.yml +62 -0
  25. package/template/.forge/templates/slice-runner/phase-report.schema.json +51 -0
  26. package/template/.forge/templates/slice-runner/phase-report.yml +58 -0
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env sh
2
+ # forge-slice-runner — run a slice's phases headless, in fresh heads, files-only.
3
+ #
4
+ # Brief-R2 step 1. The runner is a LOOP, not a daemon (NFR-028): each invocation
5
+ # reads the slice's phase list and, per phase, assembles a prompt FROM FILES ONLY
6
+ # (the phase plan + every prior phase's phase-report.yml + a repo-state snippet —
7
+ # NEVER a transcript or a --continue/session-resume of the prior phase), launches
8
+ # a bounded fresh-context run in the slice worktree via FORGE_SLICE_LAUNCHER
9
+ # (default `claude -p`), requires the phase to write a phase-report.yml, reads its
10
+ # `verdict`, and advances. It is a DUMB DISPATCHER (Fork 3): it holds no
11
+ # authoritative state — everything it knows is recomputable from files on disk, so
12
+ # killing it mid-slice loses nothing. If it ever needs resident state or a
13
+ # scheduler to work, that is a STOP-and-route-out signal, not a feature here.
14
+ #
15
+ # forge-slice-runner.sh --slice <worktree> [--work-dir <dir>]
16
+ # [--config <config.yml>] [--schema <schema.json>]
17
+ # [--report-template <phase-report.yml>]
18
+ #
19
+ # Runner env prerequisite (real launcher only): the machine must export
20
+ # CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) — or ANTHROPIC_API_KEY.
21
+ # Headless `claude -p` has no other credential in this app-brokered setup.
22
+ #
23
+ # stdout: a machine-readable summary line, last:
24
+ # SLICE_RESULT phases=<completed> touches=<operator-touches> last_ping=<name>
25
+ # All human/progress logging goes to stderr.
26
+ #
27
+ # Pure POSIX sh — no arrays, no bashisms, dependency-free (NFR-026).
28
+ set -eu
29
+
30
+ # --- defaults ----------------------------------------------------------------
31
+ WORKTREE=""
32
+ WORK_DIR=""
33
+ CONFIG=""
34
+ SCHEMA=""
35
+ REPORT_TEMPLATE=""
36
+ LAUNCHER="${FORGE_SLICE_LAUNCHER:-claude}" # the swappable launch seam
37
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
38
+ REPO_ROOT="$(cd "$SELF_DIR/../.." 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || echo "")"
39
+
40
+ # --- parse args --------------------------------------------------------------
41
+ while [ $# -gt 0 ]; do
42
+ case "$1" in
43
+ --slice) WORKTREE="${2:-}"; shift 2 ;;
44
+ --work-dir) WORK_DIR="${2:-}"; shift 2 ;;
45
+ --config) CONFIG="${2:-}"; shift 2 ;;
46
+ --schema) SCHEMA="${2:-}"; shift 2 ;;
47
+ --report-template) REPORT_TEMPLATE="${2:-}"; shift 2 ;;
48
+ *) printf 'forge-slice-runner: unknown arg: %s\n' "$1" >&2; exit 2 ;;
49
+ esac
50
+ done
51
+
52
+ [ -n "$WORKTREE" ] || { printf 'forge-slice-runner: --slice <worktree> is required\n' >&2; exit 2; }
53
+ [ -d "$WORKTREE" ] || { printf 'forge-slice-runner: slice worktree not found: %s\n' "$WORKTREE" >&2; exit 2; }
54
+ WORKTREE="$(cd "$WORKTREE" && pwd)" # absolutize (we cd into it to launch)
55
+
56
+ # Sensible defaults resolved against the slice / repo.
57
+ [ -n "$WORK_DIR" ] || WORK_DIR="$WORKTREE/.forge/runner-work"
58
+ [ -n "$CONFIG" ] || CONFIG="${REPO_ROOT:+$REPO_ROOT/}.forge/templates/slice-runner/config.yml"
59
+ [ -n "$SCHEMA" ] || SCHEMA="${REPO_ROOT:+$REPO_ROOT/}.forge/templates/slice-runner/phase-report.schema.json"
60
+ [ -n "$REPORT_TEMPLATE" ] || REPORT_TEMPLATE="${REPO_ROOT:+$REPO_ROOT/}.forge/templates/slice-runner/phase-report.yml"
61
+ mkdir -p "$WORK_DIR"
62
+ WORK_DIR="$(cd "$WORK_DIR" && pwd)"
63
+ PING_DIR="$WORK_DIR/pings" # append-once ping markers (dedup across re-runs)
64
+
65
+ # --- config (flat key: value — grep/sed, no YAML lib) ------------------------
66
+ cfg_get() {
67
+ # cfg_get <key> <default>
68
+ _v=""
69
+ [ -f "$CONFIG" ] && _v="$(grep -E "^$1:" "$CONFIG" 2>/dev/null | head -1 | sed -E "s/^$1:[[:space:]]*//; s/[[:space:]]*(#.*)?$//")"
70
+ [ -n "$_v" ] && printf '%s' "$_v" || printf '%s' "$2"
71
+ }
72
+ MAX_TURNS="$(cfg_get max_turns_per_phase 60)"
73
+ # per-phase budget cap: bounded by the per-slice cap for now. A per-phase
74
+ # --max-budget-usd <= the slice cap.
75
+ MAX_BUDGET="$(cfg_get per_slice_budget_usd 25)"
76
+ # per-SLICE budget cap (plan-47 task 2): the runner accumulates usage.cost_usd
77
+ # across phase reports and parks with a §8.4 `budget` ping when the running total
78
+ # breaches this. Same config key as the per-phase bound (the cap IS the slice cap);
79
+ # the accumulation is what makes it a slice-level guard, not a phase-level one.
80
+ SLICE_BUDGET="$(cfg_get per_slice_budget_usd 25)"
81
+ # phase-boundary refresh + divergence budget (plan-03).
82
+ INT_BRANCH="$(cfg_get integration_branch main)"
83
+ DIV_BUDGET_COMMITS="$(cfg_get divergence_budget_commits 40)"
84
+ DIV_BUDGET_LINES="$(cfg_get divergence_budget_lines 4000)"
85
+ # FORGE_SLICE_REFRESH=off disables the whole phase-boundary refresh — the neg
86
+ # control the refresh fixtures assert against, and a kill switch if a slice must
87
+ # run without integrating main.
88
+ REFRESH_MAIN="${FORGE_SLICE_REFRESH:-on}"
89
+
90
+ # --- read the phase list (POSIX awk over `phases:` block) --------------------
91
+ slice_manifest="$WORKTREE/slice.yml"
92
+ [ -f "$slice_manifest" ] || { printf 'forge-slice-runner: no slice.yml in %s\n' "$WORKTREE" >&2; exit 2; }
93
+ PHASES="$(awk '
94
+ /^phases:/ {f=1; next}
95
+ f && /^[[:space:]]*-[[:space:]]/ {sub(/^[[:space:]]*-[[:space:]]*/,""); print; next}
96
+ f && /^[^[:space:]-]/ {f=0}
97
+ ' "$slice_manifest")"
98
+ [ -n "$PHASES" ] || { printf 'forge-slice-runner: slice.yml lists no phases\n' >&2; exit 2; }
99
+
100
+ # --- report readers (grep/sed, dependency-free) ------------------------------
101
+ report_field() { # report_field <file> <key> — a TOP-LEVEL `key:` (col 0)
102
+ grep -E "^$2:" "$1" 2>/dev/null | head -1 | sed -E "s/^$2:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^\"//; s/\"$//"
103
+ }
104
+ report_nested() { # report_nested <file> <key> — an INDENTED `key:` (checks.*/usage.*)
105
+ # The report nests checks.failed and usage.model under their parent block;
106
+ # report_field's `^key:` anchor can't see them. Each nested key we read
107
+ # (checks.failed; usage.model as a fallback in model_for_phase) is unique in the
108
+ # file, so a plain indented grep is enough. NOTE: usage.cost_usd/tokens are NO
109
+ # longer read from the report — the authoritative numbers come from the launcher's
110
+ # result.json (see result_num), because a phase can't know its own usage.
111
+ grep -E "^[[:space:]]+$2:" "$1" 2>/dev/null | head -1 | sed -E "s/^[[:space:]]+$2:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^\"//; s/\"$//"
112
+ }
113
+ extract_session() { # extract_session <result.json> — the launcher's session id
114
+ # Fresh-context proof: a real `claude -p` mints a new session_id per call; the
115
+ # mock varies it per attempt. Dependency-free extraction (no jq, NFR-026).
116
+ [ -f "$1" ] || return 0
117
+ sed -n 's/.*"session_id":"\([^"]*\)".*/\1/p' "$1" 2>/dev/null | head -1
118
+ }
119
+ result_num() { # result_num <result.json> <json-key> — LAST numeric value of "key":N
120
+ # The launcher's result envelope carries the AUTHORITATIVE usage (input/output
121
+ # tokens, total_cost_usd) — unlike the phase-report's usage block, which the phase
122
+ # self-reports and cannot actually know (it wrote 0 tokens in the live walk). We
123
+ # read the real numbers here. The `"key":` anchor (leading quote) means a key like
124
+ # cache_creation_input_tokens never matches `input_tokens` (no quote before it).
125
+ # `tail -1`: the real scalar is appended AFTER the model-controlled `result` string,
126
+ # so if a phase tries to spoof e.g. total_cost_usd in its answer text, the real one
127
+ # still wins. (A JSON-escaped injection `\"key\":N` wouldn't match this grep anyway.)
128
+ [ -f "$1" ] || { printf '0'; return 0; }
129
+ _rn="$(grep -o "\"$2\":[0-9][0-9.]*" "$1" 2>/dev/null | tail -1 | sed 's/.*://')"
130
+ [ -n "$_rn" ] && printf '%s' "$_rn" || printf '0'
131
+ }
132
+ usage_num() { # usage_num <result.json> <input_tokens|output_tokens> — the AGGREGATE
133
+ # A real envelope carries MORE than one "input_tokens" (a nested per-tool/per-model
134
+ # breakdown), so a bare first/last match is unreliable. Anchor to the top-level
135
+ # `usage` object: greedy `.*"usage":{` lands on the LAST `usage:{` (the launcher's,
136
+ # after the model's answer — "modelUsage" uses camelCase, never matches), and the
137
+ # aggregate input/output tokens sit at its head, so the FIRST match within is the
138
+ # aggregate. Also drops any value the model injected before `usage` in its text.
139
+ [ -f "$1" ] || { printf '0'; return 0; }
140
+ _um="$(sed 's/.*"usage":{//' "$1" 2>/dev/null | grep -o "\"$2\":[0-9]*" | head -1 | sed 's/.*://')"
141
+ [ -n "$_um" ] && printf '%s' "$_um" || printf '0'
142
+ }
143
+
144
+ # --- model-by-phase-type map (STEP-5 SEAM — read, NOT routed) ----------------
145
+ # The runner reads config.model_by_phase_type to RECORD each phase's intended model
146
+ # into usage.tsv (spend attribution + step 5's switch surface). It deliberately
147
+ # does NOT turn that value into a routing flag on the headless run — routing on this
148
+ # map is step 5's job (FR-160 / handoff B5). Recording without routing is the seam.
149
+ cfg_map_get() { # cfg_map_get <map-key> <sub-key> <default> — an indented sub-key
150
+ _mv=""
151
+ if [ -f "$CONFIG" ]; then
152
+ _mv="$(awk -v mk="$1" -v sk="$2" '
153
+ $0 ~ ("^" mk ":[[:space:]]*$") { inb=1; next }
154
+ inb && /^[^[:space:]#]/ { inb=0 }
155
+ inb && $0 ~ ("^[[:space:]]+" sk ":") {
156
+ line=$0
157
+ sub("^[[:space:]]+" sk ":[[:space:]]*", "", line)
158
+ sub(/[[:space:]]*(#.*)?$/, "", line)
159
+ print line; exit
160
+ }
161
+ ' "$CONFIG" 2>/dev/null)"
162
+ fi
163
+ [ -n "$_mv" ] && printf '%s' "$_mv" || printf '%s' "$3"
164
+ }
165
+ phase_type_of() { # phase_type_of <phase> — optional `phase_type:` in the phase plan
166
+ _pt=""
167
+ _pl="$WORKTREE/phases/$1/plan.md"
168
+ [ -f "$_pl" ] && _pt="$(grep -E '^phase_type:' "$_pl" 2>/dev/null | head -1 \
169
+ | sed -E 's/^phase_type:[[:space:]]*//; s/[[:space:]]*(#.*)?$//')"
170
+ printf '%s' "$_pt"
171
+ }
172
+ model_for_phase() { # model_for_phase <phase> <report-file> — intended model, recorded not routed
173
+ _mp_type="$(phase_type_of "$1")"
174
+ _mp=""
175
+ [ -n "$_mp_type" ] && _mp="$(cfg_map_get model_by_phase_type "$_mp_type" "")"
176
+ # Fall back to what the phase itself recorded, then a placeholder.
177
+ [ -z "$_mp" ] && _mp="$(report_nested "$2" model)"
178
+ [ -z "$_mp" ] && _mp="-"
179
+ printf '%s' "$_mp"
180
+ }
181
+
182
+ # --- named seams (filled by later plans; explicit stubs, not silent) ---------
183
+
184
+ # notify: the swappable ping channel. Delivery is DELEGATED to an external command
185
+ # (FORGE_SLICE_NOTIFY, default forge-slice-notify.sh — a thin `claude -p` →
186
+ # PushNotification micro-call); tests point it at notify-stub.sh. The dedup stays
187
+ # HERE (the runner owns "should this ping fire at all"): APPEND-ONCE by <key>+<kind>,
188
+ # a marker records a ping fired so a crash-resume re-run never re-pings. The marker
189
+ # lives under WORK_DIR (inside the slice worktree by default), so it persists across
190
+ # runs and never touches primary.
191
+ #
192
+ # The §8 ping surface is CLOSED to five operator-facing types:
193
+ # ambiguity|irreversible|fork|budget|done
194
+ # plus `halt` as the failure slice-end (the counterpart of `done`). The park branch
195
+ # below rejects any `interrupt` value outside that set loudly, never forwarding it.
196
+ NOTIFY_CMD="${FORGE_SLICE_NOTIFY:-$SELF_DIR/forge-slice-notify.sh}"
197
+ slice_notify() { # slice_notify <kind> <key> <payload>
198
+ _kind="$1"; _key="$2"; _msg="$3"
199
+ _marker="$PING_DIR/$_key-$_kind.marker"
200
+ if [ -f "$_marker" ]; then
201
+ printf '[ping] (suppressed dup) %s: %s\n' "$_kind" "$_msg" >&2
202
+ return 0
203
+ fi
204
+ mkdir -p "$PING_DIR"
205
+ printf '%s\n' "$_msg" > "$_marker"
206
+ # Tally this actual fire by §8 type (deduped fires only) — feeds slice-summary.txt.
207
+ case "$_kind" in
208
+ ambiguity) PING_AMBIGUITY=$((PING_AMBIGUITY + 1)) ;;
209
+ irreversible) PING_IRREVERSIBLE=$((PING_IRREVERSIBLE + 1)) ;;
210
+ fork) PING_FORK=$((PING_FORK + 1)) ;;
211
+ budget) PING_BUDGET=$((PING_BUDGET + 1)) ;;
212
+ done) PING_DONE=$((PING_DONE + 1)) ;;
213
+ halt) PING_HALT=$((PING_HALT + 1)) ;;
214
+ esac
215
+ printf '[ping] %s: %s\n' "$_kind" "$_msg" >&2
216
+ # Dispatch to the swappable delivery channel — BEST-EFFORT: a channel that is
217
+ # missing, unauthed, or errors must never crash the slice (phone delivery is a
218
+ # deferred open item). The local `[ping]` line + marker above are the durable log.
219
+ "$NOTIFY_CMD" "$_kind" "$_msg" >&2 2>&1 \
220
+ || printf '[ping] (notify channel best-effort: %s exited non-zero for %s)\n' "$NOTIFY_CMD" "$_kind" >&2
221
+ LAST_PING="$_kind"
222
+ }
223
+
224
+ # refresh: at each phase boundary, integrate main into the slice worktree, then
225
+ # check how far the slice has diverged. This is where a slice incorporates other
226
+ # slices' landed work — conflicts stay small + early (J§5.1). Returns 0 to proceed
227
+ # SILENTLY; returns 1 to PARK (a §8 fork ping has already fired) so the caller
228
+ # STOPs BEFORE launching the next phase. FORGE_SLICE_REFRESH=off makes it a no-op.
229
+ # The merge-boundary half of the divergence budget lives in step 2, not here.
230
+ slice_refresh_from_main() { # slice_refresh_from_main <phase-name>
231
+ _rf_phase="${1:-?}"
232
+ [ "$REFRESH_MAIN" = "off" ] && return 0
233
+
234
+ # Resolve the integration ref: origin/<branch> when a remote has it (fetch
235
+ # first, best-effort), else the local branch. Missing entirely (e.g. the first
236
+ # slice, before main exists) → nothing to integrate → proceed silently.
237
+ _int_ref=""
238
+ if git -C "$WORKTREE" remote | grep -q .; then
239
+ git -C "$WORKTREE" fetch --quiet origin "$INT_BRANCH" 2>/dev/null || true
240
+ if git -C "$WORKTREE" rev-parse --verify --quiet "origin/$INT_BRANCH" >/dev/null 2>&1; then
241
+ _int_ref="origin/$INT_BRANCH"
242
+ fi
243
+ fi
244
+ if [ -z "$_int_ref" ] && git -C "$WORKTREE" rev-parse --verify --quiet "$INT_BRANCH" >/dev/null 2>&1; then
245
+ _int_ref="$INT_BRANCH"
246
+ fi
247
+ [ -n "$_int_ref" ] || { printf '[runner] refresh: no %s ref to integrate — skipping\n' "$INT_BRANCH" >&2; return 0; }
248
+
249
+ # Attempt the merge. A clean fast-forward or clean auto-merge is MECHANICAL →
250
+ # silent, proceed. A real merge conflict is beyond mechanical → NEVER
251
+ # auto-resolve (FORGE.md); abort and PARK with a fork ping.
252
+ if git -C "$WORKTREE" merge --no-edit "$_int_ref" >/dev/null 2>&1; then
253
+ printf '[runner] refresh: integrated %s cleanly\n' "$_int_ref" >&2
254
+ else
255
+ git -C "$WORKTREE" merge --abort >/dev/null 2>&1 || true
256
+ slice_notify fork "refresh-$_rf_phase" \
257
+ "refresh from $INT_BRANCH conflicts at phase boundary ($_rf_phase) — resolve, split, or land the slice; the runner will not auto-resolve"
258
+ return 1
259
+ fi
260
+
261
+ # Divergence budget: how far has the slice drifted from main since their shared
262
+ # base (commits and changed lines)? Over EITHER threshold → park with a fork
263
+ # ping ("split, land, or explicitly extend"). Within → proceed silently.
264
+ _base="$(git -C "$WORKTREE" merge-base "$_int_ref" HEAD 2>/dev/null || echo '')"
265
+ if [ -n "$_base" ]; then
266
+ _commits="$(git -C "$WORKTREE" rev-list --count "$_base"..HEAD 2>/dev/null || echo 0)"
267
+ _lines="$(git -C "$WORKTREE" diff --numstat "$_base" HEAD 2>/dev/null | awk '{a+=$1; d+=$2} END{print a+d+0}')"
268
+ : "${_lines:=0}"
269
+ if [ "$_commits" -gt "$DIV_BUDGET_COMMITS" ] || [ "$_lines" -gt "$DIV_BUDGET_LINES" ]; then
270
+ slice_notify fork "divergence-$_rf_phase" \
271
+ "divergence budget blown at phase boundary ($_rf_phase): ${_commits} commits / ${_lines} lines vs $INT_BRANCH (budget ${DIV_BUDGET_COMMITS}c/${DIV_BUDGET_LINES}l) — split, land, or explicitly extend"
272
+ return 1
273
+ fi
274
+ printf '[runner] refresh: divergence within budget (%sc/%sl of %sc/%sl)\n' \
275
+ "$_commits" "$_lines" "$DIV_BUDGET_COMMITS" "$DIV_BUDGET_LINES" >&2
276
+ fi
277
+ return 0
278
+ }
279
+
280
+ # resume/done-detection — COMPUTE, don't store (NFR-028 / Fork 3 / Amendment 2).
281
+ # A phase is "done" iff its work dir already holds a phase-report.yml with a
282
+ # TERMINAL verdict (proceed/done). The resume point is derived every run by this
283
+ # check — there is NO runner-owned cursor/state file. Kill the runner between
284
+ # phases and re-run: it recomputes the same point from the reports on disk, redoes
285
+ # no completed phase, and (via the append-once ping markers) re-pings nothing.
286
+ phase_already_done() { # phase_already_done <report-file>
287
+ [ -f "$1" ] || return 1
288
+ _v="$(report_field "$1" verdict)"
289
+ [ "$_v" = "proceed" ] || [ "$_v" = "done" ]
290
+ }
291
+
292
+ # --- the loop ----------------------------------------------------------------
293
+ COMPLETED=0
294
+ TOUCHES=0 # RESERVED, structurally always 0 — a files-only headless run has no
295
+ # between-phase operator interaction to count. Reported in SLICE_RESULT
296
+ # + slice-summary as a fixed 0 (a stable field for step-3's board), not a
297
+ # live measurement. Wire a real source here if attended runs ever land.
298
+ LAST_PING="none"
299
+ ACCUM=0 # cumulative usage.cost_usd across phases — the per-slice spend
300
+ idx=0
301
+ total="$(printf '%s\n' "$PHASES" | grep -c .)"
302
+
303
+ # Per-slice ping tallies by §8 type (+ halt failure-end). Incremented by
304
+ # slice_notify on each real fire; written to slice-summary.txt on every exit path.
305
+ PING_AMBIGUITY=0; PING_IRREVERSIBLE=0; PING_FORK=0; PING_BUDGET=0; PING_DONE=0; PING_HALT=0
306
+ USAGE_FILE="$WORK_DIR/usage.tsv" # per-phase receipts (one row per phase)
307
+ SUMMARY_FILE="$WORK_DIR/slice-summary.txt" # per-slice ping/touch/spend roll-up
308
+
309
+ # Per-slice summary is written from an EXIT trap so EVERY termination path — done,
310
+ # halt, park, budget breach, phase-list exhaustion, even a crash exit — leaves the
311
+ # same roll-up on disk. Uses ${..:-} defaults so it is safe under `set -u` even if
312
+ # we die before the counters are live. Files-only, never authoritative (Fork 3).
313
+ write_slice_summary() {
314
+ [ -n "${WORK_DIR:-}" ] || return 0
315
+ {
316
+ printf 'phases_completed=%s\n' "${COMPLETED:-0}"
317
+ printf 'touches=%s\n' "${TOUCHES:-0}"
318
+ printf 'spend_usd=%s\n' "${ACCUM:-0}"
319
+ printf 'ambiguity=%s\n' "${PING_AMBIGUITY:-0}"
320
+ printf 'irreversible=%s\n' "${PING_IRREVERSIBLE:-0}"
321
+ printf 'fork=%s\n' "${PING_FORK:-0}"
322
+ printf 'budget=%s\n' "${PING_BUDGET:-0}"
323
+ printf 'done=%s\n' "${PING_DONE:-0}"
324
+ printf 'halt=%s\n' "${PING_HALT:-0}"
325
+ printf 'last_ping=%s\n' "${LAST_PING:-none}"
326
+ } > "$SUMMARY_FILE" 2>/dev/null || true
327
+ }
328
+ trap write_slice_summary EXIT
329
+
330
+ # Iterate phases in order with a `for` (NOT a `printf | while`, which would run
331
+ # the loop in a subshell and swallow the `done`-verdict `exit` + the counters).
332
+ # Phase names are single tokens, so word-splitting on IFS is safe; `set -f`
333
+ # guards against a stray glob in a phase name.
334
+ set -f
335
+ for phase in $PHASES; do
336
+ [ -n "$phase" ] || continue
337
+ idx=$((idx + 1))
338
+ wdir="$WORK_DIR/phase-$idx"
339
+ mkdir -p "$wdir"
340
+ report="$wdir/phase-report.yml"
341
+
342
+ if phase_already_done "$report"; then
343
+ printf '[runner] phase %s (%s/%s) already done — skipping\n' "$phase" "$idx" "$total" >&2
344
+ COMPLETED=$((COMPLETED + 1))
345
+ continue
346
+ fi
347
+
348
+ # (1) ASSEMBLE the prompt FROM FILES ONLY -> assembled-prompt.txt.
349
+ # plan + prior phase-report.yml files + a repo-state snippet. Never a
350
+ # transcript or a prior-phase resume handle.
351
+ prompt="$wdir/assembled-prompt.txt"
352
+ {
353
+ printf '# Headless phase: %s (%s of %s)\n\n' "$phase" "$idx" "$total"
354
+ printf 'Files-only context. Everything you need is below or on disk in the slice\n'
355
+ printf 'worktree; there is no prior conversation to draw on.\n\n'
356
+
357
+ printf '## This phase plan\n\n'
358
+ if [ -f "$WORKTREE/phases/$phase/plan.md" ]; then
359
+ cat "$WORKTREE/phases/$phase/plan.md"
360
+ else
361
+ printf '(no plan.md for %s)\n' "$phase"
362
+ fi
363
+ printf '\n'
364
+
365
+ if [ "$idx" -gt 1 ]; then
366
+ printf '## Prior phase reports (files-only hand-off)\n\n'
367
+ j=1
368
+ while [ "$j" -lt "$idx" ]; do
369
+ prj="$WORK_DIR/phase-$j/phase-report.yml"
370
+ printf -- '- phase-%s/phase-report.yml:\n' "$j"
371
+ if [ -f "$prj" ]; then
372
+ sed 's/^/ /' "$prj"
373
+ else
374
+ printf ' (missing)\n'
375
+ fi
376
+ printf '\n'
377
+ j=$((j + 1))
378
+ done
379
+ fi
380
+
381
+ printf '## Repo state (slice worktree)\n\n'
382
+ printf '```\n'
383
+ git -C "$WORKTREE" log --oneline -5 2>/dev/null || true
384
+ printf -- '---\n'
385
+ git -C "$WORKTREE" status --short 2>/dev/null || true
386
+ printf '```\n'
387
+ } > "$prompt"
388
+
389
+ # (2) per-phase SYSTEM PROMPT: tell the phase to write phase-report.yml to its
390
+ # work dir as its LAST action. Carries the concrete report path so a real
391
+ # `claude -p` phase knows exactly where to put it (the mock reads the env).
392
+ sysprompt="$wdir/system-prompt.txt"
393
+ {
394
+ printf 'You are executing ONE phase of a Forge slice, headless and unattended.\n'
395
+ printf 'Do the work described in the user prompt. Rely only on the files given\n'
396
+ printf 'and the slice worktree — there is no chat history behind this run.\n\n'
397
+ printf 'As your FINAL action, write a phase report FILE to this exact path\n'
398
+ printf '(the runner reads it from disk — it is NOT parsed from your reply):\n'
399
+ printf ' %s\n' "$report"
400
+ printf 'It MUST match the annotated example here:\n'
401
+ printf ' %s\n' "$REPORT_TEMPLATE"
402
+ printf 'and the JSON schema here:\n'
403
+ printf ' %s\n' "$SCHEMA"
404
+ printf 'Set verdict to one of proceed|park|halt|done. If you hit a Rule-4 stop,\n'
405
+ printf 'an ambiguity, an irreversible step, a design fork, or a budget breach,\n'
406
+ printf 'set verdict=park and fill interrupt + interrupt_payload — do not guess.\n'
407
+ } > "$sysprompt"
408
+
409
+ # (3) LAUNCH + FAILURE POLICY. A phase that HALTS ITSELF (verdict=halt) or whose
410
+ # own checks failed (checks.failed>0) gets exactly ONE fresh-context retry (a
411
+ # brand-new launcher call — new session id), absorbing a flake. A SECOND
412
+ # failure halts the slice early with evidence attached. This is a bounded
413
+ # count (attempt <= 2), NOT a loop-until-success (handoff B3: that is the
414
+ # token furnace). A launch that writes no report at all is a CRASH, not a
415
+ # check failure — it halts immediately so crash-resume re-runs it next boot.
416
+ _pstart="$(date +%s 2>/dev/null || echo 0)" # phase wall-clock start (runner-measured)
417
+ attempt=1
418
+ max_attempt=2
419
+ verdict=""
420
+ while : ; do
421
+ printf '[runner] phase %s (%s/%s): launching %s (attempt %s)\n' \
422
+ "$phase" "$idx" "$total" "$LAUNCHER" "$attempt" >&2
423
+ (
424
+ cd "$WORKTREE"
425
+ # A headless, zero-touch phase has no TTY to approve tool use, so the per-tool
426
+ # permission gate is bypassed — otherwise the very first Write (the work, or
427
+ # the mandatory phase-report.yml) blocks forever waiting for an approval that
428
+ # can never arrive. This is SAFE by the runner's design, not in spite of it:
429
+ # the slice runs in an ISOLATED worktree (main stays clean, locked decision),
430
+ # and the runner substitutes its OWN §8 `irreversible` interrupt for Claude
431
+ # Code's permission prompt — a dangerous action is DECLARED as a park, caught
432
+ # before it runs, not gated per-tool. Budget caps bound the blast radius.
433
+ FORGE_SLICE_REPORT_PATH="$report" \
434
+ FORGE_SLICE_PHASE="$phase" \
435
+ FORGE_SLICE_PHASE_INDEX="$idx" \
436
+ FORGE_SLICE_ATTEMPT="$attempt" \
437
+ "$LAUNCHER" -p \
438
+ --output-format json \
439
+ --max-turns "$MAX_TURNS" \
440
+ --max-budget-usd "$MAX_BUDGET" \
441
+ --dangerously-skip-permissions \
442
+ --append-system-prompt-file "$sysprompt" \
443
+ < "$prompt" \
444
+ > "$wdir/result.json" 2> "$wdir/launch.err"
445
+ ) || {
446
+ printf '[runner] phase %s: launcher exited non-zero (attempt %s)\n' "$phase" "$attempt" >&2
447
+ }
448
+
449
+ # Record this attempt's session id — the fresh-context proof the retry asserts.
450
+ extract_session "$wdir/result.json" > "$wdir/attempt-$attempt.session"
451
+
452
+ # (4) REQUIRE the on-disk phase-report.yml. Absent = crash → halt now (do NOT
453
+ # spend the retry on a crash; the resume path re-runs it from files).
454
+ if [ ! -f "$report" ]; then
455
+ printf '[runner] phase %s: no phase-report.yml written at %s — halting\n' "$phase" "$report" >&2
456
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
457
+ exit 1
458
+ fi
459
+
460
+ verdict="$(report_field "$report" verdict)"
461
+ cfailed="$(report_nested "$report" failed)"
462
+ case "$cfailed" in ''|*[!0-9]*) cfailed=0 ;; esac
463
+
464
+ # FAIL = the phase halted itself OR its own checks failed (a proceed with
465
+ # checks.failed>0 is the contradiction the schema calls halt-worthy).
466
+ if [ "$verdict" = "halt" ] || [ "$cfailed" -gt 0 ]; then
467
+ if [ "$attempt" -lt "$max_attempt" ]; then
468
+ printf '[runner] phase %s: FAILED (verdict=%s checks.failed=%s) — one fresh-context retry\n' \
469
+ "$phase" "$verdict" "$cfailed" >&2
470
+ attempt=$((attempt + 1))
471
+ continue
472
+ fi
473
+ # Retry exhausted → HALT the slice early, failure evidence attached.
474
+ _ev="$(report_field "$report" evidence)"
475
+ slice_notify halt slice \
476
+ "slice HALTED at phase $phase ($idx/$total) after 1 retry — verdict=$verdict checks.failed=$cfailed; evidence: $report${_ev:+ ; $_ev}"
477
+ printf '[runner] phase %s: HALTED after retry — failure evidence at %s\n' "$phase" "$report" >&2
478
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
479
+ exit 1
480
+ fi
481
+ break # phase passed its own checks — proceed to the verdict branch below
482
+ done
483
+
484
+ # A phase that produced a valid (non-halt) report RAN — its usage is recorded
485
+ # below regardless — but "completed" is reserved for proceed/done. A `park` ran
486
+ # WITHOUT completing (it stopped for the operator), so COMPLETED is incremented in
487
+ # the proceed/done branches only, NOT here — a parked phase must not inflate the
488
+ # count (live walk: an irreversible park was showing phases_completed=2).
489
+
490
+ # Accumulate the per-slice spend from the launcher's AUTHORITATIVE receipt
491
+ # (result.json total_cost_usd), not the phase's self-reported cost (feeds the
492
+ # budget cap below + B5 instrumentation). Float math via awk — POSIX sh has no
493
+ # floats; LC_NUMERIC=C forces a `.` decimal so the stored value never picks up a
494
+ # locale comma (the live walk showed `0,000000`).
495
+ _cost="$(result_num "$wdir/result.json" total_cost_usd)"
496
+ case "$_cost" in ''|*[!0-9.]*) _cost=0 ;; esac
497
+ ACCUM="$(LC_NUMERIC=C awk -v a="$ACCUM" -v b="$_cost" 'BEGIN{printf "%.6f", (a+0)+(b+0)}')"
498
+ printf '[runner] phase %s: verdict=%s spend=$%s/$%s\n' "$phase" "$verdict" "$ACCUM" "$SLICE_BUDGET" >&2
499
+
500
+ # (4a) INSTRUMENTATION — append this phase's receipts to usage.tsv (B5 / step-5
501
+ # spend seam), one row per phase. Sourced for FIDELITY (live walk found the
502
+ # phase-report's self-reported usage was all zeros — a phase can't know its
503
+ # own token count):
504
+ # tokens = result.json input_tokens + output_tokens (launcher truth)
505
+ # wall_clock_s = runner-measured elapsed for the phase (incl. any retry)
506
+ # retry_count = runner-observed (attempt-1), not the phase's self-report
507
+ # model = intended model for the phase-type, READ from config but
508
+ # NOT routed (model_for_phase → the step-5 seam)
509
+ _tok_in="$(usage_num "$wdir/result.json" input_tokens)"
510
+ _tok_out="$(usage_num "$wdir/result.json" output_tokens)"
511
+ _u_tokens="$(awk -v a="$_tok_in" -v b="$_tok_out" 'BEGIN{printf "%d", (a+0)+(b+0)}')"
512
+ _now="$(date +%s 2>/dev/null || echo 0)"
513
+ if [ "$_now" -ge "$_pstart" ] 2>/dev/null; then _u_wall=$(( _now - _pstart )); else _u_wall=0; fi
514
+ _u_retry=$(( attempt - 1 ))
515
+ _u_model="$(model_for_phase "$phase" "$report")"
516
+ [ -f "$USAGE_FILE" ] || printf 'phase_name\ttokens\twall_clock_s\tmodel\tretry_count\n' > "$USAGE_FILE"
517
+ printf '%s\t%s\t%s\t%s\t%s\n' "$phase" "$_u_tokens" "$_u_wall" "$_u_model" "$_u_retry" >> "$USAGE_FILE"
518
+
519
+ # (5) BRANCH on verdict. proceed advances; done ends with the done ping.
520
+ # park is STUBBED here (plan-48 wires the real §8 interrupt ping) — explicit,
521
+ # not silent. halt never reaches here (handled by the retry/halt path above).
522
+ case "$verdict" in
523
+ proceed)
524
+ COMPLETED=$((COMPLETED + 1)) # this phase finished its work and advances
525
+ # Per-slice budget cap (§8.4). A breach of the running spend total parks the
526
+ # slice with a `budget` ping BEFORE launching the next phase. Checked before
527
+ # the refresh — it is cheaper (no git) and a distinct §8 interrupt type.
528
+ if LC_NUMERIC=C awk -v a="$ACCUM" -v c="$SLICE_BUDGET" 'BEGIN{exit !((a+0) > (c+0))}'; then
529
+ slice_notify budget slice \
530
+ "per-slice budget breached after phase $phase ($idx/$total): spent \$$ACCUM > cap \$$SLICE_BUDGET — split the slice or raise per_slice_budget_usd"
531
+ printf '[runner] phase %s: BUDGET CAP breached ($%s > $%s) — parking slice\n' "$phase" "$ACCUM" "$SLICE_BUDGET" >&2
532
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
533
+ exit 3
534
+ fi
535
+ # Phase-boundary refresh + divergence budget. A non-zero return means the
536
+ # slice PARKED (fork ping already fired) — STOP before the next phase.
537
+ if ! slice_refresh_from_main "$phase"; then
538
+ printf '[runner] phase %s: slice PARKED at phase boundary (fork ping) — stopping before next phase\n' "$phase" >&2
539
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
540
+ exit 3
541
+ fi
542
+ ;;
543
+ done)
544
+ COMPLETED=$((COMPLETED + 1)) # the final phase completed the slice
545
+ slice_notify done slice "slice complete: $total phases, $COMPLETED run, 0 operator touches"
546
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
547
+ exit 0
548
+ ;;
549
+ park)
550
+ # A phase parks itself with a §8 interrupt. Map it to exactly ONE of the
551
+ # closed five-type set; anything else is rejected loudly (never forwarded).
552
+ # The park happens BEFORE the next phase launches, so an irreversible action
553
+ # (declared in the report, deferred by the phase) is caught pre-execution.
554
+ interrupt="$(report_field "$report" interrupt)"
555
+ ipayload="$(report_field "$report" interrupt_payload)"
556
+
557
+ # A design fork must arrive as something to LOOK AT — the payload is a
558
+ # sketch/mockup PATH that exists, never prose describing a visual (§8).
559
+ if [ "$interrupt" = "fork" ] && { [ -z "$ipayload" ] || [ ! -e "$ipayload" ]; }; then
560
+ printf '[runner] phase %s: fork interrupt payload %s is not an existing path — a design fork must be a sketch/mockup to look at, not prose; rejecting\n' "$phase" "${ipayload:-<empty>}" >&2
561
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
562
+ exit 1
563
+ fi
564
+
565
+ case "$interrupt" in
566
+ ambiguity|irreversible|fork|budget)
567
+ slice_notify "$interrupt" "$phase" "$ipayload"
568
+ printf '[runner] phase %s: PARKED — §8 %s interrupt (stopping before next phase)\n' "$phase" "$interrupt" >&2
569
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
570
+ exit 3
571
+ ;;
572
+ *)
573
+ printf '[runner] phase %s: park with interrupt %s — not one of the five §8 types (ambiguity|irreversible|fork|budget) — rejecting loudly\n' "$phase" "${interrupt:-<none>}" >&2
574
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
575
+ exit 1
576
+ ;;
577
+ esac
578
+ ;;
579
+ *)
580
+ printf '[runner] phase %s: unrecognized verdict "%s" — halting\n' "$phase" "$verdict" >&2
581
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
582
+ exit 1
583
+ ;;
584
+ esac
585
+ done
586
+ set +f
587
+
588
+ # The loop runs in the main shell, so a `done` verdict already `exit 0`'d above.
589
+ # Reaching here means the phase list was exhausted WITHOUT a `done` verdict — the
590
+ # slice ran every phase but the last one never marked itself done.
591
+ printf '[runner] phase list exhausted with no `done` verdict\n' >&2
592
+ printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
593
+ exit 0