@opengsd/gsd-core 1.7.0-rc.6 → 1.8.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 (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -21,6 +21,9 @@ const { output, error } = io;
21
21
  const markdownSectionizer = require("./markdown-sectionizer.cjs");
22
22
  const { collectSection, tokenizeHeadings } = markdownSectionizer;
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
+ const markdownTable = require("./markdown-table.cjs");
25
+ const { splitTableRow } = markdownTable;
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
24
27
  const roadmapParser = require("./roadmap-parser.cjs");
25
28
  const { getMilestonePhaseFilter } = roadmapParser;
26
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -36,6 +39,9 @@ const { extractFrontmatter } = frontmatter;
36
39
  const phaseIdMod = require("./phase-id.cjs");
37
40
  const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
38
41
  const security_cjs_1 = require("./security.cjs");
42
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
43
+ const configLoader = require("./config-loader.cjs");
44
+ const { loadConfig } = configLoader;
39
45
  // ─── cmdAuditUat ─────────────────────────────────────────────────────────────
40
46
  function cmdAuditUat(cwd, raw) {
41
47
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
@@ -90,6 +96,29 @@ function cmdAuditUat(cwd, raw) {
90
96
  }
91
97
  }
92
98
  }
99
+ // Process deferred-items.md (#2287) — the SCOPE BOUNDARY convention
100
+ // (agents/gsd-executor.md) has the executor log out-of-scope discoveries
101
+ // to this file; nothing previously read it back. Surface every
102
+ // UNRESOLVED entry (see parseDeferredItems for the resolved/unresolved
103
+ // parsing rule) as a 'deferred'-typed result, keeping deferred-items.md
104
+ // itself the single source of truth — no duplicate pending-todo entry
105
+ // required.
106
+ const deferredFile = 'deferred-items.md';
107
+ if (files.includes(deferredFile)) {
108
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, deferredFile), 'utf-8');
109
+ const items = parseDeferredItems(content);
110
+ if (items.length > 0) {
111
+ results.push({
112
+ phase: phaseNum,
113
+ phase_dir: dir,
114
+ file: deferredFile,
115
+ file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, deferredFile))),
116
+ type: 'deferred',
117
+ status: 'unresolved',
118
+ items,
119
+ });
120
+ }
121
+ }
93
122
  }
94
123
  // Compute summary
