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.
Files changed (35) hide show
  1. package/bin/create-forge.js +6 -4
  2. package/experimental/m10/source/mcp-server/README.md +2 -2
  3. package/experimental/m10/source/mcp-server/index.js +2 -2
  4. package/experimental/m10/source/skills/orchestrating/SKILL.md +7 -3
  5. package/package.json +1 -1
  6. package/template/.claude/hooks/forge-model-outcome.sh +5 -3
  7. package/template/.claude/hooks/forge-reserve.sh +419 -106
  8. package/template/.claude/hooks/forge-slice-notify.sh +12 -3
  9. package/template/.claude/hooks/forge-slice-runner.sh +40 -19
  10. package/template/.claude/hooks/tests/forge-reserve.test.sh +169 -0
  11. package/template/.claude/skills/chief-of-staff/SKILL.md +1 -0
  12. package/template/.claude/skills/forge/SKILL.md +8 -6
  13. package/template/.claude/skills/notion-integration/SKILL.md +1 -0
  14. package/template/.claude/skills/planning/SKILL.md +3 -3
  15. package/template/.forge/FORGE.md +5 -5
  16. package/template/.forge/checks/forge-board-render.sh +21 -18
  17. package/template/.forge/checks/forge-check-lint.sh +48 -5
  18. package/template/.forge/checks/forge-hold-check.sh +123 -2
  19. package/template/.forge/checks/forge-jarvis-awareness.sh +62 -11
  20. package/template/.forge/checks/forge-jarvis-dispatch.sh +88 -8
  21. package/template/.forge/checks/forge-jarvis-relay.sh +15 -6
  22. package/template/.forge/checks/forge-jarvis.sh +28 -0
  23. package/template/.forge/checks/forge-roadmap-check.sh +106 -22
  24. package/template/.forge/checks/tests/forge-check-lint.test.sh +126 -4
  25. package/template/.forge/checks/tests/forge-hold-check.test.sh +166 -1
  26. package/template/.forge/checks/tests/forge-jarvis-awareness.test.sh +53 -0
  27. package/template/.forge/checks/tests/forge-jarvis-dispatch.test.sh +114 -0
  28. package/template/.forge/checks/tests/forge-jarvis-relay.test.sh +30 -0
  29. package/template/.forge/checks/tests/forge-jarvis.test.sh +78 -4
  30. package/template/.forge/checks/tests/forge-reserve.test.sh +235 -0
  31. package/template/.forge/checks/tests/forge-roadmap-check.test.sh +487 -0
  32. package/template/.forge/templates/hold-config.yml +15 -0
  33. package/template/.forge/templates/project.yml +10 -0
  34. package/template/.forge/templates/state/desire-path.yml +15 -45
  35. 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 > "${TMPDIR:-/tmp}/fcl_units.$$" || true
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 < "${TMPDIR:-/tmp}/fcl_units.$$"
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. Heuristic manifest parsing is
52
- # documented inline; what it can't catch, UAT stands behind.
85
+ # GitLab API, no network, no node/python/jq with ONE opt-in exception: the
86
+ # run-waiver query above, which only fires when `waiver:` is configured AND a
87
+ # hold has already fired. With `waiver:` absent the check touches no network and
88
+ # behaves byte-identically to the pre-waiver check. Heuristic manifest parsing
89
+ # is documented inline; what it can't catch, UAT stands behind.
53
90
  set -u
54
91
 
92
+ GH="${FORGE_HOLD_GH:-gh}" # host CLI; fixtures point this at an offline stub
93
+
55
94
  usage() { printf 'usage: forge-hold-check.sh <base>...<head> [--approvals <file>]\n' >&2; }
56
95
 
57
96
  RANGE=""
