@opengsd/gsd-core 1.20.0 → 1.21.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 (154) 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 +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -47,7 +47,7 @@ const { planningDir, planningRoot, resolveEnvWorkstream, resolvePhaseIdConventio
47
47
  // Naming readers and writers share the loader's scoped-key precedence and defaults.
48
48
  // eslint-disable-next-line @typescript-eslint/no-require-imports
49
49
  const configLoaderModule = require("./config-loader.cjs");
50
- const { CONFIG_DEFAULTS, _deepMergeConfig, _getConfigValue, loadConfig } = configLoaderModule;
50
+ const { CONFIG_DEFAULTS, _deepMergeConfig, _getConfigValue, loadConfig, readConfigFileBounded } = configLoaderModule;
51
51
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
52
52
  // eslint-disable-next-line @typescript-eslint/no-require-imports
53
53
  const frontmatterModule = require("./frontmatter.cjs");
@@ -253,7 +253,12 @@ function phaseDirectoryOptions(cwd, config, roadmap, ws) {
253
253
  if (naming === undefined) {
254
254
  const readConfig = (file) => {
255
255
  try {
256
- const value = JSON.parse(node_fs_1.default.readFileSync(file, 'utf8'));
256
+ // Bounded read (#5759): null is an absent file (no configError); a FIFO/device/oversized
257
+ // file throws and is reported through configError like any other unreadable config.
258
+ const raw = readConfigFileBounded(file);
259
+ if (raw === null)
260
+ return null;
261
+ const value = JSON.parse(raw);
257
262
  if (value && typeof value === 'object' && !Array.isArray(value))
258
263
  return value;
259
264
  configError = { path: file, message: 'configuration must be a JSON object' };
@@ -305,7 +310,7 @@ function phaseDirectoryOptions(cwd, config, roadmap, ws) {
305
310
  // Windows may omit these flags: lstat/fstat still require a regular file,
306
311
  // but the open is not atomically no-follow there. Adopt Windows no-follow
307
312
  // flags here if Node exposes them; no caller should weaken these checks.
308
- identityFd = node_fs_1.default.openSync(markerPath, node_fs_1.default.constants.O_RDONLY | node_fs_1.default.constants.O_NONBLOCK | node_fs_1.default.constants.O_NOFOLLOW);
313
+ identityFd = node_fs_1.default.openSync(markerPath, node_fs_1.default.constants.O_RDONLY | node_fs_1.default.constants.O_NONBLOCK | node_fs_1.default.constants.O_NOFOLLOW | (node_fs_1.default.constants.O_NOCTTY ?? 0));
309
314
  const opened = node_fs_1.default.fstatSync(identityFd);
310
315
  if (!opened.isFile() || opened.size > 1024)
311
316
  continue;
@@ -42,7 +42,7 @@ const coreUtilsMod = require("./core-utils.cjs");
42
42
  const { toPosixPath, generateSlugInternal, readSubdirectories, extractCanonicalPlanId, findUnsummarizedPlans, } = coreUtilsMod;
43
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
44
44
  const phaseIdMod = require("./phase-id.cjs");
45
- const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, phaseLookupQuery, phaseNumberForMatch, phaseKeyFromDir, customPhaseDirectoryParts, customDirectoryOwner, phaseScanTokenSource, CUSTOM_PHASE_ID_FILE, isCustomPhaseId, isSentinelPhaseId, scopeToPhase, parsePhaseId, renderPhaseId, renderMilestoneId, toDir, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, buildPhaseDeclarationScans, BRACKET_DIR_PREFIX_SRC, tokenizePhaseDependencyReferences, foldBracketId, } = phaseIdMod;
45
+ const { normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, matchPhaseDirs, phaseLookupQuery, phaseNumberForMatch, phaseKeyFromDir, customPhaseDirectoryParts, customDirectoryOwner, phaseScanTokenSource, phaseNumberTokenSource, CUSTOM_PHASE_ID_FILE, isCustomPhaseId, isSentinelPhaseId, scopeToPhase, parsePhaseId, renderPhaseId, renderMilestoneId, toDir, phaseHeadingPrefixSrcFor, parsePhaseChecklistLine, PHASE_HEADING_BASELINE, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, buildPhaseDeclarationScans, BRACKET_DIR_PREFIX_SRC, tokenizePhaseDependencyReferences, foldBracketId, } = phaseIdMod;
46
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
47
47
  const phaseIdDisplayMod = require("./phase-id-display.cjs");
48
48
  // #4304: reuse the Phase Id Display Module's milestone/phase
@@ -51,6 +51,7 @@ const phaseIdDisplayMod = require("./phase-id-display.cjs");
51
51
  const { milestoneToken, phaseToken } = phaseIdDisplayMod;
52
52
  const phase_entry_grammar_cjs_1 = require("./phase-entry-grammar.cjs");
53
53
  const pattern_cjs_1 = require("./pattern.cjs");
54
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
54
55
  const gate_exit_cjs_1 = require("./gate-exit.cjs");
55
56
  const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
56
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
@@ -61,7 +62,10 @@ const planningScopeMod = require("./planning-scope.cjs");
61
62
  const { SCOPE } = planningScopeMod;
62
63
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
63
64
  const roadmapParserMod = require("./roadmap-parser.cjs");
64
- const { stripShippedMilestones, extractCurrentMilestone, currentMilestoneRawRanges, withPhaseSection, editCurrentMilestone, findMilestoneScopeHeadingLines, getMilestoneInfo, scanMilestonePhaseIds,
65
+ const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, currentMilestoneRawRanges, withPhaseSection, editCurrentMilestone, findMilestoneScopeHeadingLines, getMilestoneInfo,
66
+ // #5679: the STATE.md milestone read getMilestoneInfo itself uses, and the
67
+ // archive-aware owner of a ROADMAP offset.
68
+ readStateMilestoneScalar, classifyMilestoneScalar, milestoneVersionAtOffset, blankCodeAndComments, scanMilestonePhaseIds,
65
69
  // #4304: every recognized milestone heading in the document,
66
70
  // not just the active one — see bracketProgressSectionOwnedByOtherMilestone.
67
71
  listMilestoneHeadings, selectMilestoneHeading,
@@ -94,7 +98,7 @@ const planning_document_cjs_1 = require("./planning-document.cjs");
94
98
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
95
99
  const clock_cjs_1 = require("./clock.cjs");
96
100
  const state_transition_cjs_1 = require("./state-transition.cjs");
97
- const markdown_table_cjs_1 = require("./markdown-table.cjs");
101
+ const markdown_table_cjs_2 = require("./markdown-table.cjs");
98
102
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
99
103
  const security_cjs_1 = require("./security.cjs");
100
104
  // #5060: the Phase Status Module owns the ROADMAP Status-cell token vocabulary.
@@ -496,6 +500,9 @@ function cmdFindPhase(cwd, phase, raw) {
496
500
  const directoryContext = phaseDirectoryOptions(cwd);
497
501
  const lookup = resolvePhaseDirectoryLookup(cwd, phase, undefined, directoryContext);
498
502
  const { normalized, convention } = lookup;
503
+ // #5676: `lookup.convention` is only ever 'bracket'; the directory token grammar
504
+ // (`03-02-beta` -> `03-02` + `beta`) needs the repo's own convention.
505
+ const tokenConvention = resolvePhaseIdConvention(cwd);
499
506
  const notFound = {
500
507
  found: false,
501
508
  directory: null,
@@ -561,7 +568,7 @@ function cmdFindPhase(cwd, phase, raw) {
561
568
  && BRACKET_DIRECTORY_PREFIX_RE.test(foldBracketId(match));
562
569
  const dirMatch = isBracketDirectory
563
570
  ? null
564
- : match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
571
+ : match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${phaseNumberTokenSource(tokenConvention)})-?(.*)`, 'i')) || match.match(new RegExp(`^(${phaseNumberTokenSource(tokenConvention)})-?(.*)`, 'i'));
565
572
  const customParts = custom ? customPhaseDirectoryParts(match, custom) : null;
566
573
  const phaseNumber = isBracketDirectory
567
574
  ? phaseNumberForMatch(match, usedBareFallback, convention)
@@ -1244,7 +1251,7 @@ function recordNewPhase(raw, cwd, id, description, after, label) {
1244
1251
  if (findPhaseTableHeader(scoped) === -1)
1245
1252
  return scoped;
1246
1253
  const cell = { Phase: `${id}. ${description}`, 'Plans Complete': '0/0', Status: 'Not started' };
1247
- const r = (0, markdown_table_cjs_1.insertTableRow)(scoped, (col) => cell[col]);
1254
+ const r = (0, markdown_table_cjs_2.insertTableRow)(scoped, (col) => cell[col]);
1248
1255
  if (!r.ok)
1249
1256
  return scoped;
1250
1257
  wrote = true;
@@ -1635,6 +1642,54 @@ function assertBracketGeneratedIdentity(command, config, customId) {
1635
1642
  'Custom naming precedence under the bracket convention lands with #5067.');
1636
1643
  }
1637
1644
  }
1645
+ const STATE_MILESTONE_ECHO_LIMIT = 80;
1646
+ /**
1647
+ * #5726: STATE.md's `milestone:` value is untrusted text. Cap it at
1648
+ * `STATE_MILESTONE_ECHO_LIMIT` characters (ellipsis past the cap), then quote it
1649
+ * with `JSON.stringify` (the `phase.cts` precedent for echoed user text), which
1650
+ * escapes ESC/BEL/newlines; the characters JSON leaves raw but a terminal acts on
1651
+ * are escaped too: DEL and C1 controls, zero-width and direction marks
1652
+ * (U+200B-U+200F), line/paragraph separators and bidi embeddings/overrides
1653
+ * (U+2028-U+202E), the invisible-format block (U+2060-U+206F, incl. bidi
1654
+ * isolates), soft hyphen (U+00AD), Arabic letter mark (U+061C), Mongolian
1655
+ * vowel separator (U+180E), interlinear annotations (U+FFF9-U+FFFB) and the
1656
+ * BOM (U+FEFF).
1657
+ */
1658
+ function echoStateMilestone(value) {
1659
+ const chars = Array.from(value);
1660
+ const capped = chars.length > STATE_MILESTONE_ECHO_LIMIT
1661
+ ? `${chars.slice(0, STATE_MILESTONE_ECHO_LIMIT).join('')}…`
1662
+ : value;
1663
+ return JSON.stringify(capped).replace(/[\u007f-\u009f\u{ad}\u{61c}\u{180e}\u{200b}-\u{200f}\u{2028}-\u{202e}\u{2060}-\u{206f}\u{feff}\u{fff9}-\u{fffb}]/gu, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
1664
+ }
1665
+ /**
1666
+ * #5726: the current-milestone window for a verb that WRITES to the ROADMAP.
1667
+ * When STATE.md names a milestone that has no heading in a ROADMAP carrying
1668
+ * versioned milestones, the window owner falls back to the whole unshipped
1669
+ * roadmap (scope UNSCOPED, `unresolvedMilestone` set), and number allocation,
1670
+ * the `Depends on` line and the insert target would run over every milestone.
1671
+ * ADR-3180: that is a non-answer, refused where it is destructive. Refuses
1672
+ * before any write, naming the milestone; every other scope (a real heading,
1673
+ * a free-form legacy ROADMAP, no milestone field) returns the window unchanged.
1674
+ * Read-only readers keep calling `extractCurrentMilestone` and keep degrading.
1675
+ */
1676
+ function currentMilestoneWindowOrRefuse(rawContent, cwd, command) {
1677
+ const { value, unresolvedMilestone, shippedMilestone } = extractCurrentMilestoneScoped(rawContent, cwd);
1678
+ if (shippedMilestone) {
1679
+ error(`${command}: STATE.md names milestone ${echoStateMilestone(shippedMilestone)}, which ROADMAP.md marks as already shipped, `
1680
+ + 'so the current milestone window is a closed one. Refusing to number the phase or link its dependencies '
1681
+ + 'against a shipped milestone. Start the next milestone (or correct the milestone field in STATE.md), then retry. '
1682
+ + 'If the heading is not shipped and its title merely contains a status word (such as "Failed Payment Recovery"), '
1683
+ + 'rename the milestone, or add an active marker to the heading, for example \u{1f6a7} or "(in progress)".');
1684
+ }
1685
+ if (unresolvedMilestone) {
1686
+ error(`${command}: STATE.md names milestone ${echoStateMilestone(unresolvedMilestone)}, but ROADMAP.md has no milestone heading for it, `
1687
+ + 'so the current milestone window cannot be resolved. Refusing to use the whole roadmap, which would number '
1688
+ + 'the phase and link its dependencies against other milestones. Add the milestone heading to ROADMAP.md '
1689
+ + 'or correct the milestone field in STATE.md, then retry.');
1690
+ }
1691
+ return value;
1692
+ }
1638
1693
  /**
1639
1694
  * #4304: refuse a bracket `phase add` / `add-batch` whose located milestone
1640
1695
  * window is a decoy. A non-milestone heading carrying the active version
@@ -1706,9 +1761,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
1706
1761
  const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
1707
1762
  const { newPhaseId, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
1708
1763
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1764
+ const content = currentMilestoneWindowOrRefuse(rawContent, cwd, 'phase add');
1709
1765
  if (bracketContext)
1710
1766
  assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add');
1711
- const content = extractCurrentMilestone(rawContent, cwd);
1712
1767
  let _newPhaseId;
1713
1768
  let _dirName = '';
1714
1769
  if (bracketContext) {
@@ -1815,9 +1870,9 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1815
1870
  const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
1816
1871
  const results = withPlanningLock(cwd, () => {
1817
1872
  let rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1873
+ const content = currentMilestoneWindowOrRefuse(rawContent, cwd, 'phase add-batch');
1818
1874
  if (bracketContext)
1819
1875
  assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add-batch');
1820
- const content = extractCurrentMilestone(rawContent, cwd);
1821
1876
  let maxPhase = 0;
1822
1877
  if (bracketContext) {
1823
1878
  const used = collectBracketPhaseNumbers(content, node_path_1.default.join(planningDir(cwd), 'phases'), bracketContext);
@@ -2187,6 +2242,34 @@ function isPhaseTokenWithin(token, ancestor) {
2187
2242
  const canon = (segment) => segment.replace(/^0+(?=\d)/, '').toLowerCase();
2188
2243
  return a.every((segment, i) => canon(segment) === canon(t[i]));
2189
2244
  }
2245
+ /**
2246
+ * #5679: the `phase insert` error for a phase that exists in the ROADMAP but
2247
+ * outside the current milestone. Names the milestone that holds it (archive
2248
+ * aware, never the current one) and the current milestone, saying "STATE.md's"
2249
+ * only when STATE.md's `milestone:` is where that version came from.
2250
+ */
2251
+ function otherMilestonePhaseMessage(rawContent, foundAt, cwd, afterPhase) {
2252
+ let stateVersion = null;
2253
+ try {
2254
+ const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'STATE.md'));
2255
+ const scalar = stateRaw === null ? null : readStateMilestoneScalar(stateRaw);
2256
+ if (scalar !== null)
2257
+ stateVersion = classifyMilestoneScalar(scalar).version;
2258
+ }
2259
+ catch { /* unreadable STATE.md: getMilestoneInfo's own fallback below decides */ }
2260
+ const derivedVersion = getMilestoneInfo(cwd).value?.version ?? null;
2261
+ const current = stateVersion ?? derivedVersion;
2262
+ const owner = milestoneVersionAtOffset(rawContent, foundAt);
2263
+ const ownerClause = owner && owner.toLowerCase() !== current?.toLowerCase()
2264
+ ? `is in milestone ${owner}`
2265
+ : 'is outside the current milestone';
2266
+ let currentClause = 'the current milestone does not include it';
2267
+ if (stateVersion)
2268
+ currentClause = `STATE.md's current milestone is ${stateVersion}`;
2269
+ else if (derivedVersion)
2270
+ currentClause = `the current milestone is ${derivedVersion}`;
2271
+ return `Phase ${afterPhase} ${ownerClause}, but ${currentClause}. phase insert only targets phases in the current milestone.`;
2272
+ }
2190
2273
  function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested') {
2191
2274
  if (!afterPhase || !description) {
2192
2275
  error('after-phase and description required for phase insert');
@@ -2202,7 +2285,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2202
2285
  const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, insertConfig) : null;
2203
2286
  const { decimalPhase, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
2204
2287
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
2205
- const content = extractCurrentMilestone(rawContent, cwd);
2288
+ const content = currentMilestoneWindowOrRefuse(rawContent, cwd, 'phase insert');
2206
2289
  // #4304: canonicalize a bracket argument
2207
2290
  // through the SAME adapter `phase remove` uses
2208
2291
  // (canonicalizeBracketPhaseArgument) instead of the legacy
@@ -2257,7 +2340,8 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2257
2340
  const anyHeadingPattern = new RegExp(`#{2,4}\\s*${phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, insertConvention)}\\d`, 'i');
2258
2341
  const roadmapHasHeadingPhases = anyHeadingPattern.test(content);
2259
2342
  // #4304: bracket identities live in headings only, so a bracket ROADMAP never takes the legacy bullet-insertion branch — a bullet-only bracket ROADMAP falls through to the checklist-refusal path below instead.
2260
- const isBulletStyle = !bracketContext && !headingMatch && bulletPattern.test(content) && !roadmapHasHeadingPhases;
2343
+ const liveCurrent = blankCodeAndComments(content);
2344
+ const isBulletStyle = !bracketContext && !headingMatch && bulletPattern.test(liveCurrent) && !roadmapHasHeadingPhases;
2261
2345
  if (bracketContext && !bracketHeading) {
2262
2346
  const searchedRanges = bracketSectionRanges?.details
2263
2347
  ? 'primary and Phase Details ranges'
@@ -2266,9 +2350,19 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2266
2350
  }
2267
2351
  if (!headingMatch && !isBulletStyle) {
2268
2352
  const checklistPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${headingIntro}${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
2269
- if (checklistPattern.test(content)) {
2353
+ if (checklistPattern.test(liveCurrent)) {
2270
2354
  error(`Phase ${afterPhase} exists in roadmap summary but is missing a detail section (### Phase ${afterPhase}: ...).`);
2271
2355
  }
2356
+ // #5679: this whole-file probe only chooses the error message; insert
2357
+ // stays current-milestone-only and never writes to another milestone.
2358
+ // Fenced blocks and HTML comments are masked (same length, offsets stay aligned) so example text never counts as a phase.
2359
+ const liveContent = blankCodeAndComments(rawContent);
2360
+ const headingOffset = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(liveContent).find((h) => targetPattern.test(h.text))?.offset;
2361
+ const checklistOffset = checklistPattern.exec(liveContent)?.index;
2362
+ const foundOffsets = [headingOffset, checklistOffset].filter((o) => o !== undefined);
2363
+ if (foundOffsets.length > 0) {
2364
+ error(otherMilestonePhaseMessage(rawContent, Math.min(...foundOffsets), cwd, afterPhase));
2365
+ }
2272
2366
  error(`Phase ${afterPhase} not found in ROADMAP.md`);
2273
2367
  }
2274
2368
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
@@ -2333,7 +2427,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2333
2427
  let roadmapEntry;
2334
2428
  if (isBulletStyle) {
2335
2429
  const boldBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:`, 'i', { bold: 'required' });
2336
- const useBold = boldBulletPattern.test(content);
2430
+ const useBold = boldBulletPattern.test(liveCurrent);
2337
2431
  const phaseLabel = useBold
2338
2432
  ? `**Phase ${_decimalPhase}: ${description}**`
2339
2433
  : `Phase ${_decimalPhase}: ${description}`;
@@ -2356,11 +2450,12 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2356
2450
  // true line-content boundary so bulletLineEnd lands cleanly before the
2357
2451
  // terminator.
2358
2452
  const targetBulletPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+${afterPhaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\r\\n]*`, 'i');
2359
- const bulletMatchResult = rawContent.match(targetBulletPattern);
2453
+ // Match against the masked text (same length, offsets aligned) so a fenced or commented example bullet is never the splice anchor; splice into the original text at the masked match index.
2454
+ const bulletMatchResult = blankCodeAndComments(rawContent).match(targetBulletPattern);
2360
2455
  if (!bulletMatchResult) {
2361
2456
  error(`Could not find Phase ${afterPhase} bullet line`);
2362
2457
  }
2363
- const bulletLineEnd = rawContent.indexOf(bulletMatchResult[0]) + bulletMatchResult[0].length;
2458
+ const bulletLineEnd = bulletMatchResult.index + bulletMatchResult[0].length;
2364
2459
  const afterBullet = rawContent.slice(bulletLineEnd);
2365
2460
  // #5599: land after the last bullet inside the allocation family's
2366
2461
  // subtree, not before the first following bullet of any number (which is
@@ -2791,7 +2886,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2791
2886
  return parseInt(leadingMatch[1], 10) === removedInt;
2792
2887
  };
2793
2888
  content = (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (progressSection) => {
2794
- const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
2889
+ const deleteResult = (0, markdown_table_cjs_2.deleteTableRow)(progressSection, matchRemovedProgressRow);
2795
2890
  return deleteResult.ok ? deleteResult.value : progressSection;
2796
2891
  }, { headingless: 'skip' });
2797
2892
  if (!isDecimal && !customTarget) {
@@ -2871,7 +2966,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2871
2966
  let matchedRowIndex = null;
2872
2967
  for (;;) {
2873
2968
  matchedRowIndex = null;
2874
- const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
2969
+ const cellResult = (0, markdown_table_cjs_2.updateTableCell)(ordinalSection, (row, index) => {
2875
2970
  if (processedOrdinalRows.has(index))
2876
2971
  return false;
2877
2972
  const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
@@ -2892,11 +2987,11 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2892
2987
  const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
2893
2988
  const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
2894
2989
  const padMatch = targetLine
2895
- ? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_1.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
2990
+ ? new RegExp(`^[ \\t]*\\|(\\s*)${(0, pattern_cjs_1.escapeRegex)((0, markdown_table_cjs_2.escapeCell)(current))}(\\s*)\\|`).exec(targetLine)
2896
2991
  : null;
2897
2992
  const leadPad = padMatch ? padMatch[1] : ' ';
2898
2993
  const trailPad = padMatch ? padMatch[2] : ' ';
2899
- return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
2994
+ return `${leadPad}${(0, markdown_table_cjs_2.escapeCell)(newContent)}${trailPad}`;
2900
2995
  });
2901
2996
  if (!cellResult.ok)
2902
2997
  break;
@@ -3486,7 +3581,7 @@ function classifyBracketOwnedLine(line) {
3486
3581
  if (checklistId)
3487
3582
  return { kind: 'checklist', id: checklistId };
3488
3583
  if (/^[ \t]*\|/.test(line)) {
3489
- const firstCell = (0, markdown_table_cjs_1.splitTableRow)(line)[0]?.replace(/^\*\*(.*)\*\*$/, '$1') ?? '';
3584
+ const firstCell = (0, markdown_table_cjs_2.splitTableRow)(line)[0]?.replace(/^\*\*(.*)\*\*$/, '$1') ?? '';
3490
3585
  const progressId = phaseIdFromOwnedLineMatch(BRACKET_CELL_ID_RE.exec(firstCell));
3491
3586
  if (progressId)
3492
3587
  return { kind: 'progress', id: progressId };
@@ -4848,7 +4943,7 @@ function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
4848
4943
  const heading = matchedHeading.text.match(headingPattern);
4849
4944
  if (!heading)
4850
4945
  return null;
4851
- const name = heading[1].replace(/\(INSERTED\)/i, '').trim();
4946
+ const name = (0, phase_entry_grammar_cjs_1.stripInsertedMarker)(heading[1]);
4852
4947
  return name || null;
4853
4948
  }
4854
4949
  function phaseDisplayNameFromSlug(slug) {
@@ -6027,12 +6122,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6027
6122
  // (`editProgressTableSlice` -> `editRoadmapProgress`), the same one `roadmap update-plan-progress` uses.
6028
6123
  s = editProgressTableSlice(s, (scoped) => {
6029
6124
  let text = scoped;
6030
- const plansResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
6125
+ const plansResult = (0, markdown_table_cjs_2.updateTableCell)(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
6031
6126
  if (plansResult.ok)
6032
6127
  text = plansResult.value;
6033
6128
  // #5165: a Status cell that already reads Complete is left untouched —
6034
6129
  // re-padding it rewrote a cell the re-run had no business editing.
6035
- const statusResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Status', (current) => (0, phase_status_cjs_1.parseRoadmapStatusCell)(current) === phase_status_cjs_1.WIRE_STATUS.COMPLETE
6130
+ const statusResult = (0, markdown_table_cjs_2.updateTableCell)(text, rowMatch, 'Status', (current) => (0, phase_status_cjs_1.parseRoadmapStatusCell)(current) === phase_status_cjs_1.WIRE_STATUS.COMPLETE
6036
6131
  ? current
6037
6132
  : ` ${(0, phase_status_cjs_1.toRoadmapStatusCell)(phase_status_cjs_1.PHASE_STATUS.COMPLETE).padEnd(11)} `);
6038
6133
  if (statusResult.ok)
@@ -6044,7 +6139,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6044
6139
  // findTableWithColumns (which requires the WHOLE table to parse —
6045
6140
  // a ragged SIBLING row elsewhere used to silently no-op this
6046
6141
  // row's date stamp too).
6047
- const completedResult = (0, markdown_table_cjs_1.updateTableCell)(text, rowMatch, 'Completed', (current) => dateShape.test(current.trim()) ? current : ` ${today} `);
6142
+ const completedResult = (0, markdown_table_cjs_2.updateTableCell)(text, rowMatch, 'Completed', (current) => dateShape.test(current.trim()) ? current : ` ${today} `);
6048
6143
  if (completedResult.ok)
6049
6144
  text = completedResult.value;
6050
6145
  return text;
@@ -6182,7 +6277,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6182
6277
  // checkbox rollback below can distinguish "row existed and accepted" from
6183
6278
  // "row existed and rejected".
6184
6279
  let tableHit = false;
6185
- const reqUpdate = (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, reqRowMatch, 'Status', (current) => {
6280
+ const reqUpdate = (0, markdown_table_cjs_2.updateTraceabilityCell)(reqContent, reqRowMatch, 'Status', (current) => {
6186
6281
  // #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
6187
6282
  // gaps_found response) can complete without hand-editing the table.
6188
6283
  if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
@@ -6359,9 +6454,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6359
6454
  // which section (deferred or not) it lives under.
6360
6455
  const reqIsRegisteredAnywhere = (id) => {
6361
6456
  const reqEscaped = (0, pattern_cjs_1.escapeRegex)(id);
6362
- // Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
6363
- // insensitive: existence check, not the write's space-only match.
6364
- if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
6457
+ // Surface 1 — checkbox, ANY single mark (`[ ]`, `[x]`, `[~]`, …; #5844),
6458
+ // case-insensitive: existence check, not the write's space-only match.
6459
+ if (new RegExp(`-\\s*\\[[^\\]\\r\\n]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
6365
6460
  return true;
6366
6461
  }
6367
6462
  // Surface 2 — Traceability row exists at all (any Status value),
@@ -6369,7 +6464,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6369
6464
  // technique milestone.cts's `hasRow` uses (:210-214): a case-
6370
6465
  // insensitive first-cell match, regardless of current Status.
6371
6466
  const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
6372
- return (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
6467
+ // #5844: a table with no Status column fails the probe above with
6468
+ // `unknown column`, so row existence is also read directly.
6469
+ return (0, markdown_table_cjs_2.updateTraceabilityCell)(reqContent, rowProbeMatch, 'Status', (current) => current).ok
6470
+ || (0, markdown_table_cjs_1.traceabilityRowExists)(reqContent, id);
6373
6471
  };
6374
6472
  const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
6375
6473
  if (ghostReqIds.length > 0) {
@@ -6381,8 +6479,15 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6381
6479
  // IDs already reported above as ghosts to avoid a duplicate message
6382
6480
  // for the same root cause.
6383
6481
  const traceabilityWriteFailures = traceabilityWriteMisses.filter((id) => !ghostReqIds.includes(id));
6384
- if (traceabilityWriteFailures.length > 0) {
6385
- warnings.push(`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (no matching row found): ${traceabilityWriteFailures.join(', ')}`);
6482
+ // #5844: a row that EXISTS but has no Status cell to write is not a
6483
+ // missing row — name the real cause instead of blaming the row.
6484
+ const noStatusCellIds = traceabilityWriteFailures.filter((id) => (0, markdown_table_cjs_1.traceabilityRowExists)(reqContent, id));
6485
+ const noRowIds = traceabilityWriteFailures.filter((id) => !noStatusCellIds.includes(id));
6486
+ if (noRowIds.length > 0) {
6487
+ warnings.push(`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (no matching row found): ${noRowIds.join(', ')}`);
6488
+ }
6489
+ if (noStatusCellIds.length > 0) {
6490
+ warnings.push(`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (row found but the table has no Status column to write): ${noStatusCellIds.join(', ')}`);
6386
6491
  }
6387
6492
  writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
6388
6493
  // #2316-3: `requirements_updated` must reflect whether REQUIREMENTS.md
@@ -6424,6 +6529,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6424
6529
  // where the roadmap would give `2` and a slugified title. Promoting the
6425
6530
  // roadmap without this would silently change the reported value on
6426
6531
  // every aligned project, which is the majority case.
6532
+ // #5676: the numeric phase-token grammar follows the convention, so a
6533
+ // milestone-prefixed `03-02-beta` is phase `3-02`, not phase `3`.
6534
+ const phaseConvention = resolvePhaseIdConvention(cwd);
6427
6535
  let diskNextNum = null;
6428
6536
  let diskNextName = null;
6429
6537
  let roadmapNextNum = null;
@@ -6442,7 +6550,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6442
6550
  if (roadmapContent !== null) {
6443
6551
  try {
6444
6552
  const milestoneForComplete = extractCurrentMilestone(roadmapContent, cwd);
6445
- const completePattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext)})`, 'gi');
6553
+ const completePattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})`, 'gi');
6446
6554
  let cm;
6447
6555
  while ((cm = completePattern.exec(milestoneForComplete)) !== null) {
6448
6556
  if (!(0, phase_entry_grammar_cjs_1.isPhaseEntryChecked)(cm[0]))
@@ -6472,7 +6580,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6472
6580
  for (const dir of dirs) {
6473
6581
  const custom = phaseIdMod.customPhaseQuery(dir, directoryContext);
6474
6582
  const owner = custom && customDirectoryOwner(dir, custom);
6475
- const dm = owner ? [dir, owner.id, owner.slug] : dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
6583
+ const dm = owner ? [dir, owner.id, owner.slug] : dir.match(new RegExp(`^(${phaseNumberTokenSource(phaseConvention)})-?(.*)`, 'i'));
6476
6584
  if (dm) {
6477
6585
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this was a local 999-only literal that admitted Phase 0.
6478
6586
  if (isSentinelPhaseId(dm[1]))
@@ -6535,8 +6643,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6535
6643
  // checkbox branch widens, and only to the separators #2199 already
6536
6644
  // accepts. The two branches keep separate capture groups, normalized
6537
6645
  // just below the loop.
6538
- const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
6539
- `|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gim');
6646
+ const phasePattern = new RegExp(`(?:#{2,4}\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)` +
6647
+ `|${(0, phase_entry_grammar_cjs_1.phaseEntryPrefixSrc)()}Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+))`, 'gim');
6540
6648
  let pm;
6541
6649
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
6542
6650
  // #4078: normalize the two alternation branches' captures (heading
@@ -6570,9 +6678,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6570
6678
  if (comparePhaseNum(pmNum, phaseNum, directoryContext) > 0
6571
6679
  && (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum, directoryContext) < 0)) {
6572
6680
  roadmapNextNum = pmNum;
6573
- roadmapNextName = pmName
6574
- .replace(/\(INSERTED\)/i, '')
6575
- .trim()
6681
+ roadmapNextName = (0, phase_entry_grammar_cjs_1.stripInsertedMarker)(pmName)
6576
6682
  .toLowerCase()
6577
6683
  .replace(/\s+/g, '-');
6578
6684
  }
@@ -6636,7 +6742,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6636
6742
  // Without it, this lowest-outstanding override was blind to dash-grammar
6637
6743
  // rows and could not correct an out-of-order completion on the same
6638
6744
  // mixed-grammar roadmaps that broke stage 2.
6639
- const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi', { state: 'captured' });
6745
+ const cbPattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`Phase\\s+(${phaseScanTokenSource(directoryContext, phaseConvention)})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*[—–:\\-]\\s*([^\\n*]+)`, 'gi', { state: 'captured' });
6640
6746
  let cbm;
6641
6747
  let lowestOutstanding = null;
6642
6748
  while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
@@ -6652,7 +6758,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6652
6758
  if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num, directoryContext) < 0) {
6653
6759
  lowestOutstanding = {
6654
6760
  num: cbm[2],
6655
- name: cbm[3].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'),
6761
+ name: (0, phase_entry_grammar_cjs_1.stripInsertedMarker)(cbm[3]).toLowerCase().replace(/\s+/g, '-'),
6656
6762
  };
6657
6763
  }
6658
6764
  }
@@ -6685,7 +6791,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6685
6791
  // pin would all rewind it. A first-time completion (box unchecked, or
6686
6792
  // STATE not past N, #2028 out-of-order) still transitions.
6687
6793
  const stateAlreadyPast = phaseWasAlreadyComplete
6688
- && (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath);
6794
+ && (0, state_transition_cjs_1.isStatePastPhase)(originalStateContent, phaseNum, statePath, phaseConvention);
6689
6795
  let stateContent = originalStateContent;
6690
6796
  // ADR-1769 Phase 3: the STATE.md field-update policy (Current Phase
6691
6797
  // shape/name, Status, Current Plan, Last Activity + Description, and
@@ -6715,7 +6821,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6715
6821
  const nextInfo = findPhaseInternal(cwd, nextPhaseNum, undefined, directoryContext);
6716
6822
  if (nextInfo && nextInfo.found && nextInfo.directory) {
6717
6823
  const { value } = (0, phase_status_cjs_1.phaseStatus)(node_path_1.default.join(cwd, nextInfo.directory), {
6718
- convention: resolvePhaseIdConvention(cwd),
6824
+ convention: phaseConvention,
6719
6825
  });
6720
6826
  nextPhaseDisk = { status: value.status, planCount: value.planCount, summaryCount: value.summaryCount };
6721
6827
  }
@@ -6882,22 +6988,39 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6882
6988
  error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}${indeterminateNote}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE, { verification_stale_check_indeterminate: staleCheckIndeterminate });
6883
6989
  }
6884
6990
  let autoPruned = false;
6991
+ // #5831: a failed auto-prune stays non-fatal (exit code and completion are
6992
+ // unchanged), but is reported instead of swallowed — prune now fails loudly
6993
+ // when it cannot write the archive, and that must reach the user.
6994
+ let autoPruneError = null;
6885
6995
  try {
6886
6996
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
6887
- if (node_fs_1.default.existsSync(configPath)) {
6888
- const rawConfig = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
6997
+ // Bounded read (#5759): null is an absent file; a FIFO/device/oversized one throws to the
6998
+ // best-effort catch below.
6999
+ const rawConfigText = configLoaderMod.readConfigFileBounded(configPath);
7000
+ if (rawConfigText !== null) {
7001
+ const rawConfig = JSON.parse(rawConfigText);
6889
7002
  const workflow = rawConfig['workflow'];
6890
7003
  const autoPruneEnabled = workflow && workflow['auto_prune_state'] === true;
6891
7004
  if (autoPruneEnabled && node_fs_1.default.existsSync(statePath)) {
6892
7005
  // Non-hoisted: load-order matters (stateMod must be fully resolved first).
6893
7006
  const { cmdStatePrune } = stateMod;
6894
- cmdStatePrune(cwd, { keepRecent: '3', dryRun: false, silent: true }, true);
6895
- autoPruned = true;
7007
+ try {
7008
+ const pruneResult = cmdStatePrune(cwd, { keepRecent: '3', dryRun: false, silent: true }, true);
7009
+ // Only a prune that moved entries counts: a no-op prune leaves auto_pruned false.
7010
+ autoPruned = pruneResult.pruned === true;
7011
+ }
7012
+ catch (err) {
7013
+ // Best-effort: completion proceeds; STATE.md and STATE-ARCHIVE.md are as
7014
+ // the failed prune left them (it never loses entries).
7015
+ autoPruneError = err instanceof Error ? err.message : String(err);
7016
+ warnings.push(`auto-prune of STATE.md failed (phase completion is unaffected; run \`state prune\` to retry): ${autoPruneError}`);
7017
+ }
6896
7018
  }
6897
7019
  }
6898
7020
  }
6899
7021
  catch {
6900
- /* intentionally empty — auto-prune is best-effort */
7022
+ // Best-effort: an unreadable or invalid config.json means auto-prune is not
7023
+ // enabled; completion proceeds silently.
6901
7024
  }
6902
7025
  const result = {
6903
7026
  completed_phase: phaseNum,
@@ -6911,6 +7034,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6911
7034
  state_updated: stateUpdated,
6912
7035
  requirements_updated: requirementsUpdated,
6913
7036
  auto_pruned: autoPruned,
7037
+ // #5831: ADDITIVE — present only when auto-prune threw.
7038
+ ...(autoPruneError !== null ? { auto_prune_error: autoPruneError } : {}),
6914
7039
  warnings,
6915
7040
  has_warnings: warnings.length > 0,
6916
7041
  // ADDITIVE, never a change to `warnings[]`'s element shape — that array is
@@ -85,10 +85,11 @@ function isHaltedStatus(status) {
85
85
  * @param summaryPath - absolute or relative path to a `*-SUMMARY.md` file.
86
86
  */
87
87
  function isSummaryFileHalted(summaryPath) {
88
+ const content = readSummaryFrontmatterPrefix(summaryPath);
89
+ if (content === null)
90
+ return false;
88
91
  try {
89
- const content = node_fs_1.default.readFileSync(summaryPath, 'utf-8');
90
- const fm = extractFrontmatter(content, summaryPath);
91
- return isHaltedStatus(fm['status']);
92
+ return isHaltedStatus(extractFrontmatter(content, summaryPath)['status']);
92
93
  }
93
94
  catch {
94
95
  return false;
@@ -140,6 +141,46 @@ function isBlockedStatus(status) {
140
141
  // kept here because this predicate's primary caller (scanPhasePlans) loops
141
142
  // over every phase directory on hot paths (state sync, roadmap progress).
142
143
  const SUMMARY_FRONTMATTER_READ_CAP = 64 * 1024;
144
+ /**
145
+ * #5789: the ONE reader behind both SUMMARY status predicates
146
+ * (`isSummaryFileHalted`, `isSummaryFileBlocked`), so they agree on which
147
+ * inputs are read. Opens O_NONBLOCK (never blocks on a FIFO; the flag is
148
+ * undefined on Windows, where FIFOs do not exist), decides the type on the
149
+ * opened fd (no stat-then-open swap window), refuses anything but a regular
150
+ * file (FIFO, /dev/zero via a symlink, socket, directory), and reads at most
151
+ * SUMMARY_FRONTMATTER_READ_CAP bytes from offset 0. Symlinks to regular files
152
+ * are still followed. Returns null on any error or non-regular input.
153
+ */
154
+ function readSummaryFrontmatterPrefix(summaryPath) {
155
+ let fd;
156
+ try {
157
+ fd = node_fs_1.default.openSync(summaryPath, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NONBLOCK || 0));
158
+ if (!node_fs_1.default.fstatSync(fd).isFile())
159
+ return null;
160
+ const buf = Buffer.allocUnsafe(SUMMARY_FRONTMATTER_READ_CAP);
161
+ let total = 0;
162
+ while (total < SUMMARY_FRONTMATTER_READ_CAP) {
163
+ const n = node_fs_1.default.readSync(fd, buf, total, SUMMARY_FRONTMATTER_READ_CAP - total, total);
164
+ if (n === 0)
165
+ break;
166
+ total += n;
167
+ }
168
+ return buf.toString('utf8', 0, total);
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ finally {
174
+ if (fd !== undefined) {
175
+ try {
176
+ node_fs_1.default.closeSync(fd);
177
+ }
178
+ catch {
179
+ // close failure on a read-only fd is not actionable
180
+ }
181
+ }
182
+ }
183
+ }
143
184
  /**
144
185
  * #3345: read a SUMMARY file's frontmatter `status` (bounded-prefix read) and
145
186
  * report whether it declares `status: blocked`. Returns false — never throws —
@@ -153,27 +194,9 @@ const SUMMARY_FRONTMATTER_READ_CAP = 64 * 1024;
153
194
  * re-diverge on the blocked rule.
154
195
  */
155
196
  function isSummaryFileBlocked(summaryPath) {
156
- let content;
157
- try {
158
- const st = node_fs_1.default.statSync(summaryPath); // follows symlinks → resolves to the target's real type
159
- if (!st.isFile())
160
- return false;
161
- const length = Math.min(st.size, SUMMARY_FRONTMATTER_READ_CAP);
162
- if (length === 0)
163
- return false;
164
- const fd = node_fs_1.default.openSync(summaryPath, 'r');
165
- try {
166
- const buf = Buffer.allocUnsafe(length);
167
- const bytesRead = node_fs_1.default.readSync(fd, buf, 0, length, 0);
168
- content = buf.toString('utf8', 0, bytesRead);
169
- }
170
- finally {
171
- node_fs_1.default.closeSync(fd);
172
- }
173
- }
174
- catch {
197
+ const content = readSummaryFrontmatterPrefix(summaryPath);
198
+ if (content === null || content.length === 0)
175
199
  return false;
176
- }
177
200
  return isBlockedStatus(extractFrontmatter(content, summaryPath)['status']);
178
201
  }
179
202
  /**