Files
msd-core/src/workstream-inventory.cts
Tom Boucher 05cd55d43b fix(#1913): derive workstream progress status from shipped signals (#1916)
workstream progress trusted the mutable STATE.md `Status` field, so a
shipped/archived milestone whose field was left at `executing` was reported
as executing — a stale hand-maintained field became the source of truth
instead of the authoritative archive/tag/ROADMAP signals.

Derive status in the inventory builder from a milestoneShipped signal
(archived milestone snapshot under milestones/, or a SHIPPED marker in the
workstream ROADMAP), collected by inspectWorkstream. The inventory now
reports `status_source` (field|derived) and `status_conflict` (true when
the derived value disagrees with the stale field), and a shipped workstream
is never reported executing.

- src/workstream-inventory-builder.cts: milestoneShipped input + status_source/status_conflict outputs + derivation
- src/workstream-inventory.cts: workstreamMilestoneShipped() signal detector wired into inspectWorkstream
- tests/workstream-inventory.test.cjs: regression (builder unit + inspectWorkstream integration + negative)

Closes #1913
2026-07-02 10:24:41 -04:00

201 lines
6.9 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 { stateExtractField } from './state-document.cjs';
import { buildWorkstreamInventory, isCompletedInventory } from './workstream-inventory-builder.cjs';
import type { WorkstreamInventory, StateProjection } from './workstream-inventory-builder.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface PhaseFileCounts {
planCount: number;
summaryCount: number;
}
interface InspectWorkstreamOptions {
active?: string | null;
}
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');
}
function countRoadmapPhases(roadmapPath: string, fallbackCount: number): number {
try {
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
const matches = roadmapContent.match(/^#{2,4}\s+Phase\s+[\w][\w.-]*/gm);
return matches ? matches.length : fallbackCount;
} catch {
return fallbackCount;
}
}
function countPhaseFiles(phaseDir: string): PhaseFileCounts {
const scan = planScan(phaseDir);
return { planCount: scan.planCount, summaryCount: scan.summaryCount };
}
function readStateProjection(statePath: string): StateProjection {
try {
const stateContent = fs.readFileSync(statePath, 'utf-8');
return {
status: stateExtractField(stateContent, 'Status') || 'unknown',
current_phase: stateExtractField(stateContent, 'Current Phase'),
last_activity: stateExtractField(stateContent, 'Last Activity'),
};
} catch {
return {
status: 'unknown',
current_phase: null,
last_activity: null,
};
}
}
/**
* #1913: detect an authoritative shipped signal for a workstream so the
* inventory status is never trusted from the mutable STATE.md `Status` field
* alone. Returns true when EITHER an archived milestone snapshot is present
* under `<planningBase>/milestones/` OR the workstream ROADMAP carries a
* SHIPPED marker — both are hard to desync, unlike the hand-maintained field.
*/
function workstreamMilestoneShipped(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;
}
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 p = planningPaths(cwd, name);
const phaseDirNames = readSubdirectories(p.phases);
// Collect per-phase file counts
const phaseFilesCounts = phaseDirNames.map(dir => {
const counts = countPhaseFiles(path.join(p.phases, dir));
return { directory: dir, planCount: counts.planCount, summaryCount: counts.summaryCount };
});
return buildWorkstreamInventory({
name,
projectDir: cwd,
workstreamDir: wsDir,
phaseDirNames,
activeWorkstreamName: activeWorkstreamName ?? '',
phaseFilesCounts,
roadmapPhaseCount: countRoadmapPhases(p.roadmap, phaseDirNames.length),
stateProjection: readStateProjection(p.state),
filesExist: {
roadmap: fs.existsSync(p.roadmap),
state: fs.existsSync(p.state),
requirements: fs.existsSync(p.requirements),
},
milestoneShipped: workstreamMilestoneShipped(p.roadmap, p.planning),
});
}
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,
};