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.
- package/package.json +1 -1
- package/template/.claude/hooks/forge-reserve.sh +51 -15
- package/template/.claude/hooks/forge-slice-notify.sh +66 -0
- package/template/.claude/hooks/forge-slice-runner.sh +593 -0
- package/template/.claude/hooks/tests/verify-signoff.test.sh +122 -0
- package/template/.claude/hooks/tests/wu-done.test.sh +68 -0
- package/template/.claude/hooks/verify-signoff.sh +139 -0
- package/template/.claude/hooks/wu-done.sh +48 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +11 -3
- package/template/.claude/skills/deferred/SKILL.md +8 -4
- package/template/.claude/skills/discussing/SKILL.md +4 -2
- package/template/.claude/skills/executing/SKILL.md +21 -17
- package/template/.claude/skills/forge/SKILL.md +13 -10
- package/template/.claude/skills/planning/SKILL.md +3 -1
- package/template/.claude/skills/prototyping/SKILL.md +10 -7
- package/template/.claude/skills/quick-tasking/SKILL.md +3 -1
- package/template/.claude/skills/researching/SKILL.md +2 -0
- package/template/.claude/skills/reviewing/SKILL.md +7 -7
- package/template/.claude/skills/verifying/SKILL.md +8 -7
- package/template/.forge/FORGE.md +14 -7
- package/template/.forge/gitignore +8 -0
- package/template/.forge/migrations/0.52.0-id-reservation-milestone-refactor.md +45 -0
- package/template/.forge/templates/deferred-issue.md +22 -0
- package/template/.forge/templates/slice-runner/config.yml +62 -0
- package/template/.forge/templates/slice-runner/phase-report.schema.json +51 -0
- 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
|