forge-orkes 0.70.0 → 0.72.2

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.
@@ -14,7 +14,7 @@
14
14
  # lint=clean
15
15
  # lint=fail + finding=<rule>:<file>[:<line>]
16
16
  # lint=justified justify="<trailer line>" + finding=<rule>:<file>[:<line>] justified
17
- # rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker
17
+ # rule ∈ cannot-fail|silent-fail|existence-only|deleted-assertion|feature-removal|skip-marker|driven-by-missing
18
18
  # exit: 0 = clean or justified; 1 = fail; 2 = bad invocation. Logs → stderr.
19
19
  #
20
20
  # Targets (nothing else is scanned):
@@ -48,11 +48,30 @@
48
48
  # shrink is ambiguous and keeps the stricter deleted-assertion.
49
49
  # skip-marker — an ADDED .only( / .skip( / it.skip / describe.only / xit( /
50
50
  # xdescribe( / @pytest.mark.skip / t.Skip() line.
51
+ # driven-by-missing — CONFIG-GATED, default OFF (hold-waiver patch, operator-
52
+ # ruled 2026-07-22). When the BASE-side .forge/project.yml
53
+ # carries:
54
+ # attribution:
55
+ # driven_by: true
56
+ # every non-merge commit of merge-base..head must carry a
57
+ # Driven-by: <email>
58
+ # trailer (optional trailing session ref allowed) — the
59
+ # durable per-commit record of the driving human, whatever
60
+ # identity authors. Finding format: the file slot carries
61
+ # the offending commit's short SHA
62
+ # (finding=driven-by-missing:<short-sha>). Config absent or
63
+ # false → rule fully off, zero behavior change. Read from
64
+ # the BASE side, so a PR cannot disable the rule for
65
+ # itself (and a project.yml edit raises a protected-path
66
+ # hold in the sibling check anyway). Merge commits are
67
+ # exempt (host- or tool-authored join points, not driven
68
+ # work). SEVERABLE: this rule dies alone if it grows
69
+ # friction; it shares only the justification channel.
51
70
  #
52
71
  # Justification channel (pure git, host-agnostic): a commit-message trailer
53
72
  # Lint-Justify: <one line>
54
73
  # in ANY commit of merge-base..head lifts (b)/(c)/deleted-assertion/feature-removal/
55
- # skip-marker → exit 0 `lint=justified`. It never lifts (a).
74
+ # skip-marker/driven-by-missing → exit 0 `lint=justified`. It never lifts (a).
56
75
  #
57
76
  # Host-agnostic + offline (NFR-032): POSIX sh over git plumbing only.
58
77
  set -u
@@ -358,6 +377,21 @@ done <<EOF
358
377
  $CHANGED
359
378
  EOF
360
379
 
