forge-orkes 0.67.0 → 0.70.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.
@@ -52,31 +52,22 @@ const FORGE_END = '<!-- forge:end -->';
52
52
 
53
53
  // --- File classification for upgrades ---
54
54
 
55
- // Framework-owned: Forge controls these entirely
55
+ // Framework-owned: Forge controls these entirely. Auto-clean removes files that
56
+ // dropped out of the template — but ownership is decided per top-level UNIT
57
+ // (a skill DIR under .claude/skills/, an agent FILE under .claude/agents/), and
58
+ // only the template defines which units Forge shipped. A unit the base template
59
+ // never shipped — user-authored, vendored-parity, or an opt-in experimental
60
+ // skill installed separately (e.g. m10's orchestrating, the convention packs) —
61
+ // is NOT framework content and is preserved, never swept, even though it lives
62
+ // under a framework-owned dir. Deleting it would destroy work Forge never owned,
63
+ // and .claude/ is frequently gitignored, so the loss is unrecoverable. Files
64
+ // removed from WITHIN a still-shipped unit (e.g. a renamed helper inside a
65
+ // template skill dir) stay cleanable — their unit is still present in the
66
+ // template. See upgradeDir(). Experimental skills are additionally diffed
67
+ // against their shipped sources by detectExperimentalStaleness() (report-only;
68
+ // re-sync consent lives in the /upgrading skill layer).
56
69
  const FRAMEWORK_OWNED_DIRS = ['.claude/agents', '.claude/skills'];
57
70
 
58
- // Experimental / opt-in skills installed separately (e.g. from experimental/m10).
59
- // They are NOT in the shipped base template, so the framework-owned auto-clean
60
- // would otherwise delete them on upgrade. Preserve them instead.
61
- //
62
- // The npm tarball ships experimental/ alongside bin/ + template/, so this route
63
- // CAN diff an installed experimental skill against its same-tarball source:
64
- // detectExperimentalStaleness() reports unchanged|stale with the differing files.
65
- // (Blind preservation is what silently froze these pre-0.33.0 — issue #5: a base
66
- // migration that changed experimental-skill behavior became a no-op.) Detection
67
- // only — consent for a re-sync lives in the /upgrading skill layer, and
68
- // load-bearing conventions are still promoted to base (FORGE.md) rather than
69
- // left to a frozen skill. This preserve-list gates auto-clean only; staleness
70
- // discovery is by directory scan of the shipped experimental/ tree.
71
- const EXPERIMENTAL_SKILL_PATHS = ['.claude/skills/orchestrating'];
72
-
73
- function isExperimentalSkillPath(displayPath) {
74
- const norm = displayPath.split(path.sep).join('/');
75
- return EXPERIMENTAL_SKILL_PATHS.some(
76
- (p) => norm === p || norm.startsWith(p + '/')
77
- );
78
- }
79
-
80
71
  // Compare dotted numeric versions (e.g. "0.19.1"). Returns 1 if a>b, -1 if a<b, 0 if equal.
