@opengsd/gsd-core 1.7.0-rc.6 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +14 -0
  4. package/README.md +2 -0
  5. package/agents/gsd-debug-session-manager.md +42 -4
  6. package/agents/gsd-debugger.md +87 -29
  7. package/agents/gsd-executor.md +31 -3
  8. package/agents/gsd-planner.md +29 -36
  9. package/agents/gsd-security-auditor.md +13 -15
  10. package/agents/gsd-verifier.md +2 -2
  11. package/bin/install.js +1157 -84
  12. package/commands/gsd/ai-integration-phase.md +1 -1
  13. package/commands/gsd/mempalace-capture.md +31 -1
  14. package/commands/gsd/new-milestone.md +1 -1
  15. package/commands/gsd/plan-phase.md +5 -3
  16. package/commands/gsd/plan-review-convergence.md +3 -2
  17. package/commands/gsd/surface.md +6 -6
  18. package/gsd-core/bin/gsd-tools.cjs +1866 -2434
  19. package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
  20. package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
  21. package/gsd-core/bin/lib/api-coverage.cjs +341 -49
  22. package/gsd-core/bin/lib/audit.cjs +7 -6
  23. package/gsd-core/bin/lib/broken-windows.cjs +716 -0
  24. package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
  25. package/gsd-core/bin/lib/capability-registry.cjs +157 -88
  26. package/gsd-core/bin/lib/capability-writer.cjs +6 -1
  27. package/gsd-core/bin/lib/check-command-router.cjs +129 -26
  28. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
  29. package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
  30. package/gsd-core/bin/lib/clock.cjs +19 -0
  31. package/gsd-core/bin/lib/command-aliases.cjs +14 -0
  32. package/gsd-core/bin/lib/commands.cjs +129 -13
  33. package/gsd-core/bin/lib/config-loader.cjs +20 -4
  34. package/gsd-core/bin/lib/config.cjs +81 -18
  35. package/gsd-core/bin/lib/core-utils.cjs +14 -3
  36. package/gsd-core/bin/lib/decisions.cjs +32 -8
  37. package/gsd-core/bin/lib/docs.cjs +6 -0
  38. package/gsd-core/bin/lib/drift.cjs +4 -4
  39. package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
  40. package/gsd-core/bin/lib/frontmatter.cjs +22 -0
  41. package/gsd-core/bin/lib/gap-checker.cjs +17 -2
  42. package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
  43. package/gsd-core/bin/lib/init.cjs +138 -60
  44. package/gsd-core/bin/lib/install-engine.cjs +301 -25
  45. package/gsd-core/bin/lib/install-profiles.cjs +239 -1
  46. package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
  47. package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
  48. package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
  49. package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
  50. package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
  51. package/gsd-core/bin/lib/markdown-table.cjs +698 -0
  52. package/gsd-core/bin/lib/milestone.cjs +463 -43
  53. package/gsd-core/bin/lib/model-catalog.cjs +19 -4
  54. package/gsd-core/bin/lib/model-resolver.cjs +189 -7
  55. package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
  56. package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
  57. package/gsd-core/bin/lib/phase-id.cjs +26 -4
  58. package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
  59. package/gsd-core/bin/lib/phase-locator.cjs +23 -2
  60. package/gsd-core/bin/lib/phase.cjs +636 -72
  61. package/gsd-core/bin/lib/plan-scan.cjs +73 -2
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
  63. package/gsd-core/bin/lib/roadmap.cjs +113 -52
  64. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
  65. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
  66. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
  67. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
  68. package/gsd-core/bin/lib/schema-detect.cjs +2 -1
  69. package/gsd-core/bin/lib/security.cjs +1 -1
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
  71. package/gsd-core/bin/lib/smart-entry.cjs +73 -7
  72. package/gsd-core/bin/lib/state-document.cjs +7 -4
  73. package/gsd-core/bin/lib/state-transition.cjs +122 -46
  74. package/gsd-core/bin/lib/state.cjs +456 -137
  75. package/gsd-core/bin/lib/surface.cjs +53 -11
  76. package/gsd-core/bin/lib/template.cjs +2 -1
  77. package/gsd-core/bin/lib/uat.cjs +474 -13
  78. package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
  79. package/gsd-core/bin/lib/validate.cjs +12 -8
  80. package/gsd-core/bin/lib/verification.cjs +112 -17
  81. package/gsd-core/bin/lib/verify.cjs +224 -25
  82. package/gsd-core/bin/lib/workstream.cjs +3 -2
  83. package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
  84. package/gsd-core/bin/lib/write-set.cjs +38 -0
  85. package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
  86. package/gsd-core/references/api-coverage.md +37 -7
  87. package/gsd-core/references/checkpoints.md +13 -1
  88. package/gsd-core/references/common-bug-patterns.md +13 -0
  89. package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
  90. package/gsd-core/references/debugger-fix-acceptance.md +157 -0
  91. package/gsd-core/references/debugger-philosophy.md +1 -0
  92. package/gsd-core/references/debugger-prevention.md +98 -0
  93. package/gsd-core/references/debugger-rca-branching.md +98 -0
  94. package/gsd-core/references/debugger-repro-hardening.md +130 -0
  95. package/gsd-core/references/debugger-sbfl.md +110 -0
  96. package/gsd-core/references/debugger-semantic-recall.md +81 -0
  97. package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
  98. package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
  99. package/gsd-core/references/execute-phase-response-language.md +7 -0
  100. package/gsd-core/references/planner-antipatterns.md +6 -0
  101. package/gsd-core/references/planner-mvp-mode.md +12 -13
  102. package/gsd-core/references/planner-preconditions.md +156 -0
  103. package/gsd-core/references/planner-reversibility.md +132 -0
  104. package/gsd-core/references/reviewer-instances.md +9 -7
  105. package/gsd-core/references/skeleton-template.md +1 -1
  106. package/gsd-core/references/thinking-models-planning.md +3 -1
  107. package/gsd-core/templates/DEBUG.md +5 -3
  108. package/gsd-core/workflows/add-phase.md +2 -0
  109. package/gsd-core/workflows/add-tests.md +4 -2
  110. package/gsd-core/workflows/add-todo.md +32 -1
  111. package/gsd-core/workflows/ai-integration-phase.md +4 -2
  112. package/gsd-core/workflows/audit-fix.md +2 -2
  113. package/gsd-core/workflows/check-todos.md +3 -1
  114. package/gsd-core/workflows/cleanup.md +7 -1
  115. package/gsd-core/workflows/code-review.md +17 -5
  116. package/gsd-core/workflows/complete-milestone.md +3 -0
  117. package/gsd-core/workflows/debug.md +27 -5
  118. package/gsd-core/workflows/diagnose-issues.md +1 -1
  119. package/gsd-core/workflows/discovery-phase.md +7 -0
  120. package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
  121. package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
  122. package/gsd-core/workflows/do.md +7 -1
  123. package/gsd-core/workflows/docs-update.md +1 -0
  124. package/gsd-core/workflows/eval-review.md +3 -0
  125. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
  126. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
  127. package/gsd-core/workflows/execute-phase.md +30 -37
  128. package/gsd-core/workflows/execute-plan.md +15 -4
  129. package/gsd-core/workflows/fast.md +8 -22
  130. package/gsd-core/workflows/graduation.md +3 -0
  131. package/gsd-core/workflows/health.md +7 -1
  132. package/gsd-core/workflows/help/modes/full.md +6 -2
  133. package/gsd-core/workflows/import.md +8 -2
  134. package/gsd-core/workflows/inbox.md +7 -0
  135. package/gsd-core/workflows/ingest-docs.md +15 -10
  136. package/gsd-core/workflows/manager.md +3 -1
  137. package/gsd-core/workflows/map-codebase.md +4 -4
  138. package/gsd-core/workflows/mvp-phase.md +3 -0
  139. package/gsd-core/workflows/new-milestone.md +69 -21
  140. package/gsd-core/workflows/new-project.md +17 -15
  141. package/gsd-core/workflows/new-workspace.md +3 -1
  142. package/gsd-core/workflows/onboard.md +3 -0
  143. package/gsd-core/workflows/plan-phase.md +14 -5
  144. package/gsd-core/workflows/plan-review-convergence.md +48 -3
  145. package/gsd-core/workflows/plant-seed.md +3 -0
  146. package/gsd-core/workflows/profile-user.md +7 -1
  147. package/gsd-core/workflows/progress.md +33 -5
  148. package/gsd-core/workflows/quick.md +21 -7
  149. package/gsd-core/workflows/remove-workspace.md +3 -0
  150. package/gsd-core/workflows/review.md +123 -68
  151. package/gsd-core/workflows/scan.md +1 -1
  152. package/gsd-core/workflows/secure-phase.md +4 -1
  153. package/gsd-core/workflows/settings-integrations.md +3 -0
  154. package/gsd-core/workflows/settings.md +3 -0
  155. package/gsd-core/workflows/ship.md +58 -5
  156. package/gsd-core/workflows/sketch.md +3 -0
  157. package/gsd-core/workflows/smart-entry.md +3 -0
  158. package/gsd-core/workflows/spec-phase.md +1 -1
  159. package/gsd-core/workflows/spike.md +7 -1
  160. package/gsd-core/workflows/transition.md +1 -1
  161. package/gsd-core/workflows/ui-phase.md +3 -1
  162. package/gsd-core/workflows/ui-review.md +3 -0
  163. package/gsd-core/workflows/undo.md +7 -0
  164. package/gsd-core/workflows/update.md +2 -0
  165. package/gsd-core/workflows/validate-phase.md +3 -0
  166. package/gsd-core/workflows/verify-phase.md +2 -2
  167. package/gsd-core/workflows/verify-work.md +7 -3
  168. package/hooks/dist/gsd-context-monitor.js +27 -9
  169. package/hooks/dist/gsd-statusline.js +252 -17
  170. package/hooks/gsd-context-monitor.js +27 -9
  171. package/hooks/gsd-statusline.js +252 -17
  172. package/package.json +8 -4
  173. package/pi/gsd.cjs +8 -2
  174. package/scripts/changeset/lint.cjs +1 -0
  175. package/scripts/changeset/parse.cjs +26 -0
  176. package/scripts/check-glossary-refs.cjs +220 -0
  177. package/scripts/ci-rebase-check.cjs +48 -4
  178. package/scripts/ci-test-scope.cjs +39 -1
  179. package/scripts/gen-adr-index.cjs +526 -0
  180. package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
  181. package/scripts/gen-install-tree-fixtures.cjs +75 -0
  182. package/scripts/gen-test-timings.cjs +201 -0
  183. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  184. package/scripts/lint-portable-timeout.cjs +140 -0
  185. package/scripts/lint-table-schema-drift.cjs +157 -0
  186. package/scripts/lint-test-file-count.allowlist.json +1 -0
  187. package/scripts/release-tarball-smoke.cjs +18 -11
  188. package/scripts/run-tests.cjs +420 -58
  189. package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
  190. package/skills/gsd-mempalace-capture/SKILL.md +31 -1
  191. package/skills/gsd-new-milestone/SKILL.md +1 -1
  192. package/skills/gsd-plan-phase/SKILL.md +5 -3
  193. package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
  194. package/skills/gsd-surface/SKILL.md +6 -6
  195. package/vscode/package.json +1 -1
