@opengsd/gsd-core 1.5.0 → 1.6.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-plan-checker.md +34 -0
  3. package/agents/gsd-planner.md +2 -0
  4. package/bin/install.js +108 -34
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +677 -2
  7. package/gsd-core/bin/lib/adr-parser.cjs +24 -17
  8. package/gsd-core/bin/lib/audit.cjs +2 -2
  9. package/gsd-core/bin/lib/capability-consent.cjs +763 -0
  10. package/gsd-core/bin/lib/capability-ledger.cjs +831 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +1551 -0
  12. package/gsd-core/bin/lib/capability-loader.cjs +764 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +553 -0
  14. package/gsd-core/bin/lib/capability-registry.cjs +198 -4
  15. package/gsd-core/bin/lib/capability-source.cjs +1242 -0
  16. package/gsd-core/bin/lib/capability-state.cjs +9 -6
  17. package/gsd-core/bin/lib/capability-trust.cjs +550 -0
  18. package/gsd-core/bin/lib/capability-validator.cjs +2066 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +14 -5
  20. package/gsd-core/bin/lib/check-command-router.cjs +69 -18
  21. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  22. package/gsd-core/bin/lib/config-loader.cjs +92 -84
  23. package/gsd-core/bin/lib/config-schema.cjs +26 -7
  24. package/gsd-core/bin/lib/config.cjs +1 -1
  25. package/gsd-core/bin/lib/decisions.cjs +149 -60
  26. package/gsd-core/bin/lib/gap-checker.cjs +126 -11
  27. package/gsd-core/bin/lib/init.cjs +91 -22
  28. package/gsd-core/bin/lib/legacy-cleanup.cjs +96 -0
  29. package/gsd-core/bin/lib/loop-resolver.cjs +26 -2
  30. package/gsd-core/bin/lib/markdown-sectionizer.cjs +471 -0
  31. package/gsd-core/bin/lib/milestone.cjs +41 -2
  32. package/gsd-core/bin/lib/phase-command-router.cjs +5 -0
  33. package/gsd-core/bin/lib/phase-lifecycle.cjs +14 -5
  34. package/gsd-core/bin/lib/phase.cjs +29 -0
  35. package/gsd-core/bin/lib/project-root.cjs +89 -2
  36. package/gsd-core/bin/lib/resolution.cjs +26 -0
  37. package/gsd-core/bin/lib/roadmap-parser.cjs +44 -98
  38. package/gsd-core/bin/lib/runtime-homes.cjs +53 -1
  39. package/gsd-core/bin/lib/semver-compare.cjs +127 -0
  40. package/gsd-core/bin/lib/state-document.cjs +4 -2
  41. package/gsd-core/bin/lib/state.cjs +317 -161
  42. package/gsd-core/bin/lib/uat-predicate.cjs +7 -47
  43. package/gsd-core/bin/lib/uat.cjs +39 -26
  44. package/gsd-core/bin/lib/verify.cjs +29 -13
  45. package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
  46. package/gsd-core/bin/shared/config-schema.manifest.json +4 -1
  47. package/gsd-core/references/execute-phase-between-wave-reset.md +43 -0
  48. package/gsd-core/references/execute-phase-wave-guard.md +33 -0
  49. package/gsd-core/references/planner-antipatterns.md +48 -0
  50. package/gsd-core/references/planning-config.md +3 -0
  51. package/gsd-core/references/scout-codebase.md +2 -2
  52. package/gsd-core/workflows/discuss-phase/templates/context.md +1 -1
  53. package/gsd-core/workflows/discuss-phase.md +1 -2
  54. package/gsd-core/workflows/execute-phase.md +4 -6
  55. package/package.json +3 -3
  56. package/scripts/gen-capability-matrix.cjs +284 -0
  57. package/scripts/gen-capability-registry.cjs +96 -1853
  58. package/scripts/lint-regression-test-names.allowlist.json +1 -0
  59. package/scripts/lint-resolution-provenance.allowlist.json +1 -0
  60. package/scripts/lint-resolution-provenance.cjs +192 -0
  61. package/scripts/lint-test-file-count.allowlist.json +9 -0
  62. package/scripts/run-tests.cjs +14 -0
  63. package/scripts/sync-manifest-versions.cjs +77 -5
@@ -8,67 +8,56 @@
8
8
  * Accepts both numeric (D-42) and alphanumeric (D-INFRA-01) IDs.
