@opengsd/gsd-core 1.20.1 → 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 (135) 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 +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -26,6 +26,7 @@ exports.appendQuickTaskRow = appendQuickTaskRow;
26
26
  exports.migrateQuickTasksTable = migrateQuickTasksTable;
27
27
  exports.resetQuickTaskRows = resetQuickTaskRows;
28
28
  exports.updateTraceabilityCell = updateTraceabilityCell;
29
+ exports.traceabilityRowExists = traceabilityRowExists;
29
30
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
30
31
  // ─── Schema registry ──────────────────────────────────────────────────────────
31
32
  /**
@@ -979,6 +980,8 @@ function resetQuickTaskRows(stateContent) {
979
980
  const content = (0, markdown_sectionizer_cjs_1.replaceSection)(stateContent, section, newBody);
980
981
  return { ok: true, value: { content, cleared, variant: match.label } };
981
982
  }
983
+ /** The one Traceability heading rule shared by the write and the existence read. */
984
+ const TRACEABILITY_HEADING_RE = /^Traceability(?:[ \t]+Status)?\b/i;
982
985
  /**
983
986
  * `updateTableCell` scoped to REQUIREMENTS.md's `## Traceability` (or
984
987
  * `## Traceability Status`) section (#2245 F1, #5762): `updateTableCell` binds
@@ -991,7 +994,7 @@ function resetQuickTaskRows(stateContent) {
991
994
  */
992
995
  function updateTraceabilityCell(text, match, column, newValue) {
993
996
  const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text);
994
- const index = headings.findIndex((h) => h.level === 2 && /^Traceability(?:[ \t]+Status)?\b/i.test(h.text));
997
+ const index = headings.findIndex((h) => h.level === 2 && TRACEABILITY_HEADING_RE.test(h.text));
995
998
  if (index === -1)
996
999
  return updateTableCell(text, match, column, newValue);
997
1000
  let result = { ok: false, reason: 'no matching row' };
@@ -1001,5 +1004,40 @@ function updateTraceabilityCell(text, match, column, newValue) {
1001
1004
  });
1002
1005
  return result.ok ? { ok: true, value: edited } : result;
1003
1006
  }
1007
+ /**
1008
+ * Row-existence READ for REQUIREMENTS.md's Traceability section (#5844): true
1009
+ * when any table row in the section has `id` (case-insensitive) as its first
1010
+ * cell, whatever the table's columns. `updateTraceabilityCell(..., 'Status', …)`
1011
+ * answers "is there a row" only for tables that HAVE a Status column; this
1012
+ * answers it for every table. Scoping mirrors `updateTraceabilityCell`: the
1013
+ * same heading rule, and only the FIRST table of the scoped text (with no
1014
+ * Traceability heading that is the first table in the file), so a table
1015
+ * elsewhere whose first cell equals the ID is not a Traceability row.
1016
+ */
1017
+ function traceabilityRowExists(text, id) {
1018
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text);
1019
+ const index = headings.findIndex((h) => h.level === 2 && TRACEABILITY_HEADING_RE.test(h.text));
1020
+ let scoped = text;
1021
+ if (index !== -1) {
1022
+ (0, markdown_sectionizer_cjs_1.editHeadingSlice)(text, (0, markdown_sectionizer_cjs_1.headingSliceRange)(text, headings, index, 2), (slice) => {
1023
+ scoped = slice;
1024
+ return slice;
1025
+ });
1026
+ }
1027
+ const wanted = id.trim().toLowerCase();
1028
+ let inTable = false;
1029
+ for (const line of scoped.split(/\r?\n/)) {
1030
+ if (!line.trim().startsWith('|')) {
1031
+ if (inTable)
1032
+ return false;
1033
+ continue;
1034
+ }
1035
+ inTable = true;
1036
+ const cells = splitTableRow(line);
1037
+ if (cells.length > 0 && cells[0].trim().toLowerCase() === wanted)
1038
+ return true;
1039
+ }
1040
+ return false;
1041
+ }
1004
1042
  // Consumers: require('../gsd-core/bin/lib/markdown-table.cjs')
