@opengsd/gsd-core 1.7.0-rc.1 → 1.7.0-rc.3

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 (191) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/GEMINI.md +25 -23
  5. package/README.md +3 -3
  6. package/agents/gsd-advisor-researcher.md +3 -1
  7. package/agents/gsd-ai-researcher.md +2 -2
  8. package/agents/gsd-assumptions-analyzer.md +2 -0
  9. package/agents/gsd-code-fixer.md +3 -1
  10. package/agents/gsd-code-reviewer.md +2 -0
  11. package/agents/gsd-codebase-mapper.md +2 -0
  12. package/agents/gsd-debug-session-manager.md +1 -1
  13. package/agents/gsd-debugger.md +3 -1
  14. package/agents/gsd-doc-writer.md +2 -0
  15. package/agents/gsd-domain-researcher.md +1 -1
  16. package/agents/gsd-eval-auditor.md +3 -1
  17. package/agents/gsd-executor.md +12 -9
  18. package/agents/gsd-integration-checker.md +2 -0
  19. package/agents/gsd-intel-updater.md +1 -1
  20. package/agents/gsd-mempalace-curator.md +1 -1
  21. package/agents/gsd-nyquist-auditor.md +2 -0
  22. package/agents/gsd-phase-researcher.md +4 -2
  23. package/agents/gsd-plan-checker.md +3 -1
  24. package/agents/gsd-planner.md +4 -2
  25. package/agents/gsd-project-researcher.md +4 -2
  26. package/agents/gsd-research-synthesizer.md +3 -1
  27. package/agents/gsd-roadmapper.md +2 -0
  28. package/agents/gsd-security-auditor.md +2 -0
  29. package/agents/gsd-ui-auditor.md +2 -0
  30. package/agents/gsd-ui-checker.md +2 -0
  31. package/agents/gsd-ui-researcher.md +4 -2
  32. package/agents/gsd-verifier.md +6 -4
  33. package/bin/install.js +226 -459
  34. package/commands/gsd/mempalace-capture.md +1 -1
  35. package/commands/gsd/mempalace-recall.md +9 -5
  36. package/commands/gsd/next.md +29 -0
  37. package/commands/gsd/ns-workflow.md +5 -2
  38. package/gsd-core/bin/ensure-runtime-build.cjs +246 -0
  39. package/gsd-core/bin/gsd-tools.cjs +43 -8
  40. package/gsd-core/bin/lib/capability-loader.cjs +27 -9
  41. package/gsd-core/bin/lib/capability-registry.cjs +209 -205
  42. package/gsd-core/bin/lib/capability-source.cjs +22 -7
  43. package/gsd-core/bin/lib/capability-validator.cjs +24 -2
  44. package/gsd-core/bin/lib/check-command-router.cjs +111 -1
  45. package/gsd-core/bin/lib/clusters.cjs +1 -0
  46. package/gsd-core/bin/lib/commands.cjs +2 -1
  47. package/gsd-core/bin/lib/config.cjs +19 -1
  48. package/gsd-core/bin/lib/core-utils.cjs +41 -0
  49. package/gsd-core/bin/lib/external-job.cjs +287 -0
  50. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  51. package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +151 -0
  52. package/gsd-core/bin/lib/graphify.cjs +41 -9
  53. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  54. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  55. package/gsd-core/bin/lib/host-integration.cjs +61 -0
  56. package/gsd-core/bin/lib/init.cjs +34 -6
  57. package/gsd-core/bin/lib/milestone.cjs +56 -11
  58. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  59. package/gsd-core/bin/lib/phase.cjs +51 -29
  60. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  61. package/gsd-core/bin/lib/plan-scan.cjs +9 -1
  62. package/gsd-core/bin/lib/probe-core.cjs +44 -4
  63. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  64. package/gsd-core/bin/lib/prohibition-enforcement.cjs +21 -19
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap.cjs +45 -22
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +18 -13
  69. package/gsd-core/bin/lib/runtime-name-policy.cjs +8 -10
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +12 -8
  71. package/gsd-core/bin/lib/smart-entry.cjs +521 -0
  72. package/gsd-core/bin/lib/state-transition.cjs +15 -0
  73. package/gsd-core/bin/lib/state.cjs +27 -8
  74. package/gsd-core/bin/lib/surface.cjs +18 -9
  75. package/gsd-core/bin/lib/update-context.cjs +0 -5
  76. package/gsd-core/bin/lib/validate.cjs +2 -1
  77. package/gsd-core/bin/lib/verify.cjs +6 -4
  78. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  79. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  80. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  81. package/gsd-core/bin/shared/model-catalog.json +8 -13
  82. package/gsd-core/bin/shared/runtime-aliases.manifest.json +0 -5
  83. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  84. package/gsd-core/references/gates.md +1 -1
  85. package/gsd-core/references/model-profiles.md +27 -0
  86. package/gsd-core/references/planning-config.md +1 -1
  87. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  88. package/gsd-core/workflows/add-backlog.md +1 -1
  89. package/gsd-core/workflows/add-phase.md +1 -1
  90. package/gsd-core/workflows/add-tests.md +1 -1
  91. package/gsd-core/workflows/add-todo.md +1 -1
  92. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  93. package/gsd-core/workflows/audit-fix.md +1 -1
  94. package/gsd-core/workflows/audit-milestone.md +1 -1
  95. package/gsd-core/workflows/audit-uat.md +1 -1
  96. package/gsd-core/workflows/autonomous.md +23 -25
  97. package/gsd-core/workflows/check-todos.md +1 -1
  98. package/gsd-core/workflows/cleanup.md +1 -1
  99. package/gsd-core/workflows/code-review-fix.md +1 -1
  100. package/gsd-core/workflows/code-review.md +1 -1
  101. package/gsd-core/workflows/complete-milestone.md +7 -11
  102. package/gsd-core/workflows/debug.md +1 -1
  103. package/gsd-core/workflows/diagnose-issues.md +1 -1
  104. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  105. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  106. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  107. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  108. package/gsd-core/workflows/discuss-phase.md +2 -2
  109. package/gsd-core/workflows/do.md +1 -1
  110. package/gsd-core/workflows/docs-update.md +1 -1
  111. package/gsd-core/workflows/edit-phase.md +1 -1
  112. package/gsd-core/workflows/eval-review.md +1 -1
  113. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  114. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  115. package/gsd-core/workflows/execute-phase.md +5 -6
  116. package/gsd-core/workflows/execute-plan.md +1 -1
  117. package/gsd-core/workflows/explore.md +1 -1
  118. package/gsd-core/workflows/extract-learnings.md +1 -1
  119. package/gsd-core/workflows/forensics.md +4 -4
  120. package/gsd-core/workflows/graduation.md +1 -1
  121. package/gsd-core/workflows/health.md +1 -1
  122. package/gsd-core/workflows/help/modes/full.md +15 -1
  123. package/gsd-core/workflows/help/modes/topic.md +1 -0
  124. package/gsd-core/workflows/import.md +1 -1
  125. package/gsd-core/workflows/ingest-docs.md +1 -1
  126. package/gsd-core/workflows/insert-phase.md +1 -1
  127. package/gsd-core/workflows/list-seeds.md +1 -1
  128. package/gsd-core/workflows/list-workspaces.md +1 -1
  129. package/gsd-core/workflows/manager.md +1 -1
  130. package/gsd-core/workflows/map-codebase.md +1 -1
  131. package/gsd-core/workflows/milestone-summary.md +4 -4
  132. package/gsd-core/workflows/mvp-phase.md +1 -1
  133. package/gsd-core/workflows/new-milestone.md +7 -1
  134. package/gsd-core/workflows/new-project.md +1 -1
  135. package/gsd-core/workflows/new-workspace.md +1 -1
  136. package/gsd-core/workflows/next.md +1 -1
  137. package/gsd-core/workflows/pause-work.md +1 -1
  138. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  139. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  140. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  141. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  142. package/gsd-core/workflows/plan-phase.md +12 -160
  143. package/gsd-core/workflows/plan-review-convergence.md +1 -1
  144. package/gsd-core/workflows/plant-seed.md +1 -1
  145. package/gsd-core/workflows/pr-branch.md +1 -1
  146. package/gsd-core/workflows/profile-user.md +1 -1
  147. package/gsd-core/workflows/progress.md +1 -1
  148. package/gsd-core/workflows/quick.md +1 -1
  149. package/gsd-core/workflows/reapply-patches.md +1 -1
  150. package/gsd-core/workflows/remove-phase.md +1 -1
  151. package/gsd-core/workflows/remove-workspace.md +1 -1
  152. package/gsd-core/workflows/resume-project.md +1 -1
  153. package/gsd-core/workflows/review.md +50 -9
  154. package/gsd-core/workflows/scan.md +1 -1
  155. package/gsd-core/workflows/secure-phase.md +1 -1
  156. package/gsd-core/workflows/settings-advanced.md +13 -11
  157. package/gsd-core/workflows/settings-integrations.md +1 -1
  158. package/gsd-core/workflows/settings.md +1 -1
  159. package/gsd-core/workflows/ship.md +1 -1
  160. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  161. package/gsd-core/workflows/sketch.md +1 -1
  162. package/gsd-core/workflows/smart-entry.md +120 -0
  163. package/gsd-core/workflows/spec-phase.md +8 -6
  164. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  165. package/gsd-core/workflows/spike.md +1 -1
  166. package/gsd-core/workflows/stats.md +1 -1
  167. package/gsd-core/workflows/thread.md +1 -1
  168. package/gsd-core/workflows/transition.md +1 -1
  169. package/gsd-core/workflows/ui-phase.md +1 -1
  170. package/gsd-core/workflows/ui-review.md +1 -1
  171. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  172. package/gsd-core/workflows/update.md +3 -6
  173. package/gsd-core/workflows/validate-phase.md +1 -1
  174. package/gsd-core/workflows/verify-phase.md +1 -1
  175. package/gsd-core/workflows/verify-work.md +63 -6
  176. package/package.json +5 -5
  177. package/scripts/ci-test-scope.cjs +19 -1
  178. package/scripts/generate-package-identity.cjs +27 -2
  179. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -148
  180. package/scripts/lint-regression-test-names.allowlist.json +1 -273
  181. package/scripts/lint-test-file-count.allowlist.json +7 -91
  182. package/scripts/research-profiles.cjs +6 -6
  183. package/scripts/run-tests.cjs +67 -4
  184. package/scripts/slurm-adapter.cjs +269 -0
  185. package/scripts/sync-manifest-versions.cjs +67 -14
  186. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  187. package/skills/gsd-mempalace-recall/SKILL.md +9 -5
  188. package/skills/gsd-next/SKILL.md +30 -0
  189. package/skills/gsd-ns-workflow/SKILL.md +4 -1
  190. package/skills/gsd-review/SKILL.md +6 -0
  191. package/gemini-extension.json +0 -6
