forge-orkes 0.71.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"
@@ -25,9 +25,13 @@
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 args
29
- # this helper consumes for itself are --dry-run, --materialize-only, and --plan (the runner has
30
- # no --plan; forwarding it would be a routing-adjacent contract change — FR-198 boundary class).
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.
31
35
  #
32
36
  # --dry-run print the exact `nohup env ... runner ...` command + env on stdout and exit 0;
33
37
  # never dispatches. Use it to show the operator what "go" will run.
@@ -55,9 +59,9 @@
55
59
  # LINE NUMBER (sibling dir); a partial reset would misalign relay offsets. Archive, never
56
60
  # delete: git commits are the durable evidence, runner-work is runtime. The resume paths are
57
61
  # untouched by construction — crash re-dispatch and park→answer→resume arrive with slice.yml
58
- # present and take the honored-as-is early return, so rotation never fires there. An explicitly
59
- # forwarded --work-dir is the caller's own state management and is NOT rotated; dispatching a
60
- # new order while the prior runner still lives in the worktree is operator error, unchanged.
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.
61
65
  #
62
66
  # WORK-ORDER MATERIALIZATION (FR-197; walk run 4, 2026-07-22). The runner's contract
63
67
  # (consumed, never edited) is <worktree>/slice.yml listing phase names, each resolving to
@@ -70,6 +74,15 @@
70
74
  # REFUSES LOUDLY AND SYNCHRONOUSLY when it cannot — the failure surface is this exit code, not
71
75
  # a silent phone. An existing slice.yml is honored untouched (a hand-authored work order wins).
72
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.
73
86
  set -u
74
87
 
75
88
  SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -98,6 +111,12 @@ while [ "$argc" -gt 0 ]; do
98
111
  [ -n "$_spec" ] || { printf 'forge-jarvis-dispatch: --plan requires a <phase>[/plan-NN] spec\n' >&2; exit 2; }
99
112
  PLAN_SPECS="${PLAN_SPECS}${_spec}
100
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 ;;
101
120
  --slice)
102
121
  WORKTREE="${1:-}"; shift; argc=$((argc - 1))
103
122
  set -- "$@" --slice "$WORKTREE" ;; # forward it too (runner needs it)
@@ -161,14 +180,43 @@ rotate_runner_work() {
161
180
  # caller sees it synchronously and must relay it to the operator.
162
181
  materialize_work_order() {
163
182
  if [ -f "$WORKTREE/slice.yml" ]; then
183
+ _hdr="$(head -n 1 "$WORKTREE/slice.yml" 2>/dev/null)"
164
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
165
213
  printf 'forge-jarvis-dispatch: slice.yml present — using it as-is\n' >&2
166
214
  return 0
167
215
  fi
168
216
  # --plan asks for a NEW work order. Only a slice.yml this helper itself wrote may be
169
217
  # replaced (and it is archived, not lost — rotate_runner_work); a hand-authored work
170
218
  # order wins, always.
171
- case "$(head -n 1 "$WORKTREE/slice.yml" 2>/dev/null)" in
219
+ case "$_hdr" in
172
220
  "# Materialized by forge-jarvis-dispatch"*)
173
221
  REPLACE_MATERIALIZED=1 ;;
174
222
  *)
@@ -273,6 +321,8 @@ PLAN_VALIDATE_EOF
273
321
  fi
274
322
  printf '# Materialized by forge-jarvis-dispatch from %s (delete to re-materialize)\nphases:\n' "${_mdir#"$WORKTREE"/}" > "$_tmp"
275
323
  _count=0
324
+ _srcs="" # per-plan source fingerprints — the staleness guard's record
325
+
276
326
  # Quoted glob iteration, numeric phase order — never word-split ls output
277
327
  # (phase/plan names are repo content; unquoted expansion would let a crafted
278
328
  # name pull arbitrary *.md into the runner's prompt).
@@ -319,6 +369,8 @@ PLAN_SELECT_EOF
319
369
  esac
320
370
  fi
321
371
  cat "$_f" >> "$_pl"
