@opengsd/gsd-core 1.7.0-rc.6 → 1.7.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.
Files changed (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-executor.md +2 -1
  4. package/agents/gsd-security-auditor.md +13 -15
  5. package/bin/install.js +5 -4
  6. package/commands/gsd/mempalace-capture.md +27 -1
  7. package/commands/gsd/surface.md +6 -6
  8. package/gsd-core/bin/gsd-tools.cjs +63 -2
  9. package/gsd-core/bin/lib/api-coverage.cjs +3 -4
  10. package/gsd-core/bin/lib/audit.cjs +7 -6
  11. package/gsd-core/bin/lib/capability-registry.cjs +57 -57
  12. package/gsd-core/bin/lib/check-command-router.cjs +1 -1
  13. package/gsd-core/bin/lib/clock.cjs +19 -0
  14. package/gsd-core/bin/lib/commands.cjs +48 -9
  15. package/gsd-core/bin/lib/config-loader.cjs +6 -2
  16. package/gsd-core/bin/lib/config.cjs +12 -0
  17. package/gsd-core/bin/lib/core-utils.cjs +8 -2
  18. package/gsd-core/bin/lib/drift.cjs +4 -4
  19. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  20. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  21. package/gsd-core/bin/lib/init.cjs +27 -13
  22. package/gsd-core/bin/lib/install-engine.cjs +3 -2
  23. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  24. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  25. package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
  26. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  27. package/gsd-core/bin/lib/milestone.cjs +217 -31
  28. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  29. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  30. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  31. package/gsd-core/bin/lib/phase.cjs +436 -61
  32. package/gsd-core/bin/lib/plan-scan.cjs +3 -0
  33. package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
  34. package/gsd-core/bin/lib/roadmap.cjs +100 -49
  35. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -6
  36. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  37. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +2 -1
  38. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +25 -17
  39. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  40. package/gsd-core/bin/lib/security.cjs +1 -1
  41. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  42. package/gsd-core/bin/lib/smart-entry.cjs +4 -3
  43. package/gsd-core/bin/lib/state-transition.cjs +100 -45
  44. package/gsd-core/bin/lib/state.cjs +391 -126
  45. package/gsd-core/bin/lib/surface.cjs +2 -2
  46. package/gsd-core/bin/lib/template.cjs +2 -1
  47. package/gsd-core/bin/lib/uat.cjs +54 -8
  48. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  49. package/gsd-core/bin/lib/verify.cjs +4 -3
  50. package/gsd-core/bin/lib/workstream.cjs +3 -2
  51. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  52. package/gsd-core/bin/lib/write-set.cjs +38 -0
  53. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  54. package/gsd-core/references/checkpoints.md +12 -0
  55. package/gsd-core/workflows/add-tests.md +1 -1
  56. package/gsd-core/workflows/debug.md +2 -0
  57. package/gsd-core/workflows/execute-phase.md +5 -3
  58. package/gsd-core/workflows/fast.md +8 -22
  59. package/gsd-core/workflows/progress.md +2 -2
  60. package/gsd-core/workflows/quick.md +2 -0
  61. package/gsd-core/workflows/review.md +42 -3
  62. package/gsd-core/workflows/secure-phase.md +1 -1
  63. package/gsd-core/workflows/ship.md +8 -2
  64. package/gsd-core/workflows/spec-phase.md +1 -1
  65. package/gsd-core/workflows/transition.md +1 -1
  66. package/hooks/dist/gsd-statusline.js +164 -14
  67. package/hooks/gsd-statusline.js +164 -14
  68. package/package.json +4 -2
  69. package/scripts/ci-test-scope.cjs +39 -1
  70. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  71. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  72. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  73. package/scripts/lint-table-schema-drift.cjs +157 -0
  74. package/skills/gsd-mempalace-capture/SKILL.md +27 -1
  75. package/skills/gsd-surface/SKILL.md +6 -6
  76. package/vscode/package.json +1 -1
@@ -21,6 +21,8 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
21
21
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
22
22
  const clock_cjs_1 = require("./clock.cjs");
23
23
  const state_transition_cjs_1 = require("./state-transition.cjs");
24
+ const write_set_cjs_1 = require("./write-set.cjs");
25
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
24
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
27
  const ioMod = require("./io.cjs");
26
28
  const { output, error } = ioMod;
@@ -36,6 +38,43 @@ const { extractOneLinerFromBody } = coreUtilsMod;
36
38
  const { planningPaths } = planningWorkspace;
37
39
  const { extractFrontmatter } = frontmatterMod;
38
40
  const { writeStateMd } = stateMod;
41
+ /**
42
+ * Scope an `updateTableCell` call to the `## Traceability` (or
43
+ * `## Traceability Status`) heading's own section — up to the next H1/H2
44
+ * heading — instead of handing it the WHOLE REQUIREMENTS.md content.
45
+ *
46
+ * F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
47
+ * found in whatever text it is given. The shipped requirements template
48
+ * (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
49
+ * (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
50
+ * an unscoped whole-file call targets the Out-of-Scope table instead, fails
51
+ * with `{ok:false, reason:'unknown column: Status'}`, and the real
52
+ * Traceability row is never flipped, while the checkbox surface still flips
53
+ * and the command reports success (the #2140 silent-divergence class one
54
+ * level deeper). Mirrors phase.cts's `editProgressHeadingSlice` scoping of
55
+ * `## Progress` writes to that heading's own slice.
56
+ *
57
+ * Falls back to running `updateTableCell` against the whole `text` when no
58
+ * `## Traceability` heading exists — matching the previous (unscoped)
59
+ * behaviour for a REQUIREMENTS.md whose traceability table sits under some
60
+ * other heading, or with no heading at all (never worse than before this fix).
61
+ */
62
+ function updateTraceabilityCell(text, match, column, newValue) {
63
+ const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
64
+ if (!headingMatch || headingMatch.index === undefined) {
65
+ return (0, markdown_table_cjs_1.updateTableCell)(text, match, column, newValue);
66
+ }
67
+ const headingOffset = headingMatch.index;
68
+ const before = text.slice(0, headingOffset);
69
+ const fromHeading = text.slice(headingOffset);
70
+ const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
71
+ const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
72
+ const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
73
+ const result = (0, markdown_table_cjs_1.updateTableCell)(scoped, match, column, newValue);
74
+ if (!result.ok)
75
+ return result;
76
+ return { ok: true, value: before + result.value + after };
77
+ }
39
78
  function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
40
79
  if (!reqIdsRaw || reqIdsRaw.length === 0) {
41
80
  error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
@@ -59,50 +98,135 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
59
98
  const updated = [];
60
99
  const alreadyComplete = [];
61
100
  const notFound = [];
101
+ // #2140: IDs reconciled on the checkbox surface only — a traceability table
102
+ // exists but has no row for the ID. Without this bucket the payload for a
103
+ // partial reconcile is byte-identical to a full one, and audit-milestone (which
104
+ // reads the table) still sees Pending while the CLI reported success.
105
+ const tableUnmatched = [];
106
+ // A traceability table is present if the file has a requirement-ID column
107
+ // header: "Requirement", "Requirement ID", or "REQ-ID" (#2769/#2203) — kept
108
+ // in sync with the positional first-cell rowMatch/hasRow below so a
109
+ // REQ-ID-headed table (the real-world format) participates in the
110
+ // write-set and the #2140 drift check below, not just the "Requirement"
111
+ // case. A REQUIREMENTS.md with no such table is legitimate (mid-roadmap),
112
+ // so a missing row only counts as drift when a table actually exists.
113
+ const hasTable = /^\|\s*(?:Requirement(?:\s*ID)?|REQ[-\s]?ID)\s*\|/im.test(reqContent);
114
+ // ADR-2143 §6 per-surface write-set, tracked PER requirement ID: a
115
+ // multi-ID batch must not OR one ID's surface outcome into another's —
116
+ // that is the exact #2140 class one level up (an ID whose traceability
117
+ // row is absent/unmatched must not have its partial write masked by a
118
+ // different ID in the same invocation that fully reconciled). Reported
119
+ // additively as `write_set` below — it does not change the existing
120
+ // marked_complete/already_complete/not_found/table_unmatched/updated
121
+ // computation, which stays byte-for-behaviour identical (#2140's tactical
122
+ // fix already surfaces the checkbox-only-partial-write case via
123
+ // table_unmatched; this only adds the structured ADR-2143 shape on top).
124
+ const writeSet = [];
62
125
  for (const reqId of reqIds) {
63
- let found = false;
64
126
  const reqEscaped = escapeRegex(reqId);
65
- // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
66
- // Use replace() directly and compare — avoids test()+replace() global regex
127
+ // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
128
+ // Use replace() + compare to avoid the test()+replace() global regex
67
129
  // lastIndex bug where test() advances state and replace() misses matches.
68
130
  const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
69
131
  const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
70
- if (afterCheckbox !== reqContent) {
132
+ const checkboxHit = afterCheckbox !== reqContent;
133
+ if (checkboxHit)
71
134
  reqContent = afterCheckbox;
72
- found = true;
135
+ // Surface 2 — the traceability row: | <REQ-ID> | Phase N | Pending | → ... Complete |
136
+ // via the markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
137
+ // regex. Match the row by its FIRST cell's value (the requirement-ID column)
138
+ // regardless of that column's HEADER name — real tables head it `REQ-ID`,
139
+ // others `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
140
+ // `\|\s*<id>\s*\|` anchor. Object.values(row) is in header order so [0] is the
141
+ // first column. Case-insensitive (mirrors the prior regex's 'i' flag).
142
+ const rowMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
143
+ // Ragged-tolerant (#2245 Blocker 2): drive the write purely off
144
+ // updateTableCell's own tolerant row scan — a DIFFERENT requirement's row
145
+ // elsewhere in the same table having a mismatched cell count must never
146
+ // silently no-op THIS requirement's write. The "only flip Pending ->
147
+ // Complete" gate is folded into the newValue callback so one
148
+ // updateTableCell call both probes the current value and writes.
149
+ let tableHit = false;
150
+ const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
151
+ if (/^pending$/i.test(current.trim())) {
152
+ tableHit = true;
153
+ return ' Complete ';
154
+ }
155
+ return current;
156
+ });
157
+ if (tableUpdate.ok) {
158
+ reqContent = tableUpdate.value;
73
159
  }
74
- // Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
75
- const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
76
- const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
77
- if (afterTable !== reqContent) {
78
- reqContent = afterTable;
79
- found = true;
160
+ // ADR-2143 §6 per-ID write-set entries: this ID's checkbox surface is
161
+ // always tracked; the traceability surface is tracked only when the file
162
+ // has a traceability table at all (same `hasTable` gate the existing
163
+ // required-surface logic below uses) — omitted entirely, not a false
164
+ // `applied:false`, when no table is required of this file.
165
+ writeSet.push({ requirement: reqId, surface: 'checkbox', applied: checkboxHit });
166
+ if (hasTable) {
167
+ writeSet.push({ requirement: reqId, surface: 'traceability', applied: tableHit });
80
168
  }
81
- if (found) {
169
+ // Coverage of the traceability surface for this ID (computed after any flip).
170
+ // hasRow keys on the ID's FIRST cell (the requirement-ID column, by position —
171
+ // see rowMatch above) so a bare mention of the ID in a non-traceability table
172
+ // does not masquerade as a real row.
173
+ // Ragged-tolerant (#2245 Blocker 2): same reasoning as the write above — a
174
+ // sibling row's raggedness must not blind this classification to a row
175
+ // that genuinely exists. Probe via a no-op updateTableCell write (its own
176
+ // tolerant scan) instead of findTableWithColumns (whole-table parse gate).
177
+ let currentStatusCell = '';
178
+ const statusProbe = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
179
+ currentStatusCell = current;
180
+ return current;
181
+ });
182
+ const hasRow = statusProbe.ok;
183
+ const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent);
184
+ const doneTable = Boolean(hasRow && /^complete$/i.test(currentStatusCell.trim()));
185
+ if (checkboxHit || tableHit) {
82
186
  updated.push(reqId);
83
187
  }
84
- else {
85
- // Check if already complete before declaring not_found.
86
- // Non-global flag is fine here — we only need to know if a match exists.
87
- const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
88
- const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
89
- if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
90
- alreadyComplete.push(reqId);
91
- }
92
- else {
93
- notFound.push(reqId);
94
- }
188
+ else if (doneTable || (doneCheckbox && !hasTable)) {
189
+ // Fully reconciled: the table row is Complete, OR the checkbox is done and
190
+ // there is no table to reconcile against. (A [x] checkbox with a Pending or
191
+ // absent row is NOT fully reconciled when a table exists — #2140.)
192
+ alreadyComplete.push(reqId);
193
+ }
194
+ else if (!doneCheckbox && !doneTable) {
195
+ notFound.push(reqId);
196
+ }
197
+ // else: doneCheckbox && hasTable && !doneTable — partially reconciled. It is
198
+ // neither updated, already_complete, nor not_found; the table_unmatched bucket
199
+ // below carries the truthful partial-reconcile signal.
200
+ // Surface traceability drift: checkbox reconciled (this run or before) but the
201
+ // table has no row for this ID. This is what makes a partial reconcile
202
+ // distinguishable from a full one (#2140).
203
+ if (hasTable && doneCheckbox && !hasRow) {
204
+ tableUnmatched.push(reqId);
95
205
  }
96
206
  }
