forge-orkes 0.64.3 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.claude/hooks/README.md +1 -1
- package/template/.claude/hooks/block-dangerous-commands.sh +29 -0
- package/template/.claude/hooks/forge-model-outcome.sh +23 -6
- package/template/.claude/hooks/forge-slice-runner.sh +120 -13
- package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +154 -0
- package/template/.claude/hooks/tests/forge-model-outcome.test.sh +24 -0
- package/template/.claude/hooks/tests/forge-release-fold.test.sh +30 -2
- package/template/.claude/skills/jarvis/SKILL.md +1 -0
- package/template/.claude/skills/planning/SKILL.md +25 -4
- package/template/.forge/checks/forge-jarvis-instruments.sh +67 -0
- package/template/.forge/checks/forge-jarvis.sh +22 -2
- package/template/.forge/checks/tests/forge-jarvis-instruments.test.sh +23 -0
- package/template/.forge/checks/tests/forge-jarvis.test.sh +63 -4
- package/template/.forge/templates/plan.md +5 -0
- package/template/.forge/templates/project.yml +6 -0
- package/template/.forge/templates/slice-runner/config.yml +22 -13
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ skipped. All require `jq` and **fail closed** (deny) if it is missing — except
|
|
|
10
10
|
|
|
11
11
|
| Hook | Event · matcher | Blocks |
|
|
12
12
|
|---|---|---|
|
|
13
|
-
| `block-dangerous-commands.sh` | PreToolUse · Bash | force push, push to a protected branch, `rm -rf` on `/`/`~`/`$VAR`/system dirs/substituted targets, `DROP`/`TRUNCATE`/`DELETE`-without-WHERE, destructive migrations (`migrate:fresh`, `db:wipe`, `rails db:reset`, `prisma migrate reset`, …), `chmod 777`, `curl\|sh`, `dd`/`mkfs` on devices, `git reset --hard`, `git clean -f`, accidental `npm/cargo/gem/twine/uv/poetry/composer publish` |
|
|
13
|
+
| `block-dangerous-commands.sh` | PreToolUse · Bash | force push, push to a protected branch, `rm -rf` on `/`/`~`/`$VAR`/system dirs/substituted targets, `DROP`/`TRUNCATE`/`DELETE`-without-WHERE, destructive migrations (`migrate:fresh`, `db:wipe`, `rails db:reset`, `prisma migrate reset`, …), `chmod 777`, `curl\|sh`, `dd`/`mkfs` on devices, `git reset --hard`, `git clean -f`, accidental `npm/cargo/gem/twine/uv/poetry/composer publish`, whole-filesystem walks (`find`/`bfs`/`fd`/`rg`/`grep -r` rooted at `/`, `~`, `$HOME`, or a bare `/Users/<user>` — the refusal names the canonical runner-artifact paths) |
|
|
14
14
|
| `scan-secrets.sh` | PreToolUse · Edit\|Write | content containing AWS/GitHub/Anthropic/Slack/Stripe/Google keys, private-key blocks, credentialed connection strings, Laravel `APP_KEY`, generic hardcoded credentials — decision `ask` so genuine fixtures can be overridden |
|
|
15
15
|
| `protect-files.sh` | PreToolUse · Edit\|Write | edits to `.env`, keys/certs, lockfiles, generated/minified files, `.git/`, `secrets/`; `ask` on `settings.json`. Allowlists `.env.example`/`.sample`/`.dist`/`.template` |
|
|
16
16
|
| `warn-large-files.sh` | PreToolUse · Edit\|Write | writes into `node_modules`/`vendor`/build output/tool caches and binary/archive/media files |
|
|
@@ -85,6 +85,35 @@ if printf '%s' "$CMD_NOQUOTE" | grep -qE 'rm[[:space:]]+(-[a-zA-Z]*[[:space:]]+)
|
|
|
85
85
|
emit_deny "Blocked: recursive force-delete with a shell-substituted target (\$(...) or \`...\`). Pass a literal path."
|
|
86
86
|
fi
|
|
87
87
|
|
|
88
|
+
# ── Whole-filesystem walks ──────────────────────────────────────────────
|
|
89
|
+
# A walk binary (find/bfs/fd/rg, or grep with -r/-R) rooted at `/` or the bare
|
|
90
|
+
# home directory scans the whole disk: 75+ minutes and one macOS TCC prompt per
|
|
91
|
+
# protected folder — and spawned sessions run permission-free (0.64.4), so no
|
|
92
|
+
# prompt catches it. (Incident 2026-07-22: `bfs -S dfs / -name result.json
|
|
93
|
+
# -path *runner-work*` hunted a runner artifact whose location is knowable —
|
|
94
|
+
# see the canonical paths in the refusal below.)
|
|
95
|
+
# NARROW by design: only a standalone root token — `/`, `~`, `$HOME`,
|
|
96
|
+
# `/Users/<user>` EXACTLY (not subpaths) — in the same `;|&`-split command
|
|
97
|
+
# segment as the walk binary refuses. `find .`, `rg pat src/`, `grep -r x .`,
|
|
98
|
+
# and `find /Users/x/Dev/repo` all pass. bash 3.2 / BWK awk (macOS) safe:
|
|
99
|
+
# segments via RS, POSIX ERE only, root pattern kept in a string.
|
|
100
|
+
CMD_WALK=${CMD_NOQUOTE//\$\{HOME\}/\$HOME} # normalize ${HOME} → $HOME for the token match
|
|
101
|
+
if printf '%s\n' "$CMD_WALK" | awk '
|
|
102
|
+
BEGIN {
|
|
103
|
+
RS = "[;&|]+"
|
|
104
|
+
root = "(^|[[:space:]])(/|~|~/|\\$HOME|\\$HOME/|/Users/[A-Za-z0-9._-]+/?)([[:space:]]|$)"
|
|
105
|
+
}
|
|
106
|
+
{
|
|
107
|
+
seg = $0
|
|
108
|
+
if (seg ~ /(^|[[:space:](`\/])(find|bfs|fd|rg)([[:space:]]|$)/ && seg ~ root) { print "BAD"; exit }
|
|
109
|
+
if (seg ~ /(^|[[:space:](`\/])grep([[:space:]]|$)/ \
|
|
110
|
+
&& (seg ~ /(^|[[:space:]])-[a-zA-Z]*[rR]/ || seg ~ /--(dereference-)?recursive/) \
|
|
111
|
+
&& seg ~ root) { print "BAD"; exit }
|
|
112
|
+
}
|
|
113
|
+
' | grep -q BAD; then
|
|
114
|
+
emit_deny "Blocked: filesystem walk rooted at / or the home directory. Runner artifacts live at knowable paths — work dir <slice-worktree>/.forge/runner-work/, per-phase result <slice-worktree>/.forge/runner-work/phase-*/result.json, notify log <slice-worktree>/.forge/runner-work/notify.log, dispatch output <slice-worktree>/.forge/runner-work/dispatch.out. Root the search at the slice worktree or repo instead."
|
|
115
|
+
fi
|
|
116
|
+
|
|
88
117
|
# ── Dangerous database operations ───────────────────────────────────────
|
|
89
118
|
if contains_icmd 'DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)[[:space:]]+'; then
|
|
90
119
|
emit_deny "Blocked: DROP TABLE/DATABASE/SCHEMA detected. Run manually if intended."
|
|
@@ -23,13 +23,17 @@
|
|
|
23
23
|
#
|
|
24
24
|
# Pinned field set (contract between record + recommend; keep names stable):
|
|
25
25
|
# ts repo milestone skill task_type complexity model first_try_pass check_exit
|
|
26
|
+
# cost_usd tokens (step-5 m-42 — durable per-phase spend; the ts field already
|
|
27
|
+
# dates every row, so cost_usd+tokens give spend-over-time stats
|
|
28
|
+
# from this ONE durable log, no new store. Both null when unset —
|
|
29
|
+
# lazy migration, existing rows/readers unaffected.)
|
|
26
30
|
set -u
|
|
27
31
|
|
|
28
32
|
usage() {
|
|
29
33
|
cat <<'EOF' >&2
|
|
30
34
|
usage: forge-model-outcome.sh record [--repo R] [--milestone M] [--skill S]
|
|
31
35
|
[--task-type T] [--complexity C] [--model MO] [--first-try-pass true|false]
|
|
32
|
-
[--check-exit N]
|
|
36
|
+
[--check-exit N] [--cost USD] [--tokens N]
|
|
33
37
|
forge-model-outcome.sh recommend
|
|
34
38
|
EOF
|
|
35
39
|
}
|
|
@@ -42,7 +46,7 @@ log_path() {
|
|
|
42
46
|
do_record() {
|
|
43
47
|
# --- parse flags (ALL optional — missing -> empty string / null) ---------
|
|
44
48
|
repo=""; milestone=""; skill=""; task_type=""; complexity=""; model=""
|
|
45
|
-
first_try_pass=""; check_exit=""
|
|
49
|
+
first_try_pass=""; check_exit=""; cost=""; tokens=""
|
|
46
50
|
while [ $# -gt 0 ]; do
|
|
47
51
|
case "$1" in
|
|
48
52
|
--repo) shift; repo="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
@@ -61,6 +65,10 @@ do_record() {
|
|
|
61
65
|
--first-try-pass=*) first_try_pass="${1#*=}"; shift ;;
|
|
62
66
|
--check-exit) shift; check_exit="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
63
67
|
--check-exit=*) check_exit="${1#*=}"; shift ;;
|
|
68
|
+
--cost) shift; cost="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
69
|
+
--cost=*) cost="${1#*=}"; shift ;;
|
|
70
|
+
--tokens) shift; tokens="${1:-}"; [ $# -gt 0 ] && shift || true ;;
|
|
71
|
+
--tokens=*) tokens="${1#*=}"; shift ;;
|
|
64
72
|
-*) printf 'forge-model-outcome: unknown option: %s (ignored)\n' "$1" >&2; shift ;;
|
|
65
73
|
*) shift ;;
|
|
66
74
|
esac
|
|
@@ -87,9 +95,16 @@ do_record() {
|
|
|
87
95
|
*) ce="$check_exit" ;;
|
|
88
96
|
esac
|
|
89
97
|
|
|
90
|
-
|
|
98
|
+
# cost_usd (float) + tokens (int) — numeric JSON, null when unset/malformed. A
|
|
99
|
+
# numeric literal (not a quoted string) keeps `jq` sums/averages trivial.
|
|
100
|
+
cost_j="null"
|
|
101
|
+
case "$cost" in ''|*[!0-9.]*) cost_j="null" ;; *) cost_j="$cost" ;; esac
|
|
102
|
+
tokens_j="null"
|
|
103
|
+
case "$tokens" in ''|*[!0-9]*) tokens_j="null" ;; *) tokens_j="$tokens" ;; esac
|
|
104
|
+
|
|
105
|
+
line=$(printf '{"ts":"%s","repo":"%s","milestone":"%s","skill":"%s","task_type":"%s","complexity":"%s","model":"%s","first_try_pass":%s,"check_exit":%s,"cost_usd":%s,"tokens":%s}' \
|
|
91
106
|
"$(esc "$ts")" "$(esc "$repo")" "$(esc "$milestone")" "$(esc "$skill")" \
|
|
92
|
-
"$(esc "$task_type")" "$(esc "$complexity")" "$(esc "$model")" "$fp" "$ce")
|
|
107
|
+
"$(esc "$task_type")" "$(esc "$complexity")" "$(esc "$model")" "$fp" "$ce" "$cost_j" "$tokens_j")
|
|
93
108
|
|
|
94
109
|
# The whole create+append is best-effort: swallow any failure (unwritable
|
|
95
110
|
# home, read-only fs, a ".forge" that's a regular file, ...) and exit 0
|
|
@@ -121,7 +136,8 @@ do_recommend() {
|
|
|
121
136
|
model: .[0].model,
|
|
122
137
|
task_type: .[0].task_type,
|
|
123
138
|
count: length,
|
|
124
|
-
pass_rate: (([ .[] | select(.first_try_pass == true) ] | length) / length)
|
|
139
|
+
pass_rate: (([ .[] | select(.first_try_pass == true) ] | length) / length),
|
|
140
|
+
total_cost: ([ .[] | (.cost_usd // 0) ] | add)
|
|
125
141
|
}
|
|
126
142
|
| . + {
|
|
127
143
|
status: (
|
|
@@ -149,7 +165,8 @@ do_recommend() {
|
|
|
149
165
|
count="$(printf '%s' "$b" | jq -r '.count')"
|
|
150
166
|
status="$(printf '%s' "$b" | jq -r '.status')"
|
|
151
167
|
pct="$(printf '%s' "$b" | jq -r '.pass_rate' | awk '{printf "%.0f", $1*100}')"
|
|
152
|
-
printf '
|
|
168
|
+
cost="$(printf '%s' "$b" | jq -r '.total_cost' | LC_NUMERIC=C awk '{printf "%.4f", $1+0}')"
|
|
169
|
+
printf ' model=%-10s task_type=%-14s status=%-10s count=%-4s first-try=%s%% spend=$%s\n' "$model" "$task_type" "$status" "$count" "$pct" "$cost"
|
|
153
170
|
done
|
|
154
171
|
|
|
155
172
|
printf '\nRouting recommendations:\n'
|
|
@@ -172,6 +172,35 @@ usage_num() { # usage_num <result.json> <input_tokens|output_tokens> — the AG
|
|
|
172
172
|
_um="$(sed 's/.*"usage":{//' "$1" 2>/dev/null | grep -o "\"$2\":[0-9]*" | head -1 | sed 's/.*://')"
|
|
173
173
|
[ -n "$_um" ] && printf '%s' "$_um" || printf '0'
|
|
174
174
|
}
|
|
175
|
+
actual_model_from_result() { # actual_model_from_result <result.json> — the PRIMARY model actually used
|
|
176
|
+
# The launcher's result envelope carries `modelUsage`, an object keyed by FULL model
|
|
177
|
+
# id (`claude-sonnet-5`, `claude-haiku-4-5-...`) with a per-model usage breakdown. A
|
|
178
|
+
# single phase legitimately touches MORE than one model (main work + background
|
|
179
|
+
# micro-calls), so "the actual model" is the PRIMARY: the entry with the max
|
|
180
|
+
# outputTokens. Step-5 attribution records reality from HERE, never from intent.
|
|
181
|
+
# Dependency-free awk (NFR-026); result.json is a single line.
|
|
182
|
+
# Anchor to the LAST `"modelUsage":{` (the launcher's real one, appended AFTER the
|
|
183
|
+
# model-controlled `result` string) — same spoof defense result_num uses with tail -1:
|
|
184
|
+
# a phase that embeds `"modelUsage":{…}` in its answer text can't win, because its copy
|
|
185
|
+
# precedes the launcher's and we take the last.
|
|
186
|
+
[ -f "$1" ] || { printf -- '-'; return 0; }
|
|
187
|
+
_am="$(awk '
|
|
188
|
+
{ ex = $0; seg = ""
|
|
189
|
+
while (match(ex, /"modelUsage":\{/)) { ex = substr(ex, RSTART+RLENGTH); seg = ex }
|
|
190
|
+
if (seg == "") next
|
|
191
|
+
best=""; bestn=-1
|
|
192
|
+
while (match(seg, /"[^"]+":\{[^}]*"outputTokens":[0-9]+/)) {
|
|
193
|
+
entry = substr(seg, RSTART, RLENGTH)
|
|
194
|
+
idm = entry; sub(/":\{.*/, "", idm); sub(/^"/, "", idm) # model id = leading quoted token
|
|
195
|
+
otm = entry; sub(/.*"outputTokens":/, "", otm); ot = otm + 0
|
|
196
|
+
if (ot > bestn) { bestn = ot; best = idm }
|
|
197
|
+
seg = substr(seg, RSTART+RLENGTH)
|
|
198
|
+
}
|
|
199
|
+
if (best != "") print best
|
|
200
|
+
exit
|
|
201
|
+
}' "$1" 2>/dev/null)"
|
|
202
|
+
[ -n "$_am" ] && printf '%s' "$_am" || printf -- '-'
|
|
203
|
+
}
|
|
175
204
|
|
|
176
205
|
# --- model-by-phase-type map (STEP-5 SEAM — read, NOT routed) ----------------
|
|
177
206
|
# The runner reads config.model_by_phase_type to RECORD each phase's intended model
|
|
@@ -201,12 +230,17 @@ phase_type_of() { # phase_type_of <phase> — optional `phase_type:` in the pha
|
|
|
201
230
|
| sed -E 's/^phase_type:[[:space:]]*//; s/[[:space:]]*(#.*)?$//')"
|
|
202
231
|
printf '%s' "$_pt"
|
|
203
232
|
}
|
|
204
|
-
model_for_phase() { # model_for_phase <phase>
|
|
233
|
+
model_for_phase() { # model_for_phase <phase> — the intended model, from the MAP ONLY
|
|
234
|
+
# The routing decision (and the recorded intended_model) come SOLELY from
|
|
235
|
+
# config.model_by_phase_type keyed by the phase's phase_type. It deliberately does
|
|
236
|
+
# NOT fall back to the phase's self-reported usage.model: a phase's own output must
|
|
237
|
+
# never influence its dispatch (that would break the absent-map byte-identical
|
|
238
|
+
# invariant on a parked-phase resume, where a prior report persists). No phase_type
|
|
239
|
+
# or no map entry → "-" (unrouted). The <report-file> arg is retained for call-site
|
|
240
|
+
# compatibility but no longer read.
|
|
205
241
|
_mp_type="$(phase_type_of "$1")"
|
|
206
242
|
_mp=""
|
|
207
243
|
[ -n "$_mp_type" ] && _mp="$(cfg_map_get model_by_phase_type "$_mp_type" "")"
|
|
208
|
-
# Fall back to what the phase itself recorded, then a placeholder.
|
|
209
|
-
[ -z "$_mp" ] && _mp="$(report_nested "$2" model)"
|
|
210
244
|
[ -z "$_mp" ] && _mp="-"
|
|
211
245
|
printf '%s' "$_mp"
|
|
212
246
|
}
|
|
@@ -453,6 +487,9 @@ for phase in $PHASES; do
|
|
|
453
487
|
printf ' %s\n' "$REPORT_TEMPLATE"
|
|
454
488
|
printf 'and the JSON schema here:\n'
|
|
455
489
|
printf ' %s\n' "$SCHEMA"
|
|
490
|
+
printf 'All runner artifacts for this slice live under %s\n' "$WORK_DIR"
|
|
491
|
+
printf '(this phase writes into %s) — the paths are known; never search the\n' "$wdir"
|
|
492
|
+
printf 'filesystem for result.json, notify.log, or any runner file.\n'
|
|
456
493
|
printf 'Set verdict to one of proceed|park|halt|done. If you hit a Rule-4 stop,\n'
|
|
457
494
|
printf 'an ambiguity, an irreversible step, a design fork, or a budget breach,\n'
|
|
458
495
|
printf 'set verdict=park and fill interrupt + interrupt_payload — do not guess.\n'
|
|
@@ -466,6 +503,16 @@ for phase in $PHASES; do
|
|
|
466
503
|
# token furnace). A launch that writes no report at all is a CRASH, not a
|
|
467
504
|
# check failure — it halts immediately so crash-resume re-runs it next boot.
|
|
468
505
|
_pstart="$(date +%s 2>/dev/null || echo 0)" # phase wall-clock start (runner-measured)
|
|
506
|
+
# STEP 5 — per-phase model routing. Resolve the intended model ONCE (map value via
|
|
507
|
+
# model_for_phase, or "-"/"" when unmapped). `_route_model` is what THIS attempt
|
|
508
|
+
# launches with; on a routed STARTUP failure it is blanked for a single unrouted
|
|
509
|
+
# retry (the fallback), recorded `routed → fallback` in the receipt. All routing
|
|
510
|
+
# lives here + at the launch below — nowhere else (FR-198/NFR-044).
|
|
511
|
+
_intended_model="$(model_for_phase "$phase" "$report")"
|
|
512
|
+
_route_model="$_intended_model"
|
|
513
|
+
[ "$_route_model" = "-" ] && _route_model=""
|
|
514
|
+
_fallback=no
|
|
515
|
+
_routed_fallback_done=0
|
|
469
516
|
attempt=1
|
|
470
517
|
max_attempt=2
|
|
471
518
|
verdict=""
|
|
@@ -474,6 +521,11 @@ for phase in $PHASES; do
|
|
|
474
521
|
"$phase" "$idx" "$total" "$LAUNCHER" "$attempt" >&2
|
|
475
522
|
(
|
|
476
523
|
cd "$WORKTREE"
|
|
524
|
+
# STEP 5 routing: prepend `--model <value>` iff this phase is routed. Built via
|
|
525
|
+
# `set --` so an unrouted launch passes NO --model flag — the dispatch args are
|
|
526
|
+
# then byte-for-byte today's (the absent-map neg control). Empty `"$@"` expands
|
|
527
|
+
# to nothing; a set value is properly word-safe.
|
|
528
|
+
if [ -n "$_route_model" ]; then set -- --model "$_route_model"; else set --; fi
|
|
477
529
|
# A headless, zero-touch phase has no TTY to approve tool use, so the per-tool
|
|
478
530
|
# permission gate is bypassed — otherwise the very first Write (the work, or
|
|
479
531
|
# the mandatory phase-report.yml) blocks forever waiting for an approval that
|
|
@@ -486,7 +538,7 @@ for phase in $PHASES; do
|
|
|
486
538
|
FORGE_SLICE_PHASE="$phase" \
|
|
487
539
|
FORGE_SLICE_PHASE_INDEX="$idx" \
|
|
488
540
|
FORGE_SLICE_ATTEMPT="$attempt" \
|
|
489
|
-
"$LAUNCHER" -p \
|
|
541
|
+
"$LAUNCHER" -p "$@" \
|
|
490
542
|
--output-format json \
|
|
491
543
|
--max-turns "$MAX_TURNS" \
|
|
492
544
|
--max-budget-usd "$MAX_BUDGET" \
|
|
@@ -495,12 +547,28 @@ for phase in $PHASES; do
|
|
|
495
547
|
< "$prompt" \
|
|
496
548
|
> "$wdir/result.json" 2> "$wdir/launch.err"
|
|
497
549
|
) || {
|
|
498
|
-
printf '[runner] phase %s: launcher exited non-zero (attempt %s)\n'
|
|
550
|
+
printf '[runner] phase %s: launcher exited non-zero (attempt %s%s)\n' \
|
|
551
|
+
"$phase" "$attempt" "${_route_model:+, --model $_route_model}" >&2
|
|
499
552
|
}
|
|
500
553
|
|
|
501
554
|
# Record this attempt's session id — the fresh-context proof the retry asserts.
|
|
502
555
|
extract_session "$wdir/result.json" > "$wdir/attempt-$attempt.session"
|
|
503
556
|
|
|
557
|
+
# STEP 5 fallback — a ROUTED launch that wrote NO report is a startup failure
|
|
558
|
+
# (e.g. an unusable model). Retry ONCE unrouted (blank _route_model), recorded
|
|
559
|
+
# `routed → fallback` in the receipt. This is a DISTINCT axis from the
|
|
560
|
+
# checks-failed retry: it does not consume `attempt`, and a bad model degrades
|
|
561
|
+
# to the default rather than halting the slice (routing must be harmless). An
|
|
562
|
+
# unrouted launch that writes no report is a genuine crash → the halt below.
|
|
563
|
+
if [ ! -f "$report" ] && [ -n "$_route_model" ] && [ "$_routed_fallback_done" -eq 0 ]; then
|
|
564
|
+
printf '[runner] phase %s: routed launch (--model %s) wrote no report — falling back to default (unrouted), one retry\n' \
|
|
565
|
+
"$phase" "$_route_model" >&2
|
|
566
|
+
_routed_fallback_done=1
|
|
567
|
+
_fallback=yes
|
|
568
|
+
_route_model=""
|
|
569
|
+
continue
|
|
570
|
+
fi
|
|
571
|
+
|
|
504
572
|
# (4) REQUIRE the on-disk phase-report.yml. Absent = crash → halt now (do NOT
|
|
505
573
|
# spend the retry on a crash; the resume path re-runs it from files).
|
|
506
574
|
if [ ! -f "$report" ]; then
|
|
@@ -553,20 +621,59 @@ for phase in $PHASES; do
|
|
|
553
621
|
# spend seam), one row per phase. Sourced for FIDELITY (live walk found the
|
|
554
622
|
# phase-report's self-reported usage was all zeros — a phase can't know its
|
|
555
623
|
# own token count):
|
|
556
|
-
# tokens
|
|
557
|
-
# wall_clock_s
|
|
558
|
-
# retry_count
|
|
559
|
-
# model
|
|
560
|
-
#
|
|
624
|
+
# tokens = result.json input_tokens + output_tokens (launcher truth)
|
|
625
|
+
# wall_clock_s = runner-measured elapsed for the phase (incl. any retry)
|
|
626
|
+
# retry_count = runner-observed (attempt-1), not the phase's self-report
|
|
627
|
+
# model = intended model (kept for back-compat with the 5-col reader)
|
|
628
|
+
# intended_model = what routing ASKED for (map value, "-" when unmapped)
|
|
629
|
+
# actual_model = the PRIMARY model actually used (result.json modelUsage,
|
|
630
|
+
# max outputTokens) — reality, not intent
|
|
631
|
+
# fallback = yes when a routed launch fell back unrouted (routed → fallback)
|
|
632
|
+
# cost_usd = per-phase spend (result.json total_cost_usd) — the spend
|
|
633
|
+
# the by-phase-type-and-model attribution surface sums
|
|
561
634
|
_tok_in="$(usage_num "$wdir/result.json" input_tokens)"
|
|
562
635
|
_tok_out="$(usage_num "$wdir/result.json" output_tokens)"
|
|
563
636
|
_u_tokens="$(awk -v a="$_tok_in" -v b="$_tok_out" 'BEGIN{printf "%d", (a+0)+(b+0)}')"
|
|
564
637
|
_now="$(date +%s 2>/dev/null || echo 0)"
|
|
565
638
|
if [ "$_now" -ge "$_pstart" ] 2>/dev/null; then _u_wall=$(( _now - _pstart )); else _u_wall=0; fi
|
|
566
639
|
_u_retry=$(( attempt - 1 ))
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
640
|
+
_u_intended="${_intended_model:--}"
|
|
641
|
+
_u_actual="$(actual_model_from_result "$wdir/result.json")"
|
|
642
|
+
_u_model="$_u_intended" # 5-col `model` = intended (back-compat)
|
|
643
|
+
_u_cost="$_cost" # per-phase spend (result.json total_cost_usd)
|
|
644
|
+
[ -f "$USAGE_FILE" ] || printf 'phase_name\ttokens\twall_clock_s\tmodel\tretry_count\tintended_model\tactual_model\tfallback\tcost_usd\n' > "$USAGE_FILE"
|
|
645
|
+
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
|
646
|
+
"$phase" "$_u_tokens" "$_u_wall" "$_u_model" "$_u_retry" "$_u_intended" "$_u_actual" "$_fallback" "$_u_cost" >> "$USAGE_FILE"
|
|
647
|
+
|
|
648
|
+
# (4b) FEED THE M24 OUTCOME LADDER (step-5 B4). Append ONE outcome line per ROUTED
|
|
649
|
+
# phase so the ladder accumulates real per-(model, task_type) volume at last.
|
|
650
|
+
# The ladder answers "should I route task_type T to model X?", so it is keyed on
|
|
651
|
+
# the model the operator MAPPED — the intended model — in ONE consistent
|
|
652
|
+
# vocabulary for BOTH outcomes (a routing that held and one that fell back share
|
|
653
|
+
# a key, so success/failure of the same mapped model aggregate instead of
|
|
654
|
+
# fragmenting). Recorded only when there is a routing decision to judge:
|
|
655
|
+
# • unrouted (no phase_type / no map entry) → SKIP: the default model's baseline
|
|
656
|
+
# is not a routing choice.
|
|
657
|
+
# • park → SKIP: an operator interrupt is not a model-quality outcome.
|
|
658
|
+
# • routed → fallback → first_try_pass=false (the mapped model failed to run).
|
|
659
|
+
# • routed & ran → first_try_pass = attempt 1 AND terminal-good AND no checks failed.
|
|
660
|
+
# ADVISORY: the helper always exits 0; guard so a missing helper never touches the
|
|
661
|
+
# slice. The routing map is consumed READ-ONLY throughout the runner; only the
|
|
662
|
+
# operator ever edits it by hand (D-F) — no code path here modifies that config.
|
|
663
|
+
_mo_type="$(phase_type_of "$phase")"
|
|
664
|
+
if [ -n "$_mo_type" ] && [ -n "$_intended_model" ] && [ "$_intended_model" != "-" ] \
|
|
665
|
+
&& [ "$verdict" != park ] && [ -x "$SELF_DIR/forge-model-outcome.sh" ]; then
|
|
666
|
+
_mo_ftp=false
|
|
667
|
+
if [ "$_fallback" = no ] && [ "$attempt" -eq 1 ] \
|
|
668
|
+
&& { [ "$verdict" = proceed ] || [ "$verdict" = done ]; } && [ "$cfailed" -eq 0 ]; then
|
|
669
|
+
_mo_ftp=true
|
|
670
|
+
fi
|
|
671
|
+
"$SELF_DIR/forge-model-outcome.sh" record \
|
|
672
|
+
--repo "$(basename "$REPO_ROOT" 2>/dev/null || echo slice)" \
|
|
673
|
+
--skill executing --task-type "$_mo_type" \
|
|
674
|
+
--model "$_intended_model" --first-try-pass "$_mo_ftp" \
|
|
675
|
+
--check-exit "$cfailed" --cost "$_u_cost" --tokens "$_u_tokens" >/dev/null 2>&1 || true
|
|
676
|
+
fi
|
|
570
677
|
|
|
571
678
|
# (5) BRANCH on verdict. proceed advances; done ends with the done ping.
|
|
572
679
|
# park is STUBBED here (plan-48 wires the real §8 interrupt ping) — explicit,
|
|
@@ -372,5 +372,159 @@
|
|
|
372
372
|
},
|
|
373
373
|
"expect_exit": 0,
|
|
374
374
|
"expect_stdout_not_contains": "permissionDecision"
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
"name": "denies whole-disk bfs walk (literal 2026-07-22 incident command)",
|
|
378
|
+
"input": {
|
|
379
|
+
"tool_name": "Bash",
|
|
380
|
+
"tool_input": {
|
|
381
|
+
"command": "bfs -S dfs / -name result.json -path *runner-work*"
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
"expect_exit": 2,
|
|
385
|
+
"expect_stdout_contains": "\"permissionDecision\":\"deny\""
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"name": "walk refusal names the canonical runner-artifact globs",
|
|
389
|
+
"input": {
|
|
390
|
+
"tool_name": "Bash",
|
|
391
|
+
"tool_input": {
|
|
392
|
+
"command": "bfs -S dfs / -name result.json -path *runner-work*"
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
"expect_exit": 2,
|
|
396
|
+
"expect_stdout_contains": ".forge/runner-work/phase-*/result.json"
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"name": "denies find rooted at /",
|
|
400
|
+
"input": {
|
|
401
|
+
"tool_name": "Bash",
|
|
402
|
+
"tool_input": {
|
|
403
|
+
"command": "find / -name result.json"
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
"expect_exit": 2,
|
|
407
|
+
"expect_stdout_contains": "\"permissionDecision\":\"deny\""
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
"name": "denies rg --files rooted at /",
|
|
411
|
+
"input": {
|
|
412
|
+
"tool_name": "Bash",
|
|
413
|
+
"tool_input": {
|
|
414
|
+
"command": "rg --files /"
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
"expect_exit": 2,
|
|
418
|
+
"expect_stdout_contains": "\"permissionDecision\":\"deny\""
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"name": "denies recursive grep rooted at bare home (~)",
|
|
422
|
+
"input": {
|
|
423
|
+
"tool_name": "Bash",
|
|
424
|
+
"tool_input": {
|
|
425
|
+
"command": "grep -r secret ~"
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
"expect_exit": 2,
|
|
429
|
+
"expect_stdout_contains": "\"permissionDecision\":\"deny\""
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
"name": "denies find rooted at bare $HOME",
|
|
433
|
+
"input": {
|
|
434
|
+
"tool_name": "Bash",
|
|
435
|
+
"tool_input": {
|
|
436
|
+
"command": "find $HOME -name notify.log"
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
"expect_exit": 2,
|
|
440
|
+
"expect_stdout_contains": "\"permissionDecision\":\"deny\""
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"name": "denies fd rooted at bare /Users/<user>",
|
|
444
|
+
"input": {
|
|
445
|
+
"tool_name": "Bash",
|
|
446
|
+
"tool_input": {
|
|
447
|
+
"command": "fd result.json /Users/somebody"
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
"expect_exit": 2,
|
|
451
|
+
"expect_stdout_contains": "\"permissionDecision\":\"deny\""
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
"name": "denies path-invoked find rooted at /",
|
|
455
|
+
"input": {
|
|
456
|
+
"tool_name": "Bash",
|
|
457
|
+
"tool_input": {
|
|
458
|
+
"command": "/usr/bin/find / -name x"
|
|
459
|
+
}
|
|
460
|
+
},
|
|
461
|
+
"expect_exit": 2,
|
|
462
|
+
"expect_stdout_contains": "\"permissionDecision\":\"deny\""
|
|
463
|
+
},
|
|
464
|
+
{
|
|
465
|
+
"name": "allows find rooted at cwd (narrow-match neg-control)",
|
|
466
|
+
"input": {
|
|
467
|
+
"tool_name": "Bash",
|
|
468
|
+
"tool_input": {
|
|
469
|
+
"command": "find . -name result.json"
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
"expect_exit": 0,
|
|
473
|
+
"expect_stdout_not_contains": "permissionDecision"
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
"name": "allows rg rooted at a project subdir (narrow-match neg-control)",
|
|
477
|
+
"input": {
|
|
478
|
+
"tool_name": "Bash",
|
|
479
|
+
"tool_input": {
|
|
480
|
+
"command": "rg pattern src/"
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
"expect_exit": 0,
|
|
484
|
+
"expect_stdout_not_contains": "permissionDecision"
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
"name": "allows recursive grep rooted at cwd (narrow-match neg-control)",
|
|
488
|
+
"input": {
|
|
489
|
+
"tool_name": "Bash",
|
|
490
|
+
"tool_input": {
|
|
491
|
+
"command": "grep -r x ."
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
"expect_exit": 0,
|
|
495
|
+
"expect_stdout_not_contains": "permissionDecision"
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
"name": "allows find rooted at a home SUBPATH (narrow-match neg-control)",
|
|
499
|
+
"input": {
|
|
500
|
+
"tool_name": "Bash",
|
|
501
|
+
"tool_input": {
|
|
502
|
+
"command": "find /Users/somebody/Dev/forge -name result.json"
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
"expect_exit": 0,
|
|
506
|
+
"expect_stdout_not_contains": "permissionDecision"
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
"name": "allows non-recursive grep on an absolute file (narrow-match neg-control)",
|
|
510
|
+
"input": {
|
|
511
|
+
"tool_name": "Bash",
|
|
512
|
+
"tool_input": {
|
|
513
|
+
"command": "grep -q result /etc/hosts"
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
"expect_exit": 0,
|
|
517
|
+
"expect_stdout_not_contains": "permissionDecision"
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
"name": "allows find rooted at a $HOME SUBPATH (narrow-match neg-control)",
|
|
521
|
+
"input": {
|
|
522
|
+
"tool_name": "Bash",
|
|
523
|
+
"tool_input": {
|
|
524
|
+
"command": "find \"$HOME/Dev/proj\" -name x"
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
"expect_exit": 0,
|
|
528
|
+
"expect_stdout_not_contains": "permissionDecision"
|
|
375
529
|
}
|
|
376
530
|
]
|
|
@@ -103,6 +103,30 @@ else
|
|
|
103
103
|
fail "case6 recommend missing insufficient-data message: $out"
|
|
104
104
|
fi
|
|
105
105
|
|
|
106
|
+
# --- Case 7: cost_usd + tokens are stored numeric, null when omitted (m-42) --
|
|
107
|
+
d="$ROOT/case7"; mkdir -p "$d"
|
|
108
|
+
HOME="$d" "$HELPER" record --repo x --skill executing --task-type verifying \
|
|
109
|
+
--model haiku --first-try-pass true --check-exit 0 --cost 0.0333 --tokens 686 >/dev/null
|
|
110
|
+
HOME="$d" "$HELPER" record --repo x --skill executing --task-type docs \
|
|
111
|
+
--model sonnet --first-try-pass true >/dev/null # no cost/tokens → null
|
|
112
|
+
log="$d/.forge/model-outcomes.jsonl"
|
|
113
|
+
if grep -q '"cost_usd":0.0333' "$log" && grep -q '"tokens":686' "$log"; then
|
|
114
|
+
pass "case7 record stores numeric cost_usd + tokens"
|
|
115
|
+
else
|
|
116
|
+
fail "case7 cost/tokens not stored numeric: $(cat "$log")"
|
|
117
|
+
fi
|
|
118
|
+
if grep -q '"cost_usd":null' "$log" && grep -q '"tokens":null' "$log"; then
|
|
119
|
+
pass "case7 omitted cost/tokens store as null (lazy migration)"
|
|
120
|
+
else
|
|
121
|
+
fail "case7 omitted cost/tokens not null: $(cat "$log")"
|
|
122
|
+
fi
|
|
123
|
+
out="$(HOME="$d" "$HELPER" recommend)"
|
|
124
|
+
if printf '%s' "$out" | grep -qE 'spend=\$0\.0333'; then
|
|
125
|
+
pass "case7 recommend surfaces per-bucket spend"
|
|
126
|
+
else
|
|
127
|
+
fail "case7 recommend missing spend column: $out"
|
|
128
|
+
fi
|
|
129
|
+
|
|
106
130
|
# --- Report -----------------------------------------------------------------
|
|
107
131
|
printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED"
|
|
108
132
|
[ "$FAILED" -eq 0 ]
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
# ./.claude/hooks/tests/forge-release-fold.test.sh ordering # ordering controls
|
|
25
25
|
# Filter tags (one arg, selects matching case blocks):
|
|
26
26
|
# unmerged merged grandfather human-verified signoff-tag stored-field
|
|
27
|
-
# exit-2 stable-sha ordering promotion forgery
|
|
27
|
+
# exit-2 stable-sha ordering promotion forgery labeled
|
|
28
28
|
# `promotion` + `forgery` (plan 51-01): validation_event=promotion_approval —
|
|
29
29
|
# a CI-signed tag on a deploy sha CONTAINING the unit's landed sha validates;
|
|
30
30
|
# tag deletion reverts; unsigned/wrong-key lookalikes and non-containing tags
|
|
@@ -763,9 +763,37 @@ lifecycle:
|
|
|
763
763
|
check_absent "signers-tamper: never validated from a working-tree signers edit" "$OUT" "release_state=validated"
|
|
764
764
|
fi
|
|
765
765
|
|
|
766
|
+
# --- case: labeled unit ids (m-AUTO01 class) -----------------------------------
|
|
767
|
+
# Numeric (m-8) and labeled (m-AUTO01) id schemes are both valid; the labeled
|
|
768
|
+
# convention keeps the m- prefix in the FILENAME (milestone-m-LABEL.yml) and the
|
|
769
|
+
# fold prefers whichever spelling is tracked. Unsafe characters stay refused.
|
|
770
|
+
if running labeled; then
|
|
771
|
+
printf '\n--- case: labeled unit ids — milestone-m-LABEL.yml resolves; unsafe ids still refused ---\n'
|
|
772
|
+
d="$(new_repo labeled)"
|
|
773
|
+
write_milestone "$d" m-AUTO01 'milestone:
|
|
774
|
+
id: m-AUTO01
|
|
775
|
+
name: "fixture: labeled pre-gate closed milestone"
|
|
776
|
+
|
|
777
|
+
current:
|
|
778
|
+
tier: standard
|
|
779
|
+
status: complete
|
|
780
|
+
completed_at: "2026-07-01"
|
|
781
|
+
last_updated: "2026-07-01"
|
|
782
|
+
|
|
783
|
+
lifecycle:
|
|
784
|
+
worktree_mode: retired'
|
|
785
|
+
run_fold "$d" m-AUTO01
|
|
786
|
+
check "labeled id: exit 0" "$RC" "0"
|
|
787
|
+
check "labeled id: resolves the milestone-m-LABEL.yml spelling" "$OUT" "release_state=validated reason=legacy-complete"
|
|
788
|
+
|
|
789
|
+
run_fold "$d" "m-AUTO;01"
|
|
790
|
+
check "unsafe labeled id: exit 2" "$RC" "2"
|
|
791
|
+
check_absent "unsafe labeled id: no verdict on stdout" "$OUT" "release_state="
|
|
792
|
+
fi
|
|
793
|
+
|
|
766
794
|
# --- summary -------------------------------------------------------------------
|
|
767
795
|
if [ -n "$FILTER" ] && [ "$SELECTED" -eq 0 ]; then
|
|
768
|
-
printf 'ERROR: filter "%s" matched no cases (tags: unmerged merged grandfather human-verified signoff-tag stored-field exit-2 stable-sha ordering promotion forgery)\n' "$FILTER" >&2
|
|
796
|
+
printf 'ERROR: filter "%s" matched no cases (tags: unmerged merged grandfather human-verified signoff-tag stored-field exit-2 stable-sha ordering promotion forgery labeled)\n' "$FILTER" >&2
|
|
769
797
|
exit 2
|
|
770
798
|
fi
|
|
771
799
|
|
|
@@ -56,6 +56,7 @@ The helper (see its header for the full contract) owns the caller-side environme
|
|
|
56
56
|
- **`FORGE_SLICE_NOTIFY` points at a log-only channel** (`forge-jarvis-notify-logonly.sh`) so the default channel's per-ping headless `claude -p` micro-call stays quiet — the B4 relay is the single delivery path; a live default channel would risk a double-buzz (B1 probe x). This is the swappable channel seam used exactly as designed: the runner is untouched, the caller owns the env.
|
|
57
57
|
- **The model map is forwarded verbatim.** The helper carries no model-selection logic (see Boundaries — routing is step 5).
|
|
58
58
|
- **The work order is materialized at dispatch** (FR-197): the runner's contract is `<worktree>/slice.yml` + `phases/<name>/plan.md`; when `slice.yml` is absent the helper builds both from the milestone's locked `.forge/phases/milestone-{id}/` plans (an existing `slice.yml` is honored untouched). When it cannot, it **refuses synchronously, before the detach** — relay that refusal to the operator immediately. And shortly after any real dispatch, read `<worktree>/.forge/runner-work/dispatch.out` once: a runner refusal lands there and must be relayed — **a silent phone is never success** (walk run 4's failure mode: the runner declined instantly and nothing ever buzzed).
|
|
59
|
+
- **Runner artifacts live at knowable paths — never walk the filesystem for them** (a walk rooted at `/` or the bare home is refused by the Bash guard): work dir `<worktree>/.forge/runner-work/`, per-phase `phase-<N>/result.json` (with `phase-report.yml` and `answer.md`), ping log `notify.log`, dispatch/refusal output `dispatch.out` — state these paths to any session you spawn about a slice.
|
|
59
60
|
|
|
60
61
|
After dispatch, the slice runs headless in its worktree, writing every ping to its notify log. That log is the event bus the callback relay watches (B4).
|
|
61
62
|
|
|
@@ -193,6 +193,7 @@ Plan naming reflects the slice: `plan-01-user-signs-up.md`, not `plan-01-models.
|
|
|
193
193
|
- `{id}`=milestone, `{phase}`=phase# (preserved verbatim, NOT renumbered), `{name}`=kebab, `{NN}`=seq
|
|
194
194
|
- Ex: `.forge/phases/milestone-3/2-providers/plan-01.md`
|
|
195
195
|
2. Frontmatter: phase, plan#, wave, deps, `slice_exception:` (optional, see Core Principle), `integration_checkpoint:` (optional, see below)
|
|
196
|
+
- **`phase_type:` (per-phase model routing, m-42).** Stamp the workflow phase this plan SERVES — one of the `model_by_phase_type` keys (`researching | discussing | architecting | planning | executing | verifying | reviewing`). Build plans are almost always `executing`; a plan that authors a verify/review phase's work is `verifying`/`reviewing`. This is what lets the headless slice runner route each phase to the model mapped for its type (the map ships commented → routing is off until the operator activates it, so stamping this is always safe and never changes behavior on its own). Empty ⇒ that phase runs unrouted (inherits the default model). Emit it on every plan; when unsure, `executing`.
|
|
196
197
|
3. must_haves:
|
|
197
198
|
- **Truths:** User-observable outcomes (3-7). MUST be phrased as something the user can see, click, or receive -- not "model X exists" or "table Y created".
|
|
198
199
|
- Each truth SHOULD carry a `check:` command authored NOW, goal-backward, before any code exists -- a check written before the code cannot be retro-fitted to what got built (semantics in `.forge/templates/plan.md`).
|
|
@@ -440,10 +441,30 @@ Issues -> fix, re-verify. Max 3 cycles.
|
|
|
440
441
|
|
|
441
442
|
## Step 9: Present
|
|
442
443
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
444
|
+
Lead the approval prompt with a scannable digest, then isolate the approval question at the very end. Full plan prose (per-phase detail, task breakdown, rationale) stays in the plan file on disk — the digest summarizes, it never reproduces the plan file into the prompt. Emit, in this order:
|
|
445
|
+
|
|
446
|
+
1. **Header + phase lines**, framed by a horizontal rule (`─`) above and below: one line per phase — id, name, deps, and `◆` if it carries `integration_checkpoint: true` (else `·` or blank).
|
|
447
|
+
2. **Counts row**, middot-separated (`·`): task count, estimated hours, the FR/NFR ids covered, the reserved version (if any).
|
|
448
|
+
3. **`Ratifying:` bullets** — the 3-5 judgment calls this plan makes that the operator is being asked to ratify (slice boundaries, tier choices, deferrals, discretion picks) — not a restatement of requirements.
|
|
449
|
+
4. **The approval question**, isolated on its own line, prefixed `→`.
|
|
450
|
+
|
|
451
|
+
Shape (fill placeholders per plan; this is the shape, not literal text):
|
|
452
|
+
|
|
453
|
+
```
|
|
454
|
+
Plan digest — m-{id} {name}
|
|
455
|
+
─────────────────────────────
|
|
456
|
+
P{n} {phase-name} deps:{…} {◆ checkpoint | ·}
|
|
457
|
+
…one line per phase…
|
|
458
|
+
─────────────────────────────
|
|
459
|
+
{T} tasks · {H}h · {FR-…, NFR-…} · v{X.Y.Z}
|
|
460
|
+
|
|
461
|
+
Ratifying:
|
|
462
|
+
• {judgment call 1}
|
|
463
|
+
• {judgment call 2}
|
|
464
|
+
• … (3–5)
|
|
465
|
+
|
|
466
|
+
→ Approve this plan? (yes / changes)
|
|
467
|
+
```
|
|
447
468
|
|
|
448
469
|
Done when approved.
|
|
449
470
|
|
|
@@ -35,6 +35,7 @@ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
|
35
35
|
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
|
|
36
36
|
|
|
37
37
|
LOGS=""
|
|
38
|
+
USAGES=""
|
|
38
39
|
MARKER_DIR_OVERRIDE=""
|
|
39
40
|
LIVE_WINDOW=120
|
|
40
41
|
SINCE=""
|
|
@@ -43,6 +44,8 @@ DRY_RUN=0
|
|
|
43
44
|
while [ $# -gt 0 ]; do
|
|
44
45
|
case "$1" in
|
|
45
46
|
--log) LOGS="${LOGS}${LOGS:+
|
|
47
|
+
}${2:-}"; shift 2 ;;
|
|
48
|
+
--usage) USAGES="${USAGES}${USAGES:+
|
|
46
49
|
}${2:-}"; shift 2 ;;
|
|
47
50
|
--marker-dir) MARKER_DIR_OVERRIDE="${2:-}"; shift 2 ;;
|
|
48
51
|
--live-window) LIVE_WINDOW="${2:-120}"; shift 2 ;;
|
|
@@ -82,9 +85,36 @@ GLOBBED_EOF
|
|
|
82
85
|
fi
|
|
83
86
|
fi
|
|
84
87
|
|
|
88
|
+
# ── resolve the usage.tsv set (spend receipts): explicit --usage wins; else disk-glob ────────────
|
|
89
|
+
# Same slice worktrees as the logs, sibling file runner-work/usage.tsv. No new store —
|
|
90
|
+
# these ARE the per-phase receipts the runner already writes (step-5 spend attribution).
|
|
91
|
+
if [ -z "$USAGES" ]; then
|
|
92
|
+
if [ -n "$WT_ROOT" ]; then
|
|
93
|
+
for _wt in "$WT_ROOT"/*/; do
|
|
94
|
+
[ -d "$_wt" ] || continue
|
|
95
|
+
_u="${_wt}.forge/runner-work/usage.tsv"
|
|
96
|
+
[ -f "$_u" ] && USAGES="${USAGES}${USAGES:+
|
|
97
|
+
}$_u"
|
|
98
|
+
done
|
|
99
|
+
else
|
|
100
|
+
_uglobbed="$(git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk '
|
|
101
|
+
/^worktree / { p = substr($0, 10) }
|
|
102
|
+
/^branch / { if ($2 ~ /refs\/heads\/forge\/m-/) print p "/.forge/runner-work/usage.tsv" }')"
|
|
103
|
+
while IFS= read -r _u; do
|
|
104
|
+
[ -n "$_u" ] || continue
|
|
105
|
+
[ -f "$_u" ] && USAGES="${USAGES}${USAGES:+
|
|
106
|
+
}$_u"
|
|
107
|
+
done <<UGLOBBED_EOF
|
|
108
|
+
$_uglobbed
|
|
109
|
+
UGLOBBED_EOF
|
|
110
|
+
fi
|
|
111
|
+
fi
|
|
112
|
+
|
|
85
113
|
if [ "$DRY_RUN" -eq 1 ]; then
|
|
86
114
|
printf 'forge-jarvis-instruments (--dry-run): resolved logs:\n'
|
|
87
115
|
if [ -n "$LOGS" ]; then printf '%s\n' "$LOGS" | sed 's/^/ /'; else printf ' (none)\n'; fi
|
|
116
|
+
printf 'forge-jarvis-instruments (--dry-run): resolved usage receipts:\n'
|
|
117
|
+
if [ -n "$USAGES" ]; then printf '%s\n' "$USAGES" | sed 's/^/ /'; else printf ' (none)\n'; fi
|
|
88
118
|
exit 0
|
|
89
119
|
fi
|
|
90
120
|
|
|
@@ -146,4 +176,41 @@ awk -F'\t' -v total="$_total" -v win="$LIVE_WINDOW" '
|
|
|
146
176
|
printf " (proxy — derived from relay latency, not an uptime store)\n"
|
|
147
177
|
}' "$TMP"
|
|
148
178
|
|
|
179
|
+
# ── 5. Spend by phase and model (step-5 attribution) ─────────────────────────────────────────────
|
|
180
|
+
# Answers "what did this cost, by phase and model?" WITHOUT opening a file — computed
|
|
181
|
+
# live from the runner's usage.tsv receipts (9-col step-5 schema: …actual_model fallback
|
|
182
|
+
# cost_usd). NO new store. Legacy 5-col receipts have no model/cost columns → skipped with
|
|
183
|
+
# a note. Groups by (phase, actual_model), sums cost, flags routed→fallback rows.
|
|
184
|
+
printf '\n5. Spend by phase and model (from usage.tsv receipts):\n'
|
|
185
|
+
printf '%s\n' "$USAGES" | while IFS= read -r U; do
|
|
186
|
+
[ -n "$U" ] && [ -f "$U" ] || continue
|
|
187
|
+
_hdr="$(head -1 "$U")"
|
|
188
|
+
case "$_hdr" in
|
|
189
|
+
*actual_model*cost_usd*)
|
|
190
|
+
# LC_NUMERIC=C so awk parses AND prints the `.`-decimal cost regardless of locale
|
|
191
|
+
# (a comma locale otherwise reads "0.63" as 0 and prints "0,63" — same fix the runner uses).
|
|
192
|
+
LC_NUMERIC=C awk -F'\t' -v src="$U" '
|
|
193
|
+
NR==1 { next }
|
|
194
|
+
NF>=9 && $1!="" {
|
|
195
|
+
key = $1 SUBSEP $7 # phase SUBSEP actual_model
|
|
196
|
+
cost[key] += ($9 + 0)
|
|
197
|
+
if ($8 == "yes") fb[key] += 1
|
|
198
|
+
seen[key] = 1
|
|
199
|
+
}
|
|
200
|
+
END {
|
|
201
|
+
for (k in seen) {
|
|
202
|
+
split(k, a, SUBSEP)
|
|
203
|
+
printf " %-16s %-26s $%.4f%s\n", a[1], a[2], cost[k], (fb[k] ? " (routed->fallback x" fb[k] ")" : "")
|
|
204
|
+
}
|
|
205
|
+
}' "$U"
|
|
206
|
+
;;
|
|
207
|
+
*)
|
|
208
|
+
printf ' (legacy 5-col receipt %s — no model/cost columns; re-run to populate)\n' "$U" ;;
|
|
209
|
+
esac
|
|
210
|
+
done
|
|
211
|
+
# Note when no receipts were found at all (a clean "nothing ran yet", not an error).
|
|
212
|
+
if [ -z "$USAGES" ]; then
|
|
213
|
+
printf ' (no usage.tsv receipts found — no slices instrumented yet)\n'
|
|
214
|
+
fi
|
|
215
|
+
|
|
149
216
|
exit 0
|
|
@@ -35,8 +35,9 @@
|
|
|
35
35
|
#
|
|
36
36
|
# PERMISSIONS — NO FLAGS NEEDED FOR THE PRE-CREATED SESSION (B1 probe vii): typed Bash,
|
|
37
37
|
# Monitor-arm, and PushNotification all run prompt-free in the pre-created session as-is;
|
|
38
|
-
# `--permission-mode` governs only SPAWNED sessions.
|
|
39
|
-
#
|
|
38
|
+
# `--permission-mode` governs only SPAWNED sessions. (Probe vii still owes an explicit
|
|
39
|
+
# check for any OTHER tool a later phase uses unattended.) Post-UAT (2026-07-22): spawned
|
|
40
|
+
# sessions were locked to "auto" — jarvis.spawn_permission_mode below is the opt-in seam.
|
|
40
41
|
#
|
|
41
42
|
# CONFIG — optional `jarvis:` block in .forge/project.yml (board-block precedent):
|
|
42
43
|
# jarvis:
|
|
@@ -44,6 +45,12 @@
|
|
|
44
45
|
# name_suffix: "" # appended: jarvis-<repo>-<suffix>; env FORGE_JARVIS_NAME_SUFFIX
|
|
45
46
|
# # overrides per machine (the multi-machine seam)
|
|
46
47
|
# capacity: 4 # RC server session capacity
|
|
48
|
+
# spawn_permission_mode: "" # --permission-mode for SPAWNED sessions (phone-spawned);
|
|
49
|
+
# # one of acceptEdits|auto|bypassPermissions|default|dontAsk|plan;
|
|
50
|
+
# # env FORGE_JARVIS_SPAWN_PERMISSION_MODE overrides (the
|
|
51
|
+
# # name_suffix pattern). Unset/empty = NO flag (today's command).
|
|
52
|
+
# # TRUST: bypassPermissions means phone-spawned sessions act
|
|
53
|
+
# # without prompts — attended-lane only.
|
|
47
54
|
# NO block → sensible defaults (the launcher is operator-invoked, not ambient — unlike
|
|
48
55
|
# the board, absence does not mean inert).
|
|
49
56
|
#
|
|
@@ -130,10 +137,23 @@ case "$CAP" in
|
|
|
130
137
|
*[!0-9]*|'') printf 'forge-jarvis: capacity must be a positive integer, got: %s\n' "$CAP" >&2; exit 2 ;;
|
|
131
138
|
esac
|
|
132
139
|
|
|
140
|
+
# --- spawn permission mode (post-UAT 2026-07-22: phone-spawned sessions offered ----
|
|
141
|
+
# only "auto"; --permission-mode is a remote-control LAUNCH flag governing spawned
|
|
142
|
+
# sessions). Env beats config (the name_suffix pattern). Unset/empty = no flag —
|
|
143
|
+
# today's command byte-for-byte. Unknown value = loud refusal, never a silent drop.
|
|
144
|
+
SPAWN_PM="${FORGE_JARVIS_SPAWN_PERMISSION_MODE:-$(jarvis_cfg spawn_permission_mode)}"
|
|
145
|
+
case "$SPAWN_PM" in
|
|
146
|
+
''|acceptEdits|auto|bypassPermissions|default|dontAsk|plan) ;;
|
|
147
|
+
*)
|
|
148
|
+
printf 'forge-jarvis: spawn_permission_mode must be one of acceptEdits|auto|bypassPermissions|default|dontAsk|plan, got: %s\n' "$SPAWN_PM" >&2
|
|
149
|
+
exit 2 ;;
|
|
150
|
+
esac
|
|
151
|
+
|
|
133
152
|
# --- resolve -----------------------------------------------------------------
|
|
134
153
|
# The ONE command shape, built once — --dry-run prints exactly the argv launch
|
|
135
154
|
# execs (modulo the caffeinate degradation below), so the two can never drift.
|
|
136
155
|
set -- claude remote-control --name "$NAME" --spawn worktree --capacity "$CAP"
|
|
156
|
+
[ -n "$SPAWN_PM" ] && set -- "$@" --permission-mode "$SPAWN_PM"
|
|
137
157
|
|
|
138
158
|
if [ "$DRY_RUN" = "1" ]; then
|
|
139
159
|
printf 'caffeinate -s %s\n' "$*"
|
|
@@ -60,5 +60,28 @@ printf '%s\n' "$OUT" | grep -qE '2/3 relayed live' && ok "presence proxy:
|
|
|
60
60
|
# 5. the presence line labels itself a proxy (never a measured SLA)
|
|
61
61
|
printf '%s\n' "$OUT" | grep -qiE 'proxy' && ok "presence figure is labelled a proxy, not an SLA" || bad "proxy label" "$OUT"
|
|
62
62
|
|
|
63
|
+
# ── spend-by-phase-and-model section (step-5 attribution, from usage.tsv receipts) ──
|
|
64
|
+
# A hand-built 9-col usage.tsv: two models, one routed→fallback row. The section must
|
|
65
|
+
# answer "what did this cost, by phase and model" from the receipts — no new store.
|
|
66
|
+
USG="$ROOT/usage.tsv"
|
|
67
|
+
{
|
|
68
|
+
printf 'phase_name\ttokens\twall_clock_s\tmodel\tretry_count\tintended_model\tactual_model\tfallback\tcost_usd\n'
|
|
69
|
+
printf 'phase-1\t1000\t2\tsonnet\t0\tsonnet\tclaude-sonnet-5\tno\t0.6300\n'
|
|
70
|
+
printf 'phase-2\t500\t1\thaiku\t0\thaiku\tclaude-haiku-4-5-20251001\tno\t0.0040\n'
|
|
71
|
+
printf 'phase-3\t800\t3\topus\t0\topus\tclaude-sonnet-5\tyes\t0.4000\n'
|
|
72
|
+
} > "$USG"
|
|
73
|
+
|
|
74
|
+
SOUT="$(sh "$INSTR" --log "$LOG" --marker-dir "$MK" --usage "$USG" 2>/dev/null)"
|
|
75
|
+
|
|
76
|
+
printf '%s\n' "$SOUT" | grep -qE '5\. Spend by phase and model' && ok "spend section renders" || bad "spend section" "$SOUT"
|
|
77
|
+
printf '%s\n' "$SOUT" | grep -qE 'phase-1.*claude-sonnet-5.*\$0\.6300' && ok "spend: phase-1 on claude-sonnet-5 = \$0.6300" || bad "spend phase-1" "$SOUT"
|
|
78
|
+
printf '%s\n' "$SOUT" | grep -qE 'phase-2.*claude-haiku-4-5.*\$0\.0040' && ok "spend: phase-2 on claude-haiku = \$0.0040" || bad "spend phase-2" "$SOUT"
|
|
79
|
+
printf '%s\n' "$SOUT" | grep -qE 'phase-3.*routed->fallback' && ok "spend: phase-3 flagged routed->fallback" || bad "spend fallback flag" "$SOUT"
|
|
80
|
+
|
|
81
|
+
# neg control — the numbers are COMPUTED at render; no new stored aggregate file exists.
|
|
82
|
+
grep -rqiE 'spend[-_]?store|aggregate\.tsv|spend\.db|cost[-_]cache' "$SCRIPT_DIR/../forge-jarvis-instruments.sh" \
|
|
83
|
+
&& bad "no-new-store neg control" "found a spend-store reference" \
|
|
84
|
+
|| ok "no new spend store — computed from usage.tsv at render"
|
|
85
|
+
|
|
63
86
|
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
|
|
64
87
|
[ "$FAILED" = 0 ]
|
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
# + optional suffix from jarvis.name_suffix, overridden per machine by
|
|
10
10
|
# FORGE_JARVIS_NAME_SUFFIX (probe ii: the sticky name is the only discoverability
|
|
11
11
|
# anchor). Capacity: --capacity flag > jarvis.capacity > 4. NO token export ever
|
|
12
|
-
# (probe iv — asserted here as an env-name absence in the output).
|
|
13
|
-
#
|
|
12
|
+
# (probe iv — asserted here as an env-name absence in the output). Spawn permission
|
|
13
|
+
# mode (post-UAT 2026-07-22): jarvis.spawn_permission_mode appends --permission-mode,
|
|
14
|
+
# env FORGE_JARVIS_SPAWN_PERMISSION_MODE beats config, unset = no flag. Refusals:
|
|
15
|
+
# jarvis.enabled: false, unknown spawn_permission_mode, or not a git repo →
|
|
16
|
+
# non-zero exit, NO command on stdout.
|
|
14
17
|
#
|
|
15
18
|
# Builds throwaway git repos in a mktemp dir, offline by construction; only ever
|
|
16
19
|
# invokes --dry-run, so `claude`/`caffeinate` need not exist (dry-run is pure
|
|
@@ -30,6 +33,7 @@ trap 'rm -rf "$ROOT"' EXIT INT TERM
|
|
|
30
33
|
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null
|
|
31
34
|
export GIT_CONFIG_GLOBAL GIT_CONFIG_SYSTEM
|
|
32
35
|
unset FORGE_JARVIS_NAME_SUFFIX 2>/dev/null || true
|
|
36
|
+
unset FORGE_JARVIS_SPAWN_PERMISSION_MODE 2>/dev/null || true
|
|
33
37
|
|
|
34
38
|
PASSED=0; FAILED=0
|
|
35
39
|
ok() { PASSED=$((PASSED+1)); printf ' ok %s\n' "$1"; }
|
|
@@ -95,7 +99,62 @@ case "$out" in
|
|
|
95
99
|
*) ok "no token export in the resolved command (probe iv)" ;;
|
|
96
100
|
esac
|
|
97
101
|
|
|
98
|
-
# 7.
|
|
102
|
+
# 7. spawn permission mode — config jarvis.spawn_permission_mode appends the flag
|
|
103
|
+
# (post-UAT 2026-07-22: spawned sessions were locked to "auto")
|
|
104
|
+
cat > "$REPO/.forge/project.yml" <<'EOF'
|
|
105
|
+
jarvis:
|
|
106
|
+
enabled: true
|
|
107
|
+
spawn_permission_mode: bypassPermissions
|
|
108
|
+
EOF
|
|
109
|
+
out="$(cd "$REPO" && sh "$JARVIS" --dry-run 2>/dev/null)"; rc=$?
|
|
110
|
+
want="caffeinate -s claude remote-control --name jarvis-proj --spawn worktree --capacity 4 --permission-mode bypassPermissions"
|
|
111
|
+
if [ "$rc" = 0 ] && [ "$out" = "$want" ]; then
|
|
112
|
+
ok "spawn_permission_mode from config → --permission-mode appended"
|
|
113
|
+
else
|
|
114
|
+
bad "spawn_permission_mode from config" "rc=$rc out=$out"
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# 8. env FORGE_JARVIS_SPAWN_PERMISSION_MODE beats config (the name_suffix pattern)
|
|
118
|
+
out="$(cd "$REPO" && FORGE_JARVIS_SPAWN_PERMISSION_MODE=plan sh "$JARVIS" --dry-run 2>/dev/null)"; rc=$?
|
|
119
|
+
case "$out" in
|
|
120
|
+
*"--permission-mode plan") ok "env FORGE_JARVIS_SPAWN_PERMISSION_MODE overrides config mode" ;;
|
|
121
|
+
*) bad "env spawn-permission-mode override" "rc=$rc out=$out" ;;
|
|
122
|
+
esac
|
|
123
|
+
|
|
124
|
+
# 9. NEG: unset → flag ABSENT (today's command byte-for-byte — the conservative default)
|
|
125
|
+
cat > "$REPO/.forge/project.yml" <<'EOF'
|
|
126
|
+
jarvis:
|
|
127
|
+
enabled: true
|
|
128
|
+
EOF
|
|
129
|
+
out="$(cd "$REPO" && sh "$JARVIS" --dry-run 2>/dev/null)"; rc=$?
|
|
130
|
+
case "$out" in
|
|
131
|
+
*"--permission-mode"*) bad "neg-control: unset spawn_permission_mode must add no flag" "out=$out" ;;
|
|
132
|
+
*)
|
|
133
|
+
if [ "$rc" = 0 ] && [ "$out" = "caffeinate -s claude remote-control --name jarvis-proj --spawn worktree --capacity 4" ]; then
|
|
134
|
+
ok "neg-control: unset spawn_permission_mode → no --permission-mode, command unchanged"
|
|
135
|
+
else
|
|
136
|
+
bad "neg-control unset spawn_permission_mode" "rc=$rc out=$out"
|
|
137
|
+
fi ;;
|
|
138
|
+
esac
|
|
139
|
+
|
|
140
|
+
# 10. NEG: unknown mode → loud refusal (never a silent drop), no command on stdout
|
|
141
|
+
cat > "$REPO/.forge/project.yml" <<'EOF'
|
|
142
|
+
jarvis:
|
|
143
|
+
enabled: true
|
|
144
|
+
spawn_permission_mode: yolo
|
|
145
|
+
EOF
|
|
146
|
+
out="$(cd "$REPO" && sh "$JARVIS" --dry-run 2>/dev/null)"; rc=$?
|
|
147
|
+
err="$(cd "$REPO" && sh "$JARVIS" --dry-run 2>&1 >/dev/null)"
|
|
148
|
+
if [ "$rc" != 0 ] && [ -z "$out" ]; then
|
|
149
|
+
case "$err" in
|
|
150
|
+
*spawn_permission_mode*yolo*) ok "neg-control: unknown spawn_permission_mode → refusal naming the bad value" ;;
|
|
151
|
+
*) bad "neg-control unknown mode: refusal message" "err=$err" ;;
|
|
152
|
+
esac
|
|
153
|
+
else
|
|
154
|
+
bad "neg-control unknown spawn_permission_mode" "rc=$rc out=$out"
|
|
155
|
+
fi
|
|
156
|
+
|
|
157
|
+
# 11. NEG: enabled: false → clean refusal (non-zero, no command on stdout)
|
|
99
158
|
cat > "$REPO/.forge/project.yml" <<'EOF'
|
|
100
159
|
jarvis:
|
|
101
160
|
enabled: false
|
|
@@ -107,7 +166,7 @@ else
|
|
|
107
166
|
bad "neg-control enabled:false" "rc=$rc out=$out"
|
|
108
167
|
fi
|
|
109
168
|
|
|
110
|
-
#
|
|
169
|
+
# 12. NEG: not a git repo → clean refusal (RC --spawn worktree needs one)
|
|
111
170
|
NOREPO="$ROOT/norepo"
|
|
112
171
|
mkdir -p "$NOREPO"
|
|
113
172
|
out="$(cd "$NOREPO" && sh "$JARVIS" --dry-run 2>/dev/null)"; rc=$?
|
|
@@ -13,6 +13,11 @@ phase: 1
|
|
|
13
13
|
phase_name: ""
|
|
14
14
|
plan: 1
|
|
15
15
|
type: execute # execute | tdd
|
|
16
|
+
phase_type: "" # the workflow phase this plan SERVES — one of the model_by_phase_type keys:
|
|
17
|
+
# researching | discussing | architecting | planning | executing | verifying | reviewing
|
|
18
|
+
# Drives per-phase model routing (m-42): the slice runner reads this to pick the phase's
|
|
19
|
+
# model from config.model_by_phase_type. Build plans are usually `executing`; a verify/review
|
|
20
|
+
# phase plan is `verifying`/`reviewing`. Empty = unrouted (inherits the default model).
|
|
16
21
|
wave: 1 # Execution wave (1 = no dependencies)
|
|
17
22
|
depends_on: [] # Plan IDs that must complete first
|
|
18
23
|
autonomous: true # false if contains checkpoints
|
|
@@ -148,6 +148,12 @@ models:
|
|
|
148
148
|
# # repos each machine overrides via env FORGE_JARVIS_NAME_SUFFIX so two
|
|
149
149
|
# # live presences never share one name
|
|
150
150
|
# capacity: 4 # RC server session capacity (--capacity), default 4
|
|
151
|
+
# spawn_permission_mode: "" # --permission-mode for SPAWNED (phone-launched) sessions; one of
|
|
152
|
+
# # acceptEdits|auto|bypassPermissions|default|dontAsk|plan; env
|
|
153
|
+
# # FORGE_JARVIS_SPAWN_PERMISSION_MODE overrides per machine. Unset =
|
|
154
|
+
# # no flag (platform default). TRUST WARNING: bypassPermissions means
|
|
155
|
+
# # phone-spawned sessions act without prompts — opt in only where the
|
|
156
|
+
# # phone lane carries the same trust as an attended terminal.
|
|
151
157
|
|
|
152
158
|
risks: # What could go wrong?
|
|
153
159
|
- risk: ""
|
|
@@ -45,18 +45,27 @@ divergence_budget_lines: 4000
|
|
|
45
45
|
# without touching the loop.
|
|
46
46
|
notify_channel: pushnotification
|
|
47
47
|
|
|
48
|
-
# --- model-by-phase-type map (
|
|
48
|
+
# --- model-by-phase-type map (per-phase model routing — step 5) --------------
|
|
49
49
|
|
|
50
|
-
#
|
|
51
|
-
# to
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
50
|
+
# Maps a phase's `phase_type:` (declared in its plan — the workflow phase it serves:
|
|
51
|
+
# researching|discussing|architecting|planning|executing|verifying|reviewing) to the
|
|
52
|
+
# model its headless `claude -p` launch runs on. `--model` accepts these aliases
|
|
53
|
+
# (or a full id like `claude-sonnet-5`).
|
|
54
|
+
#
|
|
55
|
+
# SHIPS COMMENTED = OFF BY DEFAULT. An absent/empty map reproduces today's behavior
|
|
56
|
+
# BYTE-FOR-BYTE: every phase inherits the default model, no `--model` flag is added.
|
|
57
|
+
# UNCOMMENT an entry to activate routing for that phase-type — that first uncomment
|
|
58
|
+
# is your moment to confirm it does what you expect (nothing chooses for you; the map
|
|
59
|
+
# is yours and only you edit it — no code ever writes it). A routed launch that fails
|
|
60
|
+
# at startup falls back once to the default and records `routed → fallback` in the
|
|
61
|
+
# usage receipt, so a bad value degrades loudly, never silently.
|
|
62
|
+
#
|
|
63
|
+
# Suggested starting point (uncomment the lines you want):
|
|
55
64
|
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
|
|
65
|
+
# researching: sonnet
|
|
66
|
+
# discussing: sonnet
|
|
67
|
+
# architecting: opus
|
|
68
|
+
# planning: opus
|
|
69
|
+
# executing: sonnet
|
|
70
|
+
# verifying: haiku
|
|
71
|
+
# reviewing: sonnet
|