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.
- 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/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 +63 -7
- 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 +64 -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"
|
|
@@ -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
|
*)
|
|
@@ -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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 $?
|