97
207
  if (updated.length > 0) {
98
208
  (0, shell_command_projection_cjs_1.platformWriteSync)(reqPath, reqContent);
99
209
  }
210
+ // ADR-2143 §6: `writeSet` above already carries one WriteOutcome per
211
+ // (requirement, surface) this invocation could have written to — per ID,
212
+ // not ORed across the batch. `write_set` and `write_set_complete` are
213
+ // additive: they do not replace or gate `updated` / `marked_complete` /
214
+ // `already_complete` / `not_found` / `table_unmatched`, which remain
215
+ // computed exactly as before (see #2140 note above — that fix already
216
+ // surfaces a checkbox-only partial write via `table_unmatched`;
217
+ // `write_set_complete` is a structured, ADR-2143-shaped read of the SAME
218
+ // per-surface, per-ID facts, `false` if ANY id's ANY required surface did
219
+ // not apply, since `writeSetComplete` requires EVERY entry to have
220
+ // applied, never an OR across surfaces OR across IDs).
100
221
  output({
101
222
  updated: updated.length > 0,
102
223
  marked_complete: updated,
103
224
  already_complete: alreadyComplete,
104
225
  not_found: notFound,
226
+ table_unmatched: tableUnmatched,
105
227
  total: reqIds.length,
228
+ write_set: writeSet,
229
+ write_set_complete: (0, write_set_cjs_1.writeSetComplete)(writeSet),
106
230
  }, raw, `${updated.length}/${reqIds.length} requirements marked complete`);