@@ -21,6 +21,8 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
21
21
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
22
22
  const clock_cjs_1 = require("./clock.cjs");
23
23
  const state_transition_cjs_1 = require("./state-transition.cjs");
24
+ const write_set_cjs_1 = require("./write-set.cjs");
25
+ const markdown_table_cjs_1 = require("./markdown-table.cjs");
24
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
27
  const ioMod = require("./io.cjs");
26
28
  const { output, error } = ioMod;
@@ -36,6 +38,51 @@ const { extractOneLinerFromBody } = coreUtilsMod;
36
38
  const { planningPaths } = planningWorkspace;
37
39
  const { extractFrontmatter } = frontmatterMod;
38
40
  const { writeStateMd } = stateMod;
41
+ // #2288 security: a milestone version label becomes a filesystem directory
42
+ // component (`milestones/<label>-phases/`) into which phase directories are
43
+ // MOVED. Any label used as a path segment must be a safe version token —
44
+ // letters/digits/'.'/'-'/'_', leading alphanumeric, no path separators and no
45
+ // `..` (the leading-alphanumeric anchor rejects a bare `..`). This gates both
46
+ // the caller-supplied `--archive-version` override and the STATE.md-derived
47
+ // live-read value, so a crafted value cannot escape `.planning/milestones/`.
48
+ const ARCHIVE_VERSION_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
49
+ /**
50
+ * Scope an `updateTableCell` call to the `## Traceability` (or
51
+ * `## Traceability Status`) heading's own section — up to the next H1/H2
52
+ * heading — instead of handing it the WHOLE REQUIREMENTS.md content.
53
+ *
54
+ * F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
55
+ * found in whatever text it is given. The shipped requirements template
56
+ * (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
57
+ * (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
58
+ * an unscoped whole-file call targets the Out-of-Scope table instead, fails
59
+ * with `{ok:false, reason:'unknown column: Status'}`, and the real
60
+ * Traceability row is never flipped, while the checkbox surface still flips
61
+ * and the command reports success (the #2140 silent-divergence class one
62
+ * level deeper). Mirrors phase.cts's `editProgressHeadingSlice` scoping of
63
+ * `## Progress` writes to that heading's own slice.
64
+ *
65
+ * Falls back to running `updateTableCell` against the whole `text` when no
66
+ * `## Traceability` heading exists — matching the previous (unscoped)
67
+ * behaviour for a REQUIREMENTS.md whose traceability table sits under some
68
+ * other heading, or with no heading at all (never worse than before this fix).
69
+ */
70
+ function updateTraceabilityCell(text, match, column, newValue) {
71
+ const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
72
+ if (!headingMatch || headingMatch.index === undefined) {
73
+ return (0, markdown_table_cjs_1.updateTableCell)(text, match, column, newValue);
74
+ }
75
+ const headingOffset = headingMatch.index;
76
+ const before = text.slice(0, headingOffset);
77
+ const fromHeading = text.slice(headingOffset);
78
+ const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
79
+ const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
80
+ const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
81
+ const result = (0, markdown_table_cjs_1.updateTableCell)(scoped, match, column, newValue);
82
+ if (!result.ok)
83
+ return result;
84
+ return { ok: true, value: before + result.value + after };
85
+ }
39
86
  function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
