@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +14 -0
- package/README.md +2 -0
- package/agents/gsd-debug-session-manager.md +42 -4
- package/agents/gsd-debugger.md +87 -29
- package/agents/gsd-executor.md +31 -3
- package/agents/gsd-planner.md +29 -36
- package/agents/gsd-security-auditor.md +13 -15
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +1157 -84
- package/commands/gsd/ai-integration-phase.md +1 -1
- package/commands/gsd/mempalace-capture.md +31 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +5 -3
- package/commands/gsd/plan-review-convergence.md +3 -2
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +1866 -2434
- package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
- package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
- package/gsd-core/bin/lib/api-coverage.cjs +341 -49
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/broken-windows.cjs +716 -0
- package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
- package/gsd-core/bin/lib/capability-registry.cjs +157 -88
- package/gsd-core/bin/lib/capability-writer.cjs +6 -1
- package/gsd-core/bin/lib/check-command-router.cjs +129 -26
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
- package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/command-aliases.cjs +14 -0
- package/gsd-core/bin/lib/commands.cjs +129 -13
- package/gsd-core/bin/lib/config-loader.cjs +20 -4
- package/gsd-core/bin/lib/config.cjs +81 -18
- package/gsd-core/bin/lib/core-utils.cjs +14 -3
- package/gsd-core/bin/lib/decisions.cjs +32 -8
- package/gsd-core/bin/lib/docs.cjs +6 -0
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gap-checker.cjs +17 -2
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/init.cjs +138 -60
- package/gsd-core/bin/lib/install-engine.cjs +301 -25
- package/gsd-core/bin/lib/install-profiles.cjs +239 -1
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
- package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +45 -6
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +449 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/milestone.cjs +463 -43
- package/gsd-core/bin/lib/model-catalog.cjs +19 -4
- package/gsd-core/bin/lib/model-resolver.cjs +189 -7
- package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- package/gsd-core/bin/lib/phase-id.cjs +26 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
- package/gsd-core/bin/lib/phase-locator.cjs +23 -2
- package/gsd-core/bin/lib/phase.cjs +636 -72
- package/gsd-core/bin/lib/plan-scan.cjs +73 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +225 -17
- package/gsd-core/bin/lib/roadmap.cjs +113 -52
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +14 -7
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -9
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +41 -17
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +1 -1
- package/gsd-core/bin/lib/shell-command-projection.cjs +61 -25
- package/gsd-core/bin/lib/smart-entry.cjs +73 -7
- package/gsd-core/bin/lib/state-document.cjs +7 -4
- package/gsd-core/bin/lib/state-transition.cjs +122 -46
- package/gsd-core/bin/lib/state.cjs +456 -137
- package/gsd-core/bin/lib/surface.cjs +53 -11
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +474 -13
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/validate.cjs +12 -8
- package/gsd-core/bin/lib/verification.cjs +112 -17
- package/gsd-core/bin/lib/verify.cjs +224 -25
- package/gsd-core/bin/lib/workstream.cjs +3 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
- package/gsd-core/bin/lib/write-set.cjs +38 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -2
- package/gsd-core/references/api-coverage.md +37 -7
- package/gsd-core/references/checkpoints.md +13 -1
- package/gsd-core/references/common-bug-patterns.md +13 -0
- package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
- package/gsd-core/references/debugger-fix-acceptance.md +157 -0
- package/gsd-core/references/debugger-philosophy.md +1 -0
- package/gsd-core/references/debugger-prevention.md +98 -0
- package/gsd-core/references/debugger-rca-branching.md +98 -0
- package/gsd-core/references/debugger-repro-hardening.md +130 -0
- package/gsd-core/references/debugger-sbfl.md +110 -0
- package/gsd-core/references/debugger-semantic-recall.md +81 -0
- package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
- package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
- package/gsd-core/references/execute-phase-response-language.md +7 -0
- package/gsd-core/references/planner-antipatterns.md +6 -0
- package/gsd-core/references/planner-mvp-mode.md +12 -13
- package/gsd-core/references/planner-preconditions.md +156 -0
- package/gsd-core/references/planner-reversibility.md +132 -0
- package/gsd-core/references/reviewer-instances.md +9 -7
- package/gsd-core/references/skeleton-template.md +1 -1
- package/gsd-core/references/thinking-models-planning.md +3 -1
- package/gsd-core/templates/DEBUG.md +5 -3
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +4 -2
- package/gsd-core/workflows/add-todo.md +32 -1
- package/gsd-core/workflows/ai-integration-phase.md +4 -2
- package/gsd-core/workflows/audit-fix.md +2 -2
- package/gsd-core/workflows/check-todos.md +3 -1
- package/gsd-core/workflows/cleanup.md +7 -1
- package/gsd-core/workflows/code-review.md +17 -5
- package/gsd-core/workflows/complete-milestone.md +3 -0
- package/gsd-core/workflows/debug.md +27 -5
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discovery-phase.md +7 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
- package/gsd-core/workflows/do.md +7 -1
- package/gsd-core/workflows/docs-update.md +1 -0
- package/gsd-core/workflows/eval-review.md +3 -0
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
- package/gsd-core/workflows/execute-phase.md +30 -37
- package/gsd-core/workflows/execute-plan.md +15 -4
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/graduation.md +3 -0
- package/gsd-core/workflows/health.md +7 -1
- package/gsd-core/workflows/help/modes/full.md +6 -2
- package/gsd-core/workflows/import.md +8 -2
- package/gsd-core/workflows/inbox.md +7 -0
- package/gsd-core/workflows/ingest-docs.md +15 -10
- package/gsd-core/workflows/manager.md +3 -1
- package/gsd-core/workflows/map-codebase.md +4 -4
- package/gsd-core/workflows/mvp-phase.md +3 -0
- package/gsd-core/workflows/new-milestone.md +69 -21
- package/gsd-core/workflows/new-project.md +17 -15
- package/gsd-core/workflows/new-workspace.md +3 -1
- package/gsd-core/workflows/onboard.md +3 -0
- package/gsd-core/workflows/plan-phase.md +14 -5
- package/gsd-core/workflows/plan-review-convergence.md +48 -3
- package/gsd-core/workflows/plant-seed.md +3 -0
- package/gsd-core/workflows/profile-user.md +7 -1
- package/gsd-core/workflows/progress.md +33 -5
- package/gsd-core/workflows/quick.md +21 -7
- package/gsd-core/workflows/remove-workspace.md +3 -0
- package/gsd-core/workflows/review.md +123 -68
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +4 -1
- package/gsd-core/workflows/settings-integrations.md +3 -0
- package/gsd-core/workflows/settings.md +3 -0
- package/gsd-core/workflows/ship.md +58 -5
- package/gsd-core/workflows/sketch.md +3 -0
- package/gsd-core/workflows/smart-entry.md +3 -0
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/spike.md +7 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +3 -1
- package/gsd-core/workflows/ui-review.md +3 -0
- package/gsd-core/workflows/undo.md +7 -0
- package/gsd-core/workflows/update.md +2 -0
- package/gsd-core/workflows/validate-phase.md +3 -0
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/gsd-core/workflows/verify-work.md +7 -3
- package/hooks/dist/gsd-context-monitor.js +27 -9
- package/hooks/dist/gsd-statusline.js +252 -17
- package/hooks/gsd-context-monitor.js +27 -9
- package/hooks/gsd-statusline.js +252 -17
- package/package.json +8 -4
- package/pi/gsd.cjs +8 -2
- package/scripts/changeset/lint.cjs +1 -0
- package/scripts/changeset/parse.cjs +26 -0
- package/scripts/check-glossary-refs.cjs +220 -0
- package/scripts/ci-rebase-check.cjs +48 -4
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-adr-index.cjs +526 -0
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -45
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/gen-test-timings.cjs +201 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-portable-timeout.cjs +140 -0
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-tarball-smoke.cjs +18 -11
- package/scripts/run-tests.cjs +420 -58
- package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +31 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +5 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
- package/skills/gsd-surface/SKILL.md +6 -6
- 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
|
-
//
|
|
66
|
-
// Use replace()
|
|
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
|
-
|
|
140
|
+
const checkboxHit = afterCheckbox !== reqContent;
|
|
141
|
+
if (checkboxHit)
|
|
71
142
|
reqContent = afterCheckbox;
|
|
72
|
-
|
|
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
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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 =
|
|
424
|
+
const today = clock_cjs_1.realClock.localToday();
|
|
124
425
|
const milestoneName = options.name || version;
|
|
125
|
-
// Ensure archive directory exists
|
|
126
|
-
(
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
};
|