107
231
  }
108
232
  function cmdMilestoneComplete(cwd, version, options, raw) {
@@ -120,10 +244,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
120
244
  const milestonesPath = node_path_1.default.join(planningBase, 'MILESTONES.md');
121
245
  const archiveDir = node_path_1.default.join(planningBase, 'milestones');
122
246
  const phasesDir = planningPaths(cwd).phases;
123
- const today = new Date().toISOString().split('T')[0];
247
+ const today = clock_cjs_1.realClock.localToday();
124
248
  const milestoneName = options.name || version;
125
- // Ensure archive directory exists
126
- (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
249
+ // Ensure archive directory exists (skipped in dry-run — no mutations)
250
+ if (!options.dryRun) {
251
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
252
+ }
127
253
  // Scope stats and accomplishments to only the phases belonging to the
128
254
  // current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
129
255
  // (same logic used by cmdPhasesList and other callers).
@@ -247,13 +373,62 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
247
373
  }
248
374
  }
249
375
  catch {
250
- /* intentionally empty */
376
+ /* best-effort (#2245 audit): one unreadable/malformed SUMMARY.md
377
+ * must not abort the accomplishments/task-count roll-up for every
378
+ * OTHER summary across every OTHER phase — it's simply excluded
379
+ * from the milestone's shipped-summary text. */
251
380
  }