372
+ _srcs="$_srcs# source: $_p/$(basename "$_f") cksum=$(cksum < "$_f" 2>/dev/null | cut -d' ' -f1)
373
+ "
322
374
  done
323
375
  printf ' - %s\n' "$_p" >> "$_tmp"
324
376
  _count=$((_count + 1))
@@ -330,6 +382,10 @@ MATERIALIZE_EOF
330
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
331
383
  return 2
332
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"
333
389
  # Commit point: the new work order definitely lands. Rotate stale runner state first
334
390
  # (FR-235) — this is the ONLY call site; every refusal above exits before it.
335
391
  rotate_runner_work || { rm -f "$_tmp"; return 2; }
@@ -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
@@ -20,12 +20,18 @@
20
20
  # exit: 0 = ok=clean; 1 = one or more findings; 2 = bad invocation (missing/unreadable file)
21
21
  #
22
22
  # Required fields (confirmed against the live file — zero false positives today):
23
- # milestone entry: id, name, phases (non-empty)
23
+ # milestone entry: id, name, phases (non-empty — an empty list, `phases: []` or a
24
+ # block-style key with no items, raises missing-field:milestone:<id>:phases)
24
25
  # phase entry: id, name, goal, requirements, dependencies, success_criteria,
25
26
  # estimated_hours, status
26
27
  # milestone_dir is DELIBERATELY not required — 17/65 live phase entries predate
27
28
  # the nested-layout convention. Validated only when present (bad-milestone-dir).
28
29
  #
30
+ # A milestone's phases: list is parsed in all three shapes YAML allows here:
31
+ # one-line flow ([1, 2]), wrapped flow ([1,\n 2]), and block style (- 1 items).
32
+ # Trailing "# comment" text on id/milestone_dir/phases lines is stripped before
33
+ # capture (mirrors forge-check-lint.sh strip_comment).
34
+ #
29
35
  # Pure POSIX sh + awk. No git-repo dependency (reads the file directly, unlike the
30
36
  # diff-scoped forge-hold-check.sh/forge-check-lint.sh). Collects every finding in
31
37
  # one pass before deciding exit code — a single run surfaces everything wrong.
@@ -51,6 +57,10 @@ function unquote(s) {
51
57
  gsub(/"/, "", s)
52
58
  return s
53
59
  }
60
+ function strip_comment(s) {
61
+ sub(/[ \t]#.*$/, "", s)
62
+ return s
63
+ }
54
64
  function bare_num(s, n) {
55
65
  n = unquote(trim(s))
56
66
  if (n ~ /^m-/) n = substr(n, 3)
@@ -70,6 +80,7 @@ function finish_entry() {
70
80
  ph_mdir[ph_count] = cur_mdir
71
81
  }
72
82
  entry_open = 0
83
+ ph_collect = 0
73
84
  cur_id = ""; cur_fields = ""; cur_mdir = ""; cur_phaselist = ""
74
85
  }
75
86
 
@@ -107,13 +118,14 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
107
118
  entry_type = (block == "milestones" ? "milestone" : "phase")
108
119
  val = $0
109
120
  sub(/^ - id:[ \t]*/, "", val)
110
- cur_id = unquote(trim(val))
121
+ cur_id = unquote(trim(strip_comment(val)))
111
122
  cur_fields = " id "
112
123
  next
113
124
  }
114
125
 
115
126
  # 6-space field line inside an open entry
116
127
  (block == "milestones" || block == "phases") && entry_open && $0 ~ /^ [A-Za-z_]+:/ {
128
+ ph_collect = 0
117
129
  line = $0
118
130
  sub(/^ /, "", line)
119
131
  match(line, /^[A-Za-z_]+/)
@@ -122,17 +134,45 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
122
134
  if (field == "milestone_dir") {
123
135
  val = line
124
136
  sub(/^milestone_dir:[ \t]*/, "", val)
125
- cur_mdir = unquote(trim(val))
137
+ cur_mdir = unquote(trim(strip_comment(val)))
126
138
  }
127
139
  if (field == "phases" && entry_type == "milestone") {
128
- rest = line
140
+ rest = strip_comment(line)
141
+ sub(/^phases:[ \t]*/, "", rest)
129
142
  if (match(rest, /\[[^]]*\]/)) {
130
143
  cur_phaselist = substr(rest, RSTART + 1, RLENGTH - 2)
144
+ } else if (rest ~ /^\[/) {
145
+ cur_phaselist = substr(rest, 2)
146
+ ph_collect = 1
147
+ } else if (rest == "") {
148
+ ph_collect = 2
131
149
  }
132
150
  }
133
151
  next
134
152
  }
135
153
 
154
+ # wrapped flow list continuation: collect until the ] that closes phases: [...
155
+ # (a new 6-space field line ends collection via the rule above — failsafe for
156
+ # a bracket that never closes)
157
+ block == "milestones" && entry_open && ph_collect == 1 && $0 ~ /^ / {
158
+ line = strip_comment($0)
159
+ if (match(line, /\]/)) {
160
+ cur_phaselist = cur_phaselist " " substr(line, 1, RSTART - 1)
161
+ ph_collect = 0
162
+ } else {
163
+ cur_phaselist = cur_phaselist " " line
164
+ }
165
+ next
166
+ }
167
+
168
+ # block-style list item under a bare "phases:" key: " - 4"
169
+ block == "milestones" && entry_open && ph_collect == 2 && $0 ~ /^ - / {
170
+ line = strip_comment($0)
171
+ sub(/^ - [ \t]*/, "", line)
172
+ cur_phaselist = cur_phaselist " " unquote(trim(line))
173
+ next
174
+ }
175
+
136
176
  # any other 4-space line inside milestones:/phases: (not "- id:") -> malformed