81
72
  function compareVersions(a, b) {
82
73
  const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
@@ -259,9 +250,15 @@ function upgradeDir(relDir, { autoClean = false } = {}) {
259
250
  const srcPath = path.join(srcDir, rel);
260
251
  if (!fs.existsSync(srcPath)) {
261
252
  const displayPath = path.join(relDir, rel);
262
- // Never auto-clean opt-in experimental skills they live outside the base template.
263
- if (autoClean && isExperimentalSkillPath(displayPath)) {
264
- result.preserved.push(displayPath);
253
+ // Ownership is per top-level unit: the first path segment of `rel` is the
254
+ // skill dir (or, for flat agents, the file itself). If the template no
255
+ // longer ships that unit at all, the file is user-authored, vendored, or an
256
+ // opt-in experimental skill — not Forge's to delete. Preserve it. Only
257
+ // files whose unit the template STILL ships (a stale file inside a shipped
258
+ // skill dir) remain removal candidates.
259
+ const topUnit = rel.split(path.sep)[0];
260
+ if (autoClean && !fs.existsSync(path.join(srcDir, topUnit))) {
261
+ result.preserved.push(`${displayPath} (kept — not shipped by the base template)`);
265
262
  continue;
266
263
  }
267
264
  const destPath = path.join(destDir, rel);
@@ -374,6 +371,30 @@ function mergeManagedDeny(destSettings, srcSettings) {
374
371
  }
375
372
  }
376
373
 
374
+ /**
375
+ * Union the template's managed allow rules into the project's permissions.allow.
376
+ * Additive — a user's own allow/deny entries are untouched (mirrors
377
+ * mergeManagedDeny). Forge automates a worktree-heavy workflow (streams, the
378
+ * slice runner, orchestration/human-verified teardown), but the harness auto-mode
379
+ * classifier soft-denies `git worktree remove` for a worktree the session didn't
380
+ * create in-session, and a Remote-Control / detached session cannot enter
381
+ * bypassPermissions after launch. A NARROW allow rule survives auto mode and runs
382
+ * before the classifier, so the automating session can clean up its own worktrees.
383
+ * These are read/teardown of Forge-managed worktrees only — `remove` is git-guarded
384
+ * (refuses on a dirty/untracked tree without --force) and, in the Forge flow, only
385
+ * ever runs on a merged, human-verified worktree. It never touches commits or
386
+ * branches — worktree removal drops a checkout, not history.
387
+ */
388
+ function mergeManagedAllow(destSettings, srcSettings) {
389
+ const srcAllow = (srcSettings.permissions && srcSettings.permissions.allow) || [];
390
+ if (!srcAllow.length) return;
391
+ destSettings.permissions = destSettings.permissions || {};
392
+ const destAllow = destSettings.permissions.allow || (destSettings.permissions.allow = []);
393
+ for (const rule of srcAllow) {
394
+ if (!destAllow.includes(rule)) destAllow.push(rule);
395
+ }
396
+ }
397
+
377
398
  /**
378
399
  * Strip the legacy *inline* active-skill guard from PreToolUse (forge#12). It was
379
400
  * an inline `if [ ! -f .../.forge/.active-skill ]; then ... exit 2; fi` one-liner;
@@ -437,6 +458,7 @@ function upgradeSettings() {
437
458
  // Additively install framework-managed safety guardrails (ADR-017)
438
459
  mergeManagedHooks(destSettings, srcSettings);
439
460
  mergeManagedDeny(destSettings, srcSettings);
461
+ mergeManagedAllow(destSettings, srcSettings);
440
462
 
441
463
  const after = JSON.stringify(destSettings);
442
464
  if (before === after) return 'unchanged';
@@ -949,7 +971,7 @@ async function upgrade() {
949
971
  if (results.preserved.length > 0) {
950
972
  say(` Preserved (${results.preserved.length}):`);
951
973
  for (const f of results.preserved) {
952
- say(` ${f} (kept — opt-in experimental skill)`);
974
+ say(` ${f}`);
953
975
  }
954
976
  for (const e of experimental) {
955
977
  say(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-orkes",
3
- "version": "0.67.0",
3
+ "version": "0.70.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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "forge": {
3
- "version": "0.58.3",
3
+ "version": "0.68.0",
4
4
  "default_tier": "standard",
5
5
  "beads_integration": false,
6
6
  "context_gates": {
@@ -48,6 +48,11 @@
48
48
  "Edit(**/.secrets/**)",
49
49
  "Edit(**/*.pem)",
50
50
  "Edit(**/*.key)"
51
+ ],
52
+ "allow": [
53
+ "Bash(git worktree remove *)",
54
+ "Bash(git worktree prune)",
55
+ "Bash(git worktree prune *)"
51
56
  ]
52
57
  },