9
9
  * Returns {id, text, category, tags, trackable} per decision.
10
10
  * CJS callers that only use {id, text} safely ignore the extra fields.
11
+ *
12
+ * ADR-1372 T1: rewritten to adopt the markdown-sectionizer seam.
13
+ * - `stripFencedCode` → seam's `stripFencedCode` (CommonMark-correct)
14
+ * - `extractDecisionsBlock` → seam's `extractTaggedBlocks(content,'decisions')`
15
+ * - Markdown-header fallback → seam's `collectSection(content, /decisions?/i, ...)`
16
+ * - Outer bullet loop → seam's `iterateBullets` (for the header-fallback path)
17
+ *
18
+ * Resolves #1364 (markdown-header + em-dash recall) and #1365 (fail-loud gate).
11
19
  */
12
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.extractDecisions = extractDecisions;
13
22
  exports.parseDecisions = parseDecisions;
23
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
14
24
  const DISCRETION_HEADINGS = new Set([
15
25
  "claude's discretion",
16
26
  'claudes discretion',
17
27
  'claude discretion',
18
28
  ]);
19
29
  const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
30
+ // ─── Bullet parsers (decisions-specific grammar) ─────────────────────────────
20
31
  /**
21
- * Strip fenced code blocks from `content` so example `<decisions>` snippets
22
- * inside ```` ``` ```` do not pollute the parser (review F11).
32
+ * Colon form: `- **D-NN[ [tags]]:** text`
33
+ * (#1343: `[^:*]*` subsumes any pre-colon prose, stops at `:**`)
23
34
  */
24
- function stripFencedCode(content) {
25
- return content.replace(/```[\s\S]*?```/g, ' ').replace(/~~~[\s\S]*?~~~/g, ' ');
26
- }
35
+ const bulletColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
27
36
  /**
28
- * Extract the inner text of EVERY `<decisions>...</decisions>` block in
29
- * order, concatenated by `\n\n`. Returns null when no block is present.
37
+ * Em-dash form: `- **D-NN[ [tags]] — title** body`
38
+ * The em-dash (U+2014) or its lookalike separates the ID+tags group from a title
39
+ * that lives inside the bold markers; the body (which may be empty) follows
40
+ * outside the closing `**`. This form was not handled pre-T1 (bug #1364).
30
41
  *
31
- * CONTEXT.md may legitimately contain more than one block (for example, a
32
- * "current decisions" block plus a "carry-over from prior phase" block);
33
- * dropping all-but-the-first silently lost the second batch (review F13).
42
+ * Accepts both U+2014 em-dash (—) and U+2013 en-dash (–) for robustness.
34
43
  */
35
- function extractDecisionsBlock(content) {
36
- const cleaned = stripFencedCode(content);
37
- const matches = [...cleaned.matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)];
38
- if (matches.length === 0)
39
- return null;
40
- return matches.map((m) => m[1]).join('\n\n');
41
- }
44
+ const bulletEmDashRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^*]*[—–][^*]*\*\*\s*(.*)$/;
42
45
  /**
43
- * Parse trackable decisions from CONTEXT.md content.
46
+ * Parse decision lines from a block of text (the inner text of a <decisions>
47
+ * or markdown-header section body). Returns the extracted decisions and a count
48
+ * of parse-misses (lines that looked like D-NN bullets but failed both regexes).
44
49
  *
45
- * Returns ALL D-NN decisions found inside `<decisions>` (including
46
- * non-trackable ones, with `trackable: false`). Callers that only want the
47
- * gate-enforced decisions should filter `.filter(d => d.trackable)`.
50
+ * FIX B (#1365): parseMisses > 0 means the caller must treat the result as
51
+ * could-not-parse even when some decisions were extracted — a silent drop is
52
+ * worse than a fail-loud signal.
48
53
  */
