* enhancement(#537): migrate code-review-flags to TS source of truth Collapse the hand-written get-shit-done/bin/lib/code-review-flags.cjs to a TypeScript source of truth (src/code-review-flags.cts), compiled by tsc to a gitignored .cjs build artifact at the same path, per ADR-457 (build-at-publish). Second module after the semver-compare pilot (#541). Behaviour is preserved byte-for-behaviour (characterization test added in tests/code-review-flags.test.cjs locks the parser quirks). Adds compile-time type checking: CodeReviewFlags interface + CodeReviewWorkflow literal union. The require() path is unchanged, so code-review.md and the bug-3727 test keep working. The emitted .cjs is gitignored and eslint-ignored, mirroring the pilot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 9 leaf bin/lib modules to TS source of truth ADR-457 build-at-publish, batch 1 (pure leaf modules, 0 sibling-deps): 001-legacy-orphan-files, context-utilization, redaction, artifacts, command-arg-projection, clock, ui-safety-gate, review-reviewer-selection, clusters. Each moves to src/*.cts (strict TS, typed), compiled by tsc to a gitignored .cjs at the same require() path; behaviour preserved byte-for- behaviour. Adds src/node-globals.d.ts (minimal ambient shim; "types":[]). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#537): add @types/node, drop hand-rolled node-globals shim ADR-457 migration infra: replace the temporary src/node-globals.d.ts ambient shim with @types/node@22 + "types":["node"] in tsconfig.build.json. Unblocks migrating the ~49 remaining bin/lib modules that use node:fs/path/os/ child_process. Build + full suite (3030 pass) + lint all green; no .cts type changes were needed (real Node types matched the shim). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 9 more bin/lib modules to TS (batch 2) ADR-457 build-at-publish. Clean leaves: installer-migration-report, prompt-budget. Type-error-prone leaves (were tsconfig.lint-excluded; now strict-typed and removed from that exclude list): secrets, phase-lifecycle, workstream-name-policy, decisions, validate, schema-detect. Plus runtime-name-policy. Strict type fixes narrow unknown->concrete domain types (no any/ts-ignore); behaviour preserved. Full suite green, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate runtime-slash to TS (cross-import proof) ADR-457. First cross-module TS->TS import: src/runtime-slash.cts imports ./runtime-name-policy.cjs and tsc resolves the sibling .cts types under strict (no declaration files; NodeNext .cjs->.cts mapping), emitting a correct require("./runtime-name-policy.cjs"). Confirms the recipe for coupled modules, which must be migrated in dependency order (leaves-up). Suite green, lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 10 more bin/lib modules to TS (batch 3) ADR-457 build-at-publish, Wave-1 leaves: event, workstream-inventory-builder, plan-scan, fallow-runner, project-root, installer-migration-authoring, update-context, 000-first-time-baseline, runtime-homes, model-catalog. Strict typing fixed real issues (narrowing unknown, qualified fs/path calls, removed unnecessary casts); plan-scan/project-root/workstream-inventory-builder dropped from tsconfig.lint exclude. Behaviour preserved; suite green, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 5 large Wave-1 leaves to TS (batch 4) ADR-457 build-at-publish: configuration, state-document, shell-command- projection (42 dependents), security, command-aliases. shell-command- projection keeps a namespace child_process import for mock-intercept testability. loadConfig/migrateOnDisk emit synchronously (every caller uses them sync; the one awaited migrateOnDisk caller tolerates a non-Promise) — full suite (3030 pass) confirms behaviour preserved. configuration/ state-document/command-aliases dropped from tsconfig.lint exclude. Also fixes the malformed batch-3 changeset frontmatter (type/pr) that failed lint:docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 6 Wave-2 modules to TS (batch 5) ADR-457 build-at-publish: config-schema, model-profiles, 002-codex-legacy-hooks-json, logger, active-workstream-store, adr-parser. First batch importing already-migrated siblings (configuration, model-catalog, shell-command-projection, redaction, security) via ./sibling.cjs specifiers. Strict type narrowing (typeof guards over String(unknown)); behaviour preserved; suite 3030 pass, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 5 large Wave-2 modules to TS (batch 6) ADR-457 build-at-publish: graphify, install-profiles, intel, installer-migrations, worktree-safety. installer-migrations preserves its dynamic require() loader for numbered migration modules (scoped lint suppressions). Strict typing (typeof guards over String(unknown)); behaviour preserved; suite 3030 pass, lint 0 errors. Wave 2 complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate Wave-3 modules to TS (batch 7) ADR-457 build-at-publish: planning-workspace, runtime-artifact-layout, command-routing-hub, drift. Uses `import x = require()` for export= siblings; drift's lazy require of runtime-slash hoisted to a top-level import (verified non-circular). Behaviour preserved; suite 3030 pass, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate small Wave-4 modules to TS (batch 8) ADR-457 build-at-publish: cjs-command-router-adapter, phase-command-router, surface, roadmap-upgrade. Typed the hub router handler results as the HubResult discriminated union; surface drops 4 genuinely-unused imports. Behaviour preserved; suite 3030 pass, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate core hub (2.5k LOC, 68 dependents) to TS (batch 9) ADR-457 build-at-publish: get-shit-done/bin/lib/core.cjs -> src/core.cts, preserving all 63 exports via export=. All sibling deps already migrated (shell-command-projection, model-profiles, model-catalog, worktree-safety, planning-workspace, project-root, configuration, config-schema). Strict types, no any/ts-ignore; config-schema lazy require hoisted (non-circular). Behaviour preserved (independently verified: core's shard 3030 pass / 0 fail). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#537): make ESLint-coverage + test-sprawl checks migration-aware #551 test hardcoded 12 now-migrated modules as "hand-written, must be linted"; that invariant is obsoleted by the ADR-457 migration. Rewrite it to a filesystem-driven invariant that holds at every stage: a bin/lib/*.cjs must be eslint-ignored IFF it has a src/*.cts source (tsc-generated), else linted (covers package-identity, which has no TS source). Also eslint-ignore config-types.cjs (has a src counterpart) and drop the redundant tests/clock.test.cjs (clock already covered by clock-seam + bug-474 tests), which tripped the lint-test-file-count ratchet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 9 Wave-5 router/inventory modules to TS (batch 10) ADR-457 build-at-publish: phases/verify/init/agent/task/validate/roadmap/state command routers + workstream-inventory. Router handler results typed against core's exported shapes; behaviour preserved (caught+fixed a --verify boolean flag regression mid-migration). Full suite green across all shards (only the 4 local gpg-env changeset-notes failures remain; CI passes them). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 7 Wave-5 modules to TS (batch 11) ADR-457 build-at-publish: gap-checker, docs, check-command-router, frontmatter, learnings, gsd2-import, profile-pipeline. Behaviour preserved; full suite green across all shards (only the 4 local gpg-env failures remain). Also broadens atomic-write-coverage.test.cjs to accept the tsc-compiled namespace-import form while still asserting platformWriteSync is called (safety guard intact). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate config + profile-output to TS (batch 12) ADR-457 build-at-publish: config (729 LOC), profile-output (1142 LOC). All exports preserved; cmdMigrateConfig de-asynced (migrateOnDisk is sync, awaited caller tolerates it). Behaviour preserved; suite green across all shards (only the 4 local gpg-env failures). Wave 5 complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 5 Wave-6 modules to TS (batch 13) ADR-457 build-at-publish: template, uat, workstream, roadmap, audit. Behaviour preserved (dead toPosixPath import dropped from audit; inline requires hoisted). Suite green across all shards (only the 4 local gpg-env failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate commands + state hubs to TS (batch 14) ADR-457 build-at-publish: commands (1305 LOC), state (2074 LOC, 17 dependents). All exports preserved; inner requires kept non-hoisted where load-order matters (install.js, per-call security); acquireStateLock cast inlined to preserve the err.code source token a structural test inspects. Behaviour preserved; suite green across all shards (only the 4 local gpg-env failures). Wave 6 complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate milestone to TS (batch 15a, hand-authored) ADR-457 build-at-publish: milestone -> src/milestone.cts. Authored directly (subagent capacity was unavailable). Also relaxes core.output()'s 3rd param to optional, matching its real always-optional call contract (unblocks remaining 2-arg output callers). Behaviour preserved; suite green across all shards (only the 4 local gpg-env failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate phase, verify, init to TS (batch 15, final modules) ADR-457 build-at-publish, Wave 7 (the last hubs): phase (1608 LOC), verify (1615), init (2113). Adds src/package-identity.d.cts so verify can import the permanently value-baked package-identity.cjs under strict TS. Fixes two regressions the migration introduced in verify: restore cmdValidateHealth's `return result` (callers/tests read result.warnings — it is NOT side-effect-only), and make the bug-3384 source-pattern test tolerant of the tsc-compiled bracket-notation form of the git_list_failed->W020 branch (behaviour intact). Full suite green across all shards (only the 4 local gpg-env failures); lint 0 errors. All 86 migratable bin/lib modules are now TypeScript sources. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#537): finalize ADR-457 migration — retire tsconfig.lint.json All hand-written bin/lib/*.cjs are now src/*.cts sources, so the checkJs stopgap tsconfig.lint.json (unused; not wired into eslint, scripts, or CI) is deleted per ADR-457's final step. Also gitignore the tsc-generated config-types.cjs (was still committed) for consistency with every other emitted artifact. package-identity.cjs stays value-baked (declared via src/package-identity.d.cts). Suite green; #551 ESLint-coverage test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#537): add prepare script so unpacked/git installs build bin/lib artifacts ADR-457 build-at-publish: bin/lib/*.cjs are now gitignored, built by tsc. The prepack/prepublishOnly hooks cover `npm pack`/publish, but `npm install -g <dir>` and git installs run the `prepare` lifecycle — which was missing — so the unpacked install shipped without the compiled .cjs and failed at startup with "Cannot find module './lib/core.cjs'" (caught by the smoke-unpacked CI job). Add `prepare` mirroring prepublishOnly (build:lib + build:hooks). prepare does NOT run for registry consumers (they get the pre-built tarball), only for source/local/pack installs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#537): make CI build/lockfile checks work with gitignored bin/lib artifacts ADR-457 build-at-publish exposed two CI assumptions that bin/lib/*.cjs are always present on disk: - check:env's lockfile-sync ran `npm ci --dry-run`, which now triggers the `prepare` build (tsc) — but it runs before deps are installed, so tsc is absent and it misreported the lockfile as out of sync. Add --ignore-scripts (a lockfile check must not build). - the lint-tests job installs with --ignore-scripts (no prepare build), but lint:skill-deps require()s the built install-profiles.cjs. Add an explicit `npm run build:lib` step after install. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#537): narrow prepare to build:lib only (unbreak packed-smoke pack step) prepare running build:hooks emitted "✓ Copying ..." stdout during `npm pack`, which the install-smoke "Pack root tarball" step captures into $GITHUB_OUTPUT — breaking it with "Invalid format". build:lib (tsc) is silent on success and is all the unpacked/source install needs (the smoke-unpacked assertions exercise gsd-tools, i.e. bin/lib, and tolerate hook setup with `|| true`). Matches prepack. build:hooks still runs on prepublishOnly for real publishes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#537): wire Stryker mutation gate to build-at-publish layout The gate scored 0.00 because it mutated changed bin/lib/*.cjs that (a) were generated artifacts and (b) included modules with no coverage in the command's test set. Rework: mutation.yml now derives changed COVERED modules from src/*.cts and maps them to their built bin/lib/*.cjs; Stryker mutates those built artifacts with a no-rebuild command (mutating src/*.cts + per-mutant tsc was ~3x over the 30-min CI budget). NOTE: with the gate now correctly measuring the covered modules, their actual mutation score is 42.94% (< break 50) — a pre-existing test-coverage gap (adr-parser/prompt-budget/etc.), not introduced by this behaviour-preserving migration. Reaching 50 needs more tests, a threshold/scope change, or a waiver — a maintainer decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#537): raise mutation coverage of covered modules above the 50 gate Adds focused example-based unit tests that kill surviving mutants in the two lowest-scoring covered modules: - tests/prompt-budget.unit.test.cjs (112 tests): 17.9% -> 97.9% - tests/adr-parser.unit.test.cjs (205 tests): 44.7% -> 89.4% Both wired into stryker.config.mjs's command. Fresh full run over the 6 covered modules now scores 82.25% (>= break 50); every covered module is >= 68%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537,#609): parallelize mutation gate via dynamic per-module matrix The serial Stryker run timed out at 30 min once the migration's added tests made every mutant re-run ~300 tests. Replace it with a dynamic matrix so the gate completes well under budget — folded into this PR (was tracked as #609) because it's a prerequisite for this PR's mutation gate to pass. - scripts/mutation-matrix.cjs: single source of truth (covered-module -> test files) computing changed covered modules from git diff -> {has_work, matrix}. - mutation.yml: detect -> dynamic `matrix: fromJSON(...)` mutate job (one parallel shard per changed module, scoped via MUTATION_TEST_CMD to only that module's tests, 15-min/shard) -> summary job that KEEPS the legacy check name "Stryker mutation score (changed files only)" so branch protection is unchanged. Per-shard jobs report as "Stryker (<module>)". - stryker.config.mjs: commandRunner.command reads MUTATION_TEST_CMD (falls back to the full command locally). Closes #609. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#537,#609): give each mutation shard ≥50% on its own tests; drop blacksmith note Per-module sharding revealed that active-workstream-store (46.5%) and frontmatter (7.4%) only cleared 50% in the old serial run via timeout-noise from the bloated 300-test command; on their own tests they were below the gate. Add focused unit tests: - tests/active-workstream-store.unit.test.cjs (115 tests): 46.5% -> 81.9% - tests/frontmatter.unit.test.cjs (165 tests): 7.4% -> 63.4% Both wired into scripts/mutation-matrix.cjs (per-module test map) and stryker.config.mjs DEFAULT_TEST_CMD. All 6 covered modules now clear break:50 with only their own tests (config-schema/context-utilization/prompt-budget/ adr-parser already did). Also removes the leftover blacksmith TODO comment — GitHub-hosted runners only; speed comes from parallel per-module shards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#537,#609): strengthen prompt-budget tests to clear the gate on its own tests prompt-budget scored 39.58% when mutation-tested with ONLY its own tests (the way the per-module CI shard runs it) — an earlier ~98% reading was inflated by accidentally running the full multi-module command. Add 96 targeted tests to tests/prompt-budget.unit.test.cjs (exact note-template text, plan-truncation arithmetic/percentages, drop-block strings, noteInjected/hardFailed booleans): scoped score 39.58% -> 68.75% (>= break 50). All 6 covered modules now clear the gate on their own tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
/**
|
|
* Milestone — Milestone and requirements lifecycle operations.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/milestone.cjs collapsed to
|
|
* a TypeScript source of truth, compiled by tsc to a gitignored .cjs at the same
|
|
* require() path. Behaviour preserved byte-for-behaviour; only types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module
|
|
import core = require('./core.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
|
|
import frontmatterMod = require('./frontmatter.cjs');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- state.cjs is an export= CommonJS module
|
|
import stateMod = require('./state.cjs');
|
|
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
|
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
|
|
|
const {
|
|
escapeRegex,
|
|
getMilestonePhaseFilter,
|
|
extractOneLinerFromBody,
|
|
normalizePhaseName,
|
|
phaseTokenMatches,
|
|
output,
|
|
error,
|
|
} = core;
|
|
const { planningPaths } = planningWorkspace;
|
|
const { extractFrontmatter } = frontmatterMod;
|
|
const { writeStateMd, stateReplaceFieldWithFallback } = stateMod;
|
|
|
|
interface MilestoneCompleteOptions {
|
|
name?: string;
|
|
force?: boolean;
|
|
archivePhases?: boolean;
|
|
}
|
|
|
|
function cmdRequirementsMarkComplete(cwd: string, reqIdsRaw: string[], raw: boolean): void {
|
|
if (!reqIdsRaw || reqIdsRaw.length === 0) {
|
|
error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
|
|
}
|
|
|
|
// Accept comma-separated, space-separated, or bracket-wrapped: [REQ-01, REQ-02]
|
|
const reqIds = reqIdsRaw
|
|
.join(' ')
|
|
.replace(/[\[\]]/g, '')
|
|
.split(/[,\s]+/)
|
|
.map((r) => r.trim())
|
|
.filter(Boolean);
|
|
|
|
if (reqIds.length === 0) {
|
|
error('no valid requirement IDs found');
|
|
}
|
|
|
|
const reqPath = planningPaths(cwd).requirements;
|
|
if (!fs.existsSync(reqPath)) {
|
|
output({ updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds }, raw, 'no requirements file');
|
|
return;
|
|
}
|
|
|
|
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
const updated: string[] = [];
|
|
const alreadyComplete: string[] = [];
|
|
const notFound: string[] = [];
|
|
|
|
for (const reqId of reqIds) {
|
|
let found = false;
|
|
const reqEscaped = escapeRegex(reqId);
|
|
|
|
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
|
// Use replace() directly and compare — avoids test()+replace() global regex
|
|
// lastIndex bug where test() advances state and replace() misses matches.
|
|
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
|
|
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
|
|
if (afterCheckbox !== reqContent) {
|
|
reqContent = afterCheckbox;
|
|
found = true;
|
|
}
|
|
|
|
// Update traceability table: | REQ-ID | Phase N | Pending | → | REQ-ID | Phase N | Complete |
|
|
const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
|
|
const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
|
|
if (afterTable !== reqContent) {
|
|
reqContent = afterTable;
|
|
found = true;
|
|
}
|
|
|
|
if (found) {
|
|
updated.push(reqId);
|
|
} else {
|
|
// Check if already complete before declaring not_found.
|
|
// Non-global flag is fine here — we only need to know if a match exists.
|
|
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
|
|
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
|
|
if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
|
|
alreadyComplete.push(reqId);
|
|
} else {
|
|
notFound.push(reqId);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (updated.length > 0) {
|
|
platformWriteSync(reqPath, reqContent);
|
|
}
|
|
|
|
output(
|
|
{
|
|
updated: updated.length > 0,
|
|
marked_complete: updated,
|
|
already_complete: alreadyComplete,
|
|
not_found: notFound,
|
|
total: reqIds.length,
|
|
},
|
|
raw,
|
|
`${updated.length}/${reqIds.length} requirements marked complete`,
|
|
);
|
|
}
|
|
|
|
function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCompleteOptions, raw: boolean): void {
|
|
if (!version) {
|
|
error('version required for milestone complete (e.g., v1.0)');
|
|
}
|
|
|
|
const roadmapPath = planningPaths(cwd).roadmap;
|
|
const reqPath = planningPaths(cwd).requirements;
|
|
const statePath = planningPaths(cwd).state;
|
|
const milestonesPath = path.join(cwd, '.planning', 'MILESTONES.md');
|
|
const archiveDir = path.join(cwd, '.planning', 'milestones');
|
|
const phasesDir = planningPaths(cwd).phases;
|
|
const today = new Date().toISOString().split('T')[0];
|
|
const milestoneName = options.name || version;
|
|
|
|
// Ensure archive directory exists
|
|
platformEnsureDir(archiveDir);
|
|
|
|
// Scope stats and accomplishments to only the phases belonging to the
|
|
// current milestone's ROADMAP. Uses the shared filter from core.cjs
|
|
// (same logic used by cmdPhasesList and other callers).
|
|
const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
|
|
if (isDirInMilestone.missingExplicitVersion) {
|
|
error(`no phases found for milestone ${version} in ROADMAP.md`);
|
|
}
|
|
|
|
// Guard: prevent marking complete when ROADMAP still lists phases that have
|
|
// no directory on disk (disk_status: no_directory). This catches the case
|
|
// where the active milestone was erroneously marked complete before phases
|
|
// were even started. Only fires when STATE.md confirms the current milestone
|
|
// version matches what is being completed — no false positives on fresh
|
|
// projects where phases haven't been scaffolded yet.
|
|
// Pass --force to override this guard.
|
|
if (!options.force) {
|
|
try {
|
|
// Only guard when STATE.md's milestone field matches the version being completed.
|
|
let stateVersion: string | null = null;
|
|
try {
|
|
const stateRaw = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : null;
|
|
if (stateRaw) {
|
|
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
if (milestoneMatch) stateVersion = milestoneMatch[1].trim();
|
|
}
|
|
} catch {
|
|
/* skip */
|
|
}
|
|
|
|
if (stateVersion && stateVersion === version) {
|
|
const { extractCurrentMilestone } = core;
|
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
const scopedContent = extractCurrentMilestone(roadmapContent, cwd);
|
|
const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
|
const noDirectoryPhases: string[] = [];
|
|
let pm: RegExpExecArray | null;
|
|
const phaseDirEntries = ((): string[] => {
|
|
try {
|
|
return fs
|
|
.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name);
|
|
} catch {
|
|
return [];
|
|
}
|
|
})();
|
|
while ((pm = phasePattern.exec(scopedContent)) !== null) {
|
|
const phaseNum = pm[1];
|
|
const normalized = normalizePhaseName(phaseNum);
|
|
// A phase has disk_status: 'no_directory' when no phase directory
|
|
// with a matching token exists on disk. Use the same phaseTokenMatches
|
|
// helper that roadmap.analyze uses to avoid false positives on decimal
|
|
// (2.1) and letter-suffix (12A) phase IDs.
|
|
const hasDirectory = phaseDirEntries.some((d) => phaseTokenMatches(d, normalized));
|
|
if (!hasDirectory) {
|
|
noDirectoryPhases.push(phaseNum);
|
|
}
|
|
}
|
|
if (noDirectoryPhases.length > 0) {
|
|
error(
|
|
`Cannot mark milestone complete: ROADMAP lists ${noDirectoryPhases.length} unstarted phase(s) ` +
|
|
`(e.g. Phase ${noDirectoryPhases[0]}). Re-run with --force to override.`,
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// If the error came from our guard, re-throw it; otherwise skip silently.
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
if (message && message.startsWith('Cannot mark milestone complete:')) throw e;
|
|
// Phase scan failed or STATE version mismatch — allow completion to proceed.
|
|
}
|
|
}
|
|
|
|
// Gather stats from phases (scoped to current milestone only)
|
|
let phaseCount = 0;
|
|
let totalPlans = 0;
|
|
let totalTasks = 0;
|
|
const accomplishments: string[] = [];
|
|
|
|
try {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const dirs = entries
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name)
|
|
.sort();
|
|
|
|
for (const dir of dirs) {
|
|
if (!isDirInMilestone(dir)) continue;
|
|
|
|
phaseCount++;
|
|
const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
|
|
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
totalPlans += plans.length;
|
|
|
|
// Extract one-liners from summaries
|
|
for (const s of summaries) {
|
|
try {
|
|
const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
|
|
const fm = extractFrontmatter(content);
|
|
const rawOneLiner = fm['one-liner'];
|
|
const oneLiner = (typeof rawOneLiner === 'string' ? rawOneLiner : '') || extractOneLinerFromBody(content);
|
|
if (oneLiner) {
|
|
accomplishments.push(oneLiner);
|
|
}
|
|
// Count tasks: prefer **Tasks:** N from Performance section,
|
|
// then <task XML tags, then ## Task N markdown headers
|
|
const tasksFieldMatch = content.match(/\*\*Tasks:\*\*\s*(\d+)/);
|
|
if (tasksFieldMatch) {
|
|
totalTasks += parseInt(tasksFieldMatch[1], 10);
|
|
} else {
|
|
const xmlTaskMatches = content.match(/<task[\s>]/gi) || [];
|
|
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
|
|
totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
|
|
}
|
|
} catch {
|
|
/* intentionally empty */
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* intentionally empty */
|
|
}
|
|
|
|
// Archive ROADMAP.md
|
|
if (fs.existsSync(roadmapPath)) {
|
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
platformWriteSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent);
|
|
}
|
|
|
|
// Archive REQUIREMENTS.md
|
|
if (fs.existsSync(reqPath)) {
|
|
const reqContent = fs.readFileSync(reqPath, 'utf-8');
|
|
const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
|
|
platformWriteSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent);
|
|
}
|
|
|
|
// Archive audit file if exists
|
|
const auditFile = path.join(cwd, '.planning', `${version}-MILESTONE-AUDIT.md`);
|
|
if (fs.existsSync(auditFile)) {
|
|
fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
|
|
}
|
|
|
|
// Create/append MILESTONES.md entry
|
|
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
|
|
const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
|
|
|
|
if (fs.existsSync(milestonesPath)) {
|
|
const existing = fs.readFileSync(milestonesPath, 'utf-8');
|
|
if (!existing.trim()) {
|
|
// Empty file — treat like new
|
|
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
|
} else {
|
|
// Insert after the header line(s) for reverse chronological order (newest first)
|
|
const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
|
|
if (headerMatch) {
|
|
const header = headerMatch[1];
|
|
const rest = existing.slice(header.length);
|
|
platformWriteSync(milestonesPath, header + milestoneEntry + rest);
|
|
} else {
|
|
// No recognizable header — prepend the entry
|
|
platformWriteSync(milestonesPath, milestoneEntry + existing);
|
|
}
|
|
}
|
|
} else {
|
|
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
|
}
|
|
|
|
// Update STATE.md — keep frontmatter/body semantically aligned after closure
|
|
if (fs.existsSync(statePath)) {
|
|
let stateContent = fs.readFileSync(statePath, 'utf-8');
|
|
|
|
stateContent = stateReplaceFieldWithFallback(stateContent, 'Status', null, `${version} milestone complete`);
|
|
stateContent = stateReplaceFieldWithFallback(stateContent, 'Last Activity', 'Last activity', today);
|
|
stateContent = stateReplaceFieldWithFallback(
|
|
stateContent,
|
|
'Last Activity Description',
|
|
null,
|
|
`${version} milestone completed and archived`,
|
|
);
|
|
|
|
// Reset Current Position narrative so resume/progress flows do not keep
|
|
// pointing at closed-phase execution instructions.
|
|
const positionPattern = /(##\s*Current Position\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
|
const closedPositionBody =
|
|
`\nPhase: Milestone ${version} complete\n` +
|
|
`Plan: —\n` +
|
|
`Status: Awaiting next milestone\n` +
|
|
`Last activity: ${today} — Milestone ${version} completed and archived\n\n`;
|
|
if (positionPattern.test(stateContent)) {
|
|
stateContent = stateContent.replace(positionPattern, (_m, header: string) => `${header}${closedPositionBody}`);
|
|
} else {
|
|
stateContent = `${stateContent.trimEnd()}\n\n## Current Position\n${closedPositionBody}`;
|
|
}
|
|
|
|
// Normalize operator-next-step tails that can become stale after close.
|
|
const operatorPattern = /(##\s*Operator Next Steps\s*\n)([\s\S]*?)(?=\n##|$)/i;
|
|
if (operatorPattern.test(stateContent)) {
|
|
stateContent = stateContent.replace(
|
|
operatorPattern,
|
|
`$1\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n\n`,
|
|
);
|
|
} else {
|
|
stateContent = `${stateContent.trimEnd()}\n\n## Operator Next Steps\n\n- Start the next milestone with ${formatGsdSlash('new-milestone', resolveRuntime(cwd)) as string}\n`;
|
|
}
|
|
|
|
writeStateMd(statePath, stateContent, cwd);
|
|
}
|
|
|
|
// Archive phase directories if requested
|
|
let phasesArchived = false;
|
|
if (options.archivePhases) {
|
|
try {
|
|
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
|
|
platformEnsureDir(phaseArchiveDir);
|
|
|
|
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
let archivedCount = 0;
|
|
for (const dir of phaseDirNames) {
|
|
if (!isDirInMilestone(dir)) continue;
|
|
fs.renameSync(path.join(phasesDir, dir), path.join(phaseArchiveDir, dir));
|
|
archivedCount++;
|
|
}
|
|
phasesArchived = archivedCount > 0;
|
|
} catch {
|
|
/* intentionally empty */
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
version,
|
|
name: milestoneName,
|
|
date: today,
|
|
phases: phaseCount,
|
|
plans: totalPlans,
|
|
tasks: totalTasks,
|
|
accomplishments,
|
|
archived: {
|
|
roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)),
|
|
requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
|
|
audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
|
|
phases: phasesArchived,
|
|
},
|
|
milestones_updated: true,
|
|
state_updated: fs.existsSync(statePath),
|
|
};
|
|
|
|
output(result, raw);
|
|
}
|
|
|
|
function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
|
|
const phasesDir = planningPaths(cwd).phases;
|
|
const confirm = Array.isArray(args) && args.includes('--confirm');
|
|
let cleared = 0;
|
|
|
|
if (fs.existsSync(phasesDir)) {
|
|
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
|
const dirs = entries.filter((e) => e.isDirectory() && !/^999(?:\.|$)/.test(e.name));
|
|
|
|
if (dirs.length > 0 && !confirm) {
|
|
error(
|
|
`phases clear would delete ${dirs.length} phase director${dirs.length === 1 ? 'y' : 'ies'}. ` +
|
|
`Pass --confirm to proceed.`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
for (const entry of dirs) {
|
|
fs.rmSync(path.join(phasesDir, entry.name), { recursive: true, force: true });
|
|
cleared++;
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
error('Failed to clear phases directory: ' + message);
|
|
}
|
|
}
|
|
|
|
output({ cleared }, raw, `${cleared} phase director${cleared === 1 ? 'y' : 'ies'} cleared`);
|
|
}
|
|
|
|
export = {
|
|
cmdRequirementsMarkComplete,
|
|
cmdMilestoneComplete,
|
|
cmdPhasesClear,
|
|
};
|