252
381
  }
253
382
  }
254
383
  }
255
384
  catch {
256
- /* intentionally empty */
385
+ /* best-effort (#2245 audit): mirrors the phaseDirEntries IIFE a few
386
+ * lines below this function (same phasesDir, same "try readdirSync,
387
+ * tolerate ENOENT" pattern) — phasesDir may legitimately not exist yet
388
+ * (e.g. milestone being force-completed before any phase directories
389
+ * were created). Degrades stats to phaseCount/totalPlans/totalTasks=0,
390
+ * accomplishments=[] rather than crash `milestone complete`. */
391
+ }
392
+ // #2118: --dry-run preview — compute what WOULD happen without mutating.
393
+ // The stats above are read-only; all mutations start at the archive section below.
394
+ if (options.dryRun) {
395
+ const phaseDirsToArchive = [];
396
+ if (options.archivePhases !== false) {
397
+ try {
398
+ const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
399
+ for (const e of entries) {
400
+ if (e.isDirectory() && isDirInMilestone(e.name)) {
401
+ phaseDirsToArchive.push(e.name);
402
+ }
403
+ }
404
+ }
405
+ catch { /* phasesDir missing — nothing to archive */ }
406
+ }
407
+ const dryRunResult = {
408
+ dry_run: true,
409
+ version,
410
+ name: milestoneName,
411
+ stats: { phases: phaseCount, plans: totalPlans, tasks: totalTasks },
412
+ accomplishments,
413
+ would_archive: {
414
+ roadmap: node_fs_1.default.existsSync(roadmapPath)
415
+ ? { source: node_path_1.default.relative(cwd, roadmapPath).split(node_path_1.default.sep).join('/'), target: node_path_1.default.relative(cwd, node_path_1.default.join(archiveDir, `${version}-ROADMAP.md`)).split(node_path_1.default.sep).join('/') }
416
+ : null,
417
+ requirements: node_fs_1.default.existsSync(reqPath)
418
+ ? { source: node_path_1.default.relative(cwd, reqPath).split(node_path_1.default.sep).join('/'), target: node_path_1.default.relative(cwd, node_path_1.default.join(archiveDir, `${version}-REQUIREMENTS.md`)).split(node_path_1.default.sep).join('/') }
419
+ : null,
420
+ audit: node_fs_1.default.existsSync(node_path_1.default.join(planningBase, `${version}-MILESTONE-AUDIT.md`))
421
+ ? { source: node_path_1.default.relative(cwd, node_path_1.default.join(planningBase, `${version}-MILESTONE-AUDIT.md`)).split(node_path_1.default.sep).join('/'), target: node_path_1.default.relative(cwd, node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)).split(node_path_1.default.sep).join('/') }
422
+ : null,
423
+ phases: phaseDirsToArchive,
424
+ },
425
+ would_update: {
426
+ milestones_md: node_path_1.default.relative(cwd, milestonesPath).split(node_path_1.default.sep).join('/'),
427
+ state_md: node_fs_1.default.existsSync(statePath) ? node_path_1.default.relative(cwd, statePath).split(node_path_1.default.sep).join('/') : null,
428
+ },
429
+ };
430
+ output(dryRunResult, raw);
431
+ return;
257
432
  }