49
- function parseDecisions(content) {
50
- if (!content || typeof content !== 'string')
51
- return [];
52
- const block = extractDecisionsBlock(content);
53
- if (block === null)
54
- return [];
54
+ function parseDecisionLines(block) {
55
55
  const lines = block.split(/\r?\n/);
56
56
  const out = [];
57
57
  let category = '';
58
58
  let inDiscretion = false;
59
- // Bullet line: `- **D-NN[ [tags]]:** text`
60
- // Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
61
- // in addition to numeric-only IDs (D-42). The first character after `D-` must
62
- // be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
63
- // CJS callers consume {id, text} and ignore the optional extras.
64
- // #1343: `[^:*]*` replaces the old `\s*` before `:**` so that a freeform run
65
- // such as `(parenthetical)`, an em-dash, or other prose between the optional
66
- // bracket-tag group and the closing `:**` is tolerated rather than silently
67
- // dropping the whole decision. `[^:*]*` subsumes plain whitespace and stops
68
- // correctly at `:**`. Capture groups 1 (id), 2 (bracket tags), 3 (text) are
69
- // unchanged.
70
- const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
71
59
  let current = null;
60
+ let parseMisses = 0;
72
61
  const flush = () => {
73
62
  if (current) {
74
63
  current.text = current.text.trim();
@@ -84,39 +73,49 @@ function parseDecisions(content) {
84
73
  flush();
85
74
  category = headingMatch[1];
86
75
  // Strip the full unicode-quote family so any rendering of "Claude's
87
- // Discretion" (ASCII apostrophe, curly U+2019, U+2018, U+201A, U+201B,
88
- // double-quote variants U+201C/D/E/F, etc.) collapses to the same key
89
- // (review F20).
76
+ // Discretion" (ASCII apostrophe, curly U+2019 ’, U+2018 ‘,
77
+ // U+201A, U+201B, double-quote variants U+201C/D/E/F, etc.) collapses
78
+ // to the same key (FIX C + review F20).
90
79
  const normalized = category
91
80
  .toLowerCase()
92
- .replace(/[‘’‚‛“”„‟'"`]/g, '')
81
+ .replace(/[‘’‚‛“”„‟''"`]/g, '')
93
82
  .trim();
94
83
  inDiscretion = DISCRETION_HEADINGS.has(normalized);
95
84
  continue;
96
85
  }
97
- const bulletMatch = line.match(bulletRe);
98
- if (bulletMatch) {
86
+ // Colon form: `- **D-NN[ [tags]]:** text`
87
+ const colonMatch = line.match(bulletColonRe);
88
+ if (colonMatch) {
89
+ flush();
90
+ const id = `D-${colonMatch[1]}`;
91
+ const tags = colonMatch[2]
92
+ ? colonMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
93
+ : [];
94
+ const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
95
+ current = { id, text: colonMatch[3], category, tags, trackable };
96
+ continue;
97
+ }
98
+ // Em-dash form: `- **D-NN[ [tags]] — title** body`
99
+ const emDashMatch = line.match(bulletEmDashRe);
100
+ if (emDashMatch) {
99
101
  flush();
100
- const id = `D-${bulletMatch[1]}`;
101
- const tags = bulletMatch[2]
102
- ? bulletMatch[2]
103
- .split(',')
104
- .map((t) => t.trim().toLowerCase())
105
- .filter(Boolean)
102
+ const id = `D-${emDashMatch[1]}`;
103
+ const tags = emDashMatch[2]
104
+ ? emDashMatch[2].split(',').map((t) => t.trim().toLowerCase()).filter(Boolean)
106
105
  : [];
107
106
  const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
108
- current = { id, text: bulletMatch[3], category, tags, trackable };
107
+ // The body (emDashMatch[3]) may be empty for the pure title form; the
108
+ // title itself is embedded in the bold run but we report the body as text
109
+ // (consistent with how the gate cares only about coverage, not title/body split).
110
+ current = { id, text: emDashMatch[3] || '', category, tags, trackable };
109
111
  continue;
110
112
  }
111
- // Parse-miss guard (#1343): a line that looks like a `D-NN` decision bullet
112
- // but failed `bulletRe` (e.g. a `:` or `*` inside the pre-colon run) must NOT
113
- // be silently dropped — a narrowed trackable set lets a blocking coverage gate
114
- // report a false pass. Surface it loudly instead.
113
+ // Parse-miss guard (FIX B + #1343): a line that looks like a `D-NN` decision
114
+ // bullet but failed both patterns — flush, warn, and record the miss.
115
+ // parseMisses > 0 forces could-not-parse even when other decisions parsed.
115
116
  if (/^\s*-\s+\*\*D-/.test(line)) {
116
- // A malformed D-bullet still starts a (failed) new decision, so it ends the
117
- // previous one — flush before warning so a following continuation line cannot
118
- // be mis-appended to the prior valid decision.
119
117
  flush();
118
+ parseMisses += 1;
120
119
  console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
121
120
  continue;
122
121
  }
@@ -132,5 +131,95 @@ function parseDecisions(content) {
132
131
  }
133
132
  }
134
133
  flush();
135
- return out;
134
+ return { decisions: out, parseMisses };
135
+ }
136
+ // ─── Primary entry point: extractDecisions ────────────────────────────────────
137
+ /**
138
+ * Extract decisions from CONTEXT.md content with a typed outcome.
139
+ *
140
+ * Strategy (in priority order):
141
+ * 1. If the content (fence-stripped) contains `<decisions>...</decisions>` blocks,
142
+ * parse ONLY those blocks (canonical form; markdown-header content outside blocks
143
+ * is ignored when a block is present — existing behavior preserved).
144
+ * 2. Otherwise, look for a /decisions?/i heading and collect its section body.
145
+ * This is the T1 recall fix for #1364.
146
+ * 3. If neither is found, return outcome based on decision-shape heuristics.
147
+ */
148
+ function extractDecisions(content) {
149
+ if (!content || typeof content !== 'string') {
150
+ return { decisions: [], outcome: 'none-present' };
151
+ }
152
+ // Apply fence-stripping for block extraction (prevents example blocks inside
153
+ // ``` fences from polluting the parser — review F11).
154
+ const { text: stripped, unterminatedFence } = (0, markdown_sectionizer_cjs_1.stripFencedCode)(content);
155
+ // ── Path 1: <decisions> blocks present ──────────────────────────────────────
156
+ const taggedBlocks = (0, markdown_sectionizer_cjs_1.extractTaggedBlocks)(stripped, 'decisions');
157
+ if (taggedBlocks.length > 0) {
158
+ const combined = taggedBlocks.join('\n\n');
159
+ const { decisions, parseMisses } = parseDecisionLines(combined);
160
+ if (decisions.length > 0 && parseMisses === 0) {
161
+ return { decisions, outcome: 'parsed' };
162
+ }
163
+ // FIX B: parse-misses present — could-not-parse even if some decisions extracted.
164
+ if (parseMisses > 0) {
165
+ return { decisions, outcome: 'could-not-parse' };
166
+ }
167
+ // FIX A: Block present but 0 extracted and no parse-misses.
168
+ // Only report could-not-parse when there is genuine evidence of real decisions
169
+ // that failed to parse: a \bD- token in the block text, or an unterminated fence.
170
+ // An empty scaffold (<decisions></decisions>) or an all-prose block has no such
171
+ // evidence — treat as none-present so the gate passes cleanly.
172
+ const hasDecisionTokenInBlock = /\bD-[A-Za-z0-9]/m.test(combined);
173
+ if (hasDecisionTokenInBlock || unterminatedFence) {
174
+ return { decisions: [], outcome: 'could-not-parse' };
175
+ }
176
+ return { decisions: [], outcome: 'none-present' };
177
+ }
178
+ // ── Path 2: markdown-header fallback (#1364 fix) ─────────────────────────────
179
+ // Use the seam's collectSection to find a /decisions?/i heading section.
180
+ // levelBounded:true → stop at next same-or-higher-level heading.
181
+ // stripFences:true → inner fences inside the section body are stripped.
182
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => /decisions?\b/i.test(h.text), { levelBounded: true, stripFences: true });
183
+ if (section !== null) {
184
+ const { decisions, parseMisses } = parseDecisionLines(section.body);
185
+ if (decisions.length > 0 && parseMisses === 0) {
186
+ return { decisions, outcome: 'parsed' };
187
+ }
188
+ // FIX B: parse-misses present — could-not-parse even if some decisions extracted.
189
+ if (parseMisses > 0) {
190
+ return { decisions, outcome: 'could-not-parse' };
191
+ }
192
+ // FIX A: Heading found but 0 extracted and no parse-misses.
193
+ // Only report could-not-parse when the section body contains a D- token.
194
+ // A heading with only prose, sub-headings, or all-discretion content
195
+ // (no trackable D- tokens) is a legitimate empty/discretion section → none-present.
196
+ const hasDecisionTokenInSection = /\bD-[A-Za-z0-9]/m.test(section.body);
197
+ if (hasDecisionTokenInSection) {
198
+ return { decisions: [], outcome: 'could-not-parse' };
199
+ }
200
+ return { decisions: [], outcome: 'none-present' };
201
+ }
202
+ // ── Path 3: no blocks, no heading ────────────────────────────────────────────
203
+ // Apply shape heuristics to distinguish none-present from could-not-parse.
204
+ // We re-use the already-computed unterminatedFence and check for D- tokens.
205
+ const hasDecisionToken = /\bD-[A-Za-z0-9]/m.test(stripped);
206
+ if (unterminatedFence || hasDecisionToken) {
207
+ return { decisions: [], outcome: 'could-not-parse' };
208
+ }
209
+ return { decisions: [], outcome: 'none-present' };
210
+ }
211
+ // ─── parseDecisions: thin delegate (backwards-compatible entry point) ─────────
212
+ /**
213
+ * Parse trackable decisions from CONTEXT.md content.
214
+ *
215
+ * Thin delegate over extractDecisions — callers receive the decisions array
216
+ * exactly as before; nothing breaks. Use extractDecisions directly when the
217
+ * outcome enum is needed (e.g. for the fail-loud gate logic).
218
+ *
219
+ * Returns ALL D-NN decisions found (including non-trackable ones, with
220
+ * `trackable: false`). Callers that only want the gate-enforced decisions
221
+ * should filter `.filter(d => d.trackable)`.
222
+ */
223
+ function parseDecisions(content) {
224
+ return extractDecisions(content).decisions;
136
225
  }
@@ -31,6 +31,7 @@ const { escapeRegex } = phaseId;
31
31
  const planningWorkspace = require("./planning-workspace.cjs");
32
32
  const { planningPaths, planningDir, findContextMdIn } = planningWorkspace;
33
33
  const decisions_cjs_1 = require("./decisions.cjs");
34
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
34
35
  /**
35
36
  * Parse REQ-IDs from REQUIREMENTS.md content.
36
37
  *
@@ -44,16 +45,27 @@ function parseRequirements(reqMd) {
44
45
  const seen = new Set();
45
46
  // Prefix-agnostic ID format: REQ-01, TST-01, BACK-07, INSP-04, etc.
46
47
  const ID_PATTERN = '[A-Z][A-Z0-9]*-[A-Za-z0-9_-]+';
47
- const checkboxRe = new RegExp(`^\\s*-\\s*\\[[x ]\\]\\s*\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`, 'gm');
48
- let cm = checkboxRe.exec(reqMd);
49
- while (cm !== null) {
50
- const id = cm[1];
48
+ const idRe = new RegExp(`^(${ID_PATTERN})$`);
49
+ // Checkbox-bullet path: migrate to seam's iterateBullets (checkbox markers).
50
+ // The **ID** is extracted from the bullet text caller-side — the seam provides
51
+ // the raw text; we parse the bold-ID prefix from it here.
52
+ const boldIdRe = new RegExp(`^\\*\\*(${ID_PATTERN})\\*\\*\\s*(.*)$`);
53
+ for (const bullet of (0, markdown_sectionizer_cjs_1.iterateBullets)(reqMd)) {
54
+ if (bullet.marker !== 'checkbox-unchecked' && bullet.marker !== 'checkbox-checked')
55
+ continue;
56
+ const m = boldIdRe.exec(bullet.text);
57
+ if (!m)
58
+ continue;
59
+ const id = m[1];
60
+ if (!idRe.test(id))
61
+ continue;
51
62
  if (!seen.has(id)) {
52
63
  seen.add(id);
53
- out.push({ id, text: (cm[2] || '').trim() });
64
+ out.push({ id, text: (m[2] || '').trim() });
54
65
  }
55
- cm = checkboxRe.exec(reqMd);
56
66
  }
67
+ // Pipe-table-row path and separator-row skip stay caller-side
68
+ // (table parsing is out of seam scope per ADR-1372 T3 spec).
57
69
  const tableFirstCellRe = new RegExp(`^\\s*\\|\\s*(${ID_PATTERN})\\s*\\|`);
58
70
  const separatorRowRe = /^\s*\|[\s:|-]+\|\s*$/;
59
71
  const lines = reqMd.split(/\r?\n/);
@@ -128,13 +140,77 @@ function readGate(cwd) {
128
140
  catch { /* fall through */ }
129
141
  return true;
130
142
  }
143
+ /**
144
+ * Same-prefix ascending numeric range, e.g. `SEL-01..SEL-03`. Both sides must
145
+ * share an identical prefix and a numeric suffix. Captures are:
146
+ * 1 low prefix, 2 low digits, 3 high prefix (compared to group 1 for equality), 4 high digits.
147
+ */
148
+ const PHASE_REQ_RANGE_RE = /^(.+-)(\d+)\.\.(.+-)(\d+)$/;
149
+ /**
150
+ * Maximum number of IDs a single range token may expand to. A range whose span
151
+ * exceeds this cap stays literal (fail-closed) rather than expanding, guarding
152
+ * against pathological input like `X-1..X-100000` ballooning the comparison set.
153
+ */
154
+ const MAX_PHASE_REQ_RANGE = 1000;
155
+ /**
156
+ * Expand a single `--phase-req-ids` token in place. If it is a valid ascending
157
+ * same-prefix numeric range (`<PREFIX>-NN..<PREFIX>-MM`, identical prefix both
158
+ * sides, numeric NN ≤ MM), return the individual IDs `<PREFIX>-NN … <PREFIX>-MM`
159
+ * preserving the bounds' zero-pad width. Anything that does NOT cleanly match a
160
+ * valid range stays literal (fail-closed) — returned as a single-element array.
161
+ *
162
+ * The two numeric bounds must share the same digit width; a range with
163
+ * differing widths (e.g. `SEL-9..SEL-11`) is ambiguous (padding to the wider
164
+ * width could invent IDs like `SEL-09` that never appear unpadded in
165
+ * REQUIREMENTS) and is left literal. A range spanning more than
166
+ * MAX_PHASE_REQ_RANGE IDs also stays literal.
167
+ */
168
+ function expandPhaseReqIdToken(token) {
169
+ const m = PHASE_REQ_RANGE_RE.exec(token);
170
+ if (!m)
171
+ return [token];
172
+ const [, prefixLow, lowDigits, prefixHigh, highDigits] = m;
173
+ // Fail closed unless the prefixes are identical.
174
+ if (prefixLow !== prefixHigh)
175
+ return [token];
176
+ // Fail closed unless the bounds share an identical digit width. Differing
177
+ // widths are ambiguous: padding to the wider width could invent IDs that
178
+ // never appear unpadded in REQUIREMENTS.
179
+ if (lowDigits.length !== highDigits.length)
180
+ return [token];
181
+ const low = Number(lowDigits);
182
+ const high = Number(highDigits);
183
+ // Fail closed on descending ranges (NN > MM). NN == MM is a valid single-element range.
184
+ if (!Number.isFinite(low) || !Number.isFinite(high) || low > high)
185
+ return [token];
186
+ // Fail closed (DoS guard) on ranges spanning more than the cap.
187
+ if (high - low + 1 > MAX_PHASE_REQ_RANGE)
188
+ return [token];
189
+ // Preserve the bounds' (shared) zero-pad width.
190
+ const width = lowDigits.length;
191
+ const out = [];
192
+ for (let n = low; n <= high; n++) {
193
+ out.push(`${prefixLow}${String(n).padStart(width, '0')}`);
194
+ }
195
+ return out;
196
+ }
131
197
  /**
132
198
  * Normalize a raw `--phase-req-ids` argument into the scoping signal used by
133
199
  * runGapAnalysis (#447). Mirrors §13's null/TBD skip semantics.
134
200
  *
135
- * undefined → flag absent: compare the whole REQUIREMENTS.md (back-compat)
136
- * null | '' | TBD → no requirements mapped to this phase: skip the comparison
137
- * "REQ-01,REQ-02" → restrict the comparison to these IDs
201
+ * undefined → flag absent: compare the whole REQUIREMENTS.md (back-compat)
202
+ * null | '' | TBD → no requirements mapped to this phase: skip the comparison
203
+ * "REQ-01,REQ-02" → restrict the comparison to these IDs
204
+ * "SEL-01..SEL-03" → range form: expands in place to SEL-01, SEL-02, SEL-03 (#1269)
205
+ *
206
+ * Range form (#1269): a list element of the shape `<PREFIX>-NN..<PREFIX>-MM`
207
+ * (identical prefix both sides, identical bound digit width, ascending numeric
208
+ * NN ≤ MM) is expanded in place to the individual IDs, preserving the bounds'
209
+ * zero-pad width; mixed lists expand in input order. Any element that does not
210
+ * cleanly match a valid ascending same-prefix numeric range (mismatched
211
+ * prefix, differing bound width, descending, non-numeric, missing bound, or
212
+ * spanning more than MAX_PHASE_REQ_RANGE IDs) stays literal — no partial
213
+ * expansion, no guessing.
138
214
  *
139
215
  * Tolerates JSON-array-ish input (`["REQ-01","REQ-02"]`) since callers may pass
140
216
  * the roadmap value through verbatim.
@@ -151,7 +227,9 @@ function normalizePhaseReqIds(rawVal) {
151
227
  // Tolerate comma-, space-, or newline-separated lists (callers may pass the
152
228
  // roadmap value verbatim, whose serialization is not guaranteed).
153
229
  const ids = v.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
154
- return ids.length === 0 ? null : ids;
230
+ // Expand range tokens (#1269) per-token AFTER the split, preserving input order.
231
+ const expanded = ids.flatMap(expandPhaseReqIdToken);
232
+ return expanded.length === 0 ? null : expanded;
155
233
  }
156
234
  function runGapAnalysis(cwd, phaseDir, options = {}) {
157
235
  const phaseReqIds = normalizePhaseReqIds(options.phaseReqIds);
@@ -193,7 +271,9 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
193
271
  const ctxFile = findContextMdIn(phaseDirFiles);
194
272
  const ctxPath = ctxFile ? node_path_1.default.join(absPhaseDir, ctxFile) : null;
195
273
  const ctxMd = ctxPath ? node_fs_1.default.readFileSync(ctxPath, 'utf-8') : '';
196
- const dItems = (0, decisions_cjs_1.parseDecisions)(ctxMd).map(d => ({ ...d, source: 'CONTEXT.md' }));
274
+ // Use extractDecisions so gap-checker can distinguish could-not-parse from none-present.
275
+ const ctxExtraction = (0, decisions_cjs_1.extractDecisions)(ctxMd);
276
+ const dItems = ctxExtraction.decisions.map(d => ({ ...d, source: 'CONTEXT.md' }));
197
277
  const items = [...reqItems, ...dItems];
198
278
  let planText = '';
199
279
  try {
@@ -210,6 +290,41 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
210
290
  }
211
291
  }
212
292
  catch { /* unreadable */ }
293
+ // FIX D (#1365): surface decision could-not-parse independently of whether
294
+ // requirements items exist. Without this, a could-not-parse on decisions is
295
+ // silently masked whenever REQUIREMENTS.md has ≥1 item — the mismatch must
296
+ // appear in the report regardless of the requirements row count.
297
+ if (ctxExtraction.outcome === 'could-not-parse') {
298
+ const mismatchMsg = '## Post-Planning Gap Analysis\n\nextracted 0 of N — possible format mismatch in CONTEXT.md decisions block.\n';
299
+ // If there are also requirement items, include them in the return with the
300
+ // mismatch summary appended, so the caller still sees requirement coverage.
301
+ if (items.length > 0) {
302
+ const rows = sortRows([
303
+ ...detectCoverage(items, planText),
304
+ ...ghostReqIds.map(id => ({ source: 'REQUIREMENTS.md', item: id, status: 'Missing from REQUIREMENTS.md' })),
305
+ ]);
306
+ const covered = rows.filter(r => r.status === 'Covered').length;
307
+ const uncovered = rows.length - covered;
308
+ const coverageSummary = uncovered === 0
309
+ ? `✓ All ${rows.length} items covered by plans`
310
+ : `⚠ ${uncovered} of ${rows.length} items not covered by any plan`;
311
+ return {
312
+ enabled: true,
313
+ rows,
314
+ table: formatGapTable(rows) + '\n' + coverageSummary + '\n\n' + mismatchMsg,
315
+ summary: coverageSummary + '; extracted 0 of N — possible format mismatch',
316
+ counts: { total: rows.length, covered, uncovered },
317
+ };
318
+ }
319
+ return {
320
+ enabled: true,
321
+ rows: [],
322
+ table: mismatchMsg,
323
+ summary: 'extracted 0 of N — possible format mismatch',
324
+ counts: { total: 0, covered: 0, uncovered: 0 },
325
+ };
326
+ }
327
+ // #1365: if no items at all, surface a clean no-check message.
213
328
  if (items.length === 0) {
214
329
  return {
215
330
  enabled: true,