380
+ # --- Driven-by trailer rule (config-gated, default OFF; see header) -----------
381
+ # Gate read from the BASE-side project.yml — a PR cannot disable the rule for
382
+ # itself. Absent file / absent block / not-true value → rule fully off.
383
+ DRIVEN_BY_ON="$(git show "$MB:.forge/project.yml" 2>/dev/null | awk '
384
+ /^[A-Za-z_]+:/ { insec = (index($0, "attribution:") == 1) ? 1 : 0; next }
385
+ insec && /^[[:space:]]+driven_by:[[:space:]]*true([[:space:]#]|$)/ { print "on"; exit }')"
386
+ if [ "$DRIVEN_BY_ON" = "on" ]; then
387
+ for db_c in $(git rev-list --no-merges "$MB..$HEAD_REF" 2>/dev/null); do
388
+ if ! git log -1 --format=%B "$db_c" 2>/dev/null \
389
+ | grep -qE '^Driven-by:[[:space:]]*[^[:space:]@]+@[^[:space:]@]+'; then
390
+ add_finding driven-by-missing "$(git rev-parse --short "$db_c")" ""
391
+ fi
392
+ done
393
+ fi
394
+
361
395
  FINDINGS="$(printf '%s' "$FINDINGS" | sort -u)"
362
396
 
363
397
  if [ -z "$FINDINGS" ]; then
@@ -14,6 +14,7 @@
14
14
  # hold=blocked facts, no recorded operator act
15
15
  # fact=<class>:<path> one line per fact
16
16
  # hold=waived waived_by=<login> facts + operator approval
17
+ # hold=waived waiver_run=<url> facts + approved waiver run (below)
17
18
  # fact=<class>:<path> waived one line per waived fact
18
19
  # class ∈ irreversible|structural|protected
19
20
  # exit: 0 = clear or waived; 1 = blocked; 2 = bad invocation. All logs → stderr.
@@ -47,11 +48,49 @@
47
48
  # base-side config operators: list. Absent config / empty operators → no waiver
48
49
  # possible (raise-only safe default until the repo configures operators).
49
50
  #
51
+ # Run-waiver (hold-waiver patch, operator-ruled 2026-07-22): OPTIONAL second
52
+ # waiver channel — the Environment-gated waiver run. Base-side hold-config.yml
53
+ # may carry:
54
+ # waiver:
55
+ # workflow: waive-hold.yml # dispatchable workflow under .github/workflows/
56
+ # ref: main # MUST equal the repo's default branch
57
+ # When configured AND the hold has fired AND no operator PR approval waives it,
58
+ # the check queries the host ONCE (gh, the Actions runs API) for a run of that
59
+ # workflow matching ALL of:
60
+ # - workflow path == .github/workflows/<workflow> (querying by workflow file,
61
+ # re-asserted per run — a same-named run of another workflow never counts)
62
+ # - run ref (head_branch) == the repo's DEFAULT branch, which must also equal
63
+ # the configured ref (a dispatch runs the REF's copy of the file — a
64
+ # PR-branch copy with the gate stripped can never be the run that counts)
65
+ # - conclusion == success (an Environment-gated run cannot succeed unapproved
66
+ # — the approval IS why it ran)
67
+ # - display_title carries the PR HEAD SHA (the workflow's run-name templates
68
+ # the sha input in; a new push changes the head SHA, so a stale waiver run
69
+ # stops matching — superseded-SHA fail-closed leg)
70
+ # Match → the check passes, annotated: hold=waived waiver_run=<run html_url>.
71
+ # FAIL-CLOSED ON EVERY LEG: waiver unconfigured / unsafe config value / gh
72
+ # missing / API unreachable / default branch unknown / config ref != default
73
+ # branch / no matching run (wrong path, wrong ref, non-success, stale SHA) →
74
+ # the hold stands (hold=blocked, exit 1). The waiver applies to THIS check
75
+ # ONLY — never to the check-quality lint, never to CI test checks.
76
+ #
77
+ # Host access + CI permissions: the query runs through `gh` (env override
78
+ # FORGE_HOLD_GH — fixtures stub it and stay offline). In CI the job needs the
79
+ # default GITHUB_TOKEN exposed as GH_TOKEN with `actions: read` (the runs API)
80
+ # in addition to `contents: read`; see docs/merge-floor-checks.md §5 wiring.
81
+ # gh is used for transport + a PROJECTION-only --jq (no filtering in jq — every
82
+ # matching leg is asserted in this script, so the offline fixtures exercise it).
83
+ #
50
84
  # Host-agnostic + offline (NFR-032): pure POSIX sh over git plumbing. No GitHub/
51
- # GitLab API, no network, no node/python/jq. Heuristic manifest parsing is
52
- # documented inline; what it can't catch, UAT stands behind.
85
+ # GitLab API, no network, no node/python/jq with ONE opt-in exception: the
86
+ # run-waiver query above, which only fires when `waiver:` is configured AND a
87
+ # hold has already fired. With `waiver:` absent the check touches no network and
88
+ # behaves byte-identically to the pre-waiver check. Heuristic manifest parsing
89
+ # is documented inline; what it can't catch, UAT stands behind.
53
90
  set -u
54
91
 
92
+ GH="${FORGE_HOLD_GH:-gh}" # host CLI; fixtures point this at an offline stub
93
+
55
94
  usage() { printf 'usage: forge-hold-check.sh <base>...<head> [--approvals <file>]\n' >&2; }
56
95
 
57
96
  RANGE=""
@@ -106,10 +145,25 @@ conf_list() { # key → one value per line from the base-side config
106
145
  }'
107
146
  }
108
147
 
148
+ conf_waiver() { # key → scalar under the base-side waiver: block (empty if absent)
149
+ [ -n "$CONF" ] || return 0
150
+ printf '%s\n' "$CONF" | awk -v k="$1" '
151
+ /^[A-Za-z_]+:/ { insec = (index($0, "waiver:") == 1) ? 1 : 0; next }
152
+ insec && index($0, k ":") > 0 && $0 ~ ("^[[:space:]]+" k ":") {
153
+ v = $0
154
+ sub(/^[[:space:]]*[A-Za-z_]+:[[:space:]]*/, "", v)
155
+ gsub(/["\047]/, "", v)
156
+ sub(/[[:space:]]*(#.*)?$/, "", v)
157
+ print v; exit
158
+ }'
159
+ }
160
+
109
161
  CONF_IRREVERSIBLE="$(conf_list irreversible_paths)"
110
162
  CONF_DEPLOY="$(conf_list deploy_paths)"
111
163
  CONF_GATING="$(conf_list gating_tests)"
112
164
  CONF_OPERATORS="$(conf_list operators)"
165
+ WAIVER_WF="$(conf_waiver workflow)"
166
+ WAIVER_REF="$(conf_waiver ref)"
113
167
 
114
168
  glob_match() { # path, glob-list (newline) → 0 if any glob matches
115
169
  [ -n "$2" ] || return 1
@@ -252,6 +306,71 @@ if [ -n "$WAIVED_BY" ]; then
252
306
  exit 0
253
307
  fi
254
308
 
309
+ # --- run-waiver (optional; FAIL-CLOSED on every leg — see header) -------------
310
+ # Reached only when facts exist and no operator PR approval waived them. With
311
+ # waiver: unconfigured this block is a no-op (no gh call, no network): the
312
+ # blocked verdict below is byte-identical to the pre-waiver check.
313
+ WAIVER_RUN_URL=""
314
+ if [ -n "$WAIVER_WF" ] && [ -n "$WAIVER_REF" ]; then
315
+ # Config values reach gh argv — hard-validate the charset first (fail-closed:
316
+ # a malformed binding can never widen the query or smuggle flags).
317
+ W_OK=1
318
+ case "$WAIVER_WF" in *[!A-Za-z0-9._/-]*)
319
+ printf 'forge-hold-check: waiver.workflow carries unsafe characters — waiver ignored, hold stands\n' >&2; W_OK=0 ;;
320
+ esac
321
+ case "$WAIVER_REF" in *[!A-Za-z0-9._/-]*)
322
+ printf 'forge-hold-check: waiver.ref carries unsafe characters — waiver ignored, hold stands\n' >&2; W_OK=0 ;;
323
+ esac
324
+ if [ "$W_OK" = 1 ]; then
325
+ HEAD_SHA="$(git rev-parse "$HEAD_REF^{commit}" 2>/dev/null)"
326
+ DEFAULT_BRANCH="$("$GH" api 'repos/{owner}/{repo}' --jq .default_branch 2>/dev/null)" || DEFAULT_BRANCH=""
327
+ if [ -z "$DEFAULT_BRANCH" ]; then
328
+ printf 'forge-hold-check: waiver: default branch unknown (gh missing or API unreachable) — hold stands\n' >&2
329
+ elif [ "$WAIVER_REF" != "$DEFAULT_BRANCH" ]; then
330
+ printf 'forge-hold-check: waiver.ref (%s) != repo default branch (%s) — hold stands\n' "$WAIVER_REF" "$DEFAULT_BRANCH" >&2
331
+ else
332
+ case "$WAIVER_WF" in
333
+ */*) WF_PATH="$WAIVER_WF" ;;
334
+ *) WF_PATH=".github/workflows/$WAIVER_WF" ;;
335
+ esac
336
+ # PROJECTION-only jq (no filtering there — every leg is asserted below so
337
+ # the offline fixture stub exercises each one). Fields tab-separated,
338
+ # display_title LAST (the one field that may contain spaces).
339
+ RUNS="$("$GH" api \
340
+ "repos/{owner}/{repo}/actions/workflows/$WAIVER_WF/runs?branch=$DEFAULT_BRANCH&status=success&per_page=100" \
341
+ --jq '.workflow_runs[] | [.conclusion, .head_branch, .path, .html_url, .display_title] | @tsv' \
342
+ 2>/dev/null)" || RUNS=""
343
+ if [ -z "$RUNS" ]; then
344
+ printf 'forge-hold-check: waiver: no runs of %s on %s (or API unreachable) — hold stands\n' "$WF_PATH" "$DEFAULT_BRANCH" >&2
345
+ else
346
+ W_TAB="$(printf '\t')"
347
+ while IFS="$W_TAB" read -r r_concl r_branch r_path r_url r_title; do
348
+ [ -n "$r_concl" ] || continue
349
+ [ "$r_concl" = "success" ] || continue # leg: approved (Environment-gated success)
350
+ [ "$r_branch" = "$DEFAULT_BRANCH" ] || continue # leg: run ref == default branch
351
+ [ "$r_path" = "$WF_PATH" ] || continue # leg: workflow path
352
+ case "$r_title" in # leg: run title carries the PR head SHA
353
+ *"$HEAD_SHA"*) WAIVER_RUN_URL="$r_url"; break ;;
354
+ esac
355
+ done <<WEOF
356
+ $RUNS
357
+ WEOF
358
+ [ -n "$WAIVER_RUN_URL" ] \
359
+ || printf 'forge-hold-check: waiver: no approved success run of %s on %s carries head SHA %s — hold stands\n' "$WF_PATH" "$DEFAULT_BRANCH" "$HEAD_SHA" >&2
360
+ fi
361
+ fi
362
+ fi
363
+ fi
364
+
365
+ if [ -n "$WAIVER_RUN_URL" ]; then
366
+ printf 'forge-hold-check: hold waived by approved run %s\n' "$WAIVER_RUN_URL" >&2
367
+ printf 'hold=waived waiver_run=%s\n' "$WAIVER_RUN_URL"
368
+ printf '%s\n' "$FACTS" | while IFS= read -r f; do
369
+ [ -n "$f" ] && printf 'fact=%s waived\n' "$f"
370
+ done
371
+ exit 0
372
+ fi
373
+
255
374
  printf 'hold=blocked\n'
256
375
  printf '%s\n' "$FACTS" | while IFS= read -r f; do
257
376
  [ -n "$f" ] && printf 'fact=%s\n' "$f"
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env sh
2
- # forge-jarvis-dispatch.sh --slice <worktree> [runner args...] [--dry-run]
3
- # (m-37, Brief-R2 step 4 B3; contract handoff-step4-jarvis.md REV2)
2
+ # forge-jarvis-dispatch.sh --slice <worktree> [runner args...] [--dry-run] [--plan <spec>]...
3
+ # (m-37, Brief-R2 step 4 B3; contract handoff-step4-jarvis.md REV2; m-45 re-dispatch hygiene)
4
4
  #
5
5
  # On the operator's "go", Jarvis dispatches the slice runner DETACHED so the build
6
6
  # survives Jarvis's death (B1 probe viii: nohup-&). This helper owns ONLY the caller-side
@@ -25,12 +25,44 @@
25
25
  # this dispatch ever starts choosing models, STOP — routing is step 5.
26
26
  #
27
27
  # All non-flag args are forwarded to the runner unchanged (--slice <worktree>, and optionally
28
- # --work-dir / --config / --schema / --report-template — whatever the runner accepts). The only
29
- # arg this helper consumes for itself is --dry-run.
28
+ # --config / --schema / --report-template — whatever the runner accepts). The args this helper
29
+ # consumes for itself are --dry-run, --materialize-only, and --plan (the runner has no --plan;
30
+ # forwarding it would be a routing-adjacent contract change — FR-198 boundary class).
31
+ # EXCEPTION (R092): --work-dir is REFUSED, not forwarded — dispatch pins notify.log,
32
+ # dispatch.out, and the runner-work rotation to <worktree>/.forge/runner-work, so a forwarded
33
+ # --work-dir would split-brain the work dir (runner writes one place; dispatch, relay, and
34
+ # rotation look at another). Callers managing their own work dir invoke the runner directly.
30
35
  #
31
36
  # --dry-run print the exact `nohup env ... runner ...` command + env on stdout and exit 0;
32
37
  # never dispatches. Use it to show the operator what "go" will run.
33
38
  #
39
+ # --plan <spec> (repeatable; m-45, canvaz desire path 2026-07-22 defect 2) scope the
40
+ # materialized work order instead of bundling EVERY phase dir + plan-NN.md:
41
+ # --plan 72-bbb → the whole phase dir 72-bbb (all its plan-NN.md)
42
+ # --plan 72-bbb/plan-02 → exactly that plan file (.md optional)
43
+ # The mid-milestone re-dispatch tool: plan-01 landed, plan-02 next → dispatch
44
+ # --plan <phase>/plan-02 and only the next plan is bundled — never landed work.
45
+ # A spec that resolves to nothing REFUSES (exit 2, before the detach) — a typo
46
+ # must not dispatch the wrong work order. With an existing slice.yml: one this
47
+ # helper materialized (header line) is archived + re-materialized; a hand-
48
+ # authored one REFUSES — hand-authored work orders win, delete it yourself.
49
+ # Selected phases keep numeric order regardless of flag order.
50
+ #
51
+ # STALE-STATE ROTATION (m-45, canvaz desire path 2026-07-22 defect 3). Runner-work state is
52
+ # per-WORK-ORDER: pings/{key}-{kind}.marker dedups pings across re-runs of the SAME order, and
53
+ # index-keyed phase-{idx}/phase-report.yml verdicts derive its resume point. Dispatching a NEW
54
+ # work order through the same worktree with that state in place is poison — the old done marker
55
+ # silences the new run's done ping, and a stale verdict short-circuits the new phase as already
56
+ # complete (observed live: silent phone, last_ping=none). So whenever this helper materializes a
57
+ # NEW slice.yml it first rotates a non-empty .forge/runner-work WHOLE into
58
+ # .forge/runner-work-archive/<UTC-ts>/ — whole because relay-markers are keyed by notify.log
59
+ # LINE NUMBER (sibling dir); a partial reset would misalign relay offsets. Archive, never
60
+ # delete: git commits are the durable evidence, runner-work is runtime. The resume paths are
61
+ # untouched by construction — crash re-dispatch and park→answer→resume arrive with slice.yml
62
+ # present and take the honored-as-is early return, so rotation never fires there. A --work-dir
63
+ # arg is refused at the door (R092, see above) so rotation always matches the paths dispatch
64
+ # owns; dispatching a new order while the prior runner still lives is operator error, unchanged.
65
+ #
34
66
  # WORK-ORDER MATERIALIZATION (FR-197; walk run 4, 2026-07-22). The runner's contract
35
67
  # (consumed, never edited) is <worktree>/slice.yml listing phase names, each resolving to
36
68
  # <worktree>/phases/<name>/plan.md — but the Forge intake act writes plans under
@@ -42,6 +74,15 @@
42
74
  # REFUSES LOUDLY AND SYNCHRONOUSLY when it cannot — the failure surface is this exit code, not
43
75
  # a silent phone. An existing slice.yml is honored untouched (a hand-authored work order wins).
44
76
  # --materialize-only materialize (or refuse) and exit — no dispatch. Test hook + operator tool.
77
+ #
78
+ # STALENESS GUARD (live 2026-07-23). A materialized order SNAPSHOTS its source plans, so each
79
+ # bundled plan-NN.md is fingerprinted into slice.yml (`# source: <phase>/<file> cksum=N`, after
80
+ # the phases block). A plain re-dispatch (slice.yml present, no --plan) re-checksums them: any
81
+ # divergence — an amended source plan — REFUSES loudly instead of silently building the old
82
+ # spec (observed live: a full rebuild ran to the pre-amendment plan). Refuse, never auto-
83
+ # rebuild: the honored-as-is branch also serves crash re-dispatch and park→answer→resume,
84
+ # whose runner-work must survive rotation. Hand-authored orders are never checked; pre-guard
85
+ # materialized orders (no fingerprints) skip the check — lazy migration.
45
86
  set -u
46
87
 
47
88
  SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -55,6 +96,8 @@ CHANNEL="$SELF_DIR/forge-jarvis-notify-logonly.sh" # the log-only cha
55
96
  DRY_RUN=0
56
97
  MAT_ONLY=0
57
98
  WORKTREE=""
99
+ PLAN_SPECS="" # newline-separated --plan specs (may be empty)
100
+ REPLACE_MATERIALIZED=0 # set when --plan replaces a helper-materialized slice.yml
58
101
  argc=$#
59
102
  while [ "$argc" -gt 0 ]; do
60
103
  arg="$1"; shift; argc=$((argc - 1))
@@ -63,6 +106,17 @@ while [ "$argc" -gt 0 ]; do
63
106
  DRY_RUN=1 ;; # ours — not forwarded
64
107
  --materialize-only)
65
108
  MAT_ONLY=1 ;; # ours — not forwarded
109
+ --plan) # ours — not forwarded (runner has no --plan)
110
+ _spec="${1:-}"; shift; argc=$((argc - 1))
111
+ [ -n "$_spec" ] || { printf 'forge-jarvis-dispatch: --plan requires a <phase>[/plan-NN] spec\n' >&2; exit 2; }
112
+ PLAN_SPECS="${PLAN_SPECS}${_spec}
113
+ " ;;
114
+ --work-dir|--work-dir=*) # REFUSED (R092) — see header: a forwarded
115
+ # work dir splits the brain (runner writes there; notify.log/dispatch.out/rotation
116
+ # stay pinned to <worktree>/.forge/runner-work). Refuse loudly BEFORE the detach —
117
+ # the failure surface is this exit code, not a silent phone.
118
+ printf 'forge-jarvis-dispatch: REFUSED — --work-dir cannot be forwarded through dispatch (notify.log, dispatch.out and runner-work rotation are pinned to <worktree>/.forge/runner-work; a custom work dir would split-brain them). Invoke forge-slice-runner.sh directly to manage your own work dir.\n' >&2
119
+ exit 2 ;;
66
120
  --slice)
67
121
  WORKTREE="${1:-}"; shift; argc=$((argc - 1))
68
122
  set -- "$@" --slice "$WORKTREE" ;; # forward it too (runner needs it)
@@ -78,15 +132,97 @@ WORKTREE="$(cd "$WORKTREE" && pwd)" # absolutize — the log pa
78
132
  [ -f "$RUNNER" ] || { printf 'forge-jarvis-dispatch: runner not found: %s\n' "$RUNNER" >&2; exit 2; }
79
133
  [ -f "$CHANNEL" ] || { printf 'forge-jarvis-dispatch: log-only channel not found: %s\n' "$CHANNEL" >&2; exit 2; }
80
134
 
135
+ # --- rotate stale runner state when a NEW work order lands (FR-235, m-45) --------------------
136
+ # Called at exactly ONE point: right before materialize_work_order commits a new slice.yml —
137
+ # every refusal path exits earlier, and the resume paths (crash re-dispatch, park→answer→
138
+ # resume) take the honored-as-is early return and never reach it. The dir moves WHOLE (see
139
+ # header: relay-markers are line-number-keyed siblings of notify.log). Archive, never delete.
140
+ rotate_runner_work() {
141
+ _rw="$WORKTREE/.forge/runner-work"
142
+ _stale=0
143
+ if [ -d "$_rw" ] && [ -n "$(ls -A "$_rw" 2>/dev/null)" ]; then _stale=1; fi
144
+ [ "$_stale" = 1 ] || [ "$REPLACE_MATERIALIZED" = 1 ] || return 0
145
+ _parent="$WORKTREE/.forge/runner-work-archive"
146
+ _base="$_parent/$(date -u +%Y%m%dT%H%M%SZ)"
147
+ _dest="$_base"; _n=2
148
+ while [ -e "$_dest" ] || [ -L "$_dest" ]; do _dest="$_base-$_n"; _n=$((_n + 1)); done
149
+ mkdir -p "$_parent" || {
150
+ printf 'forge-jarvis-dispatch: REFUSED — cannot create %s; stale runner-work would poison the new work order. RELAY this refusal to the operator.\n' "$_parent" >&2
151
+ return 2
152
+ }
153
+ if [ "$_stale" = 1 ]; then
154
+ mv "$_rw" "$_dest" || {
155
+ printf 'forge-jarvis-dispatch: REFUSED — cannot rotate %s → %s; stale runner-work would poison the new work order. RELAY this refusal to the operator.\n' "$_rw" "$_dest" >&2
156
+ return 2
157
+ }
158
+ else
159
+ mkdir -p "$_dest" || {
160
+ printf 'forge-jarvis-dispatch: REFUSED — cannot create %s. RELAY this refusal to the operator.\n' "$_dest" >&2
161
+ return 2
162
+ }
163
+ fi
164
+ # A replaced helper-materialized slice.yml is part of the prior run — archive it alongside.
165
+ if [ "$REPLACE_MATERIALIZED" = 1 ] && [ -f "$WORKTREE/slice.yml" ]; then
166
+ mv "$WORKTREE/slice.yml" "$_dest/slice.yml" || {
167
+ printf 'forge-jarvis-dispatch: REFUSED — cannot archive the prior slice.yml into %s. RELAY this refusal to the operator.\n' "$_dest" >&2
168
+ return 2
169
+ }
170
+ fi
171
+ printf 'forge-jarvis-dispatch: rotated stale runner state -> %s\n' "$_dest" >&2
172
+ return 0
173
+ }
174
+
81
175
  # --- materialize the work order from the milestone's locked plans (FR-197) -------------------
82
- # slice.yml present → honored untouched. Absent build it from the single
83
- # .forge/phases/milestone-*/ dir: one phases/<name>/plan.md per phase subdir (plan-NN.md
84
- # concatenated in order), slice.yml listing the names numerically. Cannot → REFUSE, exit 2,
85
- # BEFORE the detach the caller sees it synchronously and must relay it to the operator.
176
+ # slice.yml present → honored untouched (unless --plan targets a helper-materialized one
177
+ # header doc). Absent → build it from the single .forge/phases/milestone-*/ dir: one
178
+ # phases/<name>/plan.md per phase subdir (plan-NN.md concatenated in order), slice.yml listing
179
+ # the names numerically; --plan scopes both. Cannot REFUSE, exit 2, BEFORE the detach — the
180
+ # caller sees it synchronously and must relay it to the operator.
86
181
  materialize_work_order() {
87
182
  if [ -f "$WORKTREE/slice.yml" ]; then
88
- printf 'forge-jarvis-dispatch: slice.yml present — using it as-is\n' >&2
89
- return 0
183
+ _hdr="$(head -n 1 "$WORKTREE/slice.yml" 2>/dev/null)"
184
+ if [ -z "$PLAN_SPECS" ]; then
185
+ # STALENESS GUARD (live 2026-07-23): a helper-materialized work order SNAPSHOTS
186
+ # the source plans at dispatch time; amend .forge/phases/.../plan-NN.md afterward
187
+ # and a plain re-dispatch would build the OLD spec silently (observed live: a full
188
+ # rebuild ran to the pre-amendment plan; the miss surfaced only because the
189
+ # operator poked). Materialization records one `# source: <phase>/<file> cksum=N`
190
+ # line per bundled plan; here each is re-checksummed and ANY divergence refuses
191
+ # LOUDLY. Refuse, never auto-rebuild: this branch also serves crash re-dispatch
192
+ # and park→answer→resume, and an auto-rotation would wipe the parked report the
193
+ # resume derives from. Hand-authored orders are never checked (they win, always);
194
+ # older materialized orders without fingerprints skip the check (lazy migration).
195
+ case "$_hdr" in
196
+ "# Materialized by forge-jarvis-dispatch from "*)
197
+ _srcrel="${_hdr#\# Materialized by forge-jarvis-dispatch from }"
198
+ _srcrel="${_srcrel% (delete to re-materialize)}"
199
+ _stale=""
200
+ while IFS= read -r _sl; do
201
+ case "$_sl" in "# source: "*) ;; *) continue ;; esac
202
+ _ref="${_sl#\# source: }" # <phase>/<file> cksum=<n>
203
+ _rf="${_ref% cksum=*}"; _rk="${_ref##* cksum=}"
204
+ _cur=""
205
+ [ -f "$WORKTREE/$_srcrel/$_rf" ] && _cur="$(cksum < "$WORKTREE/$_srcrel/$_rf" 2>/dev/null | cut -d' ' -f1)"
206
+ [ "$_cur" = "$_rk" ] || _stale="$_stale $_rf"
207
+ done < "$WORKTREE/slice.yml"
208
+ if [ -n "$_stale" ]; then
209
+ printf 'forge-jarvis-dispatch: REFUSED — materialized slice.yml is STALE:%s changed since materialization (source plan amended or removed). Re-dispatch with --plan to rebuild the work order (the prior one is archived), or delete slice.yml to re-materialize whole. RELAY this refusal to the operator.\n' "$_stale" >&2
210
+ return 2
211
+ fi ;;
212
+ esac
213
+ printf 'forge-jarvis-dispatch: slice.yml present — using it as-is\n' >&2
214
+ return 0
215
+ fi
216
+ # --plan asks for a NEW work order. Only a slice.yml this helper itself wrote may be
217
+ # replaced (and it is archived, not lost — rotate_runner_work); a hand-authored work
218
+ # order wins, always.
219
+ case "$_hdr" in
220
+ "# Materialized by forge-jarvis-dispatch"*)
221
+ REPLACE_MATERIALIZED=1 ;;
222
+ *)
223
+ printf 'forge-jarvis-dispatch: REFUSED — slice.yml present and not materialized by this helper; hand-authored work orders win. Delete it yourself to re-materialize with --plan. RELAY this refusal to the operator.\n' >&2
224
+ return 2 ;;
225
+ esac
90
226
  fi
91
227
  # Prefer the worktree's OWN milestone — .forge/phases/ carries the repo's whole milestone
92
228
  # history (32 dirs on the forge repo), so "the only milestone dir" is a fixture-world
@@ -128,6 +264,54 @@ materialize_work_order() {
128
264
  return 2
129
265
  fi
130
266
  fi
267
+ # --plan validation (FR-234): every spec must resolve inside the picked milestone dir —
268
+ # an unknown spec is a refusal, never a silent skip (a typo must not dispatch the wrong
269
+ # work order). Whole-phase specs must also hold at least one plan-NN.md, or the phase
270
+ # would silently vanish from the order below.
271
+ if [ -n "$PLAN_SPECS" ]; then
272
+ while IFS= read -r _s; do
273
+ [ -n "$_s" ] || continue
274
+ _sp="${_s%%/*}"
275
+ _pp=""; case "$_s" in */*) _pp="${_s#*/}" ;; esac
276
+ # Path hygiene (specs are semi-trusted operator input): the phase part must be a
277
+ # real dir NAME and the plan part a file NAME — a separator or dot-dir would let a
278
+ # crafted spec reach outside the milestone dir (case * matches / in sh patterns).
279
+ case "$_sp" in .|..)
280
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: phase part must be a phase dir name. RELAY this refusal to the operator.\n' "$_s" >&2
281
+ return 2 ;;
282
+ esac
283
+ case "$_pp" in */*)
284
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: the plan part must be a bare plan-NN name (no path separators). RELAY this refusal to the operator.\n' "$_s" >&2
285
+ return 2 ;;
286
+ esac
287
+ if [ ! -d "$_mdir/$_sp" ]; then
288
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: no phase dir %s under %s. RELAY this refusal to the operator.\n' "$_s" "$_sp" "$_mdir" >&2
289
+ return 2
290
+ fi
291
+ if [ -n "$_pp" ]; then
292
+ case "$_pp" in *.md) : ;; *) _pp="$_pp.md" ;; esac
293
+ case "$_pp" in
294
+ plan-*.md) : ;;
295
+ *)
296
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: the plan part must name a plan-NN file. RELAY this refusal to the operator.\n' "$_s" >&2
297
+ return 2 ;;
298
+ esac
299
+ if [ ! -f "$_mdir/$_sp/$_pp" ]; then
300
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: no %s in %s. RELAY this refusal to the operator.\n' "$_s" "$_pp" "$_mdir/$_sp" >&2
301
+ return 2
302
+ fi
303
+ else
304
+ _has=0
305
+ for _f in "$_mdir/$_sp"/plan-*.md; do [ -f "$_f" ] && { _has=1; break; }; done
306
+ if [ "$_has" != 1 ]; then
307
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: phase %s has no plan-*.md. RELAY this refusal to the operator.\n' "$_s" "$_sp" >&2
308
+ return 2
309
+ fi
310
+ fi
311
+ done <<PLAN_VALIDATE_EOF
312
+ $PLAN_SPECS
313
+ PLAN_VALIDATE_EOF
314
+ fi
131
315
  _tmp="$WORKTREE/slice.yml.tmp"
132
316
  # Write-target hygiene: refuse a pre-existing tmp (incl. a pre-committed symlink —
133
317
  # a redirect would follow it and truncate whatever it points at).
@@ -137,12 +321,34 @@ materialize_work_order() {
137
321
  fi
138
322
  printf '# Materialized by forge-jarvis-dispatch from %s (delete to re-materialize)\nphases:\n' "${_mdir#"$WORKTREE"/}" > "$_tmp"
139
323
  _count=0
324
+ _srcs="" # per-plan source fingerprints — the staleness guard's record
325
+
140
326
  # Quoted glob iteration, numeric phase order — never word-split ls output
141
327
  # (phase/plan names are repo content; unquoted expansion would let a crafted
142
328
  # name pull arbitrary *.md into the runner's prompt).
143
329
  _names=$(for _d in "$_mdir"/*/; do [ -d "$_d" ] || continue; basename "$_d"; done | sort -n)