@@ -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 default .claude/hooks/forge-release-fold.sh
33
- # FORGE_JARVIS_BOARD default .forge/checks/forge-board-render.sh
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)" # .forge/checks → repo root
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
- # ── 1. live/parked slices a DISK GLOB of worktrees on forge/m-* branches. Same on-disk registry
65
- # the relay's --list-logs arms tail -F on; recomputed here, never stored. Emits TSV:
66
- # <milestone-id>\t<branch>\t<path>. --worktree-root globs a given dir instead (tests). ──────
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
- if ($2 ~ /refs\/heads\/forge\/m-/) {
79
- b = $2; sub(/refs\/heads\//, "", b) # forge/m-37[-<session>]
80
- m = b; sub(/^forge\//, "", m); sub(/-[0-9a-f]{8}$/, "", m) # → m-37
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
- # --work-dir / --config / --schema / --report-template — whatever the runner accepts). The args
29
- # this helper consumes for itself are --dry-run, --materialize-only, and --plan (the runner has
30
- # no --plan; forwarding it would be a routing-adjacent contract change — FR-198 boundary class).
28
+ # --config / --schema / --report-template — whatever the runner accepts). The args this helper
29
+ # consumes for itself are --dry-run, --materialize-only, and --plan (the runner has no --plan;
30
+ # forwarding it would be a routing-adjacent contract change — FR-198 boundary class).
31
+ # EXCEPTION (R092): --work-dir is REFUSED, not forwarded — dispatch pins notify.log,
32
+ # dispatch.out, and the runner-work rotation to <worktree>/.forge/runner-work, so a forwarded
33
+ # --work-dir would split-brain the work dir (runner writes one place; dispatch, relay, and
34
+ # rotation look at another). Callers managing their own work dir invoke the runner directly.
31
35
  #
32
36
  # --dry-run print the exact `nohup env ... runner ...` command + env on stdout and exit 0;
33
37
  # never dispatches. Use it to show the operator what "go" will run.
@@ -55,9 +59,9 @@
55
59
  # LINE NUMBER (sibling dir); a partial reset would misalign relay offsets. Archive, never
56
60
  # delete: git commits are the durable evidence, runner-work is runtime. The resume paths are
57
61
  # untouched by construction — crash re-dispatch and park→answer→resume arrive with slice.yml
58
- # present and take the honored-as-is early return, so rotation never fires there. An explicitly
59
- # forwarded --work-dir is the caller's own state management and is NOT rotated; dispatching a
60
- # new order while the prior runner still lives in the worktree is operator error, unchanged.
62
+ # present and take the honored-as-is early return, so rotation never fires there. A --work-dir
63
+ # arg is refused at the door (R092, see above) so rotation always matches the paths dispatch
64
+ # owns; dispatching a new order while the prior runner still lives is operator error, unchanged.
61
65
  #
62
66
  # WORK-ORDER MATERIALIZATION (FR-197; walk run 4, 2026-07-22). The runner's contract
63
67
  # (consumed, never edited) is <worktree>/slice.yml listing phase names, each resolving to
@@ -70,6 +74,15 @@
70
74
  # REFUSES LOUDLY AND SYNCHRONOUSLY when it cannot — the failure surface is this exit code, not
71
75
  # a silent phone. An existing slice.yml is honored untouched (a hand-authored work order wins).
72
76
  # --materialize-only materialize (or refuse) and exit — no dispatch. Test hook + operator tool.
77
+ #
78
+ # STALENESS GUARD (live 2026-07-23). A materialized order SNAPSHOTS its source plans, so each
79
+ # bundled plan-NN.md is fingerprinted into slice.yml (`# source: <phase>/<file> cksum=N`, after
80
+ # the phases block). A plain re-dispatch (slice.yml present, no --plan) re-checksums them: any
81
+ # divergence — an amended source plan — REFUSES loudly instead of silently building the old
82
+ # spec (observed live: a full rebuild ran to the pre-amendment plan). Refuse, never auto-
83
+ # rebuild: the honored-as-is branch also serves crash re-dispatch and park→answer→resume,
84
+ # whose runner-work must survive rotation. Hand-authored orders are never checked; pre-guard
85
+ # materialized orders (no fingerprints) skip the check — lazy migration.
73
86
  set -u
74
87
 
75
88
  SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -98,6 +111,12 @@ while [ "$argc" -gt 0 ]; do
98
111
  [ -n "$_spec" ] || { printf 'forge-jarvis-dispatch: --plan requires a <phase>[/plan-NN] spec\n' >&2; exit 2; }
99
112
  PLAN_SPECS="${PLAN_SPECS}${_spec}
100
113
  " ;;
114
+ --work-dir|--work-dir=*) # REFUSED (R092) — see header: a forwarded
115
+ # work dir splits the brain (runner writes there; notify.log/dispatch.out/rotation
116
+ # stay pinned to <worktree>/.forge/runner-work). Refuse loudly BEFORE the detach —
117
+ # the failure surface is this exit code, not a silent phone.
118
+ printf 'forge-jarvis-dispatch: REFUSED — --work-dir cannot be forwarded through dispatch (notify.log, dispatch.out and runner-work rotation are pinned to <worktree>/.forge/runner-work; a custom work dir would split-brain them). Invoke forge-slice-runner.sh directly to manage your own work dir.\n' >&2
119
+ exit 2 ;;
101
120
  --slice)
102
121
  WORKTREE="${1:-}"; shift; argc=$((argc - 1))
103
122
  set -- "$@" --slice "$WORKTREE" ;; # forward it too (runner needs it)
@@ -161,14 +180,43 @@ rotate_runner_work() {
161
180
  # caller sees it synchronously and must relay it to the operator.
162
181
  materialize_work_order() {
163
182
  if [ -f "$WORKTREE/slice.yml" ]; then
183
+ _hdr="$(head -n 1 "$WORKTREE/slice.yml" 2>/dev/null)"
164
184
  if [ -z "$PLAN_SPECS" ]; then
185
+ # STALENESS GUARD (live 2026-07-23): a helper-materialized work order SNAPSHOTS
186
+ # the source plans at dispatch time; amend .forge/phases/.../plan-NN.md afterward
187
+ # and a plain re-dispatch would build the OLD spec silently (observed live: a full
188
+ # rebuild ran to the pre-amendment plan; the miss surfaced only because the
189
+ # operator poked). Materialization records one `# source: <phase>/<file> cksum=N`
190
+ # line per bundled plan; here each is re-checksummed and ANY divergence refuses
191
+ # LOUDLY. Refuse, never auto-rebuild: this branch also serves crash re-dispatch
192
+ # and park→answer→resume, and an auto-rotation would wipe the parked report the
193
+ # resume derives from. Hand-authored orders are never checked (they win, always);
194
+ # older materialized orders without fingerprints skip the check (lazy migration).
195
+ case "$_hdr" in
196
+ "# Materialized by forge-jarvis-dispatch from "*)
197
+ _srcrel="${_hdr#\# Materialized by forge-jarvis-dispatch from }"
198
+ _srcrel="${_srcrel% (delete to re-materialize)}"
199
+ _stale=""
200
+ while IFS= read -r _sl; do
201
+ case "$_sl" in "# source: "*) ;; *) continue ;; esac
202
+ _ref="${_sl#\# source: }" # <phase>/<file> cksum=<n>
203
+ _rf="${_ref% cksum=*}"; _rk="${_ref##* cksum=}"
204
+ _cur=""
205
+ [ -f "$WORKTREE/$_srcrel/$_rf" ] && _cur="$(cksum < "$WORKTREE/$_srcrel/$_rf" 2>/dev/null | cut -d' ' -f1)"
206
+ [ "$_cur" = "$_rk" ] || _stale="$_stale $_rf"
207
+ done < "$WORKTREE/slice.yml"
208
+ if [ -n "$_stale" ]; then
209
+ printf 'forge-jarvis-dispatch: REFUSED — materialized slice.yml is STALE:%s changed since materialization (source plan amended or removed). Re-dispatch with --plan to rebuild the work order (the prior one is archived), or delete slice.yml to re-materialize whole. RELAY this refusal to the operator.\n' "$_stale" >&2
210
+ return 2
211
+ fi ;;
212
+ esac
165
213
  printf 'forge-jarvis-dispatch: slice.yml present — using it as-is\n' >&2
166
214
  return 0
167
215
  fi
168
216
  # --plan asks for a NEW work order. Only a slice.yml this helper itself wrote may be
169
217
  # replaced (and it is archived, not lost — rotate_runner_work); a hand-authored work
170
218
  # order wins, always.
171
- case "$(head -n 1 "$WORKTREE/slice.yml" 2>/dev/null)" in
219
+ case "$_hdr" in
172
220
  "# Materialized by forge-jarvis-dispatch"*)
173
221
  REPLACE_MATERIALIZED=1 ;;
174
222
  *)
@@ -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 default .claude/hooks/forge-release-fold.sh (release_state recompute)
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)" # .forge/checks → repo root
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 and emit each slice's notify-log path. Pinned with -C so the
152
- # watch list never depends on the caller's cwd (the cwd-reset footgun: a snapped cwd
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 / { if ($2 ~ /refs\/heads\/forge\/m-/) print p "/.forge/runner-work/notify.log" }
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