258
433
  // Archive ROADMAP.md
259
434
  if (node_fs_1.default.existsSync(roadmapPath)) {
@@ -323,22 +498,33 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
323
498
  let phasesArchived = false;
324
499
  // #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
325
500
  if (options.archivePhases !== false) {
501
+ // #2245 audit (was ERROR-HIDING): retryRenameSync moves one phase dir at a
502
+ // time — a mid-loop failure (e.g. the Nth rename) used to leave
503
+ // `phasesArchived` at its `false` default even though the first N-1 dirs
504
+ // had ALREADY been moved to phaseArchiveDir on disk, silently
505
+ // under-reporting a real partial archive in the JSON result. archivedCount
506
+ // is now computed in a `finally` so it reflects whatever succeeded before
507
+ // any failure, instead of being lost with the swallowed exception.
508
+ let archivedCount = 0;
326
509
  try {
327
510
  const phaseArchiveDir = node_path_1.default.join(archiveDir, `${version}-phases`);
328
511
  (0, shell_command_projection_cjs_1.platformEnsureDir)(phaseArchiveDir);
329
512
  const phaseEntries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
330
513
  const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
331
- let archivedCount = 0;
332
514
  for (const dir of phaseDirNames) {
333
515
  if (!isDirInMilestone(dir))
334
516
  continue;
335
517
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, dir), node_path_1.default.join(phaseArchiveDir, dir));
336
518
  archivedCount++;
337
519
  }
338
- phasesArchived = archivedCount > 0;
339
520
  }