144
330
  while IFS= read -r _p; do
145
331
  [ -n "$_p" ] || continue
332
+ # --plan scoping (FR-234): with specs, only selected phases materialize; a whole-phase
333
+ # spec takes all its plans, plan-part specs take exactly those files. Numeric order
334
+ # comes from the sort -n pipeline above, regardless of --plan flag order.
335
+ _whole=1; _plansel=""
336
+ if [ -n "$PLAN_SPECS" ]; then
337
+ _sel=0; _whole=0
338
+ while IFS= read -r _s; do
339
+ [ -n "$_s" ] || continue
340
+ case "$_s" in
341
+ "$_p") _sel=1; _whole=1 ;;
342
+ "$_p"/*)
343
+ _sel=1
344
+ _f="${_s#*/}"; case "$_f" in *.md) : ;; *) _f="$_f.md" ;; esac
345
+ _plansel="$_plansel $_f" ;;
346
+ esac
347
+ done <<PLAN_SELECT_EOF
348
+ $PLAN_SPECS
349
+ PLAN_SELECT_EOF
350
+ [ "$_sel" = 1 ] || continue
351
+ fi
146
352
  _have=0
147
353
  for _f in "$_mdir/$_p"/plan-*.md; do [ -f "$_f" ] && { _have=1; break; }; done