40
87
  if (!reqIdsRaw || reqIdsRaw.length === 0) {
41
88
  error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
@@ -59,56 +106,310 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
59
106
  const updated = [];
60
107
  const alreadyComplete = [];
61
108
  const notFound = [];
109
+ // #2140: IDs reconciled on the checkbox surface only — a traceability table
110
+ // exists but has no row for the ID. Without this bucket the payload for a
111
+ // partial reconcile is byte-identical to a full one, and audit-milestone (which
112
+ // reads the table) still sees Pending while the CLI reported success.
113
+ const tableUnmatched = [];
114
+ // A traceability table is present if the file has a requirement-ID column
115
+ // header: "Requirement", "Requirement ID", or "REQ-ID" (#2769/#2203) — kept
116
+ // in sync with the positional first-cell rowMatch/hasRow below so a
117
+ // REQ-ID-headed table (the real-world format) participates in the
118
+ // write-set and the #2140 drift check below, not just the "Requirement"
119
+ // case. A REQUIREMENTS.md with no such table is legitimate (mid-roadmap),
120
+ // so a missing row only counts as drift when a table actually exists.
121
+ const hasTable = /^\|\s*(?:Requirement(?:\s*ID)?|REQ[-\s]?ID)\s*\|/im.test(reqContent);
122
+ // ADR-2143 §6 per-surface write-set, tracked PER requirement ID: a
123
+ // multi-ID batch must not OR one ID's surface outcome into another's —
124
+ // that is the exact #2140 class one level up (an ID whose traceability
125
+ // row is absent/unmatched must not have its partial write masked by a
126
+ // different ID in the same invocation that fully reconciled). Reported
127
+ // additively as `write_set` below — it does not change the existing
128
+ // marked_complete/already_complete/not_found/table_unmatched/updated
129
+ // computation, which stays byte-for-behaviour identical (#2140's tactical
130
+ // fix already surfaces the checkbox-only-partial-write case via
131
+ // table_unmatched; this only adds the structured ADR-2143 shape on top).
132
+ const writeSet = [];
62
133
  for (const reqId of reqIds) {
63
- let found = false;
64
134
  const reqEscaped = escapeRegex(reqId);
65
- // Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
66
- // Use replace() directly and compare — avoids test()+replace() global regex
135
+ // Surface 1 — the checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
136
+ // Use replace() + compare to avoid the test()+replace() global regex
67
137
  // lastIndex bug where test() advances state and replace() misses matches.
68
138
  const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
69
139
  const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
70
- if (afterCheckbox !== reqContent) {
140
+ const checkboxHit = afterCheckbox !== reqContent;
141
+ if (checkboxHit)
71
142
  reqContent = afterCheckbox;
72
- found = true;
143
+ // Surface 2 — the traceability row: | <REQ-ID> | Phase N | Pending | → ... Complete |
144
+ // via the markdown-table seam (ADR-2143 §7) — supersedes the prior ordinal
145
+ // regex. Match the row by its FIRST cell's value (the requirement-ID column)
146
+ // regardless of that column's HEADER name — real tables head it `REQ-ID`,
147
+ // others `Requirement` (#2769/#2203); this mirrors the prior regex's first-cell
148
+ // `\|\s*<id>\s*\|` anchor. Object.values(row) is in header order so [0] is the
149
+ // first column. Case-insensitive (mirrors the prior regex's 'i' flag).
150
+ const rowMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
151
+ // Ragged-tolerant (#2245 Blocker 2): drive the write purely off
152
+ // updateTableCell's own tolerant row scan — a DIFFERENT requirement's row
153
+ // elsewhere in the same table having a mismatched cell count must never
154
+ // silently no-op THIS requirement's write. The "only flip Pending ->
155
+ // Complete" gate is folded into the newValue callback so one
156
+ // updateTableCell call both probes the current value and writes.
157
+ let tableHit = false;
158
+ const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
159
+ if (/^pending$/i.test(current.trim())) {
160
+ tableHit = true;
161
+ return ' Complete ';
162
+ }
163
+ return current;
164
+ });
165
+ if (tableUpdate.ok) {
166
+ reqContent = tableUpdate.value;
73
167
  }
74
- // Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
75
- const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
76
- const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
77
- if (afterTable !== reqContent) {
78
- reqContent = afterTable;
79
- found = true;
168
+ // ADR-2143 §6 per-ID write-set entries: this ID's checkbox surface is
169
+ // always tracked; the traceability surface is tracked only when the file
170
+ // has a traceability table at all (same `hasTable` gate the existing
171
+ // required-surface logic below uses) — omitted entirely, not a false
172
+ // `applied:false`, when no table is required of this file.
173
+ writeSet.push({ requirement: reqId, surface: 'checkbox', applied: checkboxHit });
174
+ if (hasTable) {
175
+ writeSet.push({ requirement: reqId, surface: 'traceability', applied: tableHit });
80
176
  }
81
- if (found) {
177
+ // Coverage of the traceability surface for this ID (computed after any flip).
178
+ // hasRow keys on the ID's FIRST cell (the requirement-ID column, by position —
179
+ // see rowMatch above) so a bare mention of the ID in a non-traceability table
180
+ // does not masquerade as a real row.
181
+ // Ragged-tolerant (#2245 Blocker 2): same reasoning as the write above — a
182
+ // sibling row's raggedness must not blind this classification to a row
183
+ // that genuinely exists. Probe via a no-op updateTableCell write (its own
184
+ // tolerant scan) instead of findTableWithColumns (whole-table parse gate).
185
+ let currentStatusCell = '';
186
+ const statusProbe = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
187
+ currentStatusCell = current;
188
+ return current;
189
+ });
190
+ const hasRow = statusProbe.ok;
191
+ const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent);
192
+ const doneTable = Boolean(hasRow && /^complete$/i.test(currentStatusCell.trim()));
193
+ if (checkboxHit || tableHit) {
82
194
  updated.push(reqId);
83
195
  }