340
521
  catch {
341
- /* intentionally empty */
522
+ /* best-effort: phasesDir may not exist yet, or the archive rename loop
523
+ * failed partway — phasesArchived below still reflects whatever
524
+ * archivedCount succeeded before the failure. */
525
+ }
526
+ finally {
527
+ phasesArchived = archivedCount > 0;
342
528
  }
343
529
  }
344
530
  const result = {
@@ -137,7 +137,32 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
137
137
  return { ok: true, data: null };
138
138
  },
139
139
  complete: (_ctx) => {
140
- phase.cmdPhaseComplete(cwd, args[2], raw);
140
+ // #2201: accept --phase N as well as the positional form (the state
141
+ // family already accepts --phase). An unrecognized flag is a usage
142
+ // error, not "Phase --phase not found".
143
+ let phaseNum = null;
144
+ for (let i = 2; i < args.length; i++) {
145
+ if (args[i] === '--phase') {
146
+ phaseNum = args[++i];
147
+ if (!phaseNum || phaseNum.startsWith('--'))
148
+ return makeInvalidArgs('--phase', '--phase requires a value');
149
+ }
150
+ else if (args[i].startsWith('--phase=')) {
151
+ phaseNum = args[i].slice(8);
152
+ }
153
+ else if (args[i] === '--raw') {
154
+ continue;
155
+ }
156
+ else if (args[i].startsWith('--')) {
157
+ return makeInvalidArgs(args[i], `phase complete does not support ${args[i]}`);
158
+ }
159
+ else {
160
+ phaseNum = args[i];
161
+ }
162
+ }
163
+ if (!phaseNum)
164
+ return makeInvalidArgs('--phase', 'phase number required (positional or --phase N)');
165
+ phase.cmdPhaseComplete(cwd, phaseNum, raw);
141
166
  return { ok: true, data: null };
142
167
  },
143
168
  'uat-passed': (_ctx) => {
@@ -162,7 +187,30 @@ function routePhaseCommand({ phase, args, cwd, raw, error }) {
162
187
  },
163
188
  // #1437 — list plan files for a phase
164
189
  'list-plans': (_ctx) => {
165
- phase.cmdPhaseListPlans(cwd, args[2], raw);
190
+ // #2201: accept --phase N as well as positional.
191
+ let phaseNum = null;
192
+ for (let i = 2; i < args.length; i++) {
193
+ if (args[i] === '--phase') {
194
+ phaseNum = args[++i];
195
+ if (!phaseNum || phaseNum.startsWith('--'))
196
+ return makeInvalidArgs('--phase', '--phase requires a value');
197
+ }
198
+ else if (args[i].startsWith('--phase=')) {
199
+ phaseNum = args[i].slice(8);
200
+ }
201
+ else if (args[i] === '--raw') {
202
+ continue;
203
+ }
204
+ else if (args[i].startsWith('--')) {
205
+ return makeInvalidArgs(args[i], `phase list-plans does not support ${args[i]}`);
206
+ }
207
+ else {
208
+ phaseNum = args[i];
209
+ }
210
+ }
211
+ if (!phaseNum)
212
+ return makeInvalidArgs('--phase', 'phase number required (positional or --phase N)');
213
+ phase.cmdPhaseListPlans(cwd, phaseNum, raw);
166
214
  return { ok: true, data: null };
167
215
  },
168
216
  },
@@ -23,53 +23,79 @@
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.deriveProgressFromRoadmap = deriveProgressFromRoadmap;
25
25
  exports.clampPercent = clampPercent;