148
354
  [ "$_have" = 1 ] || continue
@@ -156,7 +362,15 @@ materialize_work_order() {
156
362
  : > "$_pl"
157
363
  for _f in "$_mdir/$_p"/plan-*.md; do
158
364
  [ -f "$_f" ] || continue
365
+ if [ "$_whole" != 1 ]; then
366
+ case " $_plansel " in
367
+ *" $(basename "$_f") "*) : ;;
368
+ *) continue ;;
369
+ esac
370
+ fi
159
371
  cat "$_f" >> "$_pl"
372
+ _srcs="$_srcs# source: $_p/$(basename "$_f") cksum=$(cksum < "$_f" 2>/dev/null | cut -d' ' -f1)
373
+ "
160
374
  done
161
375
  printf ' - %s\n' "$_p" >> "$_tmp"
162
376
  _count=$((_count + 1))
@@ -168,6 +382,13 @@ MATERIALIZE_EOF
168
382
  printf 'forge-jarvis-dispatch: REFUSED — %s has no phase plans (plan-*.md); nothing to dispatch. RELAY this refusal to the operator.\n' "$_mdir" >&2
169
383
  return 2
170
384
  fi
385
+ # Source fingerprints land AFTER the phases block (the runner's phases parser stops
386
+ # at the first non-list line — interleaving them would truncate the phase list).
387
+ # The staleness guard above re-checks them on the next plain re-dispatch.
388
+ printf '%s' "$_srcs" >> "$_tmp"
389
+ # Commit point: the new work order definitely lands. Rotate stale runner state first
390
+ # (FR-235) — this is the ONLY call site; every refusal above exits before it.
391
+ rotate_runner_work || { rm -f "$_tmp"; return 2; }
171
392
  mv "$_tmp" "$WORKTREE/slice.yml"