@@ -29,7 +29,7 @@ const phaseIdMod = require("./phase-id.cjs");
29
29
  const { escapeRegex, normalizePhaseName, phaseTokenMatches } = phaseIdMod;
30
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
31
  const roadmapParserMod = require("./roadmap-parser.cjs");
32
- const { getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod;
32
+ const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo } = roadmapParserMod;
33
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports
34
34
  const coreUtilsMod = require("./core-utils.cjs");
35
35
  const { extractOneLinerFromBody } = coreUtilsMod;
@@ -112,8 +112,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
112
112
  const roadmapPath = planningPaths(cwd).roadmap;
113
113
  const reqPath = planningPaths(cwd).requirements;
114
114
  const statePath = planningPaths(cwd).state;
115
- const milestonesPath = node_path_1.default.join(cwd, '.planning', 'MILESTONES.md');
116
- const archiveDir = node_path_1.default.join(cwd, '.planning', 'milestones');
115
+ // #1911: derive the archive base from the workstream-aware planning root so
116
+ // `milestone complete --ws` archives into the workstream, not root. planningPaths(cwd).planning
117
+ // resolves to the workstream base when GSD_WORKSTREAM is set and to root .planning otherwise
118
+ // (flat mode is a no-op).
119
+ const planningBase = planningPaths(cwd).planning;
120
+ const milestonesPath = node_path_1.default.join(planningBase, 'MILESTONES.md');
121
+ const archiveDir = node_path_1.default.join(planningBase, 'milestones');
117
122
  const phasesDir = planningPaths(cwd).phases;
