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.
- package/package.json +1 -1
- package/template/.claude/hooks/forge-model-outcome.sh +5 -3
- package/template/.claude/hooks/forge-reserve.sh +127 -15
- package/template/.claude/hooks/forge-slice-notify.sh +12 -3
- package/template/.claude/hooks/forge-slice-runner.sh +40 -19
- package/template/.claude/skills/jarvis/SKILL.md +2 -0
- package/template/.claude/skills/notion-integration/SKILL.md +1 -0
- package/template/.claude/skills/planning/SKILL.md +3 -3
- package/template/.forge/FORGE.md +2 -2
- package/template/.forge/checks/forge-board-render.sh +21 -18
- package/template/.forge/checks/forge-check-lint.sh +36 -2
- package/template/.forge/checks/forge-hold-check.sh +121 -2
- package/template/.forge/checks/forge-jarvis-dispatch.sh +236 -12
- package/template/.forge/checks/forge-jarvis.sh +28 -0
- package/template/.forge/checks/forge-roadmap-check.sh +106 -22
- package/template/.forge/checks/tests/forge-check-lint.test.sh +126 -4
- package/template/.forge/checks/tests/forge-hold-check.test.sh +166 -1
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +211 -0
- package/template/.forge/checks/tests/forge-jarvis.test.sh +78 -4
- package/template/.forge/checks/tests/forge-reserve.test.sh +235 -0
- package/template/.forge/checks/tests/forge-roadmap-check.test.sh +487 -0
- package/template/.forge/templates/hold-config.yml +15 -0
- package/template/.forge/templates/project.yml +10 -0
- package/template/.forge/templates/waive-hold.yml +63 -0
|
@@ -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
|
|
52
|
-
#
|
|
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
|
-
# --
|
|
29
|
-
#
|
|
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
|
|
83
|
-
# .forge/phases/milestone-*/ dir: one
|
|
84
|
-
# concatenated in order), slice.yml listing
|
|
85
|
-
#
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|