172
393
  printf 'forge-jarvis-dispatch: materialized slice.yml (%s phase(s)) from %s\n' "$_count" "${_mdir#"$WORKTREE"/}" >&2
173
394
  return 0
@@ -178,10 +399,13 @@ LOG="$WORKTREE/.forge/runner-work/notify.log" # INTO the slice worktree (
178
399
 
179
400
  if [ "$DRY_RUN" = "1" ]; then
180
401
  # Side-effect-free: report work-order state to stderr, print the command to stdout.
181
- if [ -f "$WORKTREE/slice.yml" ]; then
402
+ # (Materialization AND rotation both live behind the real dispatch — dry-run touches nothing.)
403
+ if [ -n "$PLAN_SPECS" ]; then
404
+ printf 'forge-jarvis-dispatch: (dry-run) --plan specs given — a real dispatch materializes a SCOPED work order (replacing a helper-materialized slice.yml, refusing a hand-authored one) and rotates stale runner-work\n' >&2
405
+ elif [ -f "$WORKTREE/slice.yml" ]; then
182
406
  printf 'forge-jarvis-dispatch: (dry-run) slice.yml present — will be used as-is\n' >&2
183
407
  else
184
- printf 'forge-jarvis-dispatch: (dry-run) no slice.yml — a real dispatch materializes it from .forge/phases/milestone-*/\n' >&2
408
+ printf 'forge-jarvis-dispatch: (dry-run) no slice.yml — a real dispatch materializes it from .forge/phases/milestone-*/ and rotates stale runner-work\n' >&2
185
409
  fi