1005
1043
  // Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).
@@ -43,7 +43,7 @@ const pattern_cjs_1 = require("./pattern.cjs");
43
43
  const milestone_release_map_cjs_1 = require("./milestone-release-map.cjs");
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const roadmapParserMod = require("./roadmap-parser.cjs");
46
- const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo, sliceMilestoneWindow, } = roadmapParserMod;
46
+ const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo, sliceMilestoneWindow, classifyMilestoneScalar, readStateMilestoneScalar, sameMilestoneVersion, } = roadmapParserMod;
47
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports
48
48
  const planningScopeMod = require("./planning-scope.cjs");
49
49
  const { SCOPE } = planningScopeMod;
@@ -225,7 +225,9 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
225
225
  // Surface traceability drift: checkbox reconciled (this run or before) but the
226
226
  // table has no row for this ID. This is what makes a partial reconcile
227
227
  // distinguishable from a full one (#2140).
228
- if (hasTable && doneCheckbox && !hasRow) {
228
+ // #5844: a row in a table with no Status column is still a row; only the
229
+ // Status write is impossible there, so existence is read directly.
230
+ if (hasTable && doneCheckbox && !hasRow && !(0, markdown_table_cjs_1.traceabilityRowExists)(reqContent, reqId)) {
229
231
  tableUnmatched.push(reqId);
230
232
  }
231
233
  }
@@ -603,15 +605,15 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
603
605
  try {
604
606
  const stateRaw = node_fs_1.default.existsSync(statePath) ? node_fs_1.default.readFileSync(statePath, 'utf-8') : null;
605
607
  if (stateRaw) {
606
- const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
607
- if (milestoneMatch)
608
- stateVersion = milestoneMatch[1].trim();
608
+ // One owner for the STATE `milestone:` scalar (#5726): quoted, null and
609
+ // empty values and a body `milestone:` line are not drift.
610
+ stateVersion = classifyMilestoneScalar(readStateMilestoneScalar(stateRaw)).version;
609
611
  }
610
612
  }
611
613
  catch {
612
614
  /* skip — stateVersion stays null, scan still runs */
613
615
  }