26
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
26
27
  /**
27
28
  * Derive completed_phases, total_phases, and total_plans from ROADMAP content.
28
29
  * Root cause fix for issue #4 — see gen-phase-lifecycle.mjs for full documentation.
30
+ *
31
+ * ADR-2143 §3 ("addressed by NAME, never ordinal"): the Progress table is
32
+ * located via the markdown-table seam's `findTableWithColumns`, which is
33
+ * column-NAME/order/count-invariant — it matches the first table whose header
34
+ * is a SUPERSET of the canonical `Phase` / `Plans Complete` / `Status` /
35
+ * `Completed` names, in any order, tolerating extra/injected unrelated
36
+ * columns (#2137's fast-check property test shuffles headers and injects
37
+ * columns and asserts the derived counts never change). This supersedes the
38
+ * earlier `findTableBySchema` exact-schema lookup, which required an exact
39
+ * canonical column SET+ORDER and returned all-null on any reordering or
40
+ * injection.
41
+ *
42
+ * Scoped to the `## Progress` section when the document has one (#2012 decoy
43
+ * avoidance — a differently-headed table sharing the same column names must
44
+ * not be picked up instead); a headingless milestone slice (#1445) falls back
45
+ * to scanning the whole input, preserving the "Progress table not under a
46
+ * `## Progress` heading, or not the first table in the document, still
47
+ * resolves" behaviour.
48
+ *
49
+ * Cells are read by column NAME (`r['Status']`, `r['Plans Complete']`,
50
+ * `r['Phase']`), fixing #2137 (the old position-based regex assumed "Status"
51
+ * was always the 3rd cell and "Plans Complete" the 2nd, which broke for the
52
+ * 5-column milestone-grouped variant that inserts a `Milestone` column ahead
53
+ * of them).
29
54
  */