84
- else {
85
- // Check if already complete before declaring not_found.
86
- // Non-global flag is fine here — we only need to know if a match exists.
87
- const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
88
- const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
89
- if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
90
- alreadyComplete.push(reqId);
91
- }
92
- else {
93
- notFound.push(reqId);
94
- }
196
+ else if (doneTable || (doneCheckbox && !hasTable)) {
197
+ // Fully reconciled: the table row is Complete, OR the checkbox is done and
198
+ // there is no table to reconcile against. (A [x] checkbox with a Pending or
199
+ // absent row is NOT fully reconciled when a table exists — #2140.)
200
+ alreadyComplete.push(reqId);
201
+ }
202
+ else if (!doneCheckbox && !doneTable) {
203
+ notFound.push(reqId);
204
+ }
205
+ // else: doneCheckbox && hasTable && !doneTable — partially reconciled. It is
206
+ // neither updated, already_complete, nor not_found; the table_unmatched bucket
207
+ // below carries the truthful partial-reconcile signal.
208
+ // Surface traceability drift: checkbox reconciled (this run or before) but the
209
+ // table has no row for this ID. This is what makes a partial reconcile
210
+ // distinguishable from a full one (#2140).
211
+ if (hasTable && doneCheckbox && !hasRow) {
212
+ tableUnmatched.push(reqId);
95
213
  }
96
214
  }
97
215
  if (updated.length > 0) {
98
216
  (0, shell_command_projection_cjs_1.platformWriteSync)(reqPath, reqContent);
99
217
  }
218
+ // ADR-2143 §6: `writeSet` above already carries one WriteOutcome per
219
+ // (requirement, surface) this invocation could have written to — per ID,
220
+ // not ORed across the batch. `write_set` and `write_set_complete` are
221
+ // additive: they do not replace or gate `updated` / `marked_complete` /
222
+ // `already_complete` / `not_found` / `table_unmatched`, which remain
223
+ // computed exactly as before (see #2140 note above — that fix already
224
+ // surfaces a checkbox-only partial write via `table_unmatched`;
225
+ // `write_set_complete` is a structured, ADR-2143-shaped read of the SAME
226
+ // per-surface, per-ID facts, `false` if ANY id's ANY required surface did
227
+ // not apply, since `writeSetComplete` requires EVERY entry to have
228
+ // applied, never an OR across surfaces OR across IDs).
100
229
  output({
101
230
  updated: updated.length > 0,
102
231
  marked_complete: updated,
103
232
  already_complete: alreadyComplete,
104
233
  not_found: notFound,
234
+ table_unmatched: tableUnmatched,
105
235
  total: reqIds.length,
236
+ write_set: writeSet,
237
+ write_set_complete: (0, write_set_cjs_1.writeSetComplete)(writeSet),
106
238
  }, raw, `${updated.length}/${reqIds.length} requirements marked complete`);
