forge-orkes 0.64.2 → 0.64.5

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.2",
3
+ "version": "0.64.5",
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."
@@ -111,6 +111,28 @@ PHASES="$(awk '
111
111
  report_field() { # report_field <file> <key> — a TOP-LEVEL `key:` (col 0)
112
112
  grep -E "^$2:" "$1" 2>/dev/null | head -1 | sed -E "s/^$2:[[:space:]]*//; s/[[:space:]]*(#.*)?$//; s/^\"//; s/\"$//"
113
113
  }
114
+ report_field_flat() { # report_field_flat <file> <key> — report_field + YAML block scalars
115
+ # A long interrupt question is naturally written as a block scalar (`key: |` /
116
+ # `key: >`), and report_field's single-line read returns the literal indicator —
117
+ # so the §8 ping carried "|" as the whole question (seen live: m-40 phase
118
+ # 73-approval-prompt-digest, 2026-07-22). When the value is a block-scalar
119
+ # indicator, collect the block's more-indented lines and FLATTEN to one line
120
+ # (newlines → spaces) so the ping payload stays a single TSV field. Blank lines
121
+ # inside the block are kept-as-separators; the first non-blank column-0 line
122
+ # (the next top-level key) ends the block. Plain scalars pass through unchanged.
123
+ _ff="$(report_field "$1" "$2")"
124
+ case "$_ff" in
125
+ '|'*|'>'*)
126
+ awk -v k="$2" '
127
+ hit && /^[^[:space:]]/ { exit }
128
+ hit { sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "");
129
+ if (length($0)) printf "%s%s", (n++ ? " " : ""), $0; next }
130
+ !hit && index($0, k ":") == 1 { hit = 1 }
131
+ ' "$1"
132
+ ;;
133
+ *) printf '%s' "$_ff" ;;
134
+ esac
135
+ }
114
136
  report_nested() { # report_nested <file> <key> — an INDENTED `key:` (checks.*/usage.*)
115
137
  # The report nests checks.failed and usage.model under their parent block;
116
138
  # report_field's `^key:` anchor can't see them. Each nested key we read
@@ -431,6 +453,9 @@ for phase in $PHASES; do
431
453
  printf ' %s\n' "$REPORT_TEMPLATE"
432
454
  printf 'and the JSON schema here:\n'
433
455
  printf ' %s\n' "$SCHEMA"
456
+ printf 'All runner artifacts for this slice live under %s\n' "$WORK_DIR"
457
+ printf '(this phase writes into %s) — the paths are known; never search the\n' "$wdir"
458
+ printf 'filesystem for result.json, notify.log, or any runner file.\n'
434
459
  printf 'Set verdict to one of proceed|park|halt|done. If you hit a Rule-4 stop,\n'
435
460
  printf 'an ambiguity, an irreversible step, a design fork, or a budget breach,\n'
436
461
  printf 'set verdict=park and fill interrupt + interrupt_payload — do not guess.\n'
@@ -501,7 +526,7 @@ for phase in $PHASES; do
501
526
  continue
502
527
  fi
503
528
  # Retry exhausted → HALT the slice early, failure evidence attached.
504
- _ev="$(report_field "$report" evidence)"
529
+ _ev="$(report_field_flat "$report" evidence)"
505
530
  slice_notify halt slice \
506
531
  "slice HALTED at phase $phase ($idx/$total) after 1 retry — verdict=$verdict checks.failed=$cfailed; evidence: $report${_ev:+ ; $_ev}"
507
532
  printf '[runner] phase %s: HALTED after retry — failure evidence at %s\n' "$phase" "$report" >&2
@@ -582,7 +607,7 @@ for phase in $PHASES; do
582
607
  # The park happens BEFORE the next phase launches, so an irreversible action
583
608
  # (declared in the report, deferred by the phase) is caught pre-execution.
584
609
  interrupt="$(report_field "$report" interrupt)"
585
- ipayload="$(report_field "$report" interrupt_payload)"
610
+ ipayload="$(report_field_flat "$report" interrupt_payload)"
586
611
 
587
612
  # A design fork must arrive as something to LOOK AT — the payload is a
588
613
  # sketch/mockup PATH that exists, never prose describing a visual (§8).
@@ -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
  ]
@@ -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
 
@@ -440,10 +440,30 @@ Issues -> fix, re-verify. Max 3 cycles.
440
440
 
441
441
  ## Step 9: Present
442
442
 
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?"
443
+ 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:
444
+
445
+ 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).
446
+ 2. **Counts row**, middot-separated (`·`): task count, estimated hours, the FR/NFR ids covered, the reserved version (if any).
447
+ 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.
448
+ 4. **The approval question**, isolated on its own line, prefixed `→`.
449
+
450
+ Shape (fill placeholders per plan; this is the shape, not literal text):
451
+
452
+ ```
453
+ Plan digest — m-{id} {name}
454
+ ─────────────────────────────
455
+ P{n} {phase-name} deps:{…} {◆ checkpoint | ·}
456
+ …one line per phase…
457
+ ─────────────────────────────
458
+ {T} tasks · {H}h · {FR-…, NFR-…} · v{X.Y.Z}
459
+
460
+ Ratifying:
461
+ • {judgment call 1}
462
+ • {judgment call 2}
463
+ • … (3–5)
464
+
465
+ → Approve this plan? (yes / changes)
466
+ ```
447
467
 
448
468
  Done when approved.
449
469
 
@@ -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' "$*"
@@ -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=$?
@@ -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: ""
@@ -31,9 +31,13 @@ interrupt: none
31
31
 
32
32
  interrupt_payload: ""
33
33
  # interrupt_payload — REQUIRED (may be empty string when interrupt==none).
34
- # For ambiguity/irreversible/budget: the question text to put in the ping.
34
+ # For ambiguity/irreversible/budget: the question text to put in the ping. A long
35
+ # question may be a YAML block scalar (`interrupt_payload: |` + indented lines) —
36
+ # the runner flattens it to ONE line (newlines → spaces) for the ping, so write
37
+ # it to read well as a single line too.
35
38
  # For a `fork` (design decision): a PATH to a sketch/mockup the operator LOOKS AT —
36
39
  # never prose describing a visual (brief §8: a fork arrives as something to see).
40
+ # A fork payload must stay a single-line path, never a block scalar.
37
41
 
38
42
  checks:
39
43
  passed: 0