30
55
  function deriveProgressFromRoadmap(roadmapContent) {
31
56
  let completedPhases = null;
32
57
  let totalPhases = null;
33
58
  let totalPlans = null;
34
- try {
35
- // Count Complete rows in the progress table (Status column = "Complete").
36
- // Pattern: row where the phase cell starts with a digit (data row, not header),
37
- // followed by any cell content, then a "Complete" status cell.
38
- // Handles both short form ("| 4. |") and long form ("| 01. Foundation |").
39
- // See phase-lifecycle.ts ~line 1655 for the original SDK pattern.
40
- const tableCompletePattern = /\|\s*\d+[^|]*\|\s*[^|]*\|\s*Complete\s*\|/gi;
41
- const completeMatches = roadmapContent.match(tableCompletePattern);
42
- completedPhases = completeMatches ? completeMatches.length : null;
43
- // Count total phase rows in the progress table.
44
- // Identify the table by looking for Phase|...|Status|...|Completed header.
45
- const progressTableMatch = roadmapContent.match(
46
- // allow-adhoc-markdown: table-scoped regex with heading lookahead as stop; table parsing, out of seam scope; pending #1372
47
- /\|\s*Phase\s*\|[^|]*\|[^|]*Status[^|]*\|[^|]*Completed[^|]*\|[\s\S]*?(?=\n\n|\n##|$)/i);
48
- if (progressTableMatch) {
49
- const tableText = progressTableMatch[0];
50
- // Count data rows (rows starting with pipe then a phase number),
51
- // excluding 999.x backlog phases. Mirrors init.cts /^999(?:\.|$)/ filter.
52
- const dataRowPattern = /^\|\s*(\d+[^|]*)\|/gm;
53
- let dataRowCount = 0;
54
- let drm;
55
- while ((drm = dataRowPattern.exec(tableText)) !== null) {
56
- if (/^999\b/.test(drm[1].trim()))
57
- continue;
58
- dataRowCount++;
59
- }
60
- totalPhases = dataRowCount > 0 ? dataRowCount : null;
61
- }
62
- // Sum plan counts from M/N columns in progress table
59
+ // ADR-2143 §5 (fail-loud, no null-swallow): this used to be wrapped in a
60
+ // try/catch that silently fell through to the existing (null) values on any
61
+ // thrown error. `findTableWithColumns`/`parseMarkdownTable` never throw —
62
+ // an unparseable or absent table resolves to `null` /
63
+ // `{ ok: false, reason }`, not an exception — so the catch was masking
64
+ // nothing but dead code paths. Removed per ADR-2143 §5; the public
65
+ // `RoadmapProgress` contract (nulls = absent) is unchanged.
66
+ //
67
+ // ADR-2143 §3: read the Progress table by column NAME (order/injection-invariant),
68
+ // via the markdown-table seam. Scope to the `## Progress` section when present
69
+ // (#2012 decoy avoidance); a headingless milestone slice (#1445) falls back to the
70
+ // whole input. Requires the canonical Phase/Plans Complete/Status/Completed columns
71
+ // in any order (extra columns ignored) — supersedes findTableBySchema's exact-schema lookup.
72
+ const progressMatch = roadmapContent.match(/^##[ \t]+Progress\b/im);
73
+ let scoped = roadmapContent;
74
+ if (progressMatch && progressMatch.index !== undefined) {
75
+ const afterHeading = roadmapContent.slice(progressMatch.index);
76
+ const nextHeading = afterHeading.search(/\n#{1,2}[ \t]/);
77
+ scoped = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading;
78
+ }
79
+ const table = (0, markdown_table_cjs_1.findTableWithColumns)(scoped, ['Phase', 'Plans Complete', 'Status', 'Completed']);
80
+ if (table) {
81
+ const allRows = table.rows;
82
+ const completed = allRows.filter((r) => /^complete$/i.test((r['Status'] ?? '').trim())).length;
83
+ completedPhases = completed > 0 ? completed : null;
84
+ // Data rows only (exclude 999.x backlog phases). Mirrors init.cts /^999(?:\.|$)/ filter.
85
+ const dataRows = allRows.filter((r) => {
86
+ const phase = (r['Phase'] ?? '').trim();
87
+ return /^\d/.test(phase) && !/^999\b/.test(phase);
88
+ });
89
+ totalPhases = dataRows.length > 0 ? dataRows.length : null;
63
90
  let totalPlansSum = 0;
64
- const planCellPattern = /\|\s*\d+[^|]*\|\s*(\d+)\/(\d+)\s*\|/gi;
65
- let pm;
66
- while ((pm = planCellPattern.exec(roadmapContent)) !== null) {
67
- totalPlansSum += parseInt(pm[2], 10);
91
+ for (const r of allRows) {
92
+ const cell = (r['Plans Complete'] ?? '').trim();
93
+ const m = /(\d+)\s*\/\s*(\d+)/.exec(cell);
94
+ if (m)
95
+ totalPlansSum += parseInt(m[2], 10);
68
96
  }
69
- if (totalPlansSum > 0)
70
- totalPlans = totalPlansSum;
97
+ totalPlans = totalPlansSum > 0 ? totalPlansSum : null;
71
98
  }
72
- catch { /* intentionally empty — fall through to existing values */ }
73
99
  return { completedPhases, totalPhases, totalPlans };
74
100
  }
75
101
  /**
@@ -34,9 +34,30 @@ const { planningDir } = planningWorkspace;
34
34
  function searchPhaseInDir(baseDir, relBase, normalized) {
35
35
  try {
36
36
  const dirs = readSubdirectories(baseDir, true);
37
- const match = dirs.find(d => phaseTokenMatches(d, normalized));
38
- if (!match)
37
+ const matches = dirs.filter(d => phaseTokenMatches(d, normalized));
38
+ if (matches.length === 0)
39
39
  return null;
40
+ // #2237: fail loud when multiple directories match the same bare phase
41
+ // number — this happens when unrelated projects share a .planning/phases/
42
+ // tree. Silently taking the first match risks cross-project file writes.
43
+ if (matches.length > 1) {
44
+ return {
45
+ found: false,
46
+ directory: '',
47
+ phase_number: normalized,
48
+ phase_name: null,
49
+ phase_slug: null,
50
+ plans: [],
51
+ summaries: [],
52
+ incomplete_plans: [],
53
+ has_research: false,
54
+ has_context: false,
55
+ has_verification: false,
56
+ has_reviews: false,
57
+ ambiguous_matches: matches,
58
+ };
59
+ }
60
+ const match = matches[0];
40
61
  const phaseToken = extractPhaseToken(match);
41
62
  const phaseNumber = phaseToken || normalized;
42
63
  const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');