107
239
  }
240
+ /**
241
+ * #2388: a requirement ID shared by more than one plan in a phase must not be
242
+ * handed to `cmdRequirementsMarkComplete` until every plan declaring it has
243
+ * finished (i.e. has a `*-SUMMARY.md`) — otherwise the ID reads `Complete` in
244
+ * REQUIREMENTS.md ~20 minutes before its sibling plans even run, and long
245
+ * before `verify_phase_goal` has a chance to catch a gap.
246
+ *
247
+ * Pure read-only gate: scans sibling `*-PLAN.md` files in the SAME phase
248
+ * directory as `planPath` (excluding `planPath` itself) and, for each
249
+ * candidate ID, blocks it only when a sibling plan ALSO declares that ID in
250
+ * its own `requirements:` frontmatter AND that sibling has no matching
251
+ * `*-SUMMARY.md` yet. An ID no sibling declares is never blocked — a
252
+ * single-plan (non-shared) ID is always `ready`, preserving immediate
253
+ * marking with no added latency (acceptance criterion 4). Does not read or
254
+ * write REQUIREMENTS.md itself; callers pass the `ready` subset on to
255
+ * `cmdRequirementsMarkComplete` (whose own flip semantics are untouched).
256
+ */
257
+ function cmdRequirementsReadyIds(cwd, args, raw) {
258
+ const planPathArg = args[0];
259
+ if (!planPathArg) {
260
+ error('plan path required. Usage: requirements ready-ids <plan-path> REQ-01,REQ-02');
261
+ }
262
+ const reqIds = args
263
+ .slice(1)
264
+ .join(' ')
265
+ .replace(/[\[\]]/g, '')
266
+ .split(/[,\s]+/)
267
+ .map((r) => r.trim())
268
+ .filter(Boolean);
269
+ if (reqIds.length === 0) {
270
+ output({ ready: [], blocked: [], total: 0 }, raw, 'no requirement IDs provided');
271
+ return;
272
+ }
273
+ const planAbsPath = node_path_1.default.resolve(cwd, planPathArg);
274
+ const phaseDir = node_path_1.default.dirname(planAbsPath);
275
+ const currentBasename = node_path_1.default.basename(planAbsPath);
276
+ let siblingPlanFiles = [];
277
+ try {
278
+ siblingPlanFiles = node_fs_1.default
279
+ .readdirSync(phaseDir)
280
+ .filter((f) => f.endsWith('-PLAN.md') && f !== currentBasename);
281
+ }
282
+ catch {
283
+ siblingPlanFiles = [];
284
+ }
285
+ const parseFrontmatterReqIds = (content) => {
286
+ const fm = extractFrontmatter(content);
287
+ const fmReq = fm.requirements;
288
+ if (Array.isArray(fmReq))
289
+ return fmReq.map((r) => String(r).trim()).filter(Boolean);
290
+ if (typeof fmReq === 'string') {
291
+ return fmReq
292
+ .replace(/[\[\]]/g, '')
293
+ .split(/[,\s]+/)
294
+ .map((r) => r.trim())
295
+ .filter(Boolean);
296
+ }
297
+ return [];
298
+ };
299
+ const ready = [];
300
+ const blocked = [];
301
+ for (const reqId of reqIds) {
302
+ let blockedBySibling = false;
303
+ for (const siblingFile of siblingPlanFiles) {
304
+ const siblingPath = node_path_1.default.join(phaseDir, siblingFile);
305
+ let siblingContent;
306
+ try {
307
+ siblingContent = node_fs_1.default.readFileSync(siblingPath, 'utf-8');
308
+ }
309
+ catch {
310
+ continue;
311
+ }
312
+ const siblingReqIds = parseFrontmatterReqIds(siblingContent);
313
+ const siblingDeclaresId = siblingReqIds.some((id) => id.toLowerCase() === reqId.toLowerCase());
314
+ if (!siblingDeclaresId)
315
+ continue;
316
+ // Sibling declares the SAME ID — it must have finished (produced a
317
+ // SUMMARY) before this ID is ready to mark Complete.
318
+ const siblingSummaryPath = siblingPath.replace(/-PLAN\.md$/, '-SUMMARY.md');
319
+ if (!node_fs_1.default.existsSync(siblingSummaryPath)) {
320
+ blockedBySibling = true;
321
+ break;
322
+ }
323
+ }
324
+ if (blockedBySibling)
325
+ blocked.push(reqId);
326
+ else
327
+ ready.push(reqId);
328
+ }
329
+ output({ ready, blocked, total: reqIds.length }, raw, `${ready.length}/${reqIds.length} requirement(s) ready to mark complete`);
330
+ }
331
+ /**
332
+ * #2388: revert this phase's own requirement IDs out of `Complete` when
333
+ * `verify_phase_goal` returns `gaps_found` — a gap verdict must not leave a
334
+ * premature `Complete` (from a shared ID's first-declaring plan, or from any
335
+ * other early write) sitting in REQUIREMENTS.md indefinitely.
336
+ *
337
+ * Mirrors `cmdRequirementsMarkComplete`'s two write surfaces in reverse:
338
+ * checkbox `[x]` -> `[ ]`, and traceability Status `Complete` -> `Gaps
339
+ * Found`. Phase-scoping is the CALLER's responsibility — this function only
340
+ * ever touches the exact IDs it is given, so a caller passing just this
341
+ * phase's own `phase_req_ids` never touches another phase's `Complete` row.
342
+ * Never call this on the pass path; it is `gaps_found`-only.
343
+ */
344
+ function cmdRequirementsRevertPhase(cwd, reqIdsRaw, raw) {
345
+ const reqIds = (reqIdsRaw || [])
346
+ .join(' ')
347
+ .replace(/[\[\]]/g, '')
348
+ .split(/[,\s]+/)
349
+ .map((r) => r.trim())
350
+ .filter(Boolean);
351
+ if (reqIds.length === 0) {
352
+ output({ reverted: [], unchanged: [], total: 0 }, raw, 'no requirement IDs provided');
353
+ return;
354
+ }
355
+ const reqPath = planningPaths(cwd).requirements;
356
+ if (!node_fs_1.default.existsSync(reqPath)) {
357
+ output({ reverted: [], unchanged: reqIds, total: reqIds.length, reason: 'REQUIREMENTS.md not found' }, raw, 'no requirements file');
358
+ return;
359
+ }
360
+ let reqContent = node_fs_1.default.readFileSync(reqPath, 'utf-8');
361
+ const reverted = [];
362
+ const unchanged = [];
363
+ for (const reqId of reqIds) {
364
+ const reqEscaped = escapeRegex(reqId);
365
+ let idReverted = false;
366
+ // Surface 1 — checkbox: - [x] **REQ-ID** -> - [ ] **REQ-ID**
367
+ const checkboxPattern = new RegExp(`(-\\s*\\[)x(\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
368
+ const afterCheckbox = reqContent.replace(checkboxPattern, '$1 $2');
369
+ if (afterCheckbox !== reqContent) {
370
+ reqContent = afterCheckbox;
371
+ idReverted = true;
372
+ }
373
+ // Surface 2 — traceability row: Status Complete -> Gaps Found. Only
374
+ // flips a row currently reading Complete (mirrors mark-complete's own
375
+ // "only flip Pending -> Complete" gate, in reverse).
376
+ const rowMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
377
+ let tableHit = false;
378
+ const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
379
+ if (/^complete$/i.test(current.trim())) {
380
+ tableHit = true;
381
+ return ' Gaps Found ';
382
+ }
383
+ return current;
384
+ });
385
+ if (tableUpdate.ok) {
386
+ reqContent = tableUpdate.value;
387
+ if (tableHit)
388
+ idReverted = true;
389
+ }
390
+ if (idReverted)
391
+ reverted.push(reqId);
392
+ else
393
+ unchanged.push(reqId);
394
+ }
395
+ if (reverted.length > 0) {
396
+ (0, shell_command_projection_cjs_1.platformWriteSync)(reqPath, reqContent);
397
+ }
398
+ output({ reverted, unchanged, total: reqIds.length }, raw, `${reverted.length}/${reqIds.length} requirement(s) reverted from Complete`);
399
+ }
108
400
  function cmdMilestoneComplete(cwd, version, options, raw) {
109
401
  if (!version) {
110
402
  error('version required for milestone complete (e.g., v1.0)');
111
403
  }
404
+ // #2288 security: `version` is a CLI positional that is interpolated into
405
+ // multiple filesystem sinks below — `path.join(archiveDir, `${version}-ROADMAP.md`)`,
406
+ // `${version}-REQUIREMENTS.md`, `${version}-MILESTONE-AUDIT.md`, and the
407
+ // `${version}-phases` archive directory that phase dirs are MOVED into. Reject
408
+ // path separators / `..` here (same guard as `--archive-version`) so a crafted
409
+ // version cannot write or relocate content outside `.planning/milestones/`.
410
+ if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
411
+ error(`milestone complete: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
412
+ }
112
413
  const roadmapPath = planningPaths(cwd).roadmap;
113
414
  const reqPath = planningPaths(cwd).requirements;
114
415
  const statePath = planningPaths(cwd).state;
@@ -120,10 +421,12 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
120
421
  const milestonesPath = node_path_1.default.join(planningBase, 'MILESTONES.md');
121
422
  const archiveDir = node_path_1.default.join(planningBase, 'milestones');
122
423
  const phasesDir = planningPaths(cwd).phases;
123
- const today = new Date().toISOString().split('T')[0];
424
+ const today = clock_cjs_1.realClock.localToday();
124
425
  const milestoneName = options.name || version;
125
- // Ensure archive directory exists
126
- (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
426
+ // Ensure archive directory exists (skipped in dry-run — no mutations)
427
+ if (!options.dryRun) {
428
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(archiveDir);
429
+ }
127
430
  // Scope stats and accomplishments to only the phases belonging to the
128
431
  // current milestone's ROADMAP. Uses the shared filter from roadmap-parser.cjs
129
432
  // (same logic used by cmdPhasesList and other callers).
@@ -247,13 +550,62 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
247
550
  }
248
551
  }
249
552
  catch {
250
- /* intentionally empty */
553
+ /* best-effort (#2245 audit): one unreadable/malformed SUMMARY.md
554
+ * must not abort the accomplishments/task-count roll-up for every
555
+ * OTHER summary across every OTHER phase — it's simply excluded
556
+ * from the milestone's shipped-summary text. */
251
557
  }
252
558
  }