614
- if (stateVersion !== null && stateVersion !== version) {
616
+ if (stateVersion !== null && !sameMilestoneVersion(stateVersion, version)) {
615
617
  // #2946: emit a WARNING so the suspicious STATE mismatch is visible
616
618
  // rather than silently disarming the guard. Plain-text diagnostic on
617
619
  // stderr, matching the existing [gsd-tools] WARNING convention
@@ -11,7 +11,7 @@
11
11
  * epic #1267; callers import resolvers from model-resolver.cjs directly.
12
12
  *
13
13
  * Dependencies (leaf modules only):
14
- * - node:fs / node:path (read the per-install .gsd-runtime marker + project config for the #2297 omit gate)
14
+ * - node:path (project config path for the #2297 omit gate; read via config-loader's bounded reader, #5759)
15
15
  * - ./runtime-name-policy.cjs (resolveRuntimeNameFromCandidates — canonicalize the active runtime)
16
16
  * - ./planning-workspace.cjs (planningDir — workstream/project-aware project-config path)
17
17
  * - ./config-loader.cjs (loadConfig)
@@ -25,14 +25,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  };
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const configLoaderModule = require("./config-loader.cjs");
28
- const { loadConfig } = configLoaderModule;
28
+ const { loadConfig, readConfigFileBounded } = configLoaderModule;
29
29
  // ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─
30
30
  const configuration_cjs_1 = require("./configuration.cjs");
31
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
32
  const modelProfiles = require("./model-profiles.cjs");
33
33
  const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
34
34
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
35
- const node_fs_1 = __importDefault(require("node:fs"));
36
35
  const node_path_1 = __importDefault(require("node:path"));
37
36
  const runtime_name_policy_cjs_1 = require("./runtime-name-policy.cjs");
38
37
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
@@ -87,7 +86,12 @@ function projectExplicitlySetsOmit(cwd) {
87
86
  const layers = wsDir === rootDir ? [rootDir] : [wsDir, rootDir]; // workstream > root
88
87
  for (const dir of layers) {
89
88
  try {
90
- const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'config.json'), 'utf8'));
89
+ // Same bounded regular-file reader as the loader (#5759): a FIFO/device/oversized
90
+ // layer throws instead of blocking, and an absent layer returns null.
91
+ const raw = readConfigFileBounded(node_path_1.default.join(dir, 'config.json'));
92
+ if (raw === null)
93
+ continue;
94
+ const parsed = JSON.parse(raw);
91
95
  const value = parsed?.['resolve_model_ids'];
92
96
  // First layer that sets the key wins (matches loadConfig's deep-merge
93
97
  // precedence). A layer that omits the key falls through to the next.
@@ -32,6 +32,7 @@ exports.phaseEntryRegExp = phaseEntryRegExp;
32
32
  exports.phaseEntrySplitRegExp = phaseEntrySplitRegExp;
33
33
  exports.isPhaseEntryChecked = isPhaseEntryChecked;
34
34
  exports.isBoxCharChecked = isBoxCharChecked;
35
+ exports.stripInsertedMarker = stripInsertedMarker;
35
36
  const MARKER_SRC = '[-*]';
36
37
  // A list item has 1 to 4 spaces or tabs after its marker (CommonMark: none is not a list item,
37
38
  // five or more makes the rest an indented code block), so `-[ ] **Phase 1**` is not an entry.
@@ -117,3 +118,16 @@ function isPhaseEntryChecked(entryText) {
117
118
  function isBoxCharChecked(boxChar) {
118
119
  return boxChar.toLowerCase() === 'x';
119
120
  }
121
+ /**
122
+ * #5824: `phase insert` writes `(INSERTED)` into the heading text as
123
+ * bookkeeping; it is not part of the phase's name. The single place that
124
+ * removes it: only a TRAILING `(INSERTED)` (case-insensitive) is dropped, then
125
+ * a trim — `phase insert` always appends it last, and a `(INSERTED)` inside the
126
+ * user's own text is kept. A name that is only the marker is returned as
127
+ * written rather than reduced to an empty name. Every phase-name reader goes
128
+ * through here so none can drift from the others.
129
+ */
130
+ function stripInsertedMarker(name) {
131
+ const stripped = name.replace(/\s*\(INSERTED\)\s*$/i, '').trim();
132
+ return stripped === '' ? name.trim() : stripped;
133
+ }
@@ -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;
@@ -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,7 @@ 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,
65
+ const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, currentMilestoneRawRanges, withPhaseSection, editCurrentMilestone, findMilestoneScopeHeadingLines, getMilestoneInfo,
65
66
  // #5679: the STATE.md milestone read getMilestoneInfo itself uses, and the
66
67
  // archive-aware owner of a ROADMAP offset.
67
68
  readStateMilestoneScalar, classifyMilestoneScalar, milestoneVersionAtOffset, blankCodeAndComments, scanMilestonePhaseIds,
@@ -97,7 +98,7 @@ const planning_document_cjs_1 = require("./planning-document.cjs");
97
98
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
98
99
  const clock_cjs_1 = require("./clock.cjs");
99
100
  const state_transition_cjs_1 = require("./state-transition.cjs");
100
- const markdown_table_cjs_1 = require("./markdown-table.cjs");
101
+ const markdown_table_cjs_2 = require("./markdown-table.cjs");
101
102
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
102
103
  const security_cjs_1 = require("./security.cjs");
103
104
  // #5060: the Phase Status Module owns the ROADMAP Status-cell token vocabulary.
@@ -1250,7 +1251,7 @@ function recordNewPhase(raw, cwd, id, description, after, label) {
1250
1251
  if (findPhaseTableHeader(scoped) === -1)
1251
1252
  return scoped;
1252
1253
  const cell = { Phase: `${id}. ${description}`, 'Plans Complete': '0/0', Status: 'Not started' };
1253
- 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]);
1254
1255
  if (!r.ok)
1255
1256
  return scoped;
1256
1257
  wrote = true;
@@ -1641,6 +1642,54 @@ function assertBracketGeneratedIdentity(command, config, customId) {
1641
1642
  'Custom naming precedence under the bracket convention lands with #5067.');
1642
1643
  }
1643
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
+ }
1644
1693
  /**
1645
1694
  * #4304: refuse a bracket `phase add` / `add-batch` whose located milestone
1646
1695
  * window is a decoy. A non-milestone heading carrying the active version
@@ -1712,9 +1761,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
1712
1761
  const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
1713
1762
  const { newPhaseId, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
1714
1763
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1764
+ const content = currentMilestoneWindowOrRefuse(rawContent, cwd, 'phase add');
1715
1765
  if (bracketContext)
1716
1766
  assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add');
1717
- const content = extractCurrentMilestone(rawContent, cwd);
1718
1767
  let _newPhaseId;
1719
1768
  let _dirName = '';
1720
1769
  if (bracketContext) {
@@ -1821,9 +1870,9 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
1821
1870
  const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, config) : null;
1822
1871
  const results = withPlanningLock(cwd, () => {
1823
1872
  let rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1873
+ const content = currentMilestoneWindowOrRefuse(rawContent, cwd, 'phase add-batch');
1824
1874
  if (bracketContext)
1825
1875
  assertBracketAddWindowLocated(rawContent, cwd, bracketContext, 'phase add-batch');
1826
- const content = extractCurrentMilestone(rawContent, cwd);
1827
1876
  let maxPhase = 0;
1828
1877
  if (bracketContext) {
1829
1878
  const used = collectBracketPhaseNumbers(content, node_path_1.default.join(planningDir(cwd), 'phases'), bracketContext);
@@ -2236,7 +2285,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw, allocation = 'nested'
2236
2285
  const bracketContext = convention === 'bracket' ? bracketWriteContext(cwd, insertConfig) : null;
2237
2286
  const { decimalPhase, dirName, roadmapEntry } = withPlanningLock(cwd, () => {
2238
2287
  const rawContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
2239
- const content = extractCurrentMilestone(rawContent, cwd);
2288
+ const content = currentMilestoneWindowOrRefuse(rawContent, cwd, 'phase insert');
2240
2289
  // #4304: canonicalize a bracket argument
2241
2290
  // through the SAME adapter `phase remove` uses
2242
2291
  // (canonicalizeBracketPhaseArgument) instead of the legacy
@@ -2837,7 +2886,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2837
2886
  return parseInt(leadingMatch[1], 10) === removedInt;
2838
2887
  };
2839
2888
  content = (0, roadmap_progress_cjs_1.editRoadmapProgress)(content, (progressSection) => {
2840
- const deleteResult = (0, markdown_table_cjs_1.deleteTableRow)(progressSection, matchRemovedProgressRow);
2889
+ const deleteResult = (0, markdown_table_cjs_2.deleteTableRow)(progressSection, matchRemovedProgressRow);
2841
2890
  return deleteResult.ok ? deleteResult.value : progressSection;
2842
2891
  }, { headingless: 'skip' });
2843
2892
  if (!isDecimal && !customTarget) {
@@ -2917,7 +2966,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2917
2966
  let matchedRowIndex = null;
2918
2967
  for (;;) {
2919
2968
  matchedRowIndex = null;
2920
- const cellResult = (0, markdown_table_cjs_1.updateTableCell)(ordinalSection, (row, index) => {
2969
+ const cellResult = (0, markdown_table_cjs_2.updateTableCell)(ordinalSection, (row, index) => {
2921
2970
  if (processedOrdinalRows.has(index))
2922
2971
  return false;
2923
2972
  const m = phaseCellShapeRe.exec(row['Phase'] ?? '');
@@ -2938,11 +2987,11 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
2938
2987
  const newContent = `${decremented}${m[2]}${current.slice(m[0].length)}`;
2939
2988
  const targetLine = matchedRowIndex === null ? null : findDataRowLine(ordinalSection, matchedRowIndex);
2940
2989
  const padMatch = targetLine
2941
- ? 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)
2942
2991
  : null;
2943
2992
  const leadPad = padMatch ? padMatch[1] : ' ';
2944
2993
  const trailPad = padMatch ? padMatch[2] : ' ';
2945
- return `${leadPad}${(0, markdown_table_cjs_1.escapeCell)(newContent)}${trailPad}`;
2994
+ return `${leadPad}${(0, markdown_table_cjs_2.escapeCell)(newContent)}${trailPad}`;
2946
2995
  });
2947
2996
  if (!cellResult.ok)
2948
2997
  break;
@@ -3532,7 +3581,7 @@ function classifyBracketOwnedLine(line) {
3532
3581
  if (checklistId)
3533
3582
  return { kind: 'checklist', id: checklistId };
3534
3583
  if (/^[ \t]*\|/.test(line)) {
3535
- 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') ?? '';
3536
3585
  const progressId = phaseIdFromOwnedLineMatch(BRACKET_CELL_ID_RE.exec(firstCell));
3537
3586
  if (progressId)
3538
3587
  return { kind: 'progress', id: progressId };
@@ -4894,7 +4943,7 @@ function phaseDisplayNameFromRoadmap(roadmapContent, phaseNum) {
4894
4943
  const heading = matchedHeading.text.match(headingPattern);
4895
4944
  if (!heading)
4896
4945
  return null;
4897
- const name = heading[1].replace(/\(INSERTED\)/i, '').trim();
4946
+ const name = (0, phase_entry_grammar_cjs_1.stripInsertedMarker)(heading[1]);
4898
4947
  return name || null;
4899
4948
  }
4900
4949
  function phaseDisplayNameFromSlug(slug) {
@@ -6073,12 +6122,12 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6073
6122
  // (`editProgressTableSlice` -> `editRoadmapProgress`), the same one `roadmap update-plan-progress` uses.
6074
6123
  s = editProgressTableSlice(s, (scoped) => {
6075
6124
  let text = scoped;
6076
- 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} `);
6077
6126
  if (plansResult.ok)
6078
6127
  text = plansResult.value;
6079
6128
  // #5165: a Status cell that already reads Complete is left untouched —
6080
6129
  // re-padding it rewrote a cell the re-run had no business editing.
6081
- 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
6082
6131
  ? current
6083
6132
  : ` ${(0, phase_status_cjs_1.toRoadmapStatusCell)(phase_status_cjs_1.PHASE_STATUS.COMPLETE).padEnd(11)} `);
6084
6133
  if (statusResult.ok)
@@ -6090,7 +6139,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6090
6139
  // findTableWithColumns (which requires the WHOLE table to parse —
6091
6140
  // a ragged SIBLING row elsewhere used to silently no-op this
6092
6141
  // row's date stamp too).
6093
- 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} `);
6094
6143
  if (completedResult.ok)
6095
6144
  text = completedResult.value;
6096
6145
  return text;
@@ -6228,7 +6277,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6228
6277
  // checkbox rollback below can distinguish "row existed and accepted" from
6229
6278
  // "row existed and rejected".
6230
6279
  let tableHit = false;
6231
- 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) => {
6232
6281
  // #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
6233
6282
  // gaps_found response) can complete without hand-editing the table.
6234
6283
  if (/^(?:pending|in progress|gaps found)$/i.test(current.trim())) {
@@ -6405,9 +6454,9 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6405
6454
  // which section (deferred or not) it lives under.
6406
6455
  const reqIsRegisteredAnywhere = (id) => {
6407
6456
  const reqEscaped = (0, pattern_cjs_1.escapeRegex)(id);
6408
- // Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
6409
- // insensitive: existence check, not the write's space-only match.
6410
- 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)) {
6411
6460
  return true;
6412
6461
  }
6413
6462
  // Surface 2 — Traceability row exists at all (any Status value),
@@ -6415,7 +6464,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6415
6464
  // technique milestone.cts's `hasRow` uses (:210-214): a case-
6416
6465
  // insensitive first-cell match, regardless of current Status.
6417
6466
  const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
6418
- 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);
6419
6471
  };
6420
6472
  const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
6421
6473
  if (ghostReqIds.length > 0) {
@@ -6427,8 +6479,15 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6427
6479
  // IDs already reported above as ghosts to avoid a duplicate message
6428
6480
  // for the same root cause.
6429
6481
  const traceabilityWriteFailures = traceabilityWriteMisses.filter((id) => !ghostReqIds.includes(id));
6430
- if (traceabilityWriteFailures.length > 0) {
6431
- 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(', ')}`);
6432
6491
  }
6433
6492
  writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
6434
6493
  // #2316-3: `requirements_updated` must reflect whether REQUIREMENTS.md
@@ -6619,9 +6678,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6619
6678
  if (comparePhaseNum(pmNum, phaseNum, directoryContext) > 0
6620
6679
  && (roadmapNextNum === null || comparePhaseNum(pmNum, roadmapNextNum, directoryContext) < 0)) {
6621
6680
  roadmapNextNum = pmNum;
6622
- roadmapNextName = pmName
6623
- .replace(/\(INSERTED\)/i, '')
6624
- .trim()
6681
+ roadmapNextName = (0, phase_entry_grammar_cjs_1.stripInsertedMarker)(pmName)
6625
6682
  .toLowerCase()
6626
6683
  .replace(/\s+/g, '-');
6627
6684
  }
@@ -6701,7 +6758,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6701
6758
  if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num, directoryContext) < 0) {
6702
6759
  lowestOutstanding = {
6703
6760
  num: cbm[2],
6704
- 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, '-'),
6705
6762
  };
6706
6763
  }
6707
6764
  }
@@ -6931,22 +6988,39 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6931
6988
  error(`Phase ${phaseNum} verification is incomplete: ${verificationBlocked.next_action}${nextStep}${indeterminateNote}`, ERROR_REASON.PHASE_VERIFICATION_INCOMPLETE, { verification_stale_check_indeterminate: staleCheckIndeterminate });
6932
6989
  }
6933
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;
6934
6995
  try {
6935
6996
  const configPath = node_path_1.default.join(planningDir(cwd), 'config.json');
6936
- if (node_fs_1.default.existsSync(configPath)) {
6937
- 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);
6938
7002
  const workflow = rawConfig['workflow'];
6939
7003
  const autoPruneEnabled = workflow && workflow['auto_prune_state'] === true;
6940
7004
  if (autoPruneEnabled && node_fs_1.default.existsSync(statePath)) {
6941
7005
  // Non-hoisted: load-order matters (stateMod must be fully resolved first).
6942
7006
  const { cmdStatePrune } = stateMod;
6943
- cmdStatePrune(cwd, { keepRecent: '3', dryRun: false, silent: true }, true);
6944
- 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
+ }
6945
7018
  }
6946
7019
  }
6947
7020
  }
6948
7021
  catch {
6949
- /* 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.
6950
7024
  }
6951
7025
  const result = {
6952
7026
  completed_phase: phaseNum,
@@ -6960,6 +7034,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
6960
7034
  state_updated: stateUpdated,
6961
7035
  requirements_updated: requirementsUpdated,
6962
7036
  auto_pruned: autoPruned,
7037
+ // #5831: ADDITIVE — present only when auto-prune threw.
7038
+ ...(autoPruneError !== null ? { auto_prune_error: autoPruneError } : {}),
6963
7039
  warnings,
6964
7040
  has_warnings: warnings.length > 0,
6965
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
  /**