53
58
  "hooks": {
@@ -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
@@ -139,7 +139,7 @@ Source version (`{source}/packages/create-forge/package.json`) **<** installed (
139
139
 
140
140
  Dev mode syncs from a checkout, so it keeps file-classification semantics — **the npm bin's sync behavior is the spec**; do not re-derive it here. Categories:
141
141
 
142
- - **Framework-owned** (`.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.forge/FORGE.md`) → overwrite when different; a local skill dir absent from the template defers to the experimental pass below (only no-source dirs report *removed from template* never deleted).
142
+ - **Framework-owned** (`.claude/agents/*.md`, `.claude/skills/*/SKILL.md`, `.forge/FORGE.md`) → overwrite when different. Ownership is per top-level **unit** — a skill DIR under `.claude/skills/`, a flat agent FILE under `.claude/agents/`: a unit the template doesn't ship (user-authored, vendored-parity, or an opt-in experimental skill) is **preserved, never deleted**; only stale files *inside* a still-shipped skill dir are cleaned. Experimental skills additionally get the staleness pass below.
143
143
  - **Template-only** (`.forge/templates/**`, `.forge/migrations/**`, `.forge/adapters/**`, `.forge/gitignore` → `.forge/.gitignore`) → overwrite; `.forge/FORGE.md` lands BEFORE the CLAUDE.md pass.
144
144
  - **Additive** (`.claude/hooks/**` incl. `tests/`, `.claude/rules/**`, `.forge/checks/**`) → add + overwrite template-shipped files, never delete local extras, `chmod +x` synced `*.sh`.
145
145
  - **Import-managed** `CLAUDE.md` → the bin's `ensureClaudeMdImport()` cases verbatim; user content outside a forge section is never modified.
@@ -91,17 +91,25 @@ materialize_work_order() {
91
91
  # Prefer the worktree's OWN milestone — .forge/phases/ carries the repo's whole milestone
92
92
  # history (32 dirs on the forge repo), so "the only milestone dir" is a fixture-world
93
93
  # assumption. The Worktree Convention names the branch forge/{anchor} with anchor =
94
- # {milestone-id}[-{session}]; derive the id and pick that milestone's dir.
94
+ # {milestone-id}[-{session}]; derive the id and pick that milestone's dir. The id's own
95
+ # m- may be a strippable prefix (m-40 → milestone-40) or PART of a labeled id kept
96
+ # verbatim (m-EVT01 → milestone-m-EVT01) — canvaz first run 2026-07-22: stripping it
97
+ # unconditionally missed milestone-m-EVT01 and fell through to a 38-dir refusal. Try the
98
+ # anchor verbatim before the m--stripped form, then both again minus one trailing
99
+ # -{session} token; first existing dir wins (exact/longer forms first).
95
100
  _mdir=""
96
101
  _branch="$(git -C "$WORKTREE" branch --show-current 2>/dev/null)"
97
102
  case "$_branch" in
98
- forge/m-*)
99
- _anchor="${_branch#forge/m-}" # 40 | 40-a1b2c3d4 | R61-002
100
- [ -d "$WORKTREE/.forge/phases/milestone-$_anchor" ] && _mdir="$WORKTREE/.forge/phases/milestone-$_anchor"
101
- if [ -z "$_mdir" ]; then
102
- _short="${_anchor%-*}" # strip one trailing -token (session suffix)
103
- [ "$_short" != "$_anchor" ] && [ -d "$WORKTREE/.forge/phases/milestone-$_short" ] && _mdir="$WORKTREE/.forge/phases/milestone-$_short"
104
- fi ;;
103
+ forge/*)
104
+ _anchor="${_branch#forge/}" # m-40 | m-40-a1b2c3d4 | m-EVT01-e6063597
105
+ _short="${_anchor%-*}" # minus one trailing -token (session suffix)
106
+ for _cand in "$_anchor" "${_anchor#m-}" "$_short" "${_short#m-}"; do
107
+ [ -n "$_cand" ] || continue
108
+ if [ -d "$WORKTREE/.forge/phases/milestone-$_cand" ]; then
109
+ _mdir="$WORKTREE/.forge/phases/milestone-$_cand"
110
+ break
111
+ fi
112
+ done ;;
105
113
  esac
106
114
  # Fallback (non-git fixture dirs / unconventional branches): a SINGLE milestone dir is
107
115
  # unambiguous; several without a branch-derived pick is a refusal, never a guess.
@@ -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 $?
@@ -157,6 +157,41 @@ else
157
157
  bad "dry-run side effects" "dry-run wrote work-order files"
158
158
  fi
159
159
 
160
+ # --- 13-14. BRANCH-DERIVED MILESTONE PICK (canvaz first run 2026-07-22): with several
161
+ # milestone dirs, the forge/{anchor} branch names the one to build. The id's m- may be
162
+ # strippable (m-40 → milestone-40) or part of a labeled id (m-EVT01 → milestone-m-EVT01);
163
+ # unconditional stripping refused a clean labeled-id dispatch. These fixtures are real
164
+ # git repos — cases 9-12 (mktemp dirs) never reach the branch path.
165
+ mkbranch() { git -C "$1" init -q -b "$2" 2>/dev/null || { git -C "$1" init -q && git -C "$1" checkout -q -b "$2"; }; }
166
+
167
+ # 13. labeled id: forge/m-EVT01-<session> resolves milestone-m-EVT01 (m- is part of the id)
168
+ LSLICE="$ROOT/labeled-wt"
169
+ mkdir -p "$LSLICE/.forge/phases/milestone-m-EVT01/1-evt" "$LSLICE/.forge/phases/milestone-5/1-decoy"
170
+ printf 'evt plan\n' > "$LSLICE/.forge/phases/milestone-m-EVT01/1-evt/plan-01.md"
171
+ printf 'decoy\n' > "$LSLICE/.forge/phases/milestone-5/1-decoy/plan-01.md"
172
+ mkbranch "$LSLICE" forge/m-EVT01-e6063597
173
+ err13="$(sh "$DISPATCH" --slice "$LSLICE" --materialize-only 2>&1)"; rc=$?
174
+ if [ "$rc" = 0 ] && grep -q '^ - 1-evt$' "$LSLICE/slice.yml" 2>/dev/null \
175
+ && grep -q 'evt plan' "$LSLICE/phases/1-evt/plan.md" 2>/dev/null && [ ! -d "$LSLICE/phases/1-decoy" ]; then
176
+ ok "labeled milestone id: branch picks milestone-m-EVT01 among several dirs (m- kept)"
177
+ else
178
+ bad "labeled-id branch pick" "rc=$rc err=$err13"
179
+ fi
180
+
181
+ # 14. numeric id + session suffix: forge/m-40-<session> still resolves milestone-40 (m- stripped)
182
+ QSLICE="$ROOT/numeric-wt"
183
+ mkdir -p "$QSLICE/.forge/phases/milestone-40/1-num" "$QSLICE/.forge/phases/milestone-39/1-decoy"
184
+ printf 'num plan\n' > "$QSLICE/.forge/phases/milestone-40/1-num/plan-01.md"
185
+ printf 'decoy\n' > "$QSLICE/.forge/phases/milestone-39/1-decoy/plan-01.md"
186
+ mkbranch "$QSLICE" forge/m-40-a1b2c3d4
187
+ err14="$(sh "$DISPATCH" --slice "$QSLICE" --materialize-only 2>&1)"; rc=$?
188
+ if [ "$rc" = 0 ] && grep -q '^ - 1-num$' "$QSLICE/slice.yml" 2>/dev/null \
189
+ && grep -q 'num plan' "$QSLICE/phases/1-num/plan.md" 2>/dev/null && [ ! -d "$QSLICE/phases/1-decoy" ]; then
190
+ ok "numeric milestone id: branch picks milestone-40 among several dirs (m- stripped)"
191
+ else
192
+ bad "numeric-id branch pick" "rc=$rc err=$err14"
193
+ fi
194
+
160
195
  # --- summary -----------------------------------------------------------------
161
196
  printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$PASSED" "$FAILED"
162
197
  [ "$FAILED" = 0 ]