253
559
  }
254
560
  }
255
561
  catch {
256
- /* intentionally empty */
562
+ /* best-effort (#2245 audit): mirrors the phaseDirEntries IIFE a few
563
+ * lines below this function (same phasesDir, same "try readdirSync,
564
+ * tolerate ENOENT" pattern) — phasesDir may legitimately not exist yet
565
+ * (e.g. milestone being force-completed before any phase directories
566
+ * were created). Degrades stats to phaseCount/totalPlans/totalTasks=0,
567
+ * accomplishments=[] rather than crash `milestone complete`. */
568
+ }
569
+ // #2118: --dry-run preview — compute what WOULD happen without mutating.
570
+ // The stats above are read-only; all mutations start at the archive section below.
571
+ if (options.dryRun) {
572
+ const phaseDirsToArchive = [];
573
+ if (options.archivePhases !== false) {
574
+ try {
575
+ const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
576
+ for (const e of entries) {
577
+ if (e.isDirectory() && isDirInMilestone(e.name)) {
578
+ phaseDirsToArchive.push(e.name);
579
+ }
580
+ }
581
+ }
582
+ catch { /* phasesDir missing — nothing to archive */ }
583
+ }
584
+ const dryRunResult = {
585
+ dry_run: true,
586
+ version,
587
+ name: milestoneName,
588
+ stats: { phases: phaseCount, plans: totalPlans, tasks: totalTasks },
589
+ accomplishments,
590
+ would_archive: {
591
+ roadmap: node_fs_1.default.existsSync(roadmapPath)
592
+ ? { source: node_path_1.default.relative(cwd, roadmapPath).split(node_path_1.default.sep).join('/'), target: node_path_1.default.relative(cwd, node_path_1.default.join(archiveDir, `${version}-ROADMAP.md`)).split(node_path_1.default.sep).join('/') }
593
+ : null,
594
+ requirements: node_fs_1.default.existsSync(reqPath)
595
+ ? { source: node_path_1.default.relative(cwd, reqPath).split(node_path_1.default.sep).join('/'), target: node_path_1.default.relative(cwd, node_path_1.default.join(archiveDir, `${version}-REQUIREMENTS.md`)).split(node_path_1.default.sep).join('/') }
596
+ : null,
597
+ audit: node_fs_1.default.existsSync(node_path_1.default.join(planningBase, `${version}-MILESTONE-AUDIT.md`))
598
+ ? { source: node_path_1.default.relative(cwd, node_path_1.default.join(planningBase, `${version}-MILESTONE-AUDIT.md`)).split(node_path_1.default.sep).join('/'), target: node_path_1.default.relative(cwd, node_path_1.default.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)).split(node_path_1.default.sep).join('/') }
599
+ : null,
600
+ phases: phaseDirsToArchive,
601
+ },
602
+ would_update: {
603
+ milestones_md: node_path_1.default.relative(cwd, milestonesPath).split(node_path_1.default.sep).join('/'),
604
+ state_md: node_fs_1.default.existsSync(statePath) ? node_path_1.default.relative(cwd, statePath).split(node_path_1.default.sep).join('/') : null,
605
+ },
606
+ };
607
+ output(dryRunResult, raw);
608
+ return;
257
609
  }
