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 +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-slice-runner.sh +27 -2
- package/template/.claude/hooks/tests/cases/block-dangerous-commands.cases.json +154 -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 +24 -4
- package/template/.forge/checks/forge-jarvis.sh +22 -2
- package/template/.forge/checks/tests/forge-jarvis.test.sh +63 -4
- package/template/.forge/templates/project.yml +6 -0
- package/template/.forge/templates/slice-runner/phase-report.yml +5 -1
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."
|
|
@@ -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="$(
|
|
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="$(
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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.
|
|
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' "$*"
|
|
@@ -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=$?
|
|
@@ -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
|