186
410
  # Print exactly what would run — greppable: worktree-scoped log, log-only channel, nohup, forwarded args.
187
411
  printf 'nohup env FORGE_SLICE_NOTIFY_LOG=%s FORGE_SLICE_NOTIFY=%s %s' "$LOG" "$CHANNEL" "$RUNNER"
@@ -54,6 +54,15 @@
54
54
  # NO block → sensible defaults (the launcher is operator-invoked, not ambient — unlike
55
55
  # the board, absence does not mean inert).
56
56
  #
57
+ # ONE DOOR PER NAME (firstmate-review adoption, 2026-07-22): docs/jarvis.md has said
58
+ # "two live presences must never share one name" since m-37 — discipline, no mechanism.
59
+ # On REAL launch (never --dry-run, which stays pure resolution) this script scans live
60
+ # processes for a `claude remote-control` already holding the resolved sticky name on
61
+ # THIS machine and refuses loudly with the live pid. Exact --name token match, so a
62
+ # live jarvis-<repo>-personal never blocks jarvis-<repo>. The ps read is env-overridable
63
+ # (FORGE_JARVIS_PS — the FORGE_JARVIS_FOLD stub pattern) so tests inject fixtures; an
64
+ # unreadable ps warns and proceeds — advisory hygiene, not a security gate.
65
+ #
57
66
  # USAGE:
58
67
  # .forge/checks/forge-jarvis.sh [--dry-run] [--name <full-name>] [--capacity <N>]
59
68
  # --dry-run print the fully resolved command on stdout and exit 0 — never executes.
@@ -165,6 +174,25 @@ fi
165
174
  # .claude/skills/jarvis/SKILL.md ("Session binding" / arm-the-watches). The launcher
166
175
  # stays presence-only and deliberately keeps no relay glue.
167
176
 
177
+ # --- double-start guard: one door per name per machine ------------------------
178
+ # The sticky name is the phone's ONLY discoverability anchor (probe ii); a second
179
+ # same-name server on this machine would co-mingle two presences behind one list
180
+ # entry. Checked before the toolchain check so the refusal is the first word.
181
+ PS_OUT="$(${FORGE_JARVIS_PS:-ps -Ao pid=,args=} 2>/dev/null)" || {
182
+ printf 'forge-jarvis: duplicate-door check unavailable (ps read failed) — proceeding.\n' >&2
183
+ PS_OUT=""
184
+ }
185
+ LIVE_PID="$(printf '%s\n' "$PS_OUT" | awk -v name="$NAME" '
186
+ /remote-control/ {
187
+ for (i = 2; i <= NF; i++)
188
+ if (($i == "--name" && $(i+1) == name) || $i == ("--name=" name)) { print $1; exit }
189
+ }')"
190
+ if [ -n "$LIVE_PID" ]; then
191
+ printf 'forge-jarvis: %s is already live on this machine (pid %s) — one door per name.\n' "$NAME" "$LIVE_PID" >&2
192
+ printf 'forge-jarvis: attach to the running door instead, or stop it first: kill %s\n' "$LIVE_PID" >&2
193
+ exit 2
194
+ fi
195
+
168
196
  # --- launch ------------------------------------------------------------------
169
197
  command -v claude >/dev/null 2>&1 || {
170
198
  printf 'forge-jarvis: `claude` not found on PATH.\n' >&2