@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-executor.md +2 -1
- package/agents/gsd-security-auditor.md +13 -15
- package/bin/install.js +5 -4
- package/commands/gsd/mempalace-capture.md +27 -1
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +63 -2
- package/gsd-core/bin/lib/api-coverage.cjs +3 -4
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/capability-registry.cjs +57 -57
- package/gsd-core/bin/lib/check-command-router.cjs +1 -1
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +48 -9
- package/gsd-core/bin/lib/config-loader.cjs +6 -2
- package/gsd-core/bin/lib/config.cjs +12 -0
- package/gsd-core/bin/lib/core-utils.cjs +8 -2
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/init.cjs +27 -13
- package/gsd-core/bin/lib/install-engine.cjs +3 -2
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/milestone.cjs +217 -31
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
- package/gsd-core/bin/lib/phase-locator.cjs +23 -2
- package/gsd-core/bin/lib/phase.cjs +436 -61
- package/gsd-core/bin/lib/plan-scan.cjs +3 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
- package/gsd-core/bin/lib/roadmap.cjs +100 -49
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -6
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +2 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +25 -17
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +1 -1
- package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
- package/gsd-core/bin/lib/smart-entry.cjs +4 -3
- package/gsd-core/bin/lib/state-transition.cjs +100 -45
- package/gsd-core/bin/lib/state.cjs +391 -126
- package/gsd-core/bin/lib/surface.cjs +2 -2
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +54 -8
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/verify.cjs +4 -3
- package/gsd-core/bin/lib/workstream.cjs +3 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
- package/gsd-core/bin/lib/write-set.cjs +38 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/references/checkpoints.md +12 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/debug.md +2 -0
- package/gsd-core/workflows/execute-phase.md +5 -3
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/progress.md +2 -2
- package/gsd-core/workflows/quick.md +2 -0
- package/gsd-core/workflows/review.md +42 -3
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/ship.md +8 -2
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/hooks/dist/gsd-statusline.js +164 -14
- package/hooks/gsd-statusline.js +164 -14
- package/package.json +4 -2
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/skills/gsd-mempalace-capture/SKILL.md +27 -1
- package/skills/gsd-surface/SKILL.md +6 -6
- 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
|
-
//
|
|
66
|
-
// Use replace()
|
|
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
|
-
|
|
132
|
+
const checkboxHit = afterCheckbox !== reqContent;
|
|
133
|
+
if (checkboxHit)
|
|
71
134
|
reqContent = afterCheckbox;
|
|
72
|
-
|
|
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
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 =
|
|
247
|
+
const today = clock_cjs_1.realClock.localToday();
|
|
124
248
|
const milestoneName = options.name || version;
|
|
125
|
-
// Ensure archive directory exists
|
|
126
|
-
(
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
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
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
if (
|
|
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(/^-/, '');
|