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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.64.3",
3
+ "version": "0.67.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -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
- line=$(printf '{"ts":"%s","repo":"%s","milestone":"%s","skill":"%s","task_type":"%s","complexity":"%s","model":"%s","first_try_pass":%s,"check_exit":%s}' \
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 ' model=%-10s task_type=%-14s status=%-10s count=%-4s first-try=%s%%\n' "$model" "$task_type" "$status" "$count" "$pct"
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> <report-file> — intended model, recorded not routed
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' "$phase" "$attempt" >&2
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 = result.json input_tokens + output_tokens (launcher truth)
557
- # wall_clock_s = runner-measured elapsed for the phase (incl. any retry)
558
- # retry_count = runner-observed (attempt-1), not the phase's self-report
559
- # model = intended model for the phase-type, READ from config but
560
- # NOT routed (model_for_phase the step-5 seam)
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
- _u_model="$(model_for_phase "$phase" "$report")"
568
- [ -f "$USAGE_FILE" ] || printf 'phase_name\ttokens\twall_clock_s\tmodel\tretry_count\n' > "$USAGE_FILE"
569
- printf '%s\t%s\t%s\t%s\t%s\n' "$phase" "$_u_tokens" "$_u_wall" "$_u_model" "$_u_retry" >> "$USAGE_FILE"
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
- 1. Requirements (P1/P2/P3, clarifications)
444
- 2. Phase/plan structure + waves
445
- 3. Effort estimates
446
- 4. "Does this plan match expectations? Changes?"
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. Nothing to provision here. (Probe vii
39
- # still owes an explicit check for any OTHER tool a later phase uses unattended.)
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). Refusals:
13
- # jarvis.enabled: false, or not a git repo → non-zero exit, NO command on stdout.
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. NEG: enabled: false clean refusal (non-zero, no command on stdout)
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
- # 8. NEG: not a git repo → clean refusal (RC --spawn worktree needs one)
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 (STEP-5 SEAMread, not routed) ---------------
48
+ # --- model-by-phase-type map (per-phase model routing step 5) --------------
49
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.
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