forge-orkes 0.53.0 → 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-slice-notify.sh +66 -0
- package/template/.claude/hooks/forge-slice-runner.sh +593 -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
package/package.json
CHANGED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# forge-slice-notify.sh <type> <payload> — the DEFAULT notify channel for
|
|
3
|
+
# forge-slice-runner.sh. Delivers a §8 ping to the operator's phone via the
|
|
4
|
+
# PushNotification agent tool through a thin headless `claude -p` micro-call, and
|
|
5
|
+
# ALSO appends the ping to a local log so a suppressed/undelivered push is never
|
|
6
|
+
# silently lost.
|
|
7
|
+
#
|
|
8
|
+
# The runner calls this as `${FORGE_SLICE_NOTIFY:-forge-slice-notify.sh} <type>
|
|
9
|
+
# <payload>`; the whole channel is swappable via that env (tests use notify-stub.sh).
|
|
10
|
+
#
|
|
11
|
+
# type one of the §8 surface: ambiguity | irreversible | fork | budget | done
|
|
12
|
+
# (plus `halt`, the failure slice-end). The runner has already validated
|
|
13
|
+
# the type before calling; this script just delivers it.
|
|
14
|
+
# payload the ping body. For a design `fork` it is a PATH to a sketch/mockup the
|
|
15
|
+
# operator LOOKS AT (§8) — passed through so the notification names the file.
|
|
16
|
+
#
|
|
17
|
+
# BEST-EFFORT by contract: this must exit 0 even when delivery is impossible (no
|
|
18
|
+
# `claude` on PATH, no CLAUDE_CODE_OAUTH_TOKEN, the tool suppressed while the
|
|
19
|
+
# operator is active). The runner treats a non-zero exit as a best-effort miss, not
|
|
20
|
+
# a slice failure — so never hard-fail here.
|
|
21
|
+
#
|
|
22
|
+
# ── DEFERRED phone-delivery caveat (context.md M29, 2026-07-15) ───────────────
|
|
23
|
+
# A PushNotification SENT from a DETACHED one-shot `claude -p` returns success but
|
|
24
|
+
# may NOT buzz the phone — this is the Claude-app Remote-Control DEVICE-pairing
|
|
25
|
+
# side (an ephemeral unpaired session), not this code. It is a known open item,
|
|
26
|
+
# fixed later. The channel is swappable precisely so that fix can land without
|
|
27
|
+
# touching the runner. Escalate only if delivery turns out to need Remote-Control
|
|
28
|
+
# state a detached runner cannot satisfy.
|
|
29
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
set -eu
|
|
31
|
+
|
|
32
|
+
TYPE="${1:-note}"
|
|
33
|
+
PAYLOAD="${2:-}"
|
|
34
|
+
|
|
35
|
+
# 1) Durable local record — always, before any delivery attempt. Never lost even
|
|
36
|
+
# if the phone channel is down. Default location travels with the slice.
|
|
37
|
+
LOG="${FORGE_SLICE_NOTIFY_LOG:-${FORGE_SLICE_WORK_DIR:-.}/notify.log}"
|
|
38
|
+
mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
|
|
39
|
+
printf '%s\t%s\t%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo now)" "$TYPE" "$PAYLOAD" >> "$LOG" 2>/dev/null || true
|
|
40
|
+
|
|
41
|
+
# 2) Best-effort phone delivery via PushNotification. Skip cleanly if headless
|
|
42
|
+
# `claude` or its credential is absent (dev boxes, CI) — that is a miss, not a
|
|
43
|
+
# failure. A design fork's payload is a path, so name it in the body.
|
|
44
|
+
LAUNCHER="${FORGE_SLICE_NOTIFY_LAUNCHER:-claude}"
|
|
45
|
+
if command -v "$LAUNCHER" >/dev/null 2>&1 \
|
|
46
|
+
&& { [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || [ -n "${ANTHROPIC_API_KEY:-}" ]; }; then
|
|
47
|
+
_title="Forge slice: ${TYPE}"
|
|
48
|
+
case "$TYPE" in
|
|
49
|
+
fork) _body="Design fork — review the sketch: ${PAYLOAD}" ;;
|
|
50
|
+
done) _body="Slice complete. ${PAYLOAD}" ;;
|
|
51
|
+
*) _body="${PAYLOAD}" ;;
|
|
52
|
+
esac
|
|
53
|
+
# One-shot: ask the headless agent to fire PushNotification with the title/body.
|
|
54
|
+
printf 'Send a push notification with title "%s" and body "%s". Use only the PushNotification tool; do nothing else.\n' \
|
|
55
|
+
"$_title" "$_body" \
|
|
56
|
+
| "$LAUNCHER" -p \
|
|
57
|
+
--allowedTools PushNotification \
|
|
58
|
+
--permission-mode bypassPermissions \
|
|
59
|
+
--output-format json \
|
|
60
|
+
>/dev/null 2>&1 \
|
|
61
|
+
|| printf 'forge-slice-notify: push delivery best-effort miss for %s (logged locally at %s)\n' "$TYPE" "$LOG" >&2
|
|
62
|
+
else
|
|
63
|
+
printf 'forge-slice-notify: no headless claude/credential — %s ping logged locally only (%s)\n' "$TYPE" "$LOG" >&2
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
exit 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
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# config.yml — forge-slice-runner.sh per-slice configuration.
|
|
2
|
+
#
|
|
3
|
+
# Flat key: value only (the runner parses it with grep/sed — no YAML lib, NFR-026).
|
|
4
|
+
# Everything here bounds ONE slice run; the runner holds no state of its own
|
|
5
|
+
# (Fork 3 / NFR-028). Do NOT add scheduler / daemon / queue / resident-state keys —
|
|
6
|
+
# if the runner ever needs authoritative state to work, that is a STOP-and-route-out
|
|
7
|
+
# signal, not a config addition.
|
|
8
|
+
|
|
9
|
+
# --- budget & bounds --------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
# Static per-slice hard cap on spend. A breach parks the slice with a §8 `budget`
|
|
12
|
+
# ping (phase 47). Deliberately generous — the point is a runaway guard, not a
|
|
13
|
+
# tight leash. `claude -p --max-budget-usd` also bounds each phase individually.
|
|
14
|
+
per_slice_budget_usd: 25
|
|
15
|
+
|
|
16
|
+
# Max agent turns per headless phase (passed to `claude -p --max-turns`). Bounds a
|
|
17
|
+
# single phase; cross-platform (unlike a `timeout` wall-clock, which macOS lacks).
|
|
18
|
+
max_turns_per_phase: 60
|
|
19
|
+
|
|
20
|
+
# RESERVED — not yet enforced. Intended as an optional per-phase wall-clock cap in
|
|
21
|
+
# seconds (0 = off); the runner does not read this key yet (macOS has no stock
|
|
22
|
+
# `timeout`, so a portable guard is still to be built). Setting it is currently a
|
|
23
|
+
# no-op — left here so the config shape is stable for when the guard lands.
|
|
24
|
+
max_wall_clock_s_per_phase: 0
|
|
25
|
+
|
|
26
|
+
# --- divergence budget (phase-boundary check, plan-03) ----------------------
|
|
27
|
+
|
|
28
|
+
# The branch the slice integrates at each phase boundary. `origin/<branch>` is
|
|
29
|
+
# preferred when a remote has it (fetched first), else the local branch.
|
|
30
|
+
integration_branch: main
|
|
31
|
+
|
|
32
|
+
# At each phase boundary the runner refreshes the slice worktree from main and
|
|
33
|
+
# checks how far the slice has diverged. Exceeding either threshold parks the
|
|
34
|
+
# slice with a §8 `fork` ping. The merge-boundary half of this budget lives in
|
|
35
|
+
# step 2 (not here).
|
|
36
|
+
divergence_budget_commits: 40
|
|
37
|
+
divergence_budget_lines: 4000
|
|
38
|
+
|
|
39
|
+
# --- notification seam (phase 48) -------------------------------------------
|
|
40
|
+
|
|
41
|
+
# The channel the runner's `notify` seam dispatches §8 pings + the done ping over.
|
|
42
|
+
# `pushnotification` = the PushNotification agent tool via a thin `claude -p`
|
|
43
|
+
# micro-call (the only existing phone channel; smoke-tested 2026-07-15). Kept
|
|
44
|
+
# behind a swappable seam so phone-delivery follow-up can change the channel
|
|
45
|
+
# without touching the loop.
|
|
46
|
+
notify_channel: pushnotification
|
|
47
|
+
|
|
48
|
+
# --- model-by-phase-type map (STEP-5 SEAM — read, not routed) ---------------
|
|
49
|
+
|
|
50
|
+
# Read per phase-type and recorded into phase-report.yml usage.model, but NOT used
|
|
51
|
+
# to route the launch yet — this is the seam step 5 (per-phase model routing)
|
|
52
|
+
# fills. Leaving it here now keeps the report's `model` field populated and gives
|
|
53
|
+
# step 5 a config surface to switch on. The runner MUST NOT branch launch behavior
|
|
54
|
+
# on this map in this milestone.
|
|
55
|
+
model_by_phase_type:
|
|
56
|
+
researching: sonnet
|
|
57
|
+
discussing: sonnet
|
|
58
|
+
architecting: opus
|
|
59
|
+
planning: opus
|
|
60
|
+
executing: sonnet
|
|
61
|
+
verifying: haiku
|
|
62
|
+
reviewing: sonnet
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "forge-slice-runner phase report",
|
|
4
|
+
"description": "Machine-readable phase result the runner reads to decide the next action. JSON-Schema form of phase-report.yml (kept in lockstep). Passed to `claude -p --json-schema` so a headless phase's structured output mirrors the on-disk phase-report.yml it writes.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["verdict", "interrupt", "interrupt_payload", "checks", "usage", "evidence"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"verdict": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"enum": ["proceed", "park", "halt", "done"],
|
|
12
|
+
"description": "What the runner does next. proceed=advance; park=§8 interrupt, stop mid-slice; halt=failed own checks after retry, stop with evidence; done=last phase, emit done ping."
|
|
13
|
+
},
|
|
14
|
+
"interrupt": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"enum": ["none", "ambiguity", "irreversible", "fork", "budget"],
|
|
17
|
+
"description": "The §8 interrupt type when verdict==park, else none. Exactly the five-type surface."
|
|
18
|
+
},
|
|
19
|
+
"interrupt_payload": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "Question text for ambiguity/irreversible/budget; a sketch/mockup PATH for a fork. Empty when interrupt==none."
|
|
22
|
+
},
|
|
23
|
+
"checks": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"required": ["passed", "failed"],
|
|
27
|
+
"properties": {
|
|
28
|
+
"passed": { "type": "integer", "minimum": 0 },
|
|
29
|
+
"failed": { "type": "integer", "minimum": 0 }
|
|
30
|
+
},
|
|
31
|
+
"description": "Counts from the phase's must_haves.truths check: exit codes."
|
|
32
|
+
},
|
|
33
|
+
"usage": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"required": ["tokens", "cost_usd", "model", "wall_clock_s", "retry_count"],
|
|
37
|
+
"properties": {
|
|
38
|
+
"tokens": { "type": "integer", "minimum": 0 },
|
|
39
|
+
"cost_usd": { "type": "number", "minimum": 0 },
|
|
40
|
+
"model": { "type": "string" },
|
|
41
|
+
"wall_clock_s": { "type": "number", "minimum": 0 },
|
|
42
|
+
"retry_count": { "type": "integer", "minimum": 0 }
|
|
43
|
+
},
|
|
44
|
+
"description": "Per-phase receipts. Feeds B5 instrumentation + step-5 spend seam."
|
|
45
|
+
},
|
|
46
|
+
"evidence": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"description": "Pointer to the fuller execution-summary.md / verification-report.md and key commit shas."
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# phase-report.yml — the contract every headless phase writes and the runner reads.
|
|
2
|
+
#
|
|
3
|
+
# A headless phase (a fresh `claude -p` run driven by forge-slice-runner.sh) has
|
|
4
|
+
# no chat back-channel: executing's Rule-4-STOP, verifying's UNCERTAIN, and design
|
|
5
|
+
# forks must all become a MACHINE-READABLE interrupt the runner turns into a §8
|
|
6
|
+
# ping. This file is that machine-readable surface. The phase writes it to its
|
|
7
|
+
# work dir as its LAST action; the runner reads `verdict`+`interrupt` to decide
|
|
8
|
+
# what happens next, and `usage` feeds B5 instrumentation + step 5's spend seam.
|
|
9
|
+
#
|
|
10
|
+
# It LAYERS ON TOP of the fuller reports (execution-summary.md, verification-
|
|
11
|
+
# report.md) — it does not replace them; `evidence` points at those. Schema locked
|
|
12
|
+
# in context.md M29 (2026-07-15). Keep any change in lockstep with
|
|
13
|
+
# phase-report.schema.json (the --json-schema form) and assert-report-schema.sh.
|
|
14
|
+
#
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# The example below is a valid, minimal "proceed" report. Copy + overwrite.
|
|
17
|
+
|
|
18
|
+
verdict: proceed
|
|
19
|
+
# verdict — REQUIRED. What the runner does next. One of:
|
|
20
|
+
# proceed — phase met its goal; advance to the next phase.
|
|
21
|
+
# park — phase hit a §8 interrupt and cannot continue unattended; the runner
|
|
22
|
+
# pings the operator and stops mid-slice. `interrupt` MUST be set.
|
|
23
|
+
# halt — phase failed its own checks after its one retry; the runner stops
|
|
24
|
+
# early on the slice-end channel with failure evidence attached.
|
|
25
|
+
# done — this was the last phase of the slice; the runner emits the done ping.
|
|
26
|
+
|
|
27
|
+
interrupt: none
|
|
28
|
+
# interrupt — REQUIRED. The §8 interrupt type when verdict==park, else `none`.
|
|
29
|
+
# Exactly the five-type surface (context.md M29 / brief §8), nothing else:
|
|
30
|
+
# none | ambiguity | irreversible | fork | budget
|
|
31
|
+
|
|
32
|
+
interrupt_payload: ""
|
|
33
|
+
# interrupt_payload — REQUIRED (may be empty string when interrupt==none).
|
|
34
|
+
# For ambiguity/irreversible/budget: the question text to put in the ping.
|
|
35
|
+
# For a `fork` (design decision): a PATH to a sketch/mockup the operator LOOKS AT —
|
|
36
|
+
# never prose describing a visual (brief §8: a fork arrives as something to see).
|
|
37
|
+
|
|
38
|
+
checks:
|
|
39
|
+
passed: 0
|
|
40
|
+
failed: 0
|
|
41
|
+
# checks — REQUIRED. Counts from the phase's own must_haves.truths `check:` exit
|
|
42
|
+
# codes (0 = passed). `failed > 0` with verdict==proceed is a contradiction the
|
|
43
|
+
# runner treats as halt-worthy.
|
|
44
|
+
|
|
45
|
+
usage:
|
|
46
|
+
tokens: 0
|
|
47
|
+
cost_usd: 0
|
|
48
|
+
model: ""
|
|
49
|
+
wall_clock_s: 0
|
|
50
|
+
retry_count: 0
|
|
51
|
+
# usage — REQUIRED. Per-phase receipts. `retry_count` is how many fresh-context
|
|
52
|
+
# retries this phase took (0 = passed first try, max 1 under the halt-after-one
|
|
53
|
+
# policy). Feeds B5 instrumentation and step 5's spend-attribution seam. `model`
|
|
54
|
+
# is read from config per phase-type but NOT routed yet (the step-5 seam).
|
|
55
|
+
|
|
56
|
+
evidence: ""
|
|
57
|
+
# evidence — REQUIRED (may be empty). A pointer to the fuller reports and key
|
|
58
|
+
# commit shas produced this phase, e.g. "execution-summary.md; commits abc123,def456".
|