118
123
  const today = new Date().toISOString().split('T')[0];
119
124
  const milestoneName = options.name || version;
@@ -151,7 +156,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
151
156
  if (stateVersion && stateVersion === version) {
152
157
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
153
158
  const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
154
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
159
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
160
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
155
161
  const noDirectoryPhases = [];
156
162
  let pm;
157
163
  const phaseDirEntries = (() => {
@@ -257,11 +263,17 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
257
263
  // Archive REQUIREMENTS.md
258
264
  if (node_fs_1.default.existsSync(reqPath)) {
259
265
  const reqContent = node_fs_1.default.readFileSync(reqPath, 'utf-8');
260
- const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
266
+ // Derive the display path from the same source the writer uses (reqPath), so a
267
+ // workstream archive header points at `.planning/workstreams/<ws>/REQUIREMENTS.md`
268
+ // instead of the hardcoded root path (#1993). Root case is byte-identical.
269
+ // Normalize to POSIX separators so the header is cross-platform (Windows
270
+ // path.relative yields backslashes; the original literal was forward-slash).
271
+ const reqDisplay = node_path_1.default.relative(cwd, reqPath).split(node_path_1.default.sep).join('/');
272
+ const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`${reqDisplay}\`.\n\n---\n\n`;
261
273
  (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent);
262
274
  }
263
275
  // Archive audit file if exists
264
- const auditFile = node_path_1.default.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`);
276
+ const auditFile = node_path_1.default.join(planningBase, `${version}-MILESTONE-AUDIT.md`);
265
277
  if (node_fs_1.default.existsSync(auditFile)) {
266
278
  (0, shell_command_projection_cjs_1.retryRenameSync)(auditFile, node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
267
279
  }
@@ -309,7 +321,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
309
321
  }
310
322
  // Archive phase directories if requested
311
323
  let phasesArchived = false;
312
- if (options.archivePhases) {
324
+ // #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
325
+ if (options.archivePhases !== false) {
313
326
  try {
314
327
  const phaseArchiveDir = node_path_1.default.join(archiveDir, `${version}-phases`);
315
328
  (0, shell_command_projection_cjs_1.platformEnsureDir)(phaseArchiveDir);
@@ -398,10 +411,8 @@ function cmdPhasesClear(cwd, raw, args) {
398
411
  }
399
412
  }
400
413
  try {
401
- for (const entry of dirs) {
402
- node_fs_1.default.rmSync(node_path_1.default.join(phasesDir, entry.name), { recursive: true, force: true });
403
- cleared++;
404
- }
414
+ // #1871: archive phase directories instead of destroying them (shared helper).
415
+ cleared = archivePhaseDirectories(cwd, phasesDir, dirs).archived;
405
416
  }
406
417
  catch (e) {
407
418
  const message = e instanceof Error ? e.message : String(e);
@@ -410,6 +421,40 @@ function cmdPhasesClear(cwd, raw, args) {
410
421
  }
411
422
  output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
412
423
  }
424
+ /**
425
+ * #1871: move each non-999 phase directory under `phasesDir` into
426
+ * `milestones/<version>-phases/` (collision-safe; version from getMilestoneInfo,
427
+ * timestamp fallback). Shared by `phases clear` (archive-then-remove) and the
428
+ * internal milestone.complete phase archival so phase history survives a
429
+ * milestone switch instead of being hard-deleted.
430
+ */
431
+ function archivePhaseDirectories(cwd, phasesDir, dirs) {
432
+ let archiveVersion = null;
433
+ try {
434
+ archiveVersion = getMilestoneInfo(cwd).version ?? null;
435
+ }
436
+ catch {
437
+ /* ROADMAP/STATE unreadable — fall back to a dated label */
438
+ }
439
+ if (!archiveVersion) {
440
+ archiveVersion = `archived-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 8)}`;
441
+ }
442
+ const archivePhasesDir = node_path_1.default.join(planningPaths(cwd).planning, 'milestones', `${archiveVersion}-phases`);
443
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(archivePhasesDir);
444
+ let archived = 0;
445
+ for (const entry of dirs) {
446
+ const src = node_path_1.default.join(phasesDir, entry.name);
447
+ // Collision-safe: if a same-named archive entry exists (re-run), suffix it.
448
+ let dest = node_path_1.default.join(archivePhasesDir, entry.name);
449
+ let n = 1;
450
+ while (node_fs_1.default.existsSync(dest)) {
451
+ dest = node_path_1.default.join(archivePhasesDir, `${entry.name}.${n++}`);
452
+ }
453
+ (0, shell_command_projection_cjs_1.retryRenameSync)(src, dest);
454
+ archived++;
455
+ }
456
+ return { archiveDir: archivePhasesDir, archived };
457
+ }
413
458
  module.exports = {
414
459
  cmdRequirementsMarkComplete,
415
460
  cmdMilestoneComplete,
@@ -20,6 +20,23 @@ const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
20
20
  const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
21
21
  const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
22
22
  const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
23
+ // #1729: phase headers may carry a parenthetical tag between the number and the
24
+ // colon, e.g. `### Phase 26 (Cluster B): Title`. This optional, non-capturing
25
+ // fragment is injected at every phase-header regex call site (immediately after
26
+ // the phase-number token, before the colon/space delimiter) so the resolver
27
+ // tolerates the tag — mirroring how `[...]` is already tolerated before `Phase`.
28
+ // `[^)\n]*` keeps the match single-line (headers are one line) to avoid
29
+ // over-consuming across a malformed multi-line document. Injected at the call
30
+ // site (not baked into phaseMarkdownRegexSource) so it applies uniformly to
31
+ // both the numeric and project-code-exact escaped sources, and so the decimal
32
+ // sub-phase patterns can place it after the `.N` segment.
33
+ //
34
+ // Enumeration/parse call sites that read phase headers from a regex *literal*
35
+ // (rather than a `new RegExp` built from an interpolated phase number) cannot
36
+ // reference this constant; they inline its literal-regex mirror instead —
37
+ // `(?:\s*\([^)\n]*\))?` — kept character-for-character equivalent to this
38
+ // source. Both forms must change together; see the #1729 regression test.
39
+ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]*\\))?';
23
40
  function stripProjectCodePrefix(value, caseInsensitive = true) {
24
41
  const input = String(value);
25
42
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -215,6 +232,7 @@ function phaseTokenMatches(dirName, normalized) {
215
232
  module.exports = {
216
233
  escapeRegex,
217
234
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
235
+ OPTIONAL_PHASE_TAG_SOURCE,
218
236
  stripProjectCodePrefix,
219
237
  normalizePhaseName,
220
238
  getMilestoneFromPhaseId,
@@ -32,7 +32,7 @@ const coreUtilsMod = require("./core-utils.cjs");
32
32
  const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
33
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
34
  const phaseIdMod = require("./phase-id.cjs");
35
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, } = phaseIdMod;
35
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, } = phaseIdMod;
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
37
37
  const phaseLocatorMod = require("./phase-locator.cjs");
38
38
  const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
@@ -181,7 +181,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
181
181
  if (node_fs_1.default.existsSync(roadmapPath)) {
182
182
  try {
183
183
  const roadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
184
- const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)\\s*:`, 'gi');
184
+ const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalized)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
185
185
  let pm;
186
186
  while ((pm = phasePattern.exec(roadmapContent)) !== null) {
187
187
  decimalSet.add(parseInt(pm[1], 10));
@@ -221,7 +221,7 @@ function getRoadmapModeForPhase(cwd, phaseNum) {
221
221
  const milestoneContent = extractCurrentMilestone(rawContent, cwd);
222
222
  const fullContent = stripShippedMilestones(rawContent);
223
223
  const escapedPhase = phaseMarkdownRegexSource(phaseNum);
224
- const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}\\s*:`, 'i');
224
+ const phaseHeader = new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'i');
225
225
  for (const content of [milestoneContent, fullContent]) {
226
226
  const headerMatch = content.match(phaseHeader);
227
227
  if (!headerMatch || headerMatch.index === undefined)
@@ -578,7 +578,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
578
578
  // Three sources are scanned so that a phase in ANY representation
579
579
  // (section header, roadmap bullet, or on-disk directory) is counted:
580
580
  // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
581
- const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
581
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
582
+ const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
582
583
  // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
583
584
  // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
584
585
  // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
@@ -662,7 +663,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
662
663
  const content = extractCurrentMilestone(rawContent, cwd);
663
664
  let maxPhase = 0;
664
665
  if (config.phase_naming !== 'custom') {
665
- const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
666
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
667
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
666
668
  let m;
667
669
  while ((m = phasePattern.exec(content)) !== null) {
668
670
  const num = parseInt(m[1], 10);
@@ -740,14 +742,14 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
740
742
  const content = extractCurrentMilestone(rawContent, cwd);
741
743
  const normalizedAfter = normalizePhaseName(afterPhase);
742
744
  const afterPhaseEscaped = phaseMarkdownRegexSource(normalizedAfter);
743
- const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:`, 'i');
745
+ const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i');
744
746
  const headingMatch = targetPattern.test(content);
745
- const bulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`, 'i');
747
+ const bulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
746
748
  const anyHeadingPattern = /#{2,4}\s*Phase\s+\d/i;
747
749
  const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
748
750
  const isBulletStyle = !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
749
751
  if (!headingMatch && !isBulletStyle) {
750
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s]`, 'i');
752
+ const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
751
753
  if (checklistPattern.test(content)) {
752
754
  error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
753
755
  }
@@ -769,7 +771,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
769
771
  catch {
770
772
  /* intentionally empty */
771
773
  }
772
- const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)\\s*:`, 'gi');
774
+ const rmPhasePattern = new RegExp(`#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(normalizedBase)}\\.(\\d+)${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`, 'gi');
773
775
  let rmMatch;
774
776
  while ((rmMatch = rmPhasePattern.exec(rawContent)) !== null) {
775
777
  decimalSet.add(parseInt(rmMatch[1], 10));
@@ -785,13 +787,13 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
785
787
  (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dirPath, '.gitkeep'), '');
786
788
  let updatedContent;
787
789
  if (isBulletStyle) {
788
- const boldBulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}:`, 'i');
790
+ const boldBulletPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i');
789
791
  const useBold = boldBulletPattern.test(content);
790
792
  const phaseLabel = useBold
791
793
  ? `**Phase ${_decimalPhase}: ${description}**`
792
794
  : `Phase ${_decimalPhase}: ${description}`;
793
795
  const bulletEntry = `\n- [ ] ${phaseLabel}`;
794
- const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}[:\\s][^\\n]*)`, 'i');
796
+ const targetBulletPattern = new RegExp(`(-\\s*\\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
795
797
  const bulletMatchResult = rawContent.match(targetBulletPattern);
796
798
  if (!bulletMatchResult) {
797
799
  error(`Could not find Phase ${afterPhase} bullet line`);
@@ -811,7 +813,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
811
813
  }
812
814
  else {
813
815
  const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run ${(0, runtime_slash_cjs_1.formatGsdSlash)('plan-phase', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} ${_decimalPhase} to break down)\n`;
814
- const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
816
+ const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*\\n)`, 'i');
815
817
  const headerMatch = rawContent.match(headerPattern);
816
818
  if (!headerMatch) {
817
819
  error(`Could not find Phase ${afterPhase} header`);
@@ -940,11 +942,13 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
940
942
  withPlanningLock(cwd, () => {
941
943
  let content = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
942
944
  const escaped = escapeRegex(targetPhase);
943
- content = content.replace(new RegExp(`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, 'i'), '');
944
- content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
945
+ content = content.replace(new RegExp(`\\n?(?<h>#{2,4})\\s*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:[\\s\\S]*?(?=\\n\\k<h>(?!#)\\s+Phase\\s+[^\\n:]+\\s*:|$)`, 'i'), '');
946
+ content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'), '');
945
947
  content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