137
177
  # dedent: closes the current entry without opening a new one.
138
178
  (block == "milestones" || block == "phases") && $0 ~ /^ [A-Za-z_-]/ {
@@ -140,13 +180,8 @@ block == "waves" && $0 ~ /^ [0-9]+:/ {
140
180
  next
141
181
  }
142
182
 
143
- END {
144
- finish_entry()
145
-
146
- required_ms = "name phases"
147
- required_ph = "name goal requirements dependencies success_criteria estimated_hours status"
148
-
149
- # 1. required fields
183
+ # 1. required fields (+ non-empty phases list on milestones)
184
+ function check_fields( i, j, n, reqs, cnt, toks) {
150
185
  for (i = 1; i <= ms_count; i++) {
151
186
  n = split(required_ms, reqs, " ")
152
187
  for (j = 1; j <= n; j++) {
@@ -155,6 +190,15 @@ END {
155
190
  found++
156
191
  }
157
192
  }
193
+ if (index(ms_fields[i], " phases ") != 0) {
194
+ cnt = 0
195
+ n = split(ms_phaselist[i], toks, /[ ,]+/)
196
+ for (j = 1; j <= n; j++) if (trim(toks[j]) != "") cnt++
197
+ if (cnt == 0) {
198
+ print "finding=missing-field:milestone:" ms_id[i] ":phases"
199
+ found++
200
+ }
201
+ }
158
202
  }
159
203
  for (i = 1; i <= ph_count; i++) {
160
204
  n = split(required_ph, reqs, " ")
@@ -165,39 +209,60 @@ END {
165
209
  }
166
210
  }
167
211
  }
212
+ }
168
213
 
169
- # phase-id set + first-seen order tracking for duplicate detection
214
+ # phase-id bookkeeping consumed by check_dups/check_refs/check_orphans:
215
+ # dup_count (occurrences), dup_list (mdir detail per occurrence), phase_exists (set)
216
+ function collect_phase_index( i, id) {
170
217
  for (i = 1; i <= ph_count; i++) {
171
218
  id = ph_id[i]
172
- phase_seen[id] = phase_seen[id] "" (phase_seen[id] == "" ? "" : ",")
173
219
  dup_list[id] = (dup_list[id] == "" ? "" : dup_list[id] ",") (ph_mdir[i] == "" ? "(none)" : ph_mdir[i])
174
220
  dup_count[id]++
175
221
  phase_exists[id] = 1
176
222
  }
223
+ }
224
+
225
+ # pre-pass: claimed_by[] must be fully populated before check_orphans reads it
226
+ function collect_claimed( i, j, m, claimed, pid) {
227
+ for (i = 1; i <= ms_count; i++) {
228
+ m = split(ms_phaselist[i], claimed, /[ ,]+/)
229
+ for (j = 1; j <= m; j++) {
230
+ pid = trim(claimed[j])
231
+ if (pid != "") claimed_by[pid] = 1
232
+ }
233
+ }
234
+ }
177
235
 
178
- # 2. duplicate phase ids
179
- for (id in dup_count) {
180
- if (dup_count[id] > 1) {
236
+ # 2. duplicate phase ids — first-seen order over ph_id[] (stable across awk
237
+ # impls, unlike for-in), one report per id via dup_reported[]
238
+ function check_dups( i, id) {
239
+ for (i = 1; i <= ph_count; i++) {
240
+ id = ph_id[i]
241
+ if (dup_count[id] > 1 && !(id in dup_reported)) {
242
+ dup_reported[id] = 1
181
243
  print "finding=duplicate-phase-id:" id ":" dup_list[id]
182
244
  found++
183
245
  }
184
246
  }
247
+ }
185
248
 
186
- # 3. dangling-phase-ref (milestone claims a phase id with no phase entry)
249
+ # 3. dangling-phase-ref (milestone claims a phase id with no phase entry)
250
+ function check_refs( i, j, m, claimed, pid) {
187
251
  for (i = 1; i <= ms_count; i++) {
188
252
  m = split(ms_phaselist[i], claimed, /[ ,]+/)
189
253
  for (j = 1; j <= m; j++) {
190
254
  pid = trim(claimed[j])
191
255
  if (pid == "") continue
192
- claimed_by[pid] = 1
193
256
  if (!(pid in phase_exists)) {
194
257
  print "finding=dangling-phase-ref:milestone:" ms_id[i] ":phase:" pid
195
258
  found++
196
259
  }
197
260
  }
198
261
  }
262
+ }
199
263
 
200
- # orphan-phase (phase entry claimed by zero milestones)
264
+ # orphan-phase (phase entry claimed by zero milestones; reads claimed_by[])
265
+ function check_orphans( i, id) {
201
266
  for (i = 1; i <= ph_count; i++) {
202
267
  id = ph_id[i]
203
268
  if (!(id in claimed_by)) {
@@ -205,8 +270,10 @@ END {
205
270
  found++
206
271
  }
207
272
  }
273
+ }
208
274
 
209
- # 4. bad-milestone-dir (milestone_dir prefix must resolve to a real milestone id)
275
+ # 4. bad-milestone-dir (milestone_dir prefix must resolve to a real milestone id)
276
+ function check_mdirs( i, prefix, numeral) {
210
277
  for (i = 1; i <= ms_count; i++) {
211
278
  ms_numeral[bare_num(ms_id[i])] = 1
212
279
  }
@@ -222,8 +289,10 @@ END {
222
289
  }
223
290
  }
224
291
  }
292
+ }
225
293
 
226
- # 5. dangling-wave-ref
294
+ # 5. dangling-wave-ref
295
+ function check_waves( i, j, m, wids, pid) {
227
296
  for (i = 1; i <= wave_count; i++) {
228
297
  m = split(wave_ids[i], wids, /[ ,]+/)
229
298
  for (j = 1; j <= m; j++) {
@@ -235,6 +304,22 @@ END {
235
304
  }
236
305
  }
237
306
  }
307
+ }
308
+
309
+ END {
310
+ finish_entry()
311
+
312
+ required_ms = "name phases"
313
+ required_ph = "name goal requirements dependencies success_criteria estimated_hours status"
314
+
315
+ check_fields()
316
+ collect_phase_index()
317
+ collect_claimed()
318
+ check_dups()
319
+ check_refs()
320
+ check_orphans()
321
+ check_mdirs()
322
+ check_waves()
238
323
 
239
324
  if (found + 0 == 0) {
240
325
  print "ok=clean"
@@ -243,4 +328,3 @@ END {
243
328
  exit 1
244
329
  }
245
330
  ' "$FILE"
246
- exit $?