95
124
  const summary = {
@@ -124,7 +153,9 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
124
153
  if (currentTest.complete) {
125
154
  error('UAT session is already complete; no pending checkpoint to render');
126
155
  }
127
- const checkpoint = buildCheckpoint(currentTest);
156
+ const config = loadConfig(cwd);
157
+ const responseLanguage = typeof config.response_language === 'string' ? config.response_language : undefined;
158
+ const checkpoint = buildCheckpoint(currentTest, responseLanguage);
128
159
  output({
129
160
  file_path: toPosixPath(node_path_1.default.relative(cwd, resolvedPath)),
130
161
  test_number: currentTest.number,
@@ -237,11 +268,111 @@ function parseExpectedFromTestBlock(block) {
237
268
  const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m);
238
269
  return expectedInlineMatch ? expectedInlineMatch[1].trim() : null;
239
270
  }
240
- // ─── buildCheckpoint ──────────────────────────────────────────────────────────
241
- function buildCheckpoint(currentTest) {
271
+ const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical
272
+ const CHECKPOINT_FRAMES = {
273
+ english: {
274
+ banner: 'CHECKPOINT: Verification Required',
275
+ instruction: 'Type `pass` or describe what\'s wrong.',
276
+ },
277
+ spanish: {
278
+ banner: 'PUNTO DE CONTROL: Verificación requerida',
279
+ instruction: 'Escribe `pass` o describe qué está mal.',
280
+ },
281
+ french: {
282
+ banner: 'POINT DE CONTRÔLE : Vérification requise',
283
+ instruction: 'Tapez `pass` ou décrivez ce qui ne va pas.',
284
+ },
285
+ german: {
286
+ banner: 'KONTROLLPUNKT: Überprüfung erforderlich',
287
+ instruction: 'Gib `pass` ein oder beschreibe, was nicht stimmt.',
288
+ },
289
+ portuguese: {
290
+ banner: 'PONTO DE VERIFICAÇÃO: Verificação necessária',
291
+ instruction: 'Digite `pass` ou descreva o que está errado.',
292
+ },
293
+ japanese: {
294
+ banner: 'チェックポイント: 検証が必要です',
295
+ instruction: '`pass` と入力するか、問題点を説明してください。',
296
+ },
297
+ chinese: {
298
+ banner: '检查点:需要验证',
299
+ instruction: '输入 `pass` 或描述问题所在。',
300
+ },
301
+ korean: {
302
+ banner: '체크포인트: 검증 필요',
303
+ instruction: '`pass`를 입력하거나 문제를 설명하세요.',
304
+ },
305
+ italian: {
306
+ banner: 'PUNTO DI CONTROLLO: Verifica richiesta',
307
+ instruction: 'Digita `pass` o descrivi cosa non va.',
308
+ },
309
+ };
310
+ // Free-form response_language aliases → canonical CHECKPOINT_FRAMES key.
311
+ const CHECKPOINT_LANGUAGE_ALIASES = {
312
+ english: 'english', en: 'english', 'en-us': 'english', 'en-gb': 'english',
313
+ spanish: 'spanish', es: 'spanish', 'español': 'spanish', espanol: 'spanish', castellano: 'spanish',
314
+ french: 'french', fr: 'french', 'français': 'french', francais: 'french',
315
+ german: 'german', de: 'german', deutsch: 'german',
316
+ portuguese: 'portuguese', pt: 'portuguese', 'pt-br': 'portuguese', 'português': 'portuguese', portugues: 'portuguese', 'brazilian portuguese': 'portuguese',
317
+ japanese: 'japanese', ja: 'japanese', '日本語': 'japanese',
318
+ chinese: 'chinese', zh: 'chinese', 'zh-cn': 'chinese', 'zh-tw': 'chinese', mandarin: 'chinese', 'simplified chinese': 'chinese', 'traditional chinese': 'chinese', '中文': 'chinese',
319
+ korean: 'korean', ko: 'korean', '한국어': 'korean',
320
+ italian: 'italian', it: 'italian', italiano: 'italian',
321
+ };
322
+ function resolveCheckpointFrame(responseLanguage) {
323
+ if (!responseLanguage)
324
+ return CHECKPOINT_FRAMES.english;
325
+ const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().toLowerCase()];
326
+ return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
327
+ }
328
+ // Approximate East Asian Width ranges (Unicode property values W and F) — the
329
+ // CJK scripts CHECKPOINT_FRAMES ships (Japanese/Chinese/Korean) render each
330
+ // matching code point at 2 terminal/display columns, not 1. Padding computed
331
+ // from `.length` (UTF-16 code units) undercounts these by one column per
332
+ // wide character, visually misaligning the box's right border (#2402 review
333
+ // medium finding). Latin-script frames (English/Spanish/French/German/
334
+ // Portuguese/Italian) contain no wide code points, so displayWidth === length
335
+ // for them — no behavior change there.
336
+ function isWideCodePoint(codePoint) {
337
+ return ((codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
338
+ codePoint === 0x2329 || codePoint === 0x232a ||
339
+ (codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals .. CJK Symbols and Punctuation
340
+ (codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana .. CJK Compatibility
341
+ (codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A
342
+ (codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
343
+ (codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi Syllables
344
+ (codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
345
+ (codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
346
+ (codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms
347
+ (codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms
348
+ (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
349
+ (codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary
350
+ );
351
+ }
352
+ // Iterates by Unicode code point (not UTF-16 code unit) so astral characters
353
+ // are measured once, not as two surrogate units.
354
+ function displayWidth(text) {
355
+ let width = 0;
356
+ for (const ch of text) {
357
+ width += isWideCodePoint(ch.codePointAt(0)) ? 2 : 1;
358
+ }
359
+ return width;
360
+ }
361
+ // Pads `text` into a `║ text… ║` line matching CHECKPOINT_BOX_WIDTH. Content
362
+ // that overflows the box (a longer translated string) is left unpadded rather
363
+ // than truncated — a slightly ragged border beats losing text.
364
+ function checkpointBoxLine(text) {
365
+ const innerWidth = CHECKPOINT_BOX_WIDTH - 2;
366
+ const content = ` ${text}`;
367
+ const padLength = innerWidth - displayWidth(content);
368
+ const padded = padLength > 0 ? content + ' '.repeat(padLength) : content;
369
+ return `║${padded}║`;
370
+ }
371
+ function buildCheckpoint(currentTest, responseLanguage) {
372
+ const frame = resolveCheckpointFrame(responseLanguage);
242
373
  return [
243
374
  '╔══════════════════════════════════════════════════════════════╗',
244
- '║ CHECKPOINT: Verification Required ║',
375
+ checkpointBoxLine(frame.banner),
245
376
  '╚══════════════════════════════════════════════════════════════╝',
246
377
  '',
247
378
  `**Test ${currentTest.number}: ${currentTest.name}**`,
@@ -249,7 +380,7 @@ function buildCheckpoint(currentTest) {
249
380
  currentTest.expected,
250
381
  '',
251
382
  '──────────────────────────────────────────────────────────────',
252
- 'Type `pass` or describe what\'s wrong.',
383
+ frame.instruction,
253
384
  '──────────────────────────────────────────────────────────────',
254
385
  ].join('\n');
255
386
  }
@@ -283,33 +414,302 @@ function parseUatItems(content) {
283
414
  items.push(item);
284
415
  }
285
416
  }
417
+ items.push(...parseGapsItems(content));
286
418
  return items;
287
419
  }
420
+ // ─── parseGapsItems ───────────────────────────────────────────────────────────
421
+ /**
422
+ * Extract unresolved entries from a UAT file's `## Gaps` section (#2286).
423
+ *
424
+ * `## Gaps` records open findings as a YAML-lite bullet list (see
425
+ * `templates/UAT.md`'s `## Gaps` block: `- truth: "..."` followed by indented
426
+ * continuation lines `status:` / `reason:` / `severity:` / `test:` / etc.,
427
+ * and — for `artifacts:` / `missing:` — a further-nested `- ` sub-list).
428
+ * `parseUatItems`'s `### N.` test-block regex never looks at this section at
429
+ * all, so a UAT file whose only outstanding findings live in `## Gaps` was
430
+ * silently invisible — the false-negative this fix addresses.
431
+ *
432
+ * Reuses the existing `collectSection` seam (already used elsewhere in this
433
+ * file for `## Current Test` / `## Tests`) to locate the section. Field
434
+ * extraction is deliberately NOT done via `iterateBullets`: that seam folds
435
+ * every continuation line onto ONE space-joined `text` string per bullet,
436
+ * which erases line boundaries — a `key:` scan against that flattened text
437
+ * matches the FIRST `key:`-shaped substring anywhere, including one that
438
+ * happens to appear inside an EARLIER field's own quoted free-text value
439
+ * (e.g. `truth: "The status: resolved workflow should trigger"` — a real
440
+ * `status: failed` on the next line would never be reached, silently
441
+ * DROPPING a genuinely open gap — the exact false-negative class #2286
442
+ * exists to fix, so the fix must not reintroduce it). `splitGapsEntries` /
443
+ * `extractGapEntryFields` below instead walk the section PER LINE and only
444
+ * recognise a field at the START of its own (trimmed) line, so a `key:`
445
+ * embedded inside another field's quoted value can never be mistaken for a
446
+ * field declaration.
447
+ *
448
+ * Every entry whose `status` is present and NOT `resolved` (case-insensitive)
449
+ * is surfaced — mirroring the "ignore passing/resolved" convention already
450
+ * used for `### N.` test blocks (`result: pass` is never surfaced) and the
451
+ * VERIFICATION table-row PASS/resolved skip (`hasPassResult`, below). An
452
+ * entry with NO parseable `status:` field is surfaced too, as `result:
453
+ * 'unknown'` — #2286 is a false-NEGATIVE bug, and a `## Gaps` entry only
454
+ * exists to record an outstanding finding (a template-conformant RESOLVED
455
+ * entry always carries an explicit `status: resolved`); a garbled or
456
+ * non-conformant entry is far more likely to be an unresolved finding whose
457
+ * `status:` line failed to parse than a genuinely resolved one, so the
458
+ * fail-safe direction is to surface it rather than silently drop it.
459
+ */
460
+ function parseGapsItems(content) {
461
+ const gapsSection = collectSection(content, (h) => /^gaps$/i.test(h.text) && h.level === 2, { levelBounded: true });
462
+ if (!gapsSection)
463
+ return [];
464
+ const items = [];
465
+ for (const entryLines of splitGapsEntries(gapsSection.body)) {
466
+ const fields = extractGapEntryFields(entryLines);
467
+ const rawStatus = fields.status;
468
+ if (rawStatus && rawStatus.toLowerCase() === 'resolved')
469
+ continue;
470
+ // Fail-safe: missing/garbled status surfaces as 'unknown' rather than
471
+ // being dropped (see doc comment above).
472
+ const status = rawStatus || 'unknown';
473
+ const truth = fields.truth;
474
+ const reason = fields.reason;
475
+ const testNum = fields.test;
476
+ const item = {
477
+ name: truth || rawGapEntryText(entryLines),
478
+ result: status,
479
+ category: categorizeItem(status, reason, undefined),
480
+ };
481
+ if (testNum && /^\d+$/.test(testNum))
482
+ item.test = parseInt(testNum, 10);
483
+ if (reason)
484
+ item.reason = reason;
485
+ items.push(item);
486
+ }
487
+ return items;
488
+ }
489
+ // ─── parseDeferredItems ────────────────────────────────────────────────────────
490
+ /**
491
+ * Extract unresolved entries from a phase directory's `deferred-items.md`
492
+ * (#2287) — the SCOPE BOUNDARY convention `agents/gsd-executor.md` instructs
493
+ * the executor to follow: "Log out-of-scope discoveries to `deferred-items.md`
494
+ * in the phase directory". Nothing previously read this file back, so a
495
+ * deferred entry was permanently invisible outside the phase directory.
496
+ *
497
+ * The writer convention (unchanged by this fix, per the issue's stated
498
+ * out-of-scope) emits a plain bullet list, typically under a `## Deferred
499
+ * Items` heading (see the issue's own reproduction fixture), one entry per
500
+ * top-level `- ` line with optional indented continuation lines. There is no
501
+ * mandated heading text, so if no `## Deferred Items`-shaped level-2 heading
502
+ * is found, the WHOLE file is scanned as the entry list — fail-safe, so an
503
+ * agent writing a differently-headed (or headless) deferred-items.md still
504
+ * has its entries surfaced rather than silently skipped.
505
+ *
506
+ * Reuses the same per-line field/entry-splitting seams as `parseGapsItems`
507
+ * (`splitGapsEntries`, `extractGapEntryFields`, `rawGapEntryText`) — an entry
508
+ * is RESOLVED only when it carries an explicit `status: resolved` field
509
+ * (case-insensitive), mirroring the established Gaps convention so a human or
510
+ * follow-up agent can mark a deferred item done in place, keeping
511
+ * `deferred-items.md` the single source of truth (no duplicate
512
+ * `.planning/todos/pending/*.md` entry required). Every other entry —
513
+ * including one with no `status:` field at all — is UNRESOLVED and is
514
+ * surfaced.
515
+ */
516
+ function parseDeferredItems(content) {
517
+ const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
518
+ const sectionBody = deferredSection ? deferredSection.body : content;
519
+ const items = [];
520
+ for (const entryLines of splitGapsEntries(sectionBody)) {
521
+ const fields = extractGapEntryFields(entryLines);
522
+ const rawStatus = fields.status;
523
+ if (rawStatus && rawStatus.toLowerCase() === 'resolved')
524
+ continue;
525
+ const text = rawGapEntryText(entryLines);
526
+ if (!text)
527
+ continue;
528
+ items.push({
529
+ name: text,
530
+ result: 'unresolved',
531
+ category: 'deferred',
532
+ });
533
+ }
534
+ return items;
535
+ }
536
+ /**
537
+ * Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
538
+ * `- ` bullet openers.
539
+ *
540
+ * The indentation of the FIRST bullet line encountered establishes the
541
+ * "top-level" indent for the whole section; any subsequent `- `-opening line
542
+ * at that same indent (or shallower) starts a NEW entry, while everything
543
+ * more deeply indented — field continuation lines (` status: ...`) AND
544
+ * nested sub-lists (` - src/foo.ts` under ` artifacts:`) — is folded into
545
+ * the CURRENT entry. This keeps a `artifacts:`/`missing:` sub-list's `- `
546
+ * items from being mis-split into spurious standalone entries (#2286 review
547
+ * LOW finding).
548
+ *
549
+ * Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
550
+ * the template emits) are discarded. An empty/whitespace-only section body
551
+ * (heading present, no bullets) returns `[]`.
552
+ */
553
+ function splitGapsEntries(sectionBody) {
554
+ const lines = sectionBody.split('\n');
555
+ const entries = [];
556
+ let current = null;
557
+ let baseIndent = null;
558
+ for (const rawLine of lines) {
559
+ const line = rawLine.replace(/\r$/, '');
560
+ const bulletMatch = line.match(/^(\s*)-\s/);
561
+ if (bulletMatch) {
562
+ const indent = bulletMatch[1].length;
563
+ if (baseIndent === null)
564
+ baseIndent = indent;
565
+ if (indent <= baseIndent) {
566
+ if (current)
567
+ entries.push(current);
568
+ current = [line];
569
+ continue;
570
+ }
571
+ }
572
+ if (current)
573
+ current.push(line);
574
+ // else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
575
+ }
576
+ if (current)
577
+ entries.push(current);
578
+ return entries;
579
+ }
580
+ /**
581
+ * Extract `key: value` fields from one Gaps entry's lines, anchored to the
582
+ * START of each (bullet-marker-stripped, trimmed) line — never scanning the
583
+ * REST of a line, so a colon-bearing phrase inside a quoted `truth`/`reason`
584
+ * value is never misread as a field declaration (see `parseGapsItems`'s doc
585
+ * comment for the false-negative this specifically guards against).
586
+ *
587
+ * Recognises a double-quoted value (`truth: "..."`, stripped of its wrapping
588
+ * quotes — the value may itself contain any character, including `:`) or a
589
+ * bare value (`status: open`, `test: 2`, `artifacts: []`) taken verbatim.
590
+ * The FIRST occurrence of a given key wins (top-level fields always precede
591
+ * any nested sub-list content in the template's field ordering); later
592
+ * `key:`-shaped nested-list content is captured, if it parses as one, but
593
+ * never overrides an already-seen top-level field.
594
+ */
595
+ function extractGapEntryFields(entryLines) {
596
+ const fields = {};
597
+ const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
598
+ entryLines.forEach((rawLine, idx) => {
599
+ const line = rawLine.replace(/\r$/, '');
600
+ // Strip ONLY the entry-opening bullet marker (idx 0); a bullet marker on
601
+ // a later line belongs to a nested sub-list and is handled by
602
+ // `splitGapsEntries` already folding it in — it is not itself a field
603
+ // line unless it independently matches `key: value` after stripping.
604
+ const bulletStripped = line.match(/^(\s*)-\s+(.*)$/);
605
+ const content = idx === 0 && bulletStripped ? bulletStripped[2] : line.trim();
606
+ const m = fieldLineRe.exec(content);
607
+ if (!m)
608
+ return;
609
+ const key = m[1];
610
+ let value = m[2].trim();
611
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
612
+ value = value.slice(1, -1);
613
+ }
614
+ if (!(key in fields))
615
+ fields[key] = value;
616
+ });
617
+ return fields;
618
+ }
619
+ /** Fallback display text for a Gaps entry with no parseable `truth:` field. */
620
+ function rawGapEntryText(entryLines) {
621
+ return entryLines
622
+ .map((l, i) => (i === 0 ? l.replace(/^(\s*)-\s+/, '') : l.trim()))
623
+ .join(' ')
624
+ .trim();
625
+ }
288
626
  // ─── parseVerificationItems ───────────────────────────────────────────────────
289
627
  function parseVerificationItems(content, status) {
290
628
  const items = [];
291
629
  if (status === 'human_needed') {
630
+ // #2286: the frontmatter's structured `human_verification:` YAML array
631
+ // (extractFrontmatter) is the PRIMARY source of truth when present and
632
+ // non-empty — it fully bypasses the body-shape scan below, so a file
633
+ // whose frontmatter declares the array doesn't require any particular
634
+ // `## Human Verification` body shape at all. An absent or empty array
635
+ // (length 0) falls back to the body scan unchanged.
636
+ const frontmatter = extractFrontmatter(content);
637
+ const humanVerification = frontmatter.human_verification;
638
+ if (Array.isArray(humanVerification) && humanVerification.length > 0) {
639
+ humanVerification.forEach((entry, idx) => {
640
+ items.push({
641
+ test: idx + 1,
642
+ name: normalizeHumanVerificationEntry(entry),
643
+ result: 'human_needed',
644
+ category: 'human_uat',
645
+ });
646
+ });
647
+ return items;
648
+ }
292
649
  // Use the seam to locate the ## Human Verification section (ADR-1372 T5).
293
650
  const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
294
651
  if (hvSection) {
652
+ // #2245 review Fix 3: reverted to the pre-Phase-4 (HEAD 2cbf18642)
653
+ // implementation. The live Human Verification section is NOT a strict
654
+ // GFM table — the planner/verifier templates mix table rows, numbered
655
+ // items, and bullet items in the same section (and a `### N.` heading
656
+ // format is common too), so a table-XOR-list read (parse a table, and
657
+ // if it parses, suppress numbered/bullet items entirely) silently
658
+ // dropped items on any mixed or malformed section: a malformed
659
+ // `| N | … |` table with no valid header/delimiter yielded ZERO items
660
+ // instead of reading the rows positionally. This per-line scan reads
661
+ // table rows AND numbered items AND bullet items as a UNION (whichever
662
+ // pattern a given line matches), exactly like OLD, and reads
663
+ // `| N | desc |` rows even without a valid table header/delimiter.
664
+ //
665
+ // #2245 audit: the table-row branch's CELL SPLIT is name/position-
666
+ // addressed via `splitTableRow` (escape-aware, canonical) instead of a
667
+ // hand-rolled pipe regex — candidacy itself is decided WITHOUT a table
668
+ // regex (a leading `|` plus a purely-numeric first cell), so this no
669
+ // longer needs an allow-adhoc-markdown suppression at all.
295
670
  const lines = hvSection.body.split('\n');
296
671
  for (const line of lines) {
297
- // Match table rows: | N | description | ... |
298
- const tableMatch = line.match(/\|\s*(\d+)\s*\|\s*([^|]+)/);
672
+ const trimmedLine = line.trim();
673
+ // Match table rows: | N | description | ... — candidacy requires a
674
+ // leading pipe and a purely-numeric first cell (mirrors what the old
675
+ // regex effectively required: a "|digit|" cell immediately followed
676
+ // by more content), with at least 2 physical cells so a bare "| N |"
677
+ // with nothing after it is NOT treated as a row.
678
+ //
679
+ // #2245 review Fix 9: this is NOT the same as OLD for a row whose
680
+ // ONLY content past the digit cell is trailing whitespace (e.g.
681
+ // "| N | ", no second delimiting `|`). OLD's `([^|]+)` regex ran
682
+ // against the RAW (untrimmed) line and its `\s*` would backtrack to
683
+ // let `[^|]+` swallow that trailing whitespace, so OLD matched and
684
+ // pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
685
+ // `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
686
+ // `splitTableRow` ever sees it, collapsing the line to a single cell
687
+ // (`candidateCells.length === 1`), which fails the `>= 2` check —
688
+ // the item is silently dropped instead. A real, acceptable behaviour
689
+ // change (an empty-named UAT item is not useful either way), but the
690
+ // two implementations are NOT equivalent on this input.
691
+ let tableCells = null;
692
+ if (trimmedLine.startsWith('|')) {
693
+ const candidateCells = splitTableRow(trimmedLine);
694
+ if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
695
+ tableCells = candidateCells;
696
+ }
697
+ }
299
698
  // Match bullet items: - description
300
699
  const bulletMatch = line.match(/^[-*]\s+(.+)/);
301
700
  // Match numbered items: 1. description
302
701
  const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
303
- if (tableMatch) {
702
+ if (tableCells) {
304
703
  // Skip rows that already have a passing result (PASS, pass, resolved, etc.)
305
- const rowRemainder = line.slice(tableMatch.index + tableMatch[0].length);
306
- const cellValues = rowRemainder.split('|').map(c => c.trim());
307
- const hasPassResult = cellValues.some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
704
+ // — checked over every cell AFTER the description column, mirroring
705
+ // OLD's rowRemainder scan (which only ever saw cells past the
706
+ // description, the description itself having already been consumed).
707
+ const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
308
708
  if (hasPassResult)
309
709
  continue;
310
710
  items.push({
311
- test: parseInt(tableMatch[1], 10),
312
- name: tableMatch[2].trim(),
711
+ test: parseInt(tableCells[0], 10),
712
+ name: tableCells[1] ?? '',
313
713
  result: 'human_needed',
314
714
  category: 'human_uat',
315
715
  });
@@ -330,11 +730,71 @@ function parseVerificationItems(content, status) {
330
730
  });
331
731
  }
332
732
  }
733
+ // #2286: fall back to the `### N. <label>` heading + bold-led paragraph
734
+ // shape (the canonical form emitted by `templates/verification-report.md`
735
+ // — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
736
+ // **Why human:** ...`), which the table/bullet/numbered per-line scan
737
+ // above never recognises (a `###`-prefixed line matches none of those
738
+ // three patterns). Uses the same `tokenizeHeadings` seam
739
+ // `parseFirstPendingTest` already uses for `### N.` sub-headings,
740
+ // applied here to the Human Verification section body. Runs in
741
+ // addition to (a union with) the scan above — the two shapes don't
742
+ // collide, so this only adds items a `###` heading page would have
743
+ // silently produced zero for.
744
+ const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
745
+ for (let i = 0; i < hvSubHeadings.length; i += 1) {
746
+ const current = hvSubHeadings[i];
747
+ const next = hvSubHeadings[i + 1];
748
+ const block = next
749
+ ? hvSection.body.slice(current.offset, next.offset)
750
+ : hvSection.body.slice(current.offset);
751
+ const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
752
+ // Require a bold-led paragraph body (`**Test:** ...`) to distinguish
753
+ // a genuine verification item from an unrelated numbered heading.
754
+ if (!/^\s*\*\*/.test(bodyAfterHeading))
755
+ continue;
756
+ const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
757
+ if (!headingParts)
758
+ continue;
759
+ items.push({
760
+ test: parseInt(headingParts[1], 10),
761
+ name: headingParts[2].trim(),
762
+ result: 'human_needed',
763
+ category: 'human_uat',
764
+ });
765
+ }
333
766
  }
334
767
  }
335
768
  // gaps_found items are already handled by plan-phase --gaps pipeline
336
769
  return items;
337
770
  }
771
+ /**
772
+ * Normalize a single `human_verification:` frontmatter array entry (#2286)
773
+ * into a display-ready name.
774
+ *
775
+ * #2286 review (LOW finding): `extractFrontmatter`'s generic array-item
776
+ * parser (`src/frontmatter.cts`, the `line.trim().startsWith('- ')` branch)
777
+ * has NO notion of nested key/value objects — regardless of whether the
778
+ * source YAML was authored as `- test: "..."` (an implied-but-unsupported
779
+ * shorthand) or `- "plain string"`, it ALWAYS pushes the raw post-`- ` text
780
+ * (with only a single layer of wrapping quotes stripped) as a plain string.
781
+ * There is therefore no reliable signal here to distinguish a genuine
782
+ * `key: value`-shaped pseudo-field from a legitimate plain string that
783
+ * itself happens to start with a word and a colon (e.g. `"Confirm: the
784
+ * button responds"`). A prior version of this function stripped a leading
785
+ * `word:` prefix on the assumption it was always a flattened nested-object
786
+ * key — that assumption is false, and it silently truncated real plain-string
787
+ * content. No such stripping is applied: any residual wrapping-quote noise
788
+ * left by `extractFrontmatter`'s own (anchor-only) quote handling is cleaned
789
+ * up, and everything else is preserved verbatim.
790
+ */
791
+ function normalizeHumanVerificationEntry(raw) {
792
+ if (typeof raw !== 'string') {
793
+ return raw === null || raw === undefined ? '' : JSON.stringify(raw);
794
+ }
795
+ const s = raw.trim().replace(/^["']+|["']+$/g, '').trim();
796
+ return s || raw.trim();
797
+ }
338
798
  // ─── categorizeItem ───────────────────────────────────────────────────────────
339
799
  function categorizeItem(result, reason, blockedBy) {
340
800
  if (result === 'blocked' || blockedBy) {
@@ -372,4 +832,5 @@ module.exports = {
372
832
  cmdRenderCheckpoint,
373
833
  parseCurrentTest,
374
834
  buildCheckpoint,
835
+ parseDeferredItems,
375
836
  };
@@ -67,14 +67,36 @@ function checkUiPresence(text) {
67
67
  }
68
68
  // Normalise CRLF so the pattern sees consistent line boundaries.
69
69
  const normalised = text.replace(/\r\n/g, '\n');
70
+ // #2150: an explicit `**UI hint**: yes|no` metadata line is the author's
71
+ // authoritative declaration of whether the phase has a UI surface — progress.md
72
+ // and new-project.md already parse this line (`UI hint.*yes`). The bare token
73
+ // `UI` in the line itself must not count as a UI indicator, and the declaration
74
+ // overrides token-sniffing. Line-anchored (`m`) so a mid-line prose mention is
75
+ // not treated as the metadata line; word-boundary on the value so `nope`/`not`
76
+ // do not match `no`.
77
+ const hintMatch = normalised.match(/^\s*\*\*UI hint\*\*\s*:\s*(yes|no)\b/im);
78
+ const hint = hintMatch ? hintMatch[1].toLowerCase() : null;
79
+ // Strip ANY `**UI hint**:` line before token-sniffing so a hint without a
80
+ // recognised yes/no (or one we did not short-circuit on) cannot false-positive
81
+ // on the bare `UI` token.
82
+ const sniffable = normalised
83
+ .split('\n')
84
+ .filter((line) => !/^\s*\*\*UI hint\*\*\s*:/i.test(line))
85
+ .join('\n');
70
86
  const found = new Set();
71
- for (const line of normalised.split('\n')) {
87
+ for (const line of sniffable.split('\n')) {
72
88
  // Reset lastIndex before each line so the global pattern restarts from 0.
73
89
  UI_GATE_PATTERN_GLOBAL.lastIndex = 0;
74
90
  for (const m of line.matchAll(UI_GATE_PATTERN_GLOBAL)) {
75
91
  found.add(m[2].toLowerCase());
76
92
  }
77
93
  }
94
+ if (hint === 'no') {
95
+ return { hasUI: false, tokens: [] };
96
+ }
97
+ if (hint === 'yes') {
98
+ return { hasUI: true, tokens: [...found] };
99
+ }
78
100
  return { hasUI: found.size > 0, tokens: [...found] };
79
101
  }
80
102
  // ── CLI entry point ─────────────────────────────────────────────────────────
@@ -39,28 +39,32 @@ exports.buildRoadmapPhaseVariants = buildRoadmapPhaseVariants;
39
39
  exports.buildNotStartedPhaseVariants = buildNotStartedPhaseVariants;
40
40
  // eslint-disable-next-line @typescript-eslint/no-require-imports
41
41
  const phaseIdMod = require("./phase-id.cjs");
42
- const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
42
+ const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, PHASE_CONTINUATION_SEGMENT_SOURCE, } = phaseIdMod;
43
43
  // ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
44
44
  // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
45
45
  // deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
46
46
  exports.phaseDirNameRe = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`, 'i');
47
47
  // Extracts the full phase token from a directory name, including milestone-prefixed
48
48
  // multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
49
- // #2043: a *continuation* sub-phase segment must be zero-padded (≥2 digits), so a
49
+ // #2043: a *continuation* sub-phase segment must be zero-padded, so a
50
50
  // single-digit slug word after a phase number (e.g. "46-6-rs-…", slug "6 Rs …") is
51
- // NOT absorbed — it captures "46", not "46-6". The first component stays "\d+"
51
+ // NOT absorbed — it captures "46", not "46-6". #2232: the continuation width is
52
+ // exactly 2 (PHASE_CONTINUATION_SEGMENT_SOURCE), so a ≥3-digit slug word (a year:
53
+ // "14-2026-photos-…") is not absorbed either — it captures "14", not "14-2026".
54
+ // The first component stays "\d+"
52
55
  // (with the "[A-Z]?" suffix) so single-digit letter-suffixed phase ids ("1A") and
53
56
  // milestone-prefixed single-digit sub-phases ("M1-2" → prefix "M1-" stripped, then
54
57
  // "2") still match. The trailing boundary "(?:-|$)" (was "(?:-[a-z]|$)") lets a slug
55
58
  // that starts with a digit terminate the token.
56
- exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d{2,})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`, 'i');
59
+ exports.PHASE_TOKEN_FROM_DIR_RE = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`, 'i');
57
60
  exports.MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
58
61
  // ── Issue #26: I001 canonicalization ────────────────────────────────────────
59
62
  function canonicalPlanStem(stem) {
60
- // #2043: the plan component (after the phase number) must be zero-padded
61
- // (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
62
- // for a "46-6" phase/plan pair.
63
- const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-\\d{2,})`, 'i'));
63
+ // #2043: the plan component (after the phase number) must be zero-padded,
64
+ // so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
65
+ // for a "46-6" phase/plan pair. #2232: exactly 2 digits, so a year-leading
66
+ // slug ("14-2026-photos-…") is not mistaken for a "14-2026" pair either.
67
+ const m = stem.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE}-${PHASE_CONTINUATION_SEGMENT_SOURCE})`, 'i'));
64
68
  return m ? m[1] : stem;
65
69
  }
66
70
  // ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────