946
948
  if (!isDecimal) {
947
- content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)(\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
949
+ // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
950
+ // is re-emitted verbatim — a tagged later phase still gets renumbered.
951
+ content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]*\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
948
952
  content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
949
953
  content = content.replace(/(\|\s*)(\d+)(\.\s)/g, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
950
954
  content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
@@ -1047,7 +1051,7 @@ function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
1047
1051
  if (!roadmapContent || !phaseNum)
1048
1052
  return null;
1049
1053
  const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1050
- const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}\\s*:\\s*([^\\n]+)`, 'im'));
1054
+ const heading = roadmapContent.match(new RegExp(`^#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:\\s*([^\\n]+)`, 'im'));
1051
1055
  if (!heading)
1052
1056
  return null;
1053
1057
  const name = heading[1].replace(/\(INSERTED\)/i, '').trim();
@@ -1128,10 +1132,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1128
1132
  const originalRoadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1129
1133
  roadmapContent = originalRoadmapContent;
1130
1134
  const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1131
- const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`, 'i');
1135
+ const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1132
1136
  roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
1133
1137
  const tableRowPattern = new RegExp(`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
1134
- roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
1138
+ // Scope the Progress-row search to the ## Progress section so the regex
1139
+ // doesn't bind to an earlier table (e.g. | Phase | Requirements | Count |)
1140
+ // whose rows also start with the phase number. (#2012)
1141
+ const updateProgressRow = (fullRow) => {
1135
1142
  const cells = fullRow.split('|').slice(1, -1);
1136
1143
  const dateShape = /^\d{4}-\d{2}-\d{2}$/;
1137
1144
  if (cells.length === 5) {
@@ -1149,7 +1156,16 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1149
1156
  cells[3] = dateShape.test(existingDate4) ? cells[3] : ` ${today} `;
1150
1157
  }
1151
1158
  return '|' + cells.join('|') + '|';
1152
- });
1159
+ };
1160
+ const progressIdx = roadmapContent.indexOf('## Progress');
1161
+ if (progressIdx >= 0) {
1162
+ const beforeProgress = roadmapContent.slice(0, progressIdx);
1163
+ const progressSection = roadmapContent.slice(progressIdx);
1164
+ roadmapContent = beforeProgress + progressSection.replace(tableRowPattern, updateProgressRow);
1165
+ }
1166
+ else {
1167
+ roadmapContent = roadmapContent.replace(tableRowPattern, updateProgressRow);
1168
+ }
1153
1169
  const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i');
