forge-orkes 0.68.1 → 0.71.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.68.1",
3
+ "version": "0.71.0",
4
4
  "description": "Set up the Forge meta-prompting framework for Claude Code in your project",
5
5
  "bin": {
6
6
  "create-forge": "./bin/create-forge.js"
@@ -571,7 +571,15 @@ for phase in $PHASES; do
571
571
 
572
572
  # (4) REQUIRE the on-disk phase-report.yml. Absent = crash → halt now (do NOT
573
573
  # spend the retry on a crash; the resume path re-runs it from files).
574
+ # This is a slice END, so it MUST ping like the other two ends (checks-failed
575
+ # halt below, done) — a silent phone is never success. Live gap: canvaz
576
+ # 2026-07-23, m-EVT01 hit error_max_turns and the slice died dark. Key is
577
+ # per-phase ("$phase-crash"), NOT the shared "slice" key: a crash marker on
578
+ # the shared key would suppress a LATER legitimate checks-failed halt after
579
+ # resume; a re-crash of the same phase stays deduped (append-once semantics).
574
580
  if [ ! -f "$report" ]; then
581
+ slice_notify halt "$phase-crash" \
582
+ "slice HALTED at phase $phase ($idx/$total) — launcher crash, no phase-report.yml written (attempt $attempt); see $wdir/result.json + $wdir/launch.err"
575
583
  printf '[runner] phase %s: no phase-report.yml written at %s — halting\n' "$phase" "$report" >&2
576
584
  printf 'SLICE_RESULT phases=%s touches=%s last_ping=%s\n' "$COMPLETED" "$TOUCHES" "$LAST_PING"
577
585
  exit 1
@@ -56,6 +56,8 @@ The helper (see its header for the full contract) owns the caller-side environme
56
56
  - **`FORGE_SLICE_NOTIFY` points at a log-only channel** (`forge-jarvis-notify-logonly.sh`) so the default channel's per-ping headless `claude -p` micro-call stays quiet — the B4 relay is the single delivery path; a live default channel would risk a double-buzz (B1 probe x). This is the swappable channel seam used exactly as designed: the runner is untouched, the caller owns the env.
57
57
  - **The model map is forwarded verbatim.** The helper carries no model-selection logic (see Boundaries — routing is step 5).
58
58
  - **The work order is materialized at dispatch** (FR-197): the runner's contract is `<worktree>/slice.yml` + `phases/<name>/plan.md`; when `slice.yml` is absent the helper builds both from the milestone's locked `.forge/phases/milestone-{id}/` plans (an existing `slice.yml` is honored untouched). When it cannot, it **refuses synchronously, before the detach** — relay that refusal to the operator immediately. And shortly after any real dispatch, read `<worktree>/.forge/runner-work/dispatch.out` once: a runner refusal lands there and must be relayed — **a silent phone is never success** (walk run 4's failure mode: the runner declined instantly and nothing ever buzzed).
59
+ - **Mid-milestone re-dispatch is `--plan`** (FR-234): a later "go" through the same worktree (plan-01 landed, plan-02 next) adds `--plan <phase>/plan-02` (or `--plan <phase>` for a whole phase; repeatable) so only the named work is bundled — never landed plans. A typo'd spec refuses loudly; a hand-authored `slice.yml` always wins (`--plan` refuses to replace it — only a helper-materialized one is archived and rebuilt). No more hand-authoring a scoped `slice.yml` per re-dispatch.
60
+ - **A new work order starts pristine** (FR-235): materializing a new `slice.yml` automatically rotates stale `<worktree>/.forge/runner-work/` into `.forge/runner-work-archive/<ts>/` — old ping markers and phase reports would otherwise silence the new run's done ping and skip its phases. A resume (crash, or park→answer→resume) keeps the existing `slice.yml` and is never rotated. Nothing is deleted; the archive keeps the prior run's evidence.
59
61
  - **Runner artifacts live at knowable paths — never walk the filesystem for them** (a walk rooted at `/` or the bare home is refused by the Bash guard): work dir `<worktree>/.forge/runner-work/`, per-phase `phase-<N>/result.json` (with `phase-report.yml` and `answer.md`), ping log `notify.log`, dispatch/refusal output `dispatch.out` — state these paths to any session you spawn about a slice.
60
62
 
61
63
  After dispatch, the slice runs headless in its worktree, writing every ping to its notify log. That log is the event bus the callback relay watches (B4).
@@ -139,6 +139,8 @@ If a sibling milestone (e.g. m50) has state + requirements but is missing from `
139
139
 
140
140
  **Promoted-from-backlog milestone (`milestone.origin` set).** If `state/milestone-{N}.yml` carries `milestone.origin: {R-id}` (set by `forge` Step 1.2 when promoting a Standard-tier refactor item), no extra bookkeeping is needed here — the originating backlog item is flipped to `done` automatically when the milestone completes (see `reviewing` → **Promoted Milestone Completion Hook**). Just plan it as a normal milestone.
141
141
 
142
+ **Roadmap integrity check (advisory, m-39).** After writing or updating `roadmap.yml` in any of the three cases above, run `.forge/checks/forge-roadmap-check.sh`. If it exits non-zero, surface every `finding=...` line to the operator as one advisory note (e.g. *"roadmap-check found {N} issue(s): {lines}"*). This NEVER blocks — Step 9 Present and the handoff proceed regardless of findings; it is a signal, not a gate (same advisory posture as `verification.commands` in FORGE.md → Verification Gates). Script absent (pre-m-39 install) → skip silently.
143
+
142
144
  ## Step 6: Decompose Tasks
143
145
 
144
146
  ### Step 6.1: Cross-Layer Contract Detection
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env sh
2
- # forge-jarvis-dispatch.sh --slice <worktree> [runner args...] [--dry-run]
3
- # (m-37, Brief-R2 step 4 B3; contract handoff-step4-jarvis.md REV2)
2
+ # forge-jarvis-dispatch.sh --slice <worktree> [runner args...] [--dry-run] [--plan <spec>]...
3
+ # (m-37, Brief-R2 step 4 B3; contract handoff-step4-jarvis.md REV2; m-45 re-dispatch hygiene)
4
4
  #
5
5
  # On the operator's "go", Jarvis dispatches the slice runner DETACHED so the build
6
6
  # survives Jarvis's death (B1 probe viii: nohup-&). This helper owns ONLY the caller-side
@@ -25,12 +25,40 @@
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 only
29
- # arg this helper consumes for itself is --dry-run.
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).
30
31
  #
31
32
  # --dry-run print the exact `nohup env ... runner ...` command + env on stdout and exit 0;
32
33
  # never dispatches. Use it to show the operator what "go" will run.
33
34
  #
35
+ # --plan <spec> (repeatable; m-45, canvaz desire path 2026-07-22 defect 2) scope the
36
+ # materialized work order instead of bundling EVERY phase dir + plan-NN.md:
37
+ # --plan 72-bbb → the whole phase dir 72-bbb (all its plan-NN.md)
38
+ # --plan 72-bbb/plan-02 → exactly that plan file (.md optional)
39
+ # The mid-milestone re-dispatch tool: plan-01 landed, plan-02 next → dispatch
40
+ # --plan <phase>/plan-02 and only the next plan is bundled — never landed work.
41
+ # A spec that resolves to nothing REFUSES (exit 2, before the detach) — a typo
42
+ # must not dispatch the wrong work order. With an existing slice.yml: one this
43
+ # helper materialized (header line) is archived + re-materialized; a hand-
44
+ # authored one REFUSES — hand-authored work orders win, delete it yourself.
45
+ # Selected phases keep numeric order regardless of flag order.
46
+ #
47
+ # STALE-STATE ROTATION (m-45, canvaz desire path 2026-07-22 defect 3). Runner-work state is
48
+ # per-WORK-ORDER: pings/{key}-{kind}.marker dedups pings across re-runs of the SAME order, and
49
+ # index-keyed phase-{idx}/phase-report.yml verdicts derive its resume point. Dispatching a NEW
50
+ # work order through the same worktree with that state in place is poison — the old done marker
51
+ # silences the new run's done ping, and a stale verdict short-circuits the new phase as already
52
+ # complete (observed live: silent phone, last_ping=none). So whenever this helper materializes a
53
+ # NEW slice.yml it first rotates a non-empty .forge/runner-work WHOLE into
54
+ # .forge/runner-work-archive/<UTC-ts>/ — whole because relay-markers are keyed by notify.log
55
+ # LINE NUMBER (sibling dir); a partial reset would misalign relay offsets. Archive, never
56
+ # delete: git commits are the durable evidence, runner-work is runtime. The resume paths are
57
+ # 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.
61
+ #
34
62
  # WORK-ORDER MATERIALIZATION (FR-197; walk run 4, 2026-07-22). The runner's contract
35
63
  # (consumed, never edited) is <worktree>/slice.yml listing phase names, each resolving to
36
64
  # <worktree>/phases/<name>/plan.md — but the Forge intake act writes plans under
@@ -55,6 +83,8 @@ CHANNEL="$SELF_DIR/forge-jarvis-notify-logonly.sh" # the log-only cha
55
83
  DRY_RUN=0
56
84
  MAT_ONLY=0
57
85
  WORKTREE=""
86
+ PLAN_SPECS="" # newline-separated --plan specs (may be empty)
87
+ REPLACE_MATERIALIZED=0 # set when --plan replaces a helper-materialized slice.yml
58
88
  argc=$#
59
89
  while [ "$argc" -gt 0 ]; do
60
90
  arg="$1"; shift; argc=$((argc - 1))
@@ -63,6 +93,11 @@ while [ "$argc" -gt 0 ]; do
63
93
  DRY_RUN=1 ;; # ours — not forwarded
64
94
  --materialize-only)
65
95
  MAT_ONLY=1 ;; # ours — not forwarded
96
+ --plan) # ours — not forwarded (runner has no --plan)
97
+ _spec="${1:-}"; shift; argc=$((argc - 1))
98
+ [ -n "$_spec" ] || { printf 'forge-jarvis-dispatch: --plan requires a <phase>[/plan-NN] spec\n' >&2; exit 2; }
99
+ PLAN_SPECS="${PLAN_SPECS}${_spec}
100
+ " ;;
66
101
  --slice)
67
102
  WORKTREE="${1:-}"; shift; argc=$((argc - 1))
68
103
  set -- "$@" --slice "$WORKTREE" ;; # forward it too (runner needs it)
@@ -78,15 +113,68 @@ WORKTREE="$(cd "$WORKTREE" && pwd)" # absolutize — the log pa
78
113
  [ -f "$RUNNER" ] || { printf 'forge-jarvis-dispatch: runner not found: %s\n' "$RUNNER" >&2; exit 2; }
79
114
  [ -f "$CHANNEL" ] || { printf 'forge-jarvis-dispatch: log-only channel not found: %s\n' "$CHANNEL" >&2; exit 2; }
80
115
 
116
+ # --- rotate stale runner state when a NEW work order lands (FR-235, m-45) --------------------
117
+ # Called at exactly ONE point: right before materialize_work_order commits a new slice.yml —
118
+ # every refusal path exits earlier, and the resume paths (crash re-dispatch, park→answer→
119
+ # resume) take the honored-as-is early return and never reach it. The dir moves WHOLE (see
120
+ # header: relay-markers are line-number-keyed siblings of notify.log). Archive, never delete.
121
+ rotate_runner_work() {
122
+ _rw="$WORKTREE/.forge/runner-work"
123
+ _stale=0
124
+ if [ -d "$_rw" ] && [ -n "$(ls -A "$_rw" 2>/dev/null)" ]; then _stale=1; fi
125
+ [ "$_stale" = 1 ] || [ "$REPLACE_MATERIALIZED" = 1 ] || return 0
126
+ _parent="$WORKTREE/.forge/runner-work-archive"
127
+ _base="$_parent/$(date -u +%Y%m%dT%H%M%SZ)"
128
+ _dest="$_base"; _n=2
129
+ while [ -e "$_dest" ] || [ -L "$_dest" ]; do _dest="$_base-$_n"; _n=$((_n + 1)); done
130
+ mkdir -p "$_parent" || {
131
+ printf 'forge-jarvis-dispatch: REFUSED — cannot create %s; stale runner-work would poison the new work order. RELAY this refusal to the operator.\n' "$_parent" >&2
132
+ return 2
133
+ }
134
+ if [ "$_stale" = 1 ]; then
135
+ mv "$_rw" "$_dest" || {
136
+ printf 'forge-jarvis-dispatch: REFUSED — cannot rotate %s → %s; stale runner-work would poison the new work order. RELAY this refusal to the operator.\n' "$_rw" "$_dest" >&2
137
+ return 2
138
+ }
139
+ else
140
+ mkdir -p "$_dest" || {
141
+ printf 'forge-jarvis-dispatch: REFUSED — cannot create %s. RELAY this refusal to the operator.\n' "$_dest" >&2
142
+ return 2
143
+ }
144
+ fi
145
+ # A replaced helper-materialized slice.yml is part of the prior run — archive it alongside.
146
+ if [ "$REPLACE_MATERIALIZED" = 1 ] && [ -f "$WORKTREE/slice.yml" ]; then
147
+ mv "$WORKTREE/slice.yml" "$_dest/slice.yml" || {
148
+ printf 'forge-jarvis-dispatch: REFUSED — cannot archive the prior slice.yml into %s. RELAY this refusal to the operator.\n' "$_dest" >&2
149
+ return 2
150
+ }
151
+ fi
152
+ printf 'forge-jarvis-dispatch: rotated stale runner state -> %s\n' "$_dest" >&2
153
+ return 0
154
+ }
155
+
81
156
  # --- materialize the work order from the milestone's locked plans (FR-197) -------------------
82
- # slice.yml present → honored untouched. Absent build it from the single
83
- # .forge/phases/milestone-*/ dir: one phases/<name>/plan.md per phase subdir (plan-NN.md
84
- # concatenated in order), slice.yml listing the names numerically. Cannot → REFUSE, exit 2,
85
- # BEFORE the detach the caller sees it synchronously and must relay it to the operator.
157
+ # slice.yml present → honored untouched (unless --plan targets a helper-materialized one
158
+ # header doc). Absent → build it from the single .forge/phases/milestone-*/ dir: one
159
+ # phases/<name>/plan.md per phase subdir (plan-NN.md concatenated in order), slice.yml listing
160
+ # the names numerically; --plan scopes both. Cannot REFUSE, exit 2, BEFORE the detach — the
161
+ # caller sees it synchronously and must relay it to the operator.
86
162
  materialize_work_order() {
87
163
  if [ -f "$WORKTREE/slice.yml" ]; then
88
- printf 'forge-jarvis-dispatch: slice.yml present — using it as-is\n' >&2
89
- return 0
164
+ if [ -z "$PLAN_SPECS" ]; then
165
+ printf 'forge-jarvis-dispatch: slice.yml present — using it as-is\n' >&2
166
+ return 0
167
+ fi
168
+ # --plan asks for a NEW work order. Only a slice.yml this helper itself wrote may be
169
+ # replaced (and it is archived, not lost — rotate_runner_work); a hand-authored work
170
+ # order wins, always.
171
+ case "$(head -n 1 "$WORKTREE/slice.yml" 2>/dev/null)" in
172
+ "# Materialized by forge-jarvis-dispatch"*)
173
+ REPLACE_MATERIALIZED=1 ;;
174
+ *)
175
+ printf 'forge-jarvis-dispatch: REFUSED — slice.yml present and not materialized by this helper; hand-authored work orders win. Delete it yourself to re-materialize with --plan. RELAY this refusal to the operator.\n' >&2
176
+ return 2 ;;
177
+ esac
90
178
  fi
91
179
  # Prefer the worktree's OWN milestone — .forge/phases/ carries the repo's whole milestone
92
180
  # history (32 dirs on the forge repo), so "the only milestone dir" is a fixture-world
@@ -128,6 +216,54 @@ materialize_work_order() {
128
216
  return 2
129
217
  fi
130
218
  fi
219
+ # --plan validation (FR-234): every spec must resolve inside the picked milestone dir —
220
+ # an unknown spec is a refusal, never a silent skip (a typo must not dispatch the wrong
221
+ # work order). Whole-phase specs must also hold at least one plan-NN.md, or the phase
222
+ # would silently vanish from the order below.
223
+ if [ -n "$PLAN_SPECS" ]; then
224
+ while IFS= read -r _s; do
225
+ [ -n "$_s" ] || continue
226
+ _sp="${_s%%/*}"
227
+ _pp=""; case "$_s" in */*) _pp="${_s#*/}" ;; esac
228
+ # Path hygiene (specs are semi-trusted operator input): the phase part must be a
229
+ # real dir NAME and the plan part a file NAME — a separator or dot-dir would let a
230
+ # crafted spec reach outside the milestone dir (case * matches / in sh patterns).
231
+ case "$_sp" in .|..)
232
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: phase part must be a phase dir name. RELAY this refusal to the operator.\n' "$_s" >&2
233
+ return 2 ;;
234
+ esac
235
+ case "$_pp" in */*)
236
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: the plan part must be a bare plan-NN name (no path separators). RELAY this refusal to the operator.\n' "$_s" >&2
237
+ return 2 ;;
238
+ esac
239
+ if [ ! -d "$_mdir/$_sp" ]; then
240
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: no phase dir %s under %s. RELAY this refusal to the operator.\n' "$_s" "$_sp" "$_mdir" >&2
241
+ return 2
242
+ fi
243
+ if [ -n "$_pp" ]; then
244
+ case "$_pp" in *.md) : ;; *) _pp="$_pp.md" ;; esac
245
+ case "$_pp" in
246
+ plan-*.md) : ;;
247
+ *)
248
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: the plan part must name a plan-NN file. RELAY this refusal to the operator.\n' "$_s" >&2
249
+ return 2 ;;
250
+ esac
251
+ if [ ! -f "$_mdir/$_sp/$_pp" ]; then
252
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: no %s in %s. RELAY this refusal to the operator.\n' "$_s" "$_pp" "$_mdir/$_sp" >&2
253
+ return 2
254
+ fi
255
+ else
256
+ _has=0
257
+ for _f in "$_mdir/$_sp"/plan-*.md; do [ -f "$_f" ] && { _has=1; break; }; done
258
+ if [ "$_has" != 1 ]; then
259
+ printf 'forge-jarvis-dispatch: REFUSED — --plan %s: phase %s has no plan-*.md. RELAY this refusal to the operator.\n' "$_s" "$_sp" >&2
260
+ return 2
261
+ fi
262
+ fi
263
+ done <<PLAN_VALIDATE_EOF
264
+ $PLAN_SPECS
265
+ PLAN_VALIDATE_EOF
266
+ fi
131
267
  _tmp="$WORKTREE/slice.yml.tmp"
132
268
  # Write-target hygiene: refuse a pre-existing tmp (incl. a pre-committed symlink —
133
269
  # a redirect would follow it and truncate whatever it points at).
@@ -143,6 +279,26 @@ materialize_work_order() {
143
279
  _names=$(for _d in "$_mdir"/*/; do [ -d "$_d" ] || continue; basename "$_d"; done | sort -n)
144
280
  while IFS= read -r _p; do
145
281
  [ -n "$_p" ] || continue
282
+ # --plan scoping (FR-234): with specs, only selected phases materialize; a whole-phase
283
+ # spec takes all its plans, plan-part specs take exactly those files. Numeric order
284
+ # comes from the sort -n pipeline above, regardless of --plan flag order.
285
+ _whole=1; _plansel=""
286
+ if [ -n "$PLAN_SPECS" ]; then
287
+ _sel=0; _whole=0
288
+ while IFS= read -r _s; do
289
+ [ -n "$_s" ] || continue
290
+ case "$_s" in
291
+ "$_p") _sel=1; _whole=1 ;;
292
+ "$_p"/*)
293
+ _sel=1
294
+ _f="${_s#*/}"; case "$_f" in *.md) : ;; *) _f="$_f.md" ;; esac
295
+ _plansel="$_plansel $_f" ;;
296
+ esac
297
+ done <<PLAN_SELECT_EOF
298
+ $PLAN_SPECS
299
+ PLAN_SELECT_EOF
300
+ [ "$_sel" = 1 ] || continue
301
+ fi
146
302
  _have=0
147
303
  for _f in "$_mdir/$_p"/plan-*.md; do [ -f "$_f" ] && { _have=1; break; }; done
148
304
  [ "$_have" = 1 ] || continue
@@ -156,6 +312,12 @@ materialize_work_order() {
156
312
  : > "$_pl"
157
313
  for _f in "$_mdir/$_p"/plan-*.md; do
158
314
  [ -f "$_f" ] || continue
315
+ if [ "$_whole" != 1 ]; then
316
+ case " $_plansel " in
317
+ *" $(basename "$_f") "*) : ;;
318
+ *) continue ;;
319
+ esac
320
+ fi
159
321
  cat "$_f" >> "$_pl"
160
322
  done
161
323
  printf ' - %s\n' "$_p" >> "$_tmp"
@@ -168,6 +330,9 @@ MATERIALIZE_EOF
168
330
  printf 'forge-jarvis-dispatch: REFUSED — %s has no phase plans (plan-*.md); nothing to dispatch. RELAY this refusal to the operator.\n' "$_mdir" >&2
169
331
  return 2
170
332
  fi
333
+ # Commit point: the new work order definitely lands. Rotate stale runner state first
334
+ # (FR-235) — this is the ONLY call site; every refusal above exits before it.
335
+ rotate_runner_work || { rm -f "$_tmp"; return 2; }
171
336
  mv "$_tmp" "$WORKTREE/slice.yml"
172
337
  printf 'forge-jarvis-dispatch: materialized slice.yml (%s phase(s)) from %s\n' "$_count" "${_mdir#"$WORKTREE"/}" >&2
173
338
  return 0
@@ -178,10 +343,13 @@ LOG="$WORKTREE/.forge/runner-work/notify.log" # INTO the slice worktree (
178
343
 
179
344
  if [ "$DRY_RUN" = "1" ]; then
180
345
  # Side-effect-free: report work-order state to stderr, print the command to stdout.
181
- if [ -f "$WORKTREE/slice.yml" ]; then
346
+ # (Materialization AND rotation both live behind the real dispatch — dry-run touches nothing.)
347
+ if [ -n "$PLAN_SPECS" ]; then
348
+ printf 'forge-jarvis-dispatch: (dry-run) --plan specs given — a real dispatch materializes a SCOPED work order (replacing a helper-materialized slice.yml, refusing a hand-authored one) and rotates stale runner-work\n' >&2
349
+ elif [ -f "$WORKTREE/slice.yml" ]; then
182
350
  printf 'forge-jarvis-dispatch: (dry-run) slice.yml present — will be used as-is\n' >&2
183
351
  else
184
- printf 'forge-jarvis-dispatch: (dry-run) no slice.yml — a real dispatch materializes it from .forge/phases/milestone-*/\n' >&2
352
+ printf 'forge-jarvis-dispatch: (dry-run) no slice.yml — a real dispatch materializes it from .forge/phases/milestone-*/ and rotates stale runner-work\n' >&2
185
353
  fi
186
354
  # Print exactly what would run — greppable: worktree-scoped log, log-only channel, nohup, forwarded args.
187
355
  printf 'nohup env FORGE_SLICE_NOTIFY_LOG=%s FORGE_SLICE_NOTIFY=%s %s' "$LOG" "$CHANNEL" "$RUNNER"
@@ -0,0 +1,246 @@
1
+ #!/usr/bin/env sh
2
+ # forge-roadmap-check.sh — structural integrity check for .forge/roadmap.yml (m-39).
3
+ #
4
+ # Validates the roadmap against the drift class that caused the m-34/m-36 phase-id
5
+ # collision: two independent renumbering passes both picked phase id 63. A flat
6
+ # grep over "id: N" cannot tell a milestone's id from a phase's id from an
7
+ # unrelated numeral (estimated_hours: 63), and cannot catch a field that drifted
8
+ # to the wrong indentation depth. This check instead walks the file as a small
9
+ # indentation-keyed state machine over roadmap.yml's three fixed second-level
10
+ # blocks (milestones:/phases:/waves:) — HONEST LIMIT: it understands exactly this
11
+ # one fixed shape, not arbitrary YAML.
12
+ #
13
+ # forge-roadmap-check.sh [<roadmap-file>] # default: <git-root>/.forge/roadmap.yml
14
+ #
15
+ # stdout (machine-parseable, fold style):
16
+ # ok=clean # no findings
17
+ # finding=<rule>:<detail> # one line per problem
18
+ # rule ∈ missing-field | duplicate-phase-id | dangling-phase-ref | orphan-phase
19
+ # | bad-milestone-dir | dangling-wave-ref
20
+ # exit: 0 = ok=clean; 1 = one or more findings; 2 = bad invocation (missing/unreadable file)
21
+ #
22
+ # Required fields (confirmed against the live file — zero false positives today):
23
+ # milestone entry: id, name, phases (non-empty)
24
+ # phase entry: id, name, goal, requirements, dependencies, success_criteria,
25
+ # estimated_hours, status
26
+ # milestone_dir is DELIBERATELY not required — 17/65 live phase entries predate
27
+ # the nested-layout convention. Validated only when present (bad-milestone-dir).
28
+ #
29
+ # Pure POSIX sh + awk. No git-repo dependency (reads the file directly, unlike the
30
+ # diff-scoped forge-hold-check.sh/forge-check-lint.sh). Collects every finding in
31
+ # one pass before deciding exit code — a single run surfaces everything wrong.
32
+ set -u
33
+
34
+ FILE="${1:-}"
35
+ if [ -z "$FILE" ]; then
36
+ ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
37
+ FILE="$ROOT/.forge/roadmap.yml"
38
+ fi
39
+
40
+ if [ ! -f "$FILE" ] || [ ! -r "$FILE" ]; then
41
+ echo "roadmap-check: $FILE: not found or unreadable" >&2
42
+ exit 2
43
+ fi
44
+
45
+ awk '
46
+ function trim(s) {
47
+ gsub(/^[ \t]+|[ \t]+$/, "", s)
48
+ return s
49
+ }
50
+ function unquote(s) {
51
+ gsub(/"/, "", s)
52
+ return s
53
+ }
54
+ function bare_num(s, n) {
55
+ n = unquote(trim(s))
56
+ if (n ~ /^m-/) n = substr(n, 3)
57
+ return n
58
+ }
59
+ function finish_entry() {
60
+ if (!entry_open) return
61
+ if (entry_type == "milestone") {
62
+ ms_count++
63
+ ms_id[ms_count] = cur_id
64
+ ms_fields[ms_count] = cur_fields
65
+ ms_phaselist[ms_count] = cur_phaselist
66
+ } else if (entry_type == "phase") {
67
+ ph_count++
68
+ ph_id[ph_count] = cur_id + 0
69
+ ph_fields[ph_count] = cur_fields
70
+ ph_mdir[ph_count] = cur_mdir
71
+ }
72
+ entry_open = 0
73
+ cur_id = ""; cur_fields = ""; cur_mdir = ""; cur_phaselist = ""
74
+ }
75
+
76
+ # 2-space block header
77
+ $0 ~ /^ (milestones|phases|waves):[ \t]*$/ {
78
+ finish_entry()
79
+ block = trim($0)
80
+ gsub(/:$/, "", block)
81
+ next
82
+ }
83
+
84
+ # waves block: " N: [a, b, c]"
85
+ block == "waves" && $0 ~ /^ [0-9]+:/ {
86
+ line = $0
87
+ sub(/^ /, "", line)
88
+ split(line, kv, ":")
89
+ wnum = trim(kv[1])
90
+ rest = line
91
+ sub(/^[^:]*:/, "", rest)
92
+ if (match(rest, /\[[^]]*\]/)) {
93
+ ids = substr(rest, RSTART + 1, RLENGTH - 2)
94
+ } else {
95
+ ids = ""
96
+ }
97
+ wave_count++
98
+ wave_num[wave_count] = wnum
99
+ wave_ids[wave_count] = ids
100
+ next
101
+ }
102
+
103
+ # entry open: " - id: 36" / " - id: \"m-33\""
104
+ (block == "milestones" || block == "phases") && $0 ~ /^ - id:/ {
105
+ finish_entry()
106
+ entry_open = 1
107
+ entry_type = (block == "milestones" ? "milestone" : "phase")
108
+ val = $0
109
+ sub(/^ - id:[ \t]*/, "", val)
110
+ cur_id = unquote(trim(val))
111
+ cur_fields = " id "
112
+ next
113
+ }
114
+
115
+ # 6-space field line inside an open entry
116
+ (block == "milestones" || block == "phases") && entry_open && $0 ~ /^ [A-Za-z_]+:/ {
117
+ line = $0
118
+ sub(/^ /, "", line)
119
+ match(line, /^[A-Za-z_]+/)
120
+ field = substr(line, RSTART, RLENGTH)
121
+ cur_fields = cur_fields field " "
122
+ if (field == "milestone_dir") {
123
+ val = line
124
+ sub(/^milestone_dir:[ \t]*/, "", val)
125
+ cur_mdir = unquote(trim(val))
126
+ }
127
+ if (field == "phases" && entry_type == "milestone") {
128
+ rest = line
129
+ if (match(rest, /\[[^]]*\]/)) {
130
+ cur_phaselist = substr(rest, RSTART + 1, RLENGTH - 2)
131
+ }
132
+ }
133
+ next
134
+ }
135
+
136
+ # any other 4-space line inside milestones:/phases: (not "- id:") -> malformed
137
+ # dedent: closes the current entry without opening a new one.
138
+ (block == "milestones" || block == "phases") && $0 ~ /^ [A-Za-z_-]/ {
139
+ finish_entry()
140
+ next
141
+ }
142
+
143
+ END {
144
+ finish_entry()
145
+
146
+ required_ms = "name phases"
147
+ required_ph = "name goal requirements dependencies success_criteria estimated_hours status"
148
+
149
+ # 1. required fields
150
+ for (i = 1; i <= ms_count; i++) {
151
+ n = split(required_ms, reqs, " ")
152
+ for (j = 1; j <= n; j++) {
153
+ if (index(ms_fields[i], " " reqs[j] " ") == 0) {
154
+ print "finding=missing-field:milestone:" ms_id[i] ":" reqs[j]
155
+ found++
156
+ }
157
+ }
158
+ }
159
+ for (i = 1; i <= ph_count; i++) {
160
+ n = split(required_ph, reqs, " ")
161
+ for (j = 1; j <= n; j++) {
162
+ if (index(ph_fields[i], " " reqs[j] " ") == 0) {
163
+ print "finding=missing-field:phase:" ph_id[i] ":" reqs[j]
164
+ found++
165
+ }
166
+ }
167
+ }
168
+
169
+ # phase-id set + first-seen order tracking for duplicate detection
170
+ for (i = 1; i <= ph_count; i++) {
171
+ id = ph_id[i]
172
+ phase_seen[id] = phase_seen[id] "" (phase_seen[id] == "" ? "" : ",")
173
+ dup_list[id] = (dup_list[id] == "" ? "" : dup_list[id] ",") (ph_mdir[i] == "" ? "(none)" : ph_mdir[i])
174
+ dup_count[id]++
175
+ phase_exists[id] = 1
176
+ }
177
+
178
+ # 2. duplicate phase ids
179
+ for (id in dup_count) {
180
+ if (dup_count[id] > 1) {
181
+ print "finding=duplicate-phase-id:" id ":" dup_list[id]
182
+ found++
183
+ }
184
+ }
185
+
186
+ # 3. dangling-phase-ref (milestone claims a phase id with no phase entry)
187
+ for (i = 1; i <= ms_count; i++) {
188
+ m = split(ms_phaselist[i], claimed, /[ ,]+/)
189
+ for (j = 1; j <= m; j++) {
190
+ pid = trim(claimed[j])
191
+ if (pid == "") continue
192
+ claimed_by[pid] = 1
193
+ if (!(pid in phase_exists)) {
194
+ print "finding=dangling-phase-ref:milestone:" ms_id[i] ":phase:" pid
195
+ found++
196
+ }
197
+ }
198
+ }
199
+
200
+ # orphan-phase (phase entry claimed by zero milestones)
201
+ for (i = 1; i <= ph_count; i++) {
202
+ id = ph_id[i]
203
+ if (!(id in claimed_by)) {
204
+ print "finding=orphan-phase:" id ":" (ph_mdir[i] == "" ? "(none)" : ph_mdir[i])
205
+ found++
206
+ }
207
+ }
208
+
209
+ # 4. bad-milestone-dir (milestone_dir prefix must resolve to a real milestone id)
210
+ for (i = 1; i <= ms_count; i++) {
211
+ ms_numeral[bare_num(ms_id[i])] = 1
212
+ }
213
+ for (i = 1; i <= ph_count; i++) {
214
+ if (ph_mdir[i] == "") continue
215
+ if (match(ph_mdir[i], /^milestone-[^\/]+/)) {
216
+ prefix = substr(ph_mdir[i], RSTART, RLENGTH)
217
+ numeral = prefix
218
+ sub(/^milestone-/, "", numeral)
219
+ if (!(numeral in ms_numeral)) {
220
+ print "finding=bad-milestone-dir:" ph_id[i] ":" ph_mdir[i]
221
+ found++
222
+ }
223
+ }
224
+ }
225
+
226
+ # 5. dangling-wave-ref
227
+ for (i = 1; i <= wave_count; i++) {
228
+ m = split(wave_ids[i], wids, /[ ,]+/)
229
+ for (j = 1; j <= m; j++) {
230
+ pid = trim(wids[j])
231
+ if (pid == "") continue
232
+ if (!(pid in phase_exists)) {
233
+ print "finding=dangling-wave-ref:" wave_num[i] ":" pid
234
+ found++
235
+ }
236
+ }
237
+ }
238
+
239
+ if (found + 0 == 0) {
240
+ print "ok=clean"
241
+ exit 0
242
+ }
243
+ exit 1
244
+ }
245
+ ' "$FILE"
246
+ exit $?
@@ -192,6 +192,153 @@ else
192
192
  bad "numeric-id branch pick" "rc=$rc err=$err14"
193
193
  fi
194
194
 
195
+ # --- 15-18. --plan WORK-ORDER SCOPING (FR-234; canvaz desire path 2026-07-22 defect 2:
196
+ # materialization bundled ALL phase dirs + ALL plan-NN.md, so a mid-milestone re-dispatch
197
+ # re-bundled landed work). --plan <phase> takes a whole phase, <phase>/plan-NN one plan
198
+ # file; unknown specs and hand-authored slice.yml refuse loudly. mktemp fixtures — a
199
+ # single milestone dir exercises the (branch-free) fallback pick.
200
+
201
+ # 15. single-plan spec: only the named plan file lands in the phase's plan.md
202
+ PSLICE="$ROOT/plan-one-wt"
203
+ mkdir -p "$PSLICE/.forge/phases/milestone-77/1-aaa"
204
+ printf 'LANDED plan one\n' > "$PSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
205
+ printf 'NEXT plan two\n' > "$PSLICE/.forge/phases/milestone-77/1-aaa/plan-02.md"
206
+ err15="$(sh "$DISPATCH" --slice "$PSLICE" --materialize-only --plan 1-aaa/plan-02 2>&1)"; rc=$?
207
+ if [ "$rc" = 0 ] && grep -q '^ - 1-aaa$' "$PSLICE/slice.yml" 2>/dev/null \
208
+ && grep -q 'NEXT plan two' "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null \
209
+ && ! grep -q 'LANDED plan one' "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null; then
210
+ ok "--plan <phase>/plan-NN scopes to exactly that plan (landed sibling NOT re-bundled)"
211
+ else
212
+ bad "--plan single-plan scoping" "rc=$rc err=$err15 plan.md=$(cat "$PSLICE/phases/1-aaa/plan.md" 2>/dev/null)"
213
+ fi
214
+
215
+ # 16. whole-phase specs + ordering: numeric slice order regardless of flag order; unselected
216
+ # phases do NOT materialize
217
+ OSLICE="$ROOT/plan-order-wt"
218
+ mkdir -p "$OSLICE/.forge/phases/milestone-77/71-aaa" "$OSLICE/.forge/phases/milestone-77/72-bbb" "$OSLICE/.forge/phases/milestone-77/73-ccc"
219
+ printf 'plan A\n' > "$OSLICE/.forge/phases/milestone-77/71-aaa/plan-01.md"
220
+ printf 'plan B\n' > "$OSLICE/.forge/phases/milestone-77/72-bbb/plan-01.md"
221
+ printf 'decoy\n' > "$OSLICE/.forge/phases/milestone-77/73-ccc/plan-01.md"
222
+ err16="$(sh "$DISPATCH" --slice "$OSLICE" --materialize-only --plan 72-bbb --plan 71-aaa 2>&1)"; rc=$?
223
+ if [ "$rc" = 0 ] \
224
+ && [ "$(grep -E '^ - ' "$OSLICE/slice.yml" 2>/dev/null | tr -d ' -')" = "71aaa
225
+ 72bbb" ] \
226
+ && grep -q 'plan A' "$OSLICE/phases/71-aaa/plan.md" 2>/dev/null \
227
+ && grep -q 'plan B' "$OSLICE/phases/72-bbb/plan.md" 2>/dev/null \
228
+ && [ ! -d "$OSLICE/phases/73-ccc" ]; then
229
+ ok "--plan whole-phase specs keep numeric order (flag order ignored); unselected phase skipped"
230
+ else
231
+ bad "--plan ordering" "rc=$rc err=$err16 slice.yml=$(cat "$OSLICE/slice.yml" 2>/dev/null)"
232
+ fi
233
+
234
+ # 17. NEG: unknown spec → loud refusal, no slice.yml (a typo must not dispatch the wrong order)
235
+ USLICE="$ROOT/plan-unknown-wt"
236
+ mkdir -p "$USLICE/.forge/phases/milestone-77/1-aaa"
237
+ printf 'x\n' > "$USLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
238
+ err17="$(sh "$DISPATCH" --slice "$USLICE" --materialize-only --plan 2-nope 2>&1)"; rc=$?
239
+ if [ "$rc" != 0 ] && [ ! -f "$USLICE/slice.yml" ] && printf '%s' "$err17" | grep -q 'REFUSED'; then
240
+ ok "neg-control: unknown --plan spec → loud refusal, no slice.yml"
241
+ else
242
+ bad "unknown --plan spec" "rc=$rc err=$err17"
243
+ fi
244
+
245
+ # 18. NEG: --plan vs a HAND-AUTHORED slice.yml (no materializer header) → refusal; slice.yml
246
+ # byte-unchanged, seeded runner-work untouched (hand-authored work orders win, always)
247
+ HPSLICE="$ROOT/plan-hand-wt"
248
+ mkdir -p "$HPSLICE/.forge/phases/milestone-77/1-aaa" "$HPSLICE/.forge/runner-work/pings"
249
+ printf 'x\n' > "$HPSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
250
+ printf 'phases:\n - hand-rolled\n' > "$HPSLICE/slice.yml"
251
+ touch "$HPSLICE/.forge/runner-work/pings/k-done.marker"
252
+ before18="$(cat "$HPSLICE/slice.yml")"
253
+ err18="$(sh "$DISPATCH" --slice "$HPSLICE" --materialize-only --plan 1-aaa 2>&1)"; rc=$?
254
+ if [ "$rc" != 0 ] && printf '%s' "$err18" | grep -q 'REFUSED' \
255
+ && [ "$(cat "$HPSLICE/slice.yml")" = "$before18" ] \
256
+ && [ -f "$HPSLICE/.forge/runner-work/pings/k-done.marker" ]; then
257
+ ok "neg-control: --plan refuses a hand-authored slice.yml; nothing touched"
258
+ else
259
+ bad "--plan vs hand-authored" "rc=$rc err=$err18"
260
+ fi
261
+
262
+ # 18b. NEG (path hygiene): a plan part carrying a separator (traversal shape) refuses —
263
+ # sh case patterns let * match /, so plan-../x.md would pass the plan-*.md form check
264
+ # and reach outside the milestone dir without this guard.
265
+ TSLICE="$ROOT/plan-traverse-wt"
266
+ mkdir -p "$TSLICE/.forge/phases/milestone-77/1-aaa"
267
+ printf 'x\n' > "$TSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
268
+ err18b="$(sh "$DISPATCH" --slice "$TSLICE" --materialize-only --plan '1-aaa/plan-../x' 2>&1)"; rc=$?
269
+ if [ "$rc" != 0 ] && [ ! -f "$TSLICE/slice.yml" ] && printf '%s' "$err18b" | grep -q 'REFUSED'; then
270
+ ok "neg-control: --plan file part with a path separator → loud refusal (no traversal)"
271
+ else
272
+ bad "--plan traversal guard" "rc=$rc err=$err18b"
273
+ fi
274
+
275
+ # --- 19-21. RUNNER-WORK ROTATION (FR-235; canvaz desire path 2026-07-22 defect 3: stale
276
+ # pings/{key}-{kind}.marker silenced the new run's done ping and index-keyed
277
+ # phase-{idx}/phase-report.yml verdict:done short-circuited the new phase). A NEW work
278
+ # order rotates runner-work WHOLE (relay-markers are line-number-keyed siblings of
279
+ # notify.log — a partial reset would misalign offsets); resume paths keep slice.yml and
280
+ # are never rotated; archive, never delete.
281
+
282
+ # 19. materializing a NEW order rotates seeded state into runner-work-archive; a replaced
283
+ # helper-materialized slice.yml is archived alongside
284
+ RSLICE="$ROOT/rotate-wt"
285
+ mkdir -p "$RSLICE/.forge/phases/milestone-77/1-aaa"
286
+ printf 'LANDED\n' > "$RSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
287
+ printf 'NEXT\n' > "$RSLICE/.forge/phases/milestone-77/1-aaa/plan-02.md"
288
+ sh "$DISPATCH" --slice "$RSLICE" --materialize-only >/dev/null 2>&1 # first order (all plans) — seeds a materialized slice.yml
289
+ # Seed the "run happened" state AFTER the first materialization (a dispatch with stale
290
+ # runner-work already present would rotate it — exactly the behavior under test in 19).
291
+ mkdir -p "$RSLICE/.forge/runner-work/pings" "$RSLICE/.forge/runner-work/phase-1" "$RSLICE/.forge/runner-work/relay-markers"
292
+ touch "$RSLICE/.forge/runner-work/pings/k-done.marker"
293
+ printf 'verdict: done\n' > "$RSLICE/.forge/runner-work/phase-1/phase-report.yml"
294
+ touch "$RSLICE/.forge/runner-work/relay-markers/1.relayed"
295
+ err19="$(sh "$DISPATCH" --slice "$RSLICE" --materialize-only --plan 1-aaa/plan-02 2>&1)"; rc=$?
296
+ if [ "$rc" = 0 ] \
297
+ && [ ! -e "$RSLICE/.forge/runner-work/pings/k-done.marker" ] \
298
+ && [ ! -e "$RSLICE/.forge/runner-work/phase-1/phase-report.yml" ] \
299
+ && ls "$RSLICE/.forge/runner-work-archive/"*/pings/k-done.marker >/dev/null 2>&1 \
300
+ && ls "$RSLICE/.forge/runner-work-archive/"*/phase-1/phase-report.yml >/dev/null 2>&1 \
301
+ && ls "$RSLICE/.forge/runner-work-archive/"*/relay-markers/1.relayed >/dev/null 2>&1 \
302
+ && ls "$RSLICE/.forge/runner-work-archive/"*/slice.yml >/dev/null 2>&1 \
303
+ && grep -q 'NEXT' "$RSLICE/phases/1-aaa/plan.md" 2>/dev/null \
304
+ && ! grep -q 'LANDED' "$RSLICE/phases/1-aaa/plan.md" 2>/dev/null; then
305
+ ok "new work order rotates runner-work WHOLE (pings+report+relay-markers) + archives replaced slice.yml"
306
+ else
307
+ bad "rotation on new order" "rc=$rc err=$err19"
308
+ fi
309
+
310
+ # 20. NEG (resume preserved): existing materialized slice.yml, NO --plan → honored as-is,
311
+ # runner-work untouched, no archive (crash-resume / park→answer→resume path)
312
+ KSLICE="$ROOT/keep-wt"
313
+ mkdir -p "$KSLICE/.forge/phases/milestone-77/1-aaa"
314
+ printf 'x\n' > "$KSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
315
+ sh "$DISPATCH" --slice "$KSLICE" --materialize-only >/dev/null 2>&1 # materialized header slice.yml
316
+ mkdir -p "$KSLICE/.forge/runner-work/pings" # seed AFTER — see case 19 note
317
+ touch "$KSLICE/.forge/runner-work/pings/k-done.marker"
318
+ before20="$(cat "$KSLICE/slice.yml")"
319
+ err20="$(sh "$DISPATCH" --slice "$KSLICE" --materialize-only 2>&1)"; rc=$?
320
+ if [ "$rc" = 0 ] && [ "$(cat "$KSLICE/slice.yml")" = "$before20" ] \
321
+ && [ -f "$KSLICE/.forge/runner-work/pings/k-done.marker" ] \
322
+ && [ ! -d "$KSLICE/.forge/runner-work-archive" ]; then
323
+ ok "neg-control: existing slice.yml honored → NO rotation (resume state survives)"
324
+ else
325
+ bad "resume preserved" "rc=$rc err=$err20"
326
+ fi
327
+
328
+ # 21. NEG: --dry-run stays fully side-effect-free — no slice.yml, no rotation, even with
329
+ # stale runner-work present (extends case 12)
330
+ YSLICE="$ROOT/dry-rotate-wt"
331
+ mkdir -p "$YSLICE/.forge/phases/milestone-77/1-aaa" "$YSLICE/.forge/runner-work/pings"
332
+ printf 'x\n' > "$YSLICE/.forge/phases/milestone-77/1-aaa/plan-01.md"
333
+ touch "$YSLICE/.forge/runner-work/pings/k-done.marker"
334
+ sh "$DISPATCH" --slice "$YSLICE" --dry-run >/dev/null 2>&1
335
+ if [ ! -f "$YSLICE/slice.yml" ] && [ ! -d "$YSLICE/.forge/runner-work-archive" ] \
336
+ && [ -f "$YSLICE/.forge/runner-work/pings/k-done.marker" ]; then
337
+ ok "neg-control: --dry-run rotates nothing, writes nothing"
338
+ else
339
+ bad "dry-run rotation" "dry-run had side effects"
340
+ fi
341
+
195
342
  # --- summary -----------------------------------------------------------------
196
343
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
197
344
  [ "$FAILED" = 0 ]