Files
msd-core/src/workstream-inventory.cts
Tom Boucher 693f12ad56 refactor(#3187): give state field extraction one canonical owner (#3283)
* refactor(#3187): give state field extraction one canonical owner

stateFieldValue in state-document.cts becomes the single owner of the #1760
frontmatter-then-body fallback chain. The new whole-repo guard found 14
independent re-derivations where the epic scoped 5, all now routed through it:
cmdStateSnapshot (11), cmdStatePrune (2) and smart-entry fmScalar (1).

state validate was a gate that could not fail. Every warning it could emit sat
behind a phase resolved without the frontmatter tier, so a STATE.md whose phase
lives only in frontmatter skipped the drift scan entirely and returned
valid:true. It also read unstripped content, letting a frontmatter status: key
shadow the body field (#1255 class). Both fixed; output gains a scope field so
could-not-look stops being output-identical to looked-and-clean.

Verified on the remote runner.

* docs(#3187): document the state validate scope field and its reason codes

Adds docs/how-to/interpret-state-validate-results.md so a reader can tell
nothing-to-report from could-not-look, updates the COMMANDS.md and USER-GUIDE.md
entries, corrects the CONTEXT.md glossary overstatement about Current Position
sole ownership, and drops the changeset fragment.

* fix(#3187): close three drift-guard evasion shapes and test the refuse path

The isolated adversarial review found the ladder detector was evadable by
ordinary reformatting, not just deliberately: a member or computed operand
(fm.key / fm[key]) missed the bare-identifier backreference, a swapped tier
order missed a hardcoded number-then-boolean sequence, and a ladder wrapped
across lines missed single-line detection. All three now caught, each with its
own test plus a proven boundary control.

The frontmatter-parse refuse path on the destructive complete-phase route was
unreachable and therefore untested. It is now driven by an injected parse
failure and asserts STATE.md is byte-identical after the refusal, rather than
shipping untested defensive code on a path that rewrites user state.

Verified on the remote runner.

* fix(#3187): widen the drift guard to the prompt layer and disclose tier-2 changes

The code-review spec axis found the guard's scan surface was src/ only, which is
Decision 4(d)'s forbidden allowlist one directory wide - and it had a live miss:
gsd-core/workflows/smart-entry.md tells an agent to read status from frontmatter
or the body, a prose expression of this same chain. The surface now covers the
prompt layer. That one site carries a permanent written exemption rather than a
ratchet: it is the gsd-tools-is-down fallback, so it cannot call the owner by
construction, and a ratchet would imply removable debt that does not exist.

Two tier-2 output changes shipped undisclosed and are now named in the changeset
and docs: complete-phase's idempotency guard consulting frontmatter, and the
workstream inventory resolving frontmatter-only fields. docs/COMMANDS.md gains a
state complete-phase entry, which it never had.

Also records Amendment 5 on ADR-3180, extracts the duplicated frontmatter-parse
block the epic's own thesis forbids, and re-points two assertions from free-form
warning prose onto the structured drift object.

Verified on the remote runner.

* chore(#3187): backfill changeset PR number

pr:0 placeholder replaced with the real PR number now that #3283 exists.

---------

Co-authored-by: sim <sim@local>
2026-08-09 22:49:39 -04:00

832 lines
40 KiB
TypeScript

/**
* Workstream Inventory Module
*
* Owns discovery and read-only projection of .planning/workstreams/* state.
* Command handlers should render outputs from this inventory instead of
* rescanning workstream directories directly.
*
* Pure projection logic lives in workstream-inventory-builder.cts.
* This module handles I/O orchestration only.
*
* ADR-457 build-at-publish: the hand-written bin/lib/workstream-inventory.cjs
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtilsMod = require('./core-utils.cjs');
const { readSubdirectories } = coreUtilsMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planScan = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningPaths, planningRoot, getActiveWorkstream } = planningWorkspace;
import { stateFieldValue } from './state-document.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module
import frontmatterMod = require('./frontmatter.cjs');
const { extractFrontmatter, stripFrontmatter } = frontmatterMod;
import { findTableWithColumns } from './markdown-table.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
import verificationMod = require('./verification.cjs');
const { readVerificationStatus } = verificationMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
import phaseIdMod = require('./phase-id.cjs');
const { phaseKeyFromDir, phaseKeyFromProse, parentPhaseKey } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- roadmap-parser.cjs is an export= CommonJS module
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestonePhaseFilter, isMilestoneShippedInRoadmap } = roadmapParserMod;
import { buildWorkstreamInventory, isCompletedInventory, pickRollupWinners } from './workstream-inventory-builder.cjs';
import type { WorkstreamInventory, StateProjection, MilestoneShippedSignal } from './workstream-inventory-builder.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface PhaseFileCounts {
planCount: number;
summaryCount: number;
}
interface InspectWorkstreamOptions {
active?: string | null;
/**
* #3057 B3: injectable diagnostic-write seam, mirrored from
* cmdGitBaseBranch's `writeDiagnostic` (git-base-branch.cts). Called with a
* stderr-style line when a phase's readVerificationStatus staleness check
* could not run to completion — WorkstreamInventory's own return shape
* (`phases: PhaseStatus[]`) has no per-phase verification detail today, so
* this side channel surfaces the fact without widening that aggregate type.
* The second argument carries the same facts as structured, typed data
* (CONTRIBUTING: no raw-text matching on produced diagnostics) so a caller
* — tests included — can assert on `phaseDir`/`reason` directly instead of
* pattern-matching the operator-facing `message`. The default
* implementation writes only `message` to stderr; operator output is
* unchanged. Never affects routing: the ledger / rollup computation below
* is unchanged either way (the pre-existing fail-open contract).
*/
writeDiagnostic?: (message: string, meta: { phaseDir: string; reason: string }) => void;
}
interface WorkstreamInventoryList {
mode: 'flat' | 'workstream';
active: string | null;
workstreams: WorkstreamInventory[];
count: number;
message?: string;
}
// ─── Implementation ───────────────────────────────────────────────────────────
function workstreamsRoot(cwd: string): string {
return path.join(planningRoot(cwd), 'workstreams');
}
/**
* #3185 (ADR-3180 Decision 1): count the phases the CURRENT milestone
* declares, not every `Phase` heading in the file.
*
* This previously matched `^#{2,4}\s+Phase\s+…` across the whole ROADMAP with
* no milestone window and no sentinel filter, so it counted 999.* backlog and
* Phase 0 headings and spanned every milestone the document had ever had.
* `getMilestonePhaseFilter` already computes exactly this number for the
* scoped window (`phaseCount`, sentinel-filtered), and `inspectWorkstream` in
* this same file already passes a resolved `currentVersion` to it — this
* function was the sibling copy that never got the fix.
*/
function countRoadmapPhases(roadmapPath: string, fallbackCount: number, cwd?: string, ws?: string | null, versionOverride?: string | null): number {
try {
if (!fs.existsSync(roadmapPath)) return fallbackCount;
if (!cwd) return fallbackCount;
const filter = getMilestonePhaseFilter(cwd, versionOverride ?? null, null, ws ?? null);
// A pass-all degrade (phaseCount 0) means the window declared no phases —
// fall back rather than reporting a confident zero.
return filter.phaseCount > 0 ? filter.phaseCount : fallbackCount;
} catch {
return fallbackCount;
}
}
interface RoadmapProgressRow {
/** Canonical phase key (`phaseKeyFromProse`) — the SAME key space as `phaseKeyFromDir`. */
key: string;
/** `vX.Y` when the row attributes the phase to a milestone; null when the cell is absent, blank or malformed. */
version: string | null;
}
/**
* #2562: parse the ROADMAP `## Progress` table into canonical phase keys with
* their milestone attribution, e.g. `| 30. Name | v10.0 | 1/3 | … |` →
* `{ key: '30', version: 'v10.0' }`. The table is the authoritative per-phase
* milestone attribution and — crucially — lists phases declared but never
* scaffolded (no directory), which a directory-only scan misses. `Plans
* Complete` is present in BOTH RoadmapProgress variants, so this matches the
* flat (no Milestone column → every `version` null) and milestone-grouped
* shapes alike.
*
* Row keys come from `phaseKeyFromProse`, the same owner-module derivation
* `phaseKeyFromDir` uses for directories, so a `| 01. … |` row and a `1-slug`
* directory cannot land in different key spaces (the padding-asymmetry defect).
*/
function parseRoadmapProgressRows(roadmapPath: string): RoadmapProgressRow[] {
let content: string;
try {
content = fs.readFileSync(roadmapPath, 'utf-8');
} catch {
return []; /* no roadmap */
}
// Milestone-ATTRIBUTING shape first. Both shapes carry `Plans Complete`, so
// probing that column first would pick a flat table appearing earlier in the
// document over a milestone-grouped one later — every row would come back
// unattributed and be treated as current-milestone, silently over-including.
const table = findTableWithColumns(content, ['Phase', 'Milestone'])
?? findTableWithColumns(content, ['Phase', 'Plans Complete']);
if (!table) return [];
const rows: RoadmapProgressRow[] = [];
for (const row of table.rows) {
const key = phaseKeyFromProse(row['Phase']);
if (key === null) continue;
const cell = (row['Milestone'] ?? '').trim();
rows.push({ key, version: /^v\d+(?:\.\d+)+$/.test(cell) ? cell : null });
}
return rows;
}
/**
* #2562: the workstream's CURRENT milestone version, read from the STATE.md
* `milestone:` frontmatter field (the reliable per-workstream signal — the
* ROADMAP's own in-progress markers can be stale, e.g. a lingering 🚧 on an
* already-shipped milestone). Falls back to the ROADMAP in-progress heading
* marker only when STATE has no field.
*/
function readCurrentMilestoneVersion(statePath: string, roadmapPath: string): string | null {
try {
const m = fs.readFileSync(statePath, 'utf-8').match(/^milestone:\s*["']?(v\d+(?:\.\d+)+)["']?/m);
if (m) return m[1];
} catch {
/* no state */
}
try {
const rm = fs.readFileSync(roadmapPath, 'utf-8').match(/(?:🚧|🔄)\s*\*\*(v\d+(?:\.\d+)+)\b/);
if (rm) return rm[1];
} catch {
/* no roadmap */
}
return null;
}
/**
* #2562: does the CURRENT milestone's own ROADMAP heading carry a shipped
* marker? Delegated to `roadmap-parser`, the module that owns milestone-heading
* classification: heading/`<summary>` lines only (never a bullet that merely
* names the version), version-token boundary-matched so `v2.0` does not match
* inside `v2.0.1`, and in-progress markers win. Scoped to the current version,
* so a prior milestone's collapsed `<details><summary>✅ … SHIPPED</summary>`
* block can never mark the current milestone complete.
*/
function currentMilestoneHeadingShipped(roadmapPath: string, version: string): boolean {
try {
return isMilestoneShippedInRoadmap(fs.readFileSync(roadmapPath, 'utf-8'), version);
} catch {
return false; /* no roadmap */
}
}
/**
* Legacy pre-#2562 shipped detection: ANY archived milestone snapshot OR a
* SHIPPED marker anywhere in the ROADMAP. Over-broad (project-lifetime, not
* milestone-scoped) — retained ONLY as the fallback when the current milestone
* version cannot be determined (malformed/legacy STATE.md with no `milestone:`
* field), so those projects keep #1913's stale-field protection.
*/
function legacyMilestoneShipped(roadmapPath: string, planningBase: string): boolean {
try {
const milestonesDir = path.join(planningBase, 'milestones');
for (const entry of fs.readdirSync(milestonesDir, { withFileTypes: true })) {
if (entry.isFile() && /-ROADMAP\.md$/i.test(entry.name)) return true;
}
} catch {
/* no milestones archive dir */
}
try {
if (/SHIPPED/i.test(fs.readFileSync(roadmapPath, 'utf-8'))) return true;
} catch {
/* no roadmap */
}
return false;
}
/**
* #2562: directory mtime, used only to break a duplicate-phase-key tie in the
* rollup (keep the more recently touched directory — the Bug #2445 rule). 0 on
* a stat failure, which loses the tie rather than throwing.
*/
function phaseDirMtime(phaseDir: string): number {
try {
return fs.statSync(phaseDir).mtimeMs;
} catch {
return 0;
}
}
function countPhaseFiles(phaseDir: string): PhaseFileCounts {
const scan = planScan(phaseDir);
return { planCount: scan.planCount, summaryCount: scan.summaryCount };
}
// ─── #2645: verification-deletion ledger ───────────────────────────────────
//
// #2562's completeness gate (`FAILING_VERIFICATION_STATUSES`,
// workstream-inventory-builder.cts) reads a phase's verification verdict
// fresh from `*-VERIFICATION.md` on every call. That makes "verifier ran,
// found gaps, report later deleted" indistinguishable from "verifier never
// ran" — both collapse to the same `'missing'` sentinel
// (verification.cts's `missingResult()`), which is deliberately NOT in the
// failing set (so verifier-disabled projects can still reach 100%). Deleting
// a failing report is therefore sufficient to silently raise the reported
// completion percentage — a Goodhart hole (#2645).
//
// The fix persists the last REAL (non-'missing') verdict this module has
// ever observed per phase key, in a small ledger file living at the
// WORKSTREAM directory level — never inside the phase directory whose file
// is the thing being deleted, so the same `rm` that triggers the hole
// cannot also erase the memory of it. When a live read comes back 'missing',
// the ledger is consulted as a fallback; when a live read comes back with a
// real verdict — including a later 'passed' that supersedes an earlier
// failing one — the ledger is updated to match, so a genuinely re-verified
// phase is never permanently pinned.
//
// #2645 review: a NAIVE two-state read ("got entries, or nothing") fails
// OPEN — any read/parse failure degraded to `{}`, which is indistinguishable
// from "genuinely never verified", so corrupting the ledger (or deleting it
// alongside the report) silently reopened the exact hole this fix exists to
// close, one level up. THREE states, not two:
//
// 1. `'absent'` — no `.verification-ledger.json` for this workstream at
// all. This is the ONLY state that behaves exactly as pre-#2645 (a
// phase with no live report reads `'missing'`, ungated). Deliberate:
// on the day this ships, EVERY existing project is in this state for
// EVERY workstream, and gating here would drop them all to
// `in_progress` at once. A workstream stays here forever if the
// verifier is never actually used on it (criterion 2/3 — no entry is
// ever written for a `'missing'` live read, so the file itself is
// never created).
// 2. `'corrupt'` — the file exists but could not be read or parsed (I/O
// error, invalid JSON, wrong shape). This is NOT the same as absent:
// an unreadable file is evidence something existed. Treated identically
// to "present, no entry for this phase" below — fails CLOSED, not open.
// 3. `'ok'` — the file exists and parsed. A phase with an entry uses
// it; a phase WITHOUT one is "present, no entry" — this workstream has
// adopted the ledger (some phase in it has a real verdict on record),
// so an unobserved phase can no longer default to the pre-adoption
// "ungated" behavior, or the same evidence-erasure hole reopens for
// THIS phase specifically. Fails CLOSED: resolves to the `'unrecorded'`
// sentinel (`workstream-inventory-builder.cts`'s
// `FAILING_VERIFICATION_STATUSES`), not `'missing'`.
//
// Corrupt-ledger recovery: a corrupt ledger is NOT a permanent wedge. Any
// phase with a REAL live verdict on disk still writes/repairs the ledger on
// this same call (the corrupt content is fully overwritten, never patched),
// so re-running the verifier for even one phase heals the file. A phase with
// no live report and no way to re-verify stays `'unrecorded'` (gated) until
// someone re-verifies it — a deliberate, disclosed cost of failing closed,
// not an accidental one.
//
// Disclosed, ACCEPTED residual gap (not closed by this fix, and not closable
// by ledger design alone): deleting the ledger FILE ITSELF (not just the
// phase's report) returns a workstream to state 1 (`'absent'`) and restores
// pre-#2645 behavior for it. Any durable store that can fail open when
// absent has this property at its own root — the ledger raises the bar from
// "delete one file" to "delete two files in two different directories,
// including one this issue's own reproduction never needed to touch", but a
// deliberately absent ledger is indistinguishable from a never-adopted one
// by design (criterion 2/3 depend on that same indistinguishability). Rail B
// is PROSPECTIVE ONLY: a phase verified and its report deleted BEFORE this
// fix ships has no ledger entry to fall back on and cannot be retroactively
// recovered.
interface VerificationLedger { [phaseKey: string]: string; }
interface VerificationLedgerRead {
state: 'absent' | 'corrupt' | 'ok';
entries: VerificationLedger;
}
function verificationLedgerPath(wsDir: string): string {
return path.join(wsDir, '.verification-ledger.json');
}
function readVerificationLedger(wsDir: string): VerificationLedgerRead {
const ledgerPath = verificationLedgerPath(wsDir);
let raw: string;
try {
raw = fs.readFileSync(ledgerPath, 'utf-8');
} catch (err) {
// `fs.readFileSync` FOLLOWS symlinks, so a broken symlink at this path
// (the entry exists, its target does not) reports the EXACT SAME
// `ENOENT` as genuine absence — `code` alone cannot distinguish
// "nothing was ever here" from "something is here and cannot be read".
// `fs.lstatSync` does NOT follow symlinks, so it still finds the
// symlink entry itself even when its target is gone. Only when NEITHER
// call finds anything is this genuinely `'absent'` (pre-adoption); a
// present-but-broken symlink is evidence something existed and must
// fail CLOSED like any other unreadable ledger, not fall open.
const code = (err as NodeJS.ErrnoException)?.code;
if (code === 'ENOENT') {
try {
fs.lstatSync(ledgerPath);
return { state: 'corrupt', entries: {} }; // a symlink entry exists; its target does not
} catch (lstatErr) {
// #2645 review: every OTHER failure path in this function fails
// CLOSED — this one must too. A failed `lstatSync` is only proof of
// absence when IT ALSO reports `ENOENT`; anything else (a raced
// permission change, a path component that became inaccessible
// between the two calls, …) is not evidence the file was never
// there, and a bare `catch {}` here would silently fall OPEN exactly
// like the two-state design this fix replaced.
const lstatCode = (lstatErr as NodeJS.ErrnoException)?.code;
if (lstatCode === 'ENOENT') return { state: 'absent', entries: {} }; // truly nothing at this path
return { state: 'corrupt', entries: {} };
}
}
// Any OTHER read failure (EACCES, EISDIR, …) also means the path EXISTS
// in some form but this process cannot see its content right now — that
// is corruption from this reader's point of view, not absence, and must
// fail closed rather than silently falling back to the ungated
// pre-adoption behavior.
return { state: 'corrupt', entries: {} };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { state: 'corrupt', entries: {} };
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { state: 'corrupt', entries: {} };
}
const out: VerificationLedger = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value === 'string') out[key] = value;
}
return { state: 'ok', entries: out };
}
// #2645 review: Windows can transiently hold the rename target busy (AV
// scanners, indexers) — the SAME retry shape `broken-windows.cts`'s
// `writeLedgerAtomic`/`renameWithRetry` already uses for its own ledger
// write, mirrored here rather than imported (that function is private to
// its module) so this fix does not widen its own blast radius by exporting
// a new cross-module utility.
const LEDGER_RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
const LEDGER_RENAME_MAX_ATTEMPTS = 5;
const LEDGER_RENAME_BACKOFF_MS = 25;
function renameVerificationLedgerWithRetry(tmpPath: string, finalPath: string): void {
let lastErr: unknown;
for (let attempt = 0; attempt < LEDGER_RENAME_MAX_ATTEMPTS; attempt++) {
try {
fs.renameSync(tmpPath, finalPath);
return;
} catch (err: unknown) {
lastErr = err;
const code = (err && typeof err === 'object' && 'code' in err) ? String((err as { code?: unknown }).code) : '';
if (code && LEDGER_RENAME_RETRY_ERRNOS.has(code) && attempt < LEDGER_RENAME_MAX_ATTEMPTS - 1) {
// Exponential-ish backoff: 25ms, 50ms, 100ms, 200ms. Transient
// Windows locks usually clear well inside that window.
const delay = LEDGER_RENAME_BACKOFF_MS * Math.pow(2, attempt);
const start = Date.now();
while (Date.now() - start < delay) {
// Deliberate short busy-wait — no async/timer seam is available
// in this synchronous read path.
}
continue;
}
throw err;
}
}
throw lastErr;
}
function writeVerificationLedger(wsDir: string, ledger: VerificationLedger): void {
// #2645 review: atomic write. A crash or a concurrent read mid-write
// against `verificationLedgerPath(wsDir)` directly would leave (or briefly
// expose) TRUNCATED JSON — and now that `'corrupt'` carries real semantic
// weight (it fails CLOSED, holding every phase with no live report at
// `'unrecorded'`), producing a corrupt file ourselves is a self-inflicted
// version of the exact failure mode this fix exists to survive. Write to a
// sibling temp file in the SAME directory (same filesystem — `rename` is
// only atomic within one) and rename into place: a reader can only ever
// observe the prior complete content or the new complete content.
const finalPath = verificationLedgerPath(wsDir);
const tmpPath = `${finalPath}.${process.pid}.tmp`;
try {
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(tmpPath, `${JSON.stringify(ledger, null, 2)}\n`, 'utf-8');
renameVerificationLedgerWithRetry(tmpPath, finalPath);
} catch {
// Best-effort persistence: a missing parent directory that `mkdirSync`
// itself cannot create, a read-only filesystem, a write failure, or a
// rename failure that exhausts its retries must not break inventory
// reads, which are otherwise pure. Losing this observation only
// re-opens the pre-#2645 window for THIS run; the next successful read
// while a report is on disk repairs it. Clean up a half-written temp
// file so repeated failures cannot accumulate orphaned
// `.verification-ledger.json.<pid>.tmp` files.
try { fs.unlinkSync(tmpPath); } catch { /* nothing to clean up, or cleanup itself failed — not fatal */ }
}
}
function readStateProjection(statePath: string): StateProjection {
try {
const stateContent = fs.readFileSync(statePath, 'utf-8');
// #3187: route Status/Current Phase/Last Activity through the single
// #1760 fallback-chain owner (state-document.cjs's stateFieldValue)
// instead of a frontmatter-blind stateExtractField(stateContent, …) call,
// mirroring cmdStateValidate/cmdStateSnapshot — a STATE.md whose fields
// live only in frontmatter is no longer projected as absent here.
const fm = extractFrontmatter(stateContent, statePath) as Record<string, unknown>;
const body = stripFrontmatter(stateContent);
return {
status: stateFieldValue(fm, body, 'status', 'Status').value || 'unknown',
current_phase: stateFieldValue(fm, body, 'current_phase', 'Current Phase').value,
last_activity: stateFieldValue(fm, body, 'last_activity', 'Last Activity').value,
};
} catch {
// Read/parse failure (missing file, permission fault, etc.) degrades to
// an all-unknown projection — unchanged. Not widened to also carry a
// `scope` here: doing so would ripple `StateProjection`
// (workstream-inventory-builder.cjs) and every consumer of this
// read-only rollup — the design doc's blast-radius table rates
// `readStateProjection` "low"/Tier-2, and this call site's migration is
// scoped to routing the fallback chain, not to widening the return type.
// The existing all-unknown degrade already distinguishes "could not
// read" from any real field value; only its scope-vs-absence *reason*
// stays uncaptured, same as before this change.
return {
status: 'unknown',
current_phase: null,
last_activity: null,
};
}
}
/**
* #1913 + #2562: detect an authoritative shipped signal for a workstream's
* CURRENT milestone, so the inventory status is never trusted from the mutable
* STATE.md `Status` field alone (#1913) yet is never pinned to "milestone
* complete" by a PRIOR milestone's shipped marker (#2562).
*
* When the current milestone version is known, the signal is scoped to it:
* an archived snapshot `milestones/<version>-ROADMAP.md` (the canonical
* "milestone shipped" artifact) OR the current milestone's own ROADMAP line
* marked shipped. When the version cannot be determined, we fall back to the
* over-broad legacy detection to preserve #1913's protection for those
* (malformed/legacy) projects.
*
* Returns WHICH signal fired, not merely that one did. The two differ in how
* much they can be trusted and therefore in how the builder cross-validates
* them against the milestone's own artifacts — see the `shippedContradicted`
* block in `workstream-inventory-builder.cts`. Collapsing them to a boolean is
* what forced a single completeness check to serve two incompatible shapes.
*/
function workstreamShippedSignal(
roadmapPath: string,
planningBase: string,
currentVersion: string | null,
): MilestoneShippedSignal {
if (!currentVersion) {
return legacyMilestoneShipped(roadmapPath, planningBase) ? 'legacy' : null;
}
// Canonical shipped artifact: the archived ROADMAP snapshot of the CURRENT
// milestone (`vX.Y-ROADMAP.md`), written at milestone close. REQUIREMENTS
// snapshots are intentionally NOT accepted — they can be written at milestone
// START (requirements-locked), so they do not imply shipped.
const snapshot = path.join(planningBase, 'milestones', `${currentVersion}-ROADMAP.md`);
if (fs.existsSync(snapshot)) return 'snapshot';
return currentMilestoneHeadingShipped(roadmapPath, currentVersion) ? 'heading' : null;
}
function sortWorkstreamInventories(inventories: WorkstreamInventory[], activeWorkstreamName: string | null): WorkstreamInventory[] {
return [...inventories].sort((a, b) => {
const aActive = a.name === activeWorkstreamName ? 1 : 0;
const bActive = b.name === activeWorkstreamName ? 1 : 0;
if (aActive !== bActive) {
return bActive - aActive;
}
return a.name.localeCompare(b.name);
});
}
function inspectWorkstream(cwd: string, name: string, options: InspectWorkstreamOptions = {}): WorkstreamInventory | null {
const wsDir = path.join(workstreamsRoot(cwd), name);
if (!fs.existsSync(wsDir)) return null;
const activeWorkstreamName = options.active === undefined ? getActiveWorkstream(cwd) : options.active;
const writeDiagnostic = options.writeDiagnostic ?? ((message: string) => process.stderr.write(message));
const p = planningPaths(cwd, name);
const phaseDirNames = readSubdirectories(p.phases);
// #2562: scope progress to the CURRENT milestone. Membership and the
// denominator are derived in ONE key space (`phaseKeyFromDir` /
// `phaseKeyFromProse`, both from the phase-id owner module) so the two sides
// of the rollup cannot disagree.
const currentVersion = readCurrentMilestoneVersion(p.state, p.roadmap);
const progressRows = parseRoadmapProgressRows(p.roadmap);
// Phase keys the ROADMAP attributes to the current milestone. A row whose
// Milestone cell is blank or malformed is INCLUDED rather than dropped: a
// phase we cannot attribute must still be visible to the rollup. Dropping it
// from both sides was the silent-deletion defect — it let an unstarted phase
// vanish and the percentage round to 100. Over-inclusive-never-under is the
// degrade direction this codebase already commits to for unparseable roadmap
// input (see the getMilestonePhaseFilter catch in roadmap-parser.cts).
const currentMilestoneKeys = new Set<string>();
if (currentVersion) {
for (const row of progressRows) {
if (row.version === null || row.version === currentVersion) currentMilestoneKeys.add(row.key);
}
}
// Roadmap-heading membership, from the module that OWNS milestone-phase
// filtering. Consulted only when it is genuinely scoped to a single milestone
// (`versionScoped`); the unversioned whole-roadmap shape spans the project's
// lifetime and would re-admit prior-milestone phases — the very defect here.
const headingFilter = getMilestonePhaseFilter(cwd, currentVersion, null, name);
const headingScoped = headingFilter.versionScoped && headingFilter.phaseCount > 0;
// Phase keys the ROADMAP attributes to some OTHER milestone. A row carrying an
// explicit version that is not the current one is a positive claim by a prior
// (or future) milestone — the only reliable evidence that a phase does NOT
// belong to the current one.
const claimedElsewhere = new Set<string>();
for (const row of progressRows) {
if (row.version !== null && row.version !== currentVersion) claimedElsewhere.add(row.key);
}
// #2562: the current milestone is DECLARED but nothing attributes a phase to
// it yet — the window right after `/gsd-new-milestone`, where STATE.md's
// `milestone:` field updates the moment the heading lands but the Progress
// table and phase sections have not caught up.
//
// Treating that as "unscoped" was a hole in the original fix: scoping switched
// off entirely and the fallback below counted the project's ENTIRE phase
// history as both numerator and denominator, so a workstream whose current
// milestone had zero phases done reported 100% off its predecessors' work.
// That is the very symptom #2562 reports, reached by a different route.
//
// Three independent signals witness it; ANY of them is enough, and each covers
// a ROADMAP shape the others miss:
// - `versionSectionFound` — the milestone's own section exists but declares
// no phases (heading-only ROADMAPs, and the common `## v3.0` stub).
// - `missingExplicitVersion` — the ROADMAP versions its milestones but has
// no section for this one at all.
// - a Progress table that attributes every row elsewhere (`claimedElsewhere`
// non-empty while `currentMilestoneKeys` is empty).
// A ROADMAP that attributes NO versions anywhere matches none of them: its
// rows parse with `version: null`, land in `currentMilestoneKeys`, and never
// reach here. That is deliberate — for a free-form legacy project the
// whole-roadmap count IS the current milestone, and `readCurrentMilestoneVersion`
// hands back a non-null version for almost every project, so keying off
// `currentVersion` alone would regress every one of them to 0%.
const currentMilestoneDeclaredEmpty =
currentVersion !== null &&
currentMilestoneKeys.size === 0 &&
!headingScoped &&
(headingFilter.versionSectionFound || headingFilter.missingExplicitVersion || claimedElsewhere.size > 0);
// A dir-only phase joins the current milestone when the roadmap names it, or
// when it is a sub-phase (`30.1-…`) of a phase the roadmap names — sub-phases
// inserted mid-milestone rarely get a row of their own. Membership feeds BOTH
// the numerator and (via `milestoneKeys` below) the denominator, so a member
// can never exceed the denominator that counts it.
const scoped = currentMilestoneKeys.size > 0 || headingScoped || currentMilestoneDeclaredEmpty;
const isDirInCurrentMilestone = (dir: string): boolean => {
if (!scoped) return true;
const key = phaseKeyFromDir(dir);
if (currentMilestoneKeys.has(key)) return true;
const parent = parentPhaseKey(key);
if (parent !== null && currentMilestoneKeys.has(parent)) return true;
// An empty current milestone has no roadmap declarations to match against,
// so membership inverts: a directory belongs UNLESS another milestone claims
// it. A phase scaffolded before the roadmap caught up would otherwise vanish
// from both sides of the rollup — under-reporting, the direction this
// codebase never degrades in.
if (currentMilestoneDeclaredEmpty) {
const parentKey = parentPhaseKey(key);
return !claimedElsewhere.has(key) && (parentKey === null || !claimedElsewhere.has(parentKey));
}
return headingScoped && headingFilter(dir);
};
// Collect per-phase file counts (+ canonical key, milestone membership,
// verification verdict). `phaseKey` lets the builder de-duplicate stale
// same-numbered directories (Bug #2445's scenario) in the rollup.
//
// #2645 review: built from `[...phaseDirNames].sort()`, NOT the raw
// `phaseDirNames` (unsorted `readdirSync` order — `readSubdirectories`'s
// default). `buildWorkstreamInventory`'s own de-dup (`rollupDirByKey`,
// workstream-inventory-builder.cts) iterates the SAME sorted order; the
// ledger-winner tie-break below (incumbent wins unless a later entry has a
// STRICTLY newer mtime) only agrees with the builder's winner on an exact
// mtime tie if both walk entries in the same order. Iterating unsorted
// input here let the two independently pick different "winning"
// directories for one phase key on a tie — reopening the stale-duplicate-
// clobbers-live-verdict hole criterion 4 exists to close.
const rawPhaseEntries = [...phaseDirNames].sort().map(dir => {
const phaseDir = path.join(p.phases, dir);
const counts = countPhaseFiles(phaseDir);
const verificationResult = readVerificationStatus(phaseDir);
// #3057 B3: routing is UNCHANGED — `liveVerificationStatus` below is still
// `.status`, exactly as before, so the ledger/rollup logic that consumes
// it is unaffected. This only makes an indeterminate staleness check
// visible (stderr), matching cmdGitBaseBranch's own non-blocking
// unverified-fallback diagnostic (#3057 B4) — the closest existing idiom,
// since `WorkstreamInventory`'s aggregate return shape carries no
// per-phase verification detail for this to attach to.
if (verificationResult.staleCheckIndeterminate) {
writeDiagnostic(
`⚠ workstream-inventory: verification staleness check could not complete for phase directory '${dir}' in workstream '${name}' — routed as not-stale, but this was not actually verified. See #3057.\n`,
{ phaseDir: dir, reason: 'staleCheckIndeterminate' },
);
}
return {
directory: dir,
phaseKey: phaseKeyFromDir(dir),
mtimeMs: phaseDirMtime(phaseDir),
planCount: counts.planCount,
summaryCount: counts.summaryCount,
inMilestone: isDirInCurrentMilestone(dir),
liveVerificationStatus: verificationResult.status,
};
});
// #2645: only the directory Bug #2445's de-dup rollup would actually pick
// for a phase key may read or write that key's ledger entry. Letting every
// same-keyed directory (including a stale leftover) write would let a
// stale duplicate's stale verdict clobber the live directory's remembered
// one.
//
// #2645 review — CORRECTED: an earlier version of this comment claimed the
// milestone-scoping exclusion (`scoped && entry.inMilestone === false`)
// was safe to drop here as "out of scope", and hand-wrote a scoping-free
// tie-break rule. That was a real bug, not a scope call: in a SCOPED
// workstream, a stale OUT-of-milestone directory sharing a phase key with
// the live IN-milestone one can have a newer mtime (plausible after a
// checkout/rebase resets mtimes) and would then win THIS selection while
// the builder's own `rollupDirByKey` — which DOES apply the scoping filter
// — picks the live directory instead. `isLedgerWinner` would then be false
// for the live directory, so deleting ITS `*-VERIFICATION.md` would never
// consult the ledger and would reopen #2645's exact hole for the phase
// that actually counts toward `completed_phases` — reachable with a plain
// `rm`, no ledger tampering required. Fixed by calling the SAME shared
// `pickRollupWinners` the builder's `rollupDirByKey` now also calls, with
// the identical scoping filter, rather than a second hand-written copy.
const ledgerWinnerByKey = pickRollupWinners(
rawPhaseEntries,
(entry) => entry.phaseKey,
(entry) => entry.mtimeMs,
(entry) => !(scoped && entry.inMilestone === false),
);
const ledgerRead = readVerificationLedger(wsDir);
// Both `'corrupt'` and `'ok'` start from whatever entries could actually be
// trusted (empty for `'corrupt'` — nothing in an unparseable file is
// trusted) and get REPAIRED below by any real verdict this call observes;
// only `'absent'` skips the ledger mechanism entirely (pre-adoption).
const verificationLedger = ledgerRead.entries;
let ledgerDirty = false;
for (const winner of ledgerWinnerByKey.values()) {
if (winner.liveVerificationStatus === 'missing') continue;
if (verificationLedger[winner.phaseKey] !== winner.liveVerificationStatus) {
verificationLedger[winner.phaseKey] = winner.liveVerificationStatus;
ledgerDirty = true;
}
}
// A `'corrupt'` read that observes no real verdict this call has nothing to
// repair with — writing an empty `{}` would DESTROY whatever the corrupt
// file's bytes might still hold (a human could recover it by hand; this
// fix must not foreclose that). Only write when there is something real to
// persist, exactly as for `'absent'`/`'ok'`.
if (ledgerDirty) writeVerificationLedger(wsDir, verificationLedger);
const phaseFilesCounts = rawPhaseEntries.map(entry => {
const isLedgerWinner = ledgerWinnerByKey.get(entry.phaseKey) === entry;
let verificationStatus = entry.liveVerificationStatus;
if (entry.liveVerificationStatus === 'missing' && isLedgerWinner) {
if (ledgerRead.state === 'absent') {
// State 1: pre-adoption. Exactly today's behavior — 'missing' is
// NOT in FAILING_VERIFICATION_STATUSES, so this does not gate.
verificationStatus = 'missing';
} else {
// States 2/3 ('corrupt' or 'ok'): this workstream has adopted the
// ledger. A remembered entry wins; no entry fails CLOSED to the
// 'unrecorded' sentinel rather than falling open to 'missing'.
const remembered = verificationLedger[entry.phaseKey];
verificationStatus = remembered !== undefined ? remembered : 'unrecorded';
}
}
return {
directory: entry.directory,
phaseKey: entry.phaseKey,
mtimeMs: entry.mtimeMs,
planCount: entry.planCount,
summaryCount: entry.summaryCount,
inMilestone: entry.inMilestone,
verificationStatus,
};
});
// The denominator is the union of what the roadmap DECLARES for the current
// milestone (including never-scaffolded phases) and the keys of the member
// directories (including dir-only sub-phases). One key space, so
// `completed_phases <= denominator` holds by construction rather than by a
// `Math.min` cap that hid the inconsistency.
const milestoneKeys = new Set(currentMilestoneKeys);
for (const entry of phaseFilesCounts) {
if (entry.inMilestone) milestoneKeys.add(entry.phaseKey);
}
const currentMilestonePhaseCount = scoped
? Math.max(milestoneKeys.size, headingScoped ? headingFilter.phaseCount : 0)
: 0;
// Unscoped fallback: the denominator must STILL count phases the ROADMAP
// declares in its Progress table but never scaffolded — the heading-only
// count drops them, even when other headings exist. Union the declared rows
// with the phase directories so neither source can silently shrink it.
let fallbackPhaseCount = countRoadmapPhases(p.roadmap, phaseDirNames.length, cwd, name, currentVersion);
if (!scoped && progressRows.length > 0) {
const union = new Set(progressRows.map(row => row.key));
for (const entry of phaseFilesCounts) union.add(entry.phaseKey);
fallbackPhaseCount = union.size;
}
return buildWorkstreamInventory({
name,
projectDir: cwd,
workstreamDir: wsDir,
phaseDirNames,
activeWorkstreamName: activeWorkstreamName ?? '',
phaseFilesCounts,
roadmapPhaseCount: fallbackPhaseCount,
currentMilestonePhaseCount,
// Stated, not inferred from the count: a declared-but-empty current
// milestone is legitimately scoped AND legitimately zero-phase, and the
// builder cannot tell those apart from `currentMilestonePhaseCount` alone.
milestoneScoped: scoped,
stateProjection: readStateProjection(p.state),
filesExist: {
roadmap: fs.existsSync(p.roadmap),
state: fs.existsSync(p.state),
requirements: fs.existsSync(p.requirements),
},
milestoneShippedSignal: workstreamShippedSignal(p.roadmap, p.planning, currentVersion),
});
}
function listWorkstreamInventories(cwd: string): WorkstreamInventoryList {
const wsRoot = workstreamsRoot(cwd);
if (!fs.existsSync(wsRoot)) {
return {
mode: 'flat',
active: null,
workstreams: [],
count: 0,
message: 'No workstreams — operating in flat mode',
};
}
const active = getActiveWorkstream(cwd);
const entries = fs.readdirSync(wsRoot, { withFileTypes: true });
const workstreams: WorkstreamInventory[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const inventory = inspectWorkstream(cwd, entry.name, { active });
if (inventory) workstreams.push(inventory);
}
const ordered = sortWorkstreamInventories(workstreams, active);
return {
mode: 'workstream',
active,
workstreams: ordered,
count: ordered.length,
};
}
function getOtherActiveWorkstreamInventories(cwd: string, excludeWs: string): WorkstreamInventory[] {
return listWorkstreamInventories(cwd).workstreams
.filter(inventory => inventory.name !== excludeWs)
.filter(inventory => !isCompletedInventory(inventory.status));
}
export = {
countPhaseFiles,
countRoadmapPhases,
getOtherActiveWorkstreamInventories,
inspectWorkstream,
isCompletedInventory,
listWorkstreamInventories,
sortWorkstreamInventories,
workstreamsRoot,
};