forge-orkes 0.71.0 → 0.73.1
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/bin/create-forge.js +6 -4
- package/experimental/m10/source/mcp-server/README.md +2 -2
- package/experimental/m10/source/mcp-server/index.js +2 -2
- package/experimental/m10/source/skills/orchestrating/SKILL.md +7 -3
- package/package.json +1 -1
- package/template/.claude/hooks/forge-model-outcome.sh +5 -3
- package/template/.claude/hooks/forge-reserve.sh +419 -106
- package/template/.claude/hooks/forge-slice-notify.sh +12 -3
- package/template/.claude/hooks/forge-slice-runner.sh +40 -19
- package/template/.claude/hooks/tests/forge-reserve.test.sh +169 -0
- package/template/.claude/skills/chief-of-staff/SKILL.md +1 -0
- package/template/.claude/skills/forge/SKILL.md +8 -6
- package/template/.claude/skills/notion-integration/SKILL.md +1 -0
- package/template/.claude/skills/planning/SKILL.md +3 -3
- package/template/.forge/FORGE.md +5 -5
- package/template/.forge/checks/forge-board-render.sh +21 -18
- package/template/.forge/checks/forge-check-lint.sh +48 -5
- package/template/.forge/checks/forge-hold-check.sh +123 -2
- package/template/.forge/checks/forge-jarvis-awareness.sh +62 -11
- package/template/.forge/checks/forge-jarvis-dispatch.sh +88 -8
- package/template/.forge/checks/forge-jarvis-relay.sh +15 -6
- 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-awareness.test.sh +53 -0
- package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +114 -0
- package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +30 -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/state/desire-path.yml +15 -45
- 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
|
|
@@ -85,6 +104,8 @@ JUSTIFY="$(git log --format=%B "$MB..$HEAD_REF" 2>/dev/null \
|
|
|
85
104
|
| sed -n 's/^Lint-Justify:[[:space:]]*//p' | head -1 | tr '"' "'")"
|
|
86
105
|
|
|
87
106
|
# Base-side gating_tests globs (same additive config the hold check reads).
|
|
107
|
+
# Twin parser: keep in sync with forge-hold-check.sh:conf_list() —
|
|
108
|
+
# duplicated deliberately (R075, self-contained-hooks convention).
|
|
88
109
|
GATING="$(git show "$MB:.forge/hold-config.yml" 2>/dev/null \
|
|
89
110
|
| awk '
|
|
90
111
|
/^[A-Za-z_]+:/ { insec = (index($0, "gating_tests:") == 1) ? 1 : 0; next }
|
|
@@ -295,6 +316,13 @@ add_finding() { # rule, file, line("" ok)
|
|
|
295
316
|
CHANGED="$(git diff --name-only "$MB" "$HEAD_REF" 2>/dev/null)" \
|
|
296
317
|
|| { printf 'forge-check-lint: git diff failed for %s\n' "$RANGE" >&2; exit 2; }
|
|
297
318
|
|
|
319
|
+
# Scratch file for classify_units | while-read (escapes the pipe subshell so
|
|
320
|
+
# add_finding's FINDINGS/HAS_A accumulation survives past the loop). No
|
|
321
|
+
# predictable path (R077): allocated on first use, trap-cleaned.
|
|
322
|
+
FCL_SCRATCH=""
|
|
323
|
+
cleanup_fcl_scratch() { [ -n "$FCL_SCRATCH" ] && rm -f "$FCL_SCRATCH"; }
|
|
324
|
+
trap cleanup_fcl_scratch EXIT INT TERM
|
|
325
|
+
|
|
298
326
|
while IFS= read -r path; do
|
|
299
327
|
[ -n "$path" ] || continue
|
|
300
328
|
base_name="$(basename "$path")"
|
|
@@ -304,6 +332,7 @@ while IFS= read -r path; do
|
|
|
304
332
|
.github/workflows/*.yml|.github/workflows/*.yaml|.gitlab-ci.yml)
|
|
305
333
|
ADDED="$(added_lines "$path")"
|
|
306
334
|
[ -n "$ADDED" ] || continue
|
|
335
|
+
[ -n "$FCL_SCRATCH" ] || FCL_SCRATCH="$(mktemp)" || { printf 'forge-check-lint: mktemp failed\n' >&2; exit 2; }
|
|
307
336
|
classify_units "$path" | while IFS=' ' read -r cls ustart uend; do
|
|
308
337
|
[ "$cls" != "clean" ] || continue
|
|
309
338
|
hit=""
|
|
@@ -314,11 +343,10 @@ while IFS= read -r path; do
|
|
|
314
343
|
$ADDED
|
|
315
344
|
EOF2
|
|
316
345
|
[ -n "$hit" ] && printf '%s %s %s\n' "$cls" "$path" "$ustart"
|
|
317
|
-
done > "$
|
|
346
|
+
done > "$FCL_SCRATCH" || true
|
|
318
347
|
while IFS=' ' read -r cls p ln; do
|
|
319
348
|
[ -n "$cls" ] && add_finding "$cls" "$p" "$ln"
|
|
320
|
-
done < "$
|
|
321
|
-
rm -f "${TMPDIR:-/tmp}/fcl_units.$$"
|
|
349
|
+
done < "$FCL_SCRATCH"
|
|
322
350
|
continue
|
|
323
351
|
;;
|
|
324
352
|
esac
|
|
@@ -358,6 +386,21 @@ done <<EOF
|
|
|
358
386
|
$CHANGED
|
|
359
387
|
EOF
|
|
360
388
|
|
|
389
|
+
# --- Driven-by trailer rule (config-gated, default OFF; see header) -----------
|
|
390
|
+
# Gate read from the BASE-side project.yml — a PR cannot disable the rule for
|
|
391
|
+
# itself. Absent file / absent block / not-true value → rule fully off.
|
|
392
|
+
DRIVEN_BY_ON="$(git show "$MB:.forge/project.yml" 2>/dev/null | awk '
|
|
393
|
+
/^[A-Za-z_]+:/ { insec = (index($0, "attribution:") == 1) ? 1 : 0; next }
|
|
394
|
+
insec && /^[[:space:]]+driven_by:[[:space:]]*true([[:space:]#]|$)/ { print "on"; exit }')"
|
|
395
|
+
if [ "$DRIVEN_BY_ON" = "on" ]; then
|
|
396
|
+
for db_c in $(git rev-list --no-merges "$MB..$HEAD_REF" 2>/dev/null); do
|
|
397
|
+
if ! git log -1 --format=%B "$db_c" 2>/dev/null \
|
|
398
|
+
| grep -qE '^Driven-by:[[:space:]]*[^[:space:]@]+@[^[:space:]@]+'; then
|
|
399
|
+
add_finding driven-by-missing "$(git rev-parse --short "$db_c")" ""
|
|
400
|
+
fi
|
|
401
|
+
done
|
|
402
|
+
fi
|
|
403
|
+
|
|
361
404
|
FINDINGS="$(printf '%s' "$FINDINGS" | sort -u)"
|
|
362
405
|
|
|
363
406
|
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=""
|
|
@@ -93,6 +132,8 @@ fi
|
|
|
93
132
|
# --- base-side config (additive; the PR cannot rewrite the rules it is judged by)
|
|
94
133
|
CONF="$(git show "$MB:.forge/hold-config.yml" 2>/dev/null || true)"
|
|
95
134
|
|
|
135
|
+
# Twin parser: keep in sync with forge-check-lint.sh:GATING awk block —
|
|
136
|
+
# duplicated deliberately (R075, self-contained-hooks convention).
|
|
96
137
|
conf_list() { # key → one value per line from the base-side config
|
|
97
138
|
[ -n "$CONF" ] || return 0
|
|
98
139
|
printf '%s\n' "$CONF" | awk -v k="$1" '
|
|
@@ -106,10 +147,25 @@ conf_list() { # key → one value per line from the base-side config
|
|
|
106
147
|
}'
|
|
107
148
|
}
|
|
108
149
|
|
|
150
|
+
conf_waiver() { # key → scalar under the base-side waiver: block (empty if absent)
|
|
151
|
+
[ -n "$CONF" ] || return 0
|
|
152
|
+
printf '%s\n' "$CONF" | awk -v k="$1" '
|
|
153
|
+
/^[A-Za-z_]+:/ { insec = (index($0, "waiver:") == 1) ? 1 : 0; next }
|
|
154
|
+
insec && index($0, k ":") > 0 && $0 ~ ("^[[:space:]]+" k ":") {
|
|
155
|
+
v = $0
|
|
156
|
+
sub(/^[[:space:]]*[A-Za-z_]+:[[:space:]]*/, "", v)
|
|
157
|
+
gsub(/["\047]/, "", v)
|
|
158
|
+
sub(/[[:space:]]*(#.*)?$/, "", v)
|
|
159
|
+
print v; exit
|
|
160
|
+
}'
|
|
161
|
+
}
|
|
162
|
+
|
|
109
163
|
CONF_IRREVERSIBLE="$(conf_list irreversible_paths)"
|
|
110
164
|
CONF_DEPLOY="$(conf_list deploy_paths)"
|
|
111
165
|
CONF_GATING="$(conf_list gating_tests)"
|
|
112
166
|
CONF_OPERATORS="$(conf_list operators)"
|
|
167
|
+
WAIVER_WF="$(conf_waiver workflow)"
|
|
168
|
+
WAIVER_REF="$(conf_waiver ref)"
|
|
113
169
|
|
|
114
170
|
glob_match() { # path, glob-list (newline) → 0 if any glob matches
|
|
115
171
|
[ -n "$2" ] || return 1
|
|
@@ -252,6 +308,71 @@ if [ -n "$WAIVED_BY" ]; then
|
|
|
252
308
|
exit 0
|
|
253
309
|
fi
|
|
254
310
|
|
|
311
|
+
# --- run-waiver (optional; FAIL-CLOSED on every leg — see header) -------------
|
|
312
|
+
# Reached only when facts exist and no operator PR approval waived them. With
|
|
313
|
+
# waiver: unconfigured this block is a no-op (no gh call, no network): the
|
|
314
|
+
# blocked verdict below is byte-identical to the pre-waiver check.
|
|
315
|
+
WAIVER_RUN_URL=""
|
|
316
|
+
if [ -n "$WAIVER_WF" ] && [ -n "$WAIVER_REF" ]; then
|
|
317
|
+
# Config values reach gh argv — hard-validate the charset first (fail-closed:
|
|
318
|
+
# a malformed binding can never widen the query or smuggle flags).
|
|
319
|
+
W_OK=1
|
|
320
|
+
case "$WAIVER_WF" in *[!A-Za-z0-9._/-]*)
|
|
321
|
+
printf 'forge-hold-check: waiver.workflow carries unsafe characters — waiver ignored, hold stands\n' >&2; W_OK=0 ;;
|
|
322
|
+
esac
|
|
323
|
+
case "$WAIVER_REF" in *[!A-Za-z0-9._/-]*)
|
|
324
|
+
printf 'forge-hold-check: waiver.ref carries unsafe characters — waiver ignored, hold stands\n' >&2; W_OK=0 ;;
|
|
325
|
+
esac
|
|
326
|
+
if [ "$W_OK" = 1 ]; then
|
|
327
|
+
HEAD_SHA="$(git rev-parse "$HEAD_REF^{commit}" 2>/dev/null)"
|
|
328
|
+
DEFAULT_BRANCH="$("$GH" api 'repos/{owner}/{repo}' --jq .default_branch 2>/dev/null)" || DEFAULT_BRANCH=""
|
|
329
|
+
if [ -z "$DEFAULT_BRANCH" ]; then
|
|
330
|
+
printf 'forge-hold-check: waiver: default branch unknown (gh missing or API unreachable) — hold stands\n' >&2
|
|
331
|
+
elif [ "$WAIVER_REF" != "$DEFAULT_BRANCH" ]; then
|
|
332
|
+
printf 'forge-hold-check: waiver.ref (%s) != repo default branch (%s) — hold stands\n' "$WAIVER_REF" "$DEFAULT_BRANCH" >&2
|
|
333
|
+
else
|
|
334
|
+
case "$WAIVER_WF" in
|
|
335
|
+
*/*) WF_PATH="$WAIVER_WF" ;;
|
|
336
|
+
*) WF_PATH=".github/workflows/$WAIVER_WF" ;;
|
|
337
|
+
esac
|
|
338
|
+
# PROJECTION-only jq (no filtering there — every leg is asserted below so
|
|
339
|
+
# the offline fixture stub exercises each one). Fields tab-separated,
|
|
340
|
+
# display_title LAST (the one field that may contain spaces).
|
|
341
|
+
RUNS="$("$GH" api \
|
|
342
|
+
"repos/{owner}/{repo}/actions/workflows/$WAIVER_WF/runs?branch=$DEFAULT_BRANCH&status=success&per_page=100" \
|
|
343
|
+
--jq '.workflow_runs[] | [.conclusion, .head_branch, .path, .html_url, .display_title] | @tsv' \
|
|
344
|
+
2>/dev/null)" || RUNS=""
|
|
345
|
+
if [ -z "$RUNS" ]; then
|
|
346
|
+
printf 'forge-hold-check: waiver: no runs of %s on %s (or API unreachable) — hold stands\n' "$WF_PATH" "$DEFAULT_BRANCH" >&2
|
|
347
|
+
else
|
|
348
|
+
W_TAB="$(printf '\t')"
|
|
349
|
+
while IFS="$W_TAB" read -r r_concl r_branch r_path r_url r_title; do
|
|
350
|
+
[ -n "$r_concl" ] || continue
|
|
351
|
+
[ "$r_concl" = "success" ] || continue # leg: approved (Environment-gated success)
|
|
352
|
+
[ "$r_branch" = "$DEFAULT_BRANCH" ] || continue # leg: run ref == default branch
|
|
353
|
+
[ "$r_path" = "$WF_PATH" ] || continue # leg: workflow path
|
|
354
|
+
case "$r_title" in # leg: run title carries the PR head SHA
|
|
355
|
+
*"$HEAD_SHA"*) WAIVER_RUN_URL="$r_url"; break ;;
|
|
356
|
+
esac
|
|
357
|
+
done <<WEOF
|
|
358
|
+
$RUNS
|
|
359
|
+
WEOF
|
|
360
|
+
[ -n "$WAIVER_RUN_URL" ] \
|
|
361
|
+
|| 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
|
|
362
|
+
fi
|
|
363
|
+
fi
|
|
364
|
+
fi
|
|
365
|
+
fi
|
|
366
|
+
|
|
367
|
+
if [ -n "$WAIVER_RUN_URL" ]; then
|
|
368
|
+
printf 'forge-hold-check: hold waived by approved run %s\n' "$WAIVER_RUN_URL" >&2
|
|
369
|
+
printf 'hold=waived waiver_run=%s\n' "$WAIVER_RUN_URL"
|
|
370
|
+
printf '%s\n' "$FACTS" | while IFS= read -r f; do
|
|
371
|
+
[ -n "$f" ] && printf 'fact=%s waived\n' "$f"
|
|
372
|
+
done
|
|
373
|
+
exit 0
|
|
374
|
+
fi
|
|
375
|
+
|
|
255
376
|
printf 'hold=blocked\n'
|
|
256
377
|
printf '%s\n' "$FACTS" | while IFS= read -r f; do
|
|
257
378
|
[ -n "$f" ] && printf 'fact=%s\n' "$f"
|
|
@@ -23,20 +23,28 @@
|
|
|
23
23
|
# NO OWNED STATE: nothing here is persisted; the next ask recomputes from disk + git. The only
|
|
24
24
|
# durable Jarvis state anywhere is B4's relay markers (a different helper).
|
|
25
25
|
#
|
|
26
|
+
# BARE BRANCH LOOSENING (issue #25): the disk glob also matches a bare `m-<id>-slug` or
|
|
27
|
+
# `m<digits>-slug` branch (no `forge/` prefix) — a worktree created by hand or by another tool,
|
|
28
|
+
# not just the Worktree Convention's `forge/{anchor}`. Its milestone id is derived from the
|
|
29
|
+
# LONGEST-matching `.forge/state/milestone-*.yml` filename in that worktree, else the id's first
|
|
30
|
+
# hyphen-segment. A bare NUMBER alone (no `m` token) never matches — see derive_milestone_id.
|
|
31
|
+
#
|
|
26
32
|
# forge-jarvis-awareness.sh [--dry-run] [--worktree-root <dir>]
|
|
27
33
|
# --dry-run resolve + print structure without shelling to the fold/board (fast
|
|
28
34
|
# plumbing check); tests and quick sanity checks use it.
|
|
29
35
|
# --worktree-root override the worktree scan root (tests); default: real `git worktree list`.
|
|
30
36
|
#
|
|
31
37
|
# Host-truth commands are env-overridable so tests inject stubs and degradation is explicit:
|
|
32
|
-
# FORGE_JARVIS_FOLD
|
|
33
|
-
# FORGE_JARVIS_BOARD
|
|
38
|
+
# FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh
|
|
39
|
+
# FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh
|
|
40
|
+
# FORGE_JARVIS_REPO_ROOT default the repo root derived from this script's own path (test seam:
|
|
41
|
+
# points the git-worktree glob at a throwaway fixture repo)
|
|
34
42
|
# BEST-EFFORT: a missing fold/board degrades to a stated 'unknown' / '(unavailable)', never a hard
|
|
35
43
|
# fail — the awareness answer always renders.
|
|
36
44
|
set -u
|
|
37
45
|
|
|
38
46
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
39
|
-
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
|
|
47
|
+
REPO_ROOT="${FORGE_JARVIS_REPO_ROOT:-$(cd "$SELF_DIR/../.." && pwd)}" # .forge/checks → repo root
|
|
40
48
|
FOLD="${FORGE_JARVIS_FOLD:-$REPO_ROOT/.claude/hooks/forge-release-fold.sh}"
|
|
41
49
|
BOARD="${FORGE_JARVIS_BOARD:-$REPO_ROOT/.forge/checks/forge-board-render.sh}"
|
|
42
50
|
TAB="$(printf '\t')"
|
|
@@ -61,9 +69,50 @@ fold_state() { # fold_state <milestone-id>
|
|
|
61
69
|
[ -n "$_rs" ] && printf '%s' "$_rs" || printf 'unknown'
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
# ──
|
|
65
|
-
#
|
|
66
|
-
#
|
|
72
|
+
# ── milestone id derivation for a matched branch (issue #25, bare-branch loosening). A
|
|
73
|
+
# forge/m-* branch keeps its original textual derivation unchanged (strip forge/, strip the
|
|
74
|
+
# 8-hex session suffix). A bare branch (m-<id>-slug | m<digits>-slug) derives the id from the
|
|
75
|
+
# LONGEST-matching .forge/state/milestone-*.yml file name in that worktree, else falls back to
|
|
76
|
+
# the id's first hyphen-segment. Same rule text as forge-jarvis-dispatch.sh's branch-derived
|
|
77
|
+
# milestone-dir pick (there against .forge/phases/milestone-*/ dirs instead of state files). ──
|
|
78
|
+
derive_milestone_id() { # derive_milestone_id <branch> <worktree-path>
|
|
79
|
+
_b="$1"; _p="$2"
|
|
80
|
+
case "$_b" in
|
|
81
|
+
forge/*)
|
|
82
|
+
_m="${_b#forge/}" # m-37[-<session>]
|
|
83
|
+
case "$_m" in
|
|
84
|
+
*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
|
|
85
|
+
_m="${_m%-*}" ;; # → m-37
|
|
86
|
+
esac
|
|
87
|
+
printf '%s' "$_m"
|
|
88
|
+
return 0 ;;
|
|
89
|
+
esac
|
|
90
|
+
_base="$_b"
|
|
91
|
+
case "$_base" in
|
|
92
|
+
*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
|
|
93
|
+
_base="${_base%-*}" ;; # strip an 8-hex session suffix
|
|
94
|
+
esac
|
|
95
|
+
_best=""
|
|
96
|
+
for _f in "$_p"/.forge/state/milestone-*.yml; do
|
|
97
|
+
[ -f "$_f" ] || continue
|
|
98
|
+
_id="$(basename "$_f" .yml)"; _id="${_id#milestone-}"
|
|
99
|
+
case "$_base" in
|
|
100
|
+
"m-$_id"|"m-$_id"-*|"m$_id"|"m$_id"-*)
|
|
101
|
+
[ "${#_id}" -gt "${#_best}" ] && _best="$_id" ;;
|
|
102
|
+
esac
|
|
103
|
+
done
|
|
104
|
+
if [ -n "$_best" ]; then
|
|
105
|
+
printf 'm-%s' "$_best"
|
|
106
|
+
else
|
|
107
|
+
_seg="${_base#m}"; _seg="${_seg#-}"; _seg="${_seg%%-*}" # first hyphen-segment fallback
|
|
108
|
+
printf 'm-%s' "$_seg"
|
|
109
|
+
fi
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# ── 1. live/parked slices — a DISK GLOB of worktrees on forge/m-* branches OR a bare m-<id>
|
|
113
|
+
# branch (issue #25). Same on-disk registry the relay's --list-logs arms tail -F on;
|
|
114
|
+
# recomputed here, never stored. Emits TSV: <milestone-id>\t<branch>\t<path>.
|
|
115
|
+
# --worktree-root globs a given dir instead (tests). ─────────────────────────────────────
|
|
67
116
|
list_slices() {
|
|
68
117
|
if [ -n "$WT_ROOT" ]; then
|
|
69
118
|
for _wt in "$WT_ROOT"/*/; do
|
|
@@ -75,12 +124,14 @@ list_slices() {
|
|
|
75
124
|
git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk -v tab="$TAB" '
|
|
76
125
|
/^worktree / { p = substr($0, 10) } # full remainder — paths may contain spaces
|
|
77
126
|
/^branch / {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
print m tab b tab p
|
|
127
|
+
b = $2; sub(/refs\/heads\//, "", b)
|
|
128
|
+
if (b ~ /^forge\/m-/ || b ~ /^m-[0-9A-Za-z]/ || b ~ /^m[0-9]/) {
|
|
129
|
+
print b tab p
|
|
82
130
|
}
|
|
83
|
-
}'
|
|
131
|
+
}' | while IFS="$TAB" read -r _b _p; do
|
|
132
|
+
_m="$(derive_milestone_id "$_b" "$_p")"
|
|
133
|
+
printf '%s%s%s%s%s\n' "$_m" "$TAB" "$_b" "$TAB" "$_p"
|
|
134
|
+
done
|
|
84
135
|
fi
|
|
85
136
|
}
|
|
86
137
|
|
|
@@ -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
|
-
# --
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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.
|
|
59
|
-
#
|
|
60
|
-
# new order while the prior runner still lives
|
|
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 "$
|
|
219
|
+
case "$_hdr" in
|
|
172
220
|
"# Materialized by forge-jarvis-dispatch"*)
|
|
173
221
|
REPLACE_MATERIALIZED=1 ;;
|
|
174
222
|
*)
|
|
@@ -185,6 +233,14 @@ materialize_work_order() {
|
|
|
185
233
|
# unconditionally missed milestone-m-EVT01 and fell through to a 38-dir refusal. Try the
|
|
186
234
|
# anchor verbatim before the m--stripped form, then both again minus one trailing
|
|
187
235
|
# -{session} token; first existing dir wins (exact/longer forms first).
|
|
236
|
+
#
|
|
237
|
+
# BARE BRANCH LOOSENING (issue #25): a worktree may also sit on a bare `m-<id>-slug` or
|
|
238
|
+
# `m<digits>-slug` branch (no `forge/` prefix) — same match family as the awareness/relay
|
|
239
|
+
# glob. Unlike the forge/ convention (id + at most one session token), a bare branch's slug
|
|
240
|
+
# can carry several descriptive words (m-176-marcy-mcp), so the id is found by repeatedly
|
|
241
|
+
# truncating the LAST hyphen-segment and testing for an existing milestone dir at each
|
|
242
|
+
# length — the longest-matching truncation wins (mirrors awareness's state-file prefix
|
|
243
|
+
# match, here against .forge/phases/milestone-*/ dirs instead).
|
|
188
244
|
_mdir=""
|
|
189
245
|
_branch="$(git -C "$WORKTREE" branch --show-current 2>/dev/null)"
|
|
190
246
|
case "$_branch" in
|
|
@@ -198,6 +254,22 @@ materialize_work_order() {
|
|
|
198
254
|
break
|
|
199
255
|
fi
|
|
200
256
|
done ;;
|
|
257
|
+
m-*|m[0-9]*)
|
|
258
|
+
_cand="$_branch"
|
|
259
|
+
while [ -n "$_cand" ]; do
|
|
260
|
+
for _try in "$_cand" "${_cand#m-}" "${_cand#m}"; do
|
|
261
|
+
[ -n "$_try" ] || continue
|
|
262
|
+
if [ -d "$WORKTREE/.forge/phases/milestone-$_try" ]; then
|
|
263
|
+
_mdir="$WORKTREE/.forge/phases/milestone-$_try"
|
|
264
|
+
break
|
|
265
|
+
fi
|
|
266
|
+
done
|
|
267
|
+
[ -n "$_mdir" ] && break
|
|
268
|
+
case "$_cand" in
|
|
269
|
+
*-*) _cand="${_cand%-*}" ;; # drop the last hyphen-segment, retry shorter
|
|
270
|
+
*) break ;;
|
|
271
|
+
esac
|
|
272
|
+
done ;;
|
|
201
273
|
esac
|
|
202
274
|
# Fallback (non-git fixture dirs / unconventional branches): a SINGLE milestone dir is
|
|
203
275
|
# unambiguous; several without a branch-derived pick is a refusal, never a guess.
|
|
@@ -212,7 +284,7 @@ materialize_work_order() {
|
|
|
212
284
|
return 2
|
|
213
285
|
fi
|
|
214
286
|
if [ "$_mdirs" -gt 1 ]; then
|
|
215
|
-
printf 'forge-jarvis-dispatch: REFUSED — %s milestone plan dirs in %s and no forge/m-* branch to pick by; ambiguous work order. Write slice.yml explicitly. RELAY this refusal to the operator.\n' "$_mdirs" "$WORKTREE" >&2
|
|
287
|
+
printf 'forge-jarvis-dispatch: REFUSED — %s milestone plan dirs in %s and no forge/m-* or bare m-<id> branch to pick by; ambiguous work order. Write slice.yml explicitly. RELAY this refusal to the operator.\n' "$_mdirs" "$WORKTREE" >&2
|
|
216
288
|
return 2
|
|
217
289
|
fi
|
|
218
290
|
fi
|
|
@@ -273,6 +345,8 @@ PLAN_VALIDATE_EOF
|
|
|
273
345
|
fi
|
|
274
346
|
printf '# Materialized by forge-jarvis-dispatch from %s (delete to re-materialize)\nphases:\n' "${_mdir#"$WORKTREE"/}" > "$_tmp"
|
|
275
347
|
_count=0
|
|
348
|
+
_srcs="" # per-plan source fingerprints — the staleness guard's record
|
|
349
|
+
|
|
276
350
|
# Quoted glob iteration, numeric phase order — never word-split ls output
|
|
277
351
|
# (phase/plan names are repo content; unquoted expansion would let a crafted
|
|
278
352
|
# name pull arbitrary *.md into the runner's prompt).
|
|
@@ -319,6 +393,8 @@ PLAN_SELECT_EOF
|
|
|
319
393
|
esac
|
|
320
394
|
fi
|
|
321
395
|
cat "$_f" >> "$_pl"
|
|
396
|
+
_srcs="$_srcs# source: $_p/$(basename "$_f") cksum=$(cksum < "$_f" 2>/dev/null | cut -d' ' -f1)
|
|
397
|
+
"
|
|
322
398
|
done
|
|
323
399
|
printf ' - %s\n' "$_p" >> "$_tmp"
|
|
324
400
|
_count=$((_count + 1))
|
|
@@ -330,6 +406,10 @@ MATERIALIZE_EOF
|
|
|
330
406
|
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
407
|
return 2
|
|
332
408
|
fi
|
|
409
|
+
# Source fingerprints land AFTER the phases block (the runner's phases parser stops
|
|
410
|
+
# at the first non-list line — interleaving them would truncate the phase list).
|
|
411
|
+
# The staleness guard above re-checks them on the next plain re-dispatch.
|
|
412
|
+
printf '%s' "$_srcs" >> "$_tmp"
|
|
333
413
|
# Commit point: the new work order definitely lands. Rotate stale runner state first
|
|
334
414
|
# (FR-235) — this is the ONLY call site; every refusal above exits before it.
|
|
335
415
|
rotate_runner_work || { rm -f "$_tmp"; return 2; }
|
|
@@ -31,16 +31,22 @@
|
|
|
31
31
|
# the payload rather than fold/gh/board (acceptance #3).
|
|
32
32
|
#
|
|
33
33
|
# HOST-TRUTH COMMANDS (env-overridable so tests inject stubs, and so degradation is explicit):
|
|
34
|
-
# FORGE_JARVIS_FOLD
|
|
34
|
+
# FORGE_JARVIS_FOLD default .claude/hooks/forge-release-fold.sh (release_state recompute)
|
|
35
|
+
# FORGE_JARVIS_REPO_ROOT default the repo root derived from this script's own path (test seam:
|
|
36
|
+
# points --list-logs's git-worktree glob at a throwaway fixture repo)
|
|
35
37
|
# (gh-based facts live in forge-jarvis-prwatch.sh, which documents its own FORGE_JARVIS_GH;
|
|
36
38
|
# the board recompute lives in awareness/promote — this helper consults the fold only.)
|
|
37
39
|
#
|
|
40
|
+
# BARE BRANCH LOOSENING (issue #25): --list-logs also matches a bare `m-<id>-slug` or
|
|
41
|
+
# `m<digits>-slug` worktree branch (no `forge/` prefix), same match rule as the awareness glob —
|
|
42
|
+
# it only needs the notify-log PATH, not the milestone id, so no id derivation lives here.
|
|
43
|
+
#
|
|
38
44
|
# BEST-EFFORT: a fold/gh/board that is absent or errors degrades to release_state=unknown and
|
|
39
45
|
# says so — never a silent success, never a hard fail of the relay path.
|
|
40
46
|
set -u
|
|
41
47
|
|
|
42
48
|
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
43
|
-
REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
|
|
49
|
+
REPO_ROOT="${FORGE_JARVIS_REPO_ROOT:-$(cd "$SELF_DIR/../.." && pwd)}" # .forge/checks → repo root
|
|
44
50
|
FOLD="${FORGE_JARVIS_FOLD:-$REPO_ROOT/.claude/hooks/forge-release-fold.sh}"
|
|
45
51
|
|
|
46
52
|
# ── host truth: the slice's release_state, recomputed from the fold (never the payload) ───────
|
|
@@ -148,12 +154,15 @@ mode_list_logs() {
|
|
|
148
154
|
done
|
|
149
155
|
else
|
|
150
156
|
# live path: `git worktree list` is the on-disk registry of worktrees; glob it to the
|
|
151
|
-
# forge/m-* slice branches
|
|
152
|
-
#
|
|
153
|
-
# would silently arm ZERO watches).
|
|
157
|
+
# forge/m-* slice branches (OR a bare m-<id>-slug / m<digits>-slug branch, issue #25) and
|
|
158
|
+
# emit each slice's notify-log path. Pinned with -C so the watch list never depends on the
|
|
159
|
+
# caller's cwd (the cwd-reset footgun: a snapped cwd would silently arm ZERO watches).
|
|
154
160
|
git -C "$REPO_ROOT" worktree list --porcelain 2>/dev/null | awk '
|
|
155
161
|
/^worktree / { p=substr($0, 10) } # full remainder — paths may contain spaces
|
|
156
|
-
/^branch / {
|
|
162
|
+
/^branch / {
|
|
163
|
+
b=$2; sub(/refs\/heads\//, "", b)
|
|
164
|
+
if (b ~ /^forge\/m-/ || b ~ /^m-[0-9A-Za-z]/ || b ~ /^m[0-9]/) print p "/.forge/runner-work/notify.log"
|
|
165
|
+
}
|
|
157
166
|
'
|
|
158
167
|
fi
|
|
159
168
|
exit 0
|
|
@@ -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
|