1154
1170
  roadmapContent = roadmapContent.replace(planCountPattern, `$1${summaryCount}/${planCount} plans complete`);
1155
1171
  const phaseInfoSummaries = phaseInfo['summaries'];
@@ -1170,7 +1186,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1170
1186
  if (node_fs_1.default.existsSync(reqPath)) {
1171
1187
  const phaseEsc = phaseMarkdownRegexSource(phaseNum);
1172
1188
  const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
1173
- const phaseSectionMatch = currentMilestoneRoadmap.match(new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i'));
1189
+ const phaseSectionMatch = currentMilestoneRoadmap.match(new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i'));
1174
1190
  const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
1175
1191
  const reqMatch = sectionText.match(/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i);
1176
1192
  const originalReqContent = node_fs_1.default.readFileSync(reqPath, 'utf-8');
@@ -1288,15 +1304,21 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1288
1304
  try {
1289
1305
  const roadmapForPhases = extractCurrentMilestone(roadmapContent, cwd);
1290
1306
  // #1591: match BOTH heading-style phases (`### Phase N:`) AND
1291
- // checkbox-list items (`- [ ] Phase N:` / `- [x] Phase N:`). When
1292
- // the active milestone's checklist is `- [ ]` items inside a
1293
- // <details> block (and the next phase has no directory yet, so the
1294
- // disk-based resolver finds nothing), this roadmap-enumeration
1295
- // fallback is the only path that can find the next phase. The prior
1296
- // heading-only pattern missed checkbox items → is_last_phase=true on
1297
- // a mid-milestone phase. The marker alternation is the only change;
1298
- // the number/name captures are unchanged.
1299
- const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1307
+ // checkbox-list items, INCLUDING the canonical bold form the roadmap
1308
+ // template emits (`- [ ] **Phase N: Name**`). When the active
1309
+ // milestone's checklist is `- [ ]` items inside a <details> block
1310
+ // (and the next phase has no directory yet, so the disk-based
1311
+ // resolver finds nothing), this roadmap-enumeration fallback is the
1312
+ // only path that can find the next phase. The prior heading-only
1313
+ // pattern missed checkbox items, and a checkbox-only broadening still
1314
+ // missed the bold template rows → is_last_phase=true on a mid-milestone
1315
+ // phase. Allow optional `**`/`__` emphasis after the marker and stop
1316
+ // the name capture at emphasis so bold names slug cleanly; the number
1317
+ // capture is unchanged.
1318
+ // #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon
1319
+ // ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
1320
+ // `### Phase N (Cluster B): X` resolves. Captures are unchanged.
1321
+ const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi;
1300
1322
  let pm;
1301
1323
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
1302
1324
  if (comparePhaseNum(pm[1], phaseNum) > 0) {
@@ -4,8 +4,8 @@
4
4
  * Keeps gsd-tools.cjs thin while preserving current CJS semantics.
5
5
  *
6
6
  * Unsupported in this router (treated as unknown):
7
- * - archive: `phases archive` is excluded from the subcommands list so it
8
- * falls through to the unknown-subcommand error path.
7
+ * - archive: `phases archive` is excluded (#2684) — it is an internal
8
+ * subcommand that milestone.complete forwards to, not a public surface.
9
9
  *
10
10
  * ADR-457 build-at-publish: the hand-written bin/lib/phases-command-router.cjs
11
11
  * collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
@@ -19,7 +19,8 @@ const { routeCjsCommandFamily } = cjsCommandRouterAdapter;
19
19
  function routePhasesCommand({ phase, milestone, args, cwd, raw, error }) {
20
20
  routeCjsCommandFamily({
21
21
  args,
22
- // Exclude 'archive' so it hits the unknownMessage path.
22
+ // #2684: `phases archive` is deliberately excluded — it is an internal
23
+ // subcommand that milestone.complete forwards to, not a public surface.
23
24
  subcommands: command_aliases_cjs_1.PHASES_SUBCOMMANDS.filter((s) => s !== 'archive'),
24
25
  error,
25
26
  unknownMessage: (_subcommand, available) => `Unknown phases subcommand. Available: ${available.join(', ')}`,
@@ -9,6 +9,9 @@
9
9
  */
10
10
  const node_fs_1 = require("node:fs");
11
11
  const node_path_1 = require("node:path");
12
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
13
+ const coreUtils = require("./core-utils.cjs");
14
+ const { countMatchedSummaries } = coreUtils;
12
15
  // Excluded derivative files
13
16
  const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
14
17
  const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
@@ -72,7 +75,12 @@ function scanPhasePlans(phaseDir) {
72
75
  const planFiles = rootPlanFiles.concat(nestedPlanFiles);
73
76
  const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
74
77
  const planCount = planFiles.length;
75
- const summaryCount = summaryFiles.length;
78
+ // Count only summaries that are the PLAN→SUMMARY partner of an existing plan
79
+ // (#1988): stray non-plan summaries (e.g. 30-FIX-CR02-SUMMARY.md,
80
+ // 30-GAPCLOSURE-SUMMARY.md) must not inflate summary_count or flip a phase to
81
+ // Complete when plans are still missing summaries. summaryFiles (the array)
82
+ // still holds every summary on disk for callers that read/list them.
83
+ const summaryCount = countMatchedSummaries(planFiles, summaryFiles);
76
84
  return {
77
85
  planCount,
78
86
  summaryCount,
@@ -48,11 +48,40 @@ exports.VALID_STATUS = ['resolved', 'dismissed', 'unresolved'];
48
48
  function errMessage(e) {
49
49
  return e instanceof Error ? e.message : String(e);
50
50
  }
51
+ /**
52
+ * Structural guard for ONE item of the report `items[]` — enforces the `Item` contract (#1907).
53
+ * `analyzeCoverage` always emits fully-populated, category/status-validated Items, so this never
54
+ * rejects legitimate output; it catches an adapter that bypasses the merge and hands back per-item
55
+ * garbage (e.g. `items:[{}]`) inside a well-shaped envelope — which the container-only guard let
56
+ * sail through as green output despite the fail-closed docstring below.
57
+ */
58
+ function isValidItem(item) {
59
+ if (item == null || typeof item !== 'object')
60
+ return false;
61
+ const i = item;
62
+ if (typeof i.requirement_id !== 'string' || !i.requirement_id.trim())
63
+ return false;
64
+ if (typeof i.category !== 'string' || !i.category.trim())
65
+ return false;
66
+ if (!exports.VALID_STATUS.includes(i.status))
67
+ return false;
68
+ if (typeof i.probe !== 'string')
69
+ return false;
70
+ // The three nullable fields must be a string or null — never some other type.
71
+ if (i.verification !== null && typeof i.verification !== 'string')
72
+ return false;
73
+ if (i.resolution !== null && typeof i.resolution !== 'string')
74
+ return false;
75
+ if (i.reason !== null && typeof i.reason !== 'string')
76
+ return false;
77
+ return true;
78
+ }
51
79
  /**
52
80
  * Structural guard for the report an adapter's `analyze` returns. The scaffold types `analyze`
53
81
  * loosely (it runs over JSON-parsed input the adapter `as`-casts), so a future adapter (#644)
54
82
  * that forgets to validate inside its closure could hand back a malformed object. Rather than
55
- * stringify garbage as green output, `runProbeCli` checks the report shape and fails closed.
83
+ * stringify garbage as green output, `runProbeCli` checks the report shape — container AND every
84
+ * item — and fails closed.
56
85
  */
57
86
  function isValidReport(report) {
58
87
  if (report == null || typeof report !== 'object')
@@ -60,6 +89,8 @@ function isValidReport(report) {
60
89
  const r = report;
61
90
  if (!Array.isArray(r.items))
62
91
  return false;
92
+ if (!r.items.every(isValidItem))
93
+ return false;
63
94
  const c = r.coverage;
64
95
  if (c == null || typeof c !== 'object')
65
96
  return false;
@@ -373,13 +404,22 @@ function truthStatement(truth) {
373
404
  /**
374
405
  * Extract a truth's verification tier, or `null` when it carries none (a plain string, or an object
375
406
  * with no/garbled marker). Failing toward `null` is the Postel-safe direction: an unrecognized marker
376
- * grades NORMALLY (never a spurious abstention — the over-abstention guard, AC#3), and the marker is
377
- * machine-emitted from validated edge data so garbling is not a live input path.
407
+ * grades NORMALLY (never a spurious abstention — the over-abstention guard, AC#3).
408
+ *
409
+ * The marker is NOT only machine-emitted: `must_haves` markers can be authored BY HAND (#1820's
410
+ * spec-optional predicate rail), and the frontmatter continuation-KV parser preserves stray
411
+ * surrounding whitespace/quotes on a hand-authored value. So we normalize before comparison
412
+ * (Postel: be liberal in what you accept) — `'backstop '`, `' backstop'`, `'"backstop"'` all
413
+ * recognize as the tier. Without this, a hand-authored non-inferable `backstop` truth with a stray
414
+ * trailing space silently grades green instead of abstaining — the exact #1154 false-pass (#1905).
415
+ * An unrecoverably-corrupted marker (e.g. an embedded quote) stays unrecognized → null → graded
416
+ * normally (AC#3): we cannot know its intent, and abstaining on it would be a spurious abstention.
378
417
  */
379
418
  function truthVerification(truth) {
380
419
  if (truth == null || typeof truth !== 'object')
381
420
  return null;
382
- const v = truth.verification;
421
+ const raw = truth.verification;
422
+ const v = typeof raw === 'string' ? raw.trim().replace(/^["']|["']$/g, '').trim() : raw;
383
423
  return v === 'explicit' || v === 'backstop' ? v : null;
384
424
  }
385
425
  /**
@@ -1024,7 +1024,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
1024
1024
  // project-instruction-file`). Previously this was a codex-only override
1025
1025
  // (#3163) that left AGENTS-native runtimes (opencode/kilo/kimi) emitting
1026
1026
  // CLAUDE.md; copilot now resolves to .github/copilot-instructions.md, and
1027
- // antigravity/gemini to GEMINI.md. GSD_RUNTIME env var takes precedence
1027
+ // antigravity to GEMINI.md. GSD_RUNTIME env var takes precedence
1028
1028
  // over config.runtime, mirroring detectRuntime().
1029
1029
  //
1030
1030
  // Non-claude runtimes always win over a stale `claude_md_path` (the #3163
@@ -515,12 +515,12 @@ function defaultProveFailFirst(check, cwd, timeoutMs) {
515
515
  // a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning
516
516
  // closes the realistic typo/stale-path case (#1279 review, Major 1).
517
517
  //
518
- // CAUSATION (#1346): existence + a non-vacuous red is necessary but not sufficient — a deceptive
519
- // negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set (rather than because the
520
- // subject's CONTENT violates the must-NOT) would otherwise be accepted. The OPTIONAL `cleanFixture`
521
- // control below proves content-dependence when supplied (red on bad AND green on clean). When NO
522
- // clean fixture is authored the control cannot run, so the residual remains a documented constraint
523
- // for that case (an author opts into the stronger proof by supplying a known-clean control subject).
518
+ // CAUSATION (#1346; MANDATORY as of #1906): existence + a non-vacuous red is necessary but not
519
+ // sufficient — a deceptive negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set
520
+ // (rather than because the subject's CONTENT violates the must-NOT) would otherwise be accepted.
521
+ // The `cleanFixture` control below proves content-dependence (red on bad AND green on clean) and is
522
+ // now REQUIRED for the node-test kind: absent it, the check is un-provable (fail-closed), not
523
+ // accepted under the weaker violation-only proof (#1906 supersedes #1346's opt-in; ADR-1606 D4).
524
524
  // Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads
525
525
  // `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must
526
526
  // use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole).
@@ -530,20 +530,22 @@ function defaultProveFailFirst(check, cwd, timeoutMs) {
530
530
  const redOut = runNodeTestWithSubject(check, cwd, fixture, timeoutMs);
531
531
  if (!isNonVacuousNodeTestRed(redOut, check.target))
532
532
  return { provenFailFirst: false, method: 'violation-fixture' };
533
- // #1346 CAUSATION CONTROL (optional): if a clean control subject is supplied, run the SAME test
534
- // against it and require it to stay GREEN. This proves the red above was caused by the subject's
535
- // CONTENT — a deceptive test that reds merely because GSD_PROHIB_SUBJECT is SET reds here too →
536
- // not content-dependent → not proven. Absent → no control (documented residual; backward-compat).
533
+ // #1906 CAUSATION CONTROL (MANDATORY for node-test — supersedes #1346's opt-in, ADR-1606 D4): the
534
+ // clean control subject is REQUIRED. Run the SAME test against it and require it to stay GREEN,
535
+ // proving the red above was caused by the subject's CONTENT — a deceptive test that reds merely
536
+ // because GSD_PROHIB_SUBJECT is SET reds here too → not content-dependent → not proven. ABSENT →
537
+ // the control cannot run → un-provable → fail-closed (NOT accepted under the weaker violation-only
538
+ // proof). This is the one behavior change vs #1346: absent `cleanFixture` was previously proven.
537
539
  const clean = check.cleanFixture;
538
- if (clean) {
539
- // A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric
540
- // with the violation-fixture existence guard (resolve against the SAME `cwd` as the child).
541
- if (!node_fs_1.default.existsSync(node_path_1.default.resolve(cwd, clean)))
542
- return { provenFailFirst: false, method: 'violation-fixture' };
543
- const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs);
544
- if (!isNonVacuousNodeTestPass(cleanOut, check.target))
545
- return { provenFailFirst: false, method: 'violation-fixture' };
546
- }
540
+ if (!clean)
541
+ return { provenFailFirst: false, method: 'violation-fixture' };
542
+ // A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric
543
+ // with the violation-fixture existence guard (resolve against the SAME `cwd` as the child).
544
+ if (!node_fs_1.default.existsSync(node_path_1.default.resolve(cwd, clean)))
545
+ return { provenFailFirst: false, method: 'violation-fixture' };
546
+ const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs);
547
+ if (!isNonVacuousNodeTestPass(cleanOut, check.target))
548
+ return { provenFailFirst: false, method: 'violation-fixture' };
547
549
  return { provenFailFirst: true, method: 'violation-fixture' };
548
550
  }
549
551
  // Unknown kind — defensive; the LOCATE guard already rejects it.
@@ -46,9 +46,10 @@ function checkW021(content) {
46
46
  // Capture the major integer.
47
47
  const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
48
48
  // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
49
- const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*\s*:/i;
49
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
50
+ const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i;
50
51
  // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
51
- const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)\s*:/i;
52
+ const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i;
52
53
  let currentMilestoneMajor = null;
53
54
  const lines = content.split('\n');
54
55
  for (const line of lines) {