258
610
  // Archive ROADMAP.md
259
611
  if (node_fs_1.default.existsSync(roadmapPath)) {
@@ -323,22 +675,33 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
323
675
  let phasesArchived = false;
324
676
  // #1871: archive phase dirs by default on milestone complete (opt out via --no-archive-phases).
325
677
  if (options.archivePhases !== false) {
678
+ // #2245 audit (was ERROR-HIDING): retryRenameSync moves one phase dir at a
679
+ // time — a mid-loop failure (e.g. the Nth rename) used to leave
680
+ // `phasesArchived` at its `false` default even though the first N-1 dirs
681
+ // had ALREADY been moved to phaseArchiveDir on disk, silently
682
+ // under-reporting a real partial archive in the JSON result. archivedCount
683
+ // is now computed in a `finally` so it reflects whatever succeeded before
684
+ // any failure, instead of being lost with the swallowed exception.
685
+ let archivedCount = 0;
326
686
  try {
327
687
  const phaseArchiveDir = node_path_1.default.join(archiveDir, `${version}-phases`);
328
688
  (0, shell_command_projection_cjs_1.platformEnsureDir)(phaseArchiveDir);
329
689
  const phaseEntries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
330
690
  const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
331
- let archivedCount = 0;
332
691
  for (const dir of phaseDirNames) {
333
692
  if (!isDirInMilestone(dir))
334
693
  continue;
335
694
  (0, shell_command_projection_cjs_1.retryRenameSync)(node_path_1.default.join(phasesDir, dir), node_path_1.default.join(phaseArchiveDir, dir));
336
695
  archivedCount++;
337
696
  }
338
- phasesArchived = archivedCount > 0;
339
697
  }
340
698
  catch {
341
- /* intentionally empty */
699
+ /* best-effort: phasesDir may not exist yet, or the archive rename loop
700
+ * failed partway — phasesArchived below still reflects whatever
701
+ * archivedCount succeeded before the failure. */
702
+ }
703
+ finally {
704
+ phasesArchived = archivedCount > 0;
342
705
  }
343
706
  }
344
707
  const result = {
@@ -366,6 +729,32 @@ function cmdPhasesClear(cwd, raw, args) {
366
729
  // --force bypasses the uncommitted-changes guard. Only use when the caller
367
730
  // has already archived or explicitly accepts loss of uncommitted work. (#1447)
368
731
  const force = Array.isArray(args) && args.includes('--force');
732
+ // #2288: explicit outgoing-version override for the archive destination.
733
+ // new-milestone.md runs `state.milestone-switch` BEFORE `phases.clear --confirm`,
734
+ // so a live read of STATE.md would already report the NEW milestone version by
735
+ // the time we get here. Callers that know the outgoing version pass it explicitly;
736
+ // absent an override, archivePhaseDirectories falls back to the live read.
737
+ const avIndex = Array.isArray(args) ? args.indexOf('--archive-version') : -1;
738
+ let archiveVersionOverride = null;
739
+ if (avIndex !== -1) {
740
+ const rawArchiveVersion = args[avIndex + 1];
741
+ // Missing / flag-shaped value: fail loud instead of silently dropping the
742
+ // override. A truncated invocation (e.g. a broken template substitution
743
+ // leaving `--archive-version` with no value) must NOT fall through to the
744
+ // live read — that silently re-files the archive under the new milestone,
745
+ // the exact #2288 bug this flag exists to prevent.
746
+ if (typeof rawArchiveVersion !== 'string' || rawArchiveVersion.startsWith('--') || rawArchiveVersion.trim() === '') {
747
+ error('--archive-version requires a value (a milestone version token, e.g. v1.0)');
748
+ }
749
+ const trimmed = rawArchiveVersion.trim();
750
+ // #2288 security: reject path separators / `..` so a crafted value cannot
751
+ // relocate phase history outside `.planning/milestones/` (phase dirs are
752
+ // MOVED into the archive dir — a traversal is data loss, not just an odd name).
753
+ if (!ARCHIVE_VERSION_LABEL_RE.test(trimmed)) {
754
+ error(`--archive-version "${trimmed}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
755
+ }
756
+ archiveVersionOverride = trimmed;
757
+ }
369
758
  let cleared = 0;
370
759
  if (node_fs_1.default.existsSync(phasesDir)) {
371
760
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
@@ -412,7 +801,8 @@ function cmdPhasesClear(cwd, raw, args) {
412
801
  }
413
802
  try {
414
803
  // #1871: archive phase directories instead of destroying them (shared helper).
415
- cleared = archivePhaseDirectories(cwd, phasesDir, dirs).archived;
804
+ // #2288: thread the explicit --archive-version override (if any) through.
805
+ cleared = archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride).archived;
416
806
  }
417
807
  catch (e) {
418
808
  const message = e instanceof Error ? e.message : String(e);
@@ -423,18 +813,46 @@ function cmdPhasesClear(cwd, raw, args) {
423
813
  }
424
814
  /**
425
815
  * #1871: move each non-999 phase directory under `phasesDir` into
426
- * `milestones/<version>-phases/` (collision-safe; version from getMilestoneInfo,
427
- * timestamp fallback). Shared by `phases clear` (archive-then-remove) and the
428
- * internal milestone.complete phase archival so phase history survives a
429
- * milestone switch instead of being hard-deleted.
816
+ * `milestones/<version>-phases/` (collision-safe). Shared by `phases clear`
817
+ * (archive-then-remove) and the internal milestone.complete phase archival so
818
+ * phase history survives a milestone switch instead of being hard-deleted.
819
+ *
820
+ * Archive-version precedence (#2288): an explicit `archiveVersionOverride` wins
821
+ * first, then a live `getMilestoneInfo(cwd)` read (which itself defaults to a
822
+ * version like `v1.0` when ROADMAP/STATE is absent), and only a dated fallback
823
+ * label if no safe version label is resolvable at all. The override is validated
824
+ * by the caller (`cmdPhasesClear`); the live-read value is re-validated here
825
+ * (defense in depth) because `getMilestoneInfo` derives it from STATE.md's
826
+ * unvalidated `milestone:` field. The override exists because `new-milestone.md`
827
+ * runs `state.milestone-switch` BEFORE `phases.clear --confirm` — by the time
828
+ * this runs, a live read of STATE.md would already report the NEW milestone
829
+ * version, so phase history from the OLD milestone would be misfiled under the
830
+ * new version's archive directory. Callers that know the outgoing version must
831
+ * pass it explicitly.
430
832
  */
431
- function archivePhaseDirectories(cwd, phasesDir, dirs) {
432
- let archiveVersion = null;
433
- try {
434
- archiveVersion = getMilestoneInfo(cwd).version ?? null;
435
- }
436
- catch {
437
- /* ROADMAP/STATE unreadable — fall back to a dated label */
833
+ function archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride = null) {
834
+ // Self-protecting (#2288 security defense in depth): the sole current caller
835
+ // (`cmdPhasesClear`) already validates the override, but re-test it here so a
836
+ // future caller cannot reopen the path-traversal sink at line ~742. An override
837
+ // that fails the safe-label check is discarded (falls through to the live read
838
+ // / dated label) rather than reaching `path.join` unvalidated.
839
+ const safeOverride = archiveVersionOverride && ARCHIVE_VERSION_LABEL_RE.test(archiveVersionOverride.trim())
840
+ ? archiveVersionOverride.trim()
841
+ : null;
842
+ let archiveVersion = safeOverride;
843
+ if (!archiveVersion) {
844
+ try {
845
+ const liveVersion = getMilestoneInfo(cwd).version ?? null;
846
+ // Defense in depth (#2288 security): getMilestoneInfo reads STATE.md's
847
+ // `milestone:` field, which is unvalidated file content. Only accept it
848
+ // as a path component if it is a safe version label; a crafted value
849
+ // (path separators / `..`) falls through to the dated label below rather
850
+ // than escaping `.planning/milestones/`.
851
+ archiveVersion = liveVersion && ARCHIVE_VERSION_LABEL_RE.test(liveVersion) ? liveVersion : null;
852
+ }
853
+ catch {
854
+ /* ROADMAP/STATE unreadable — fall back to a dated label */
855
+ }
438
856
  }
439
857
  if (!archiveVersion) {
440
858
  archiveVersion = `archived-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 8)}`;
@@ -457,6 +875,8 @@ function archivePhaseDirectories(cwd, phasesDir, dirs) {
457
875
  }
458
876
  module.exports = {
459
877
  cmdRequirementsMarkComplete,
878
+ cmdRequirementsReadyIds,
879
+ cmdRequirementsRevertPhase,
460
880
  cmdMilestoneComplete,
461
881
  cmdPhasesClear,
462
882
  };