144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
/**
|
|
* Workstream Inventory Module.
|
|
*
|
|
* Owns discovery and read-only projection of .planning/workstreams/* state.
|
|
* Query handlers should render outputs from this inventory instead of
|
|
* rescanning workstream directories directly.
|
|
*
|
|
* Pure projection logic lives in ../workstream/builder.ts.
|
|
* This module handles I/O orchestration only.
|
|
*/
|
|
|
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { scanPhasePlans } from './plan-scan.js';
|
|
import { stateExtractField } from '../state/index.js';
|
|
import { readActiveWorkstream } from './active-workstream-store.js';
|
|
import { buildWorkstreamInventory } from '../workstream/builder.js';
|
|
|
|
// Re-export types from the builder so downstream consumers can import from here.
|
|
export type {
|
|
WorkstreamPhaseInventory,
|
|
WorkstreamInventory,
|
|
WorkstreamInventoryList,
|
|
} from '../workstream/builder.js';
|
|
|
|
import type { WorkstreamInventory, WorkstreamInventoryList } from '../workstream/builder.js';
|
|
|
|
export const planningRoot = (projectDir: string): string =>
|
|
join(projectDir, '.planning');
|
|
|
|
export const workstreamsRoot = (projectDir: string): string =>
|
|
join(planningRoot(projectDir), 'workstreams');
|
|
|
|
function wsPlanningPaths(projectDir: string, name: string) {
|
|
const base = join(planningRoot(projectDir), 'workstreams', name);
|
|
return {
|
|
state: join(base, 'STATE.md'),
|
|
roadmap: join(base, 'ROADMAP.md'),
|
|
phases: join(base, 'phases'),
|
|
requirements: join(base, 'REQUIREMENTS.md'),
|
|
};
|
|
}
|
|
|
|
function readSubdirectories(dir: string): string[] {
|
|
if (!existsSync(dir)) return [];
|
|
return readdirSync(dir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
|
|
}
|
|
|
|
export function countRoadmapPhases(roadmapPath: string, fallbackCount: number): number {
|
|
try {
|
|
const roadmapContent = readFileSync(roadmapPath, 'utf-8');
|
|
const matches = roadmapContent.match(/^#{2,4}\s+Phase\s+[\w][\w.-]*/gm);
|
|
return matches ? matches.length : fallbackCount;
|
|
} catch {
|
|
return fallbackCount;
|
|
}
|
|
}
|
|
|
|
export function countPhaseFiles(phaseDir: string): { planCount: number; summaryCount: number } {
|
|
const scan = scanPhasePlans(phaseDir);
|
|
return { planCount: scan.planCount, summaryCount: scan.summaryCount };
|
|
}
|
|
|
|
function readStateProjection(statePath: string): { status: string; current_phase: string | null; last_activity: string | null } {
|
|
try {
|
|
const stateContent = 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,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function inspectWorkstream(
|
|
projectDir: string,
|
|
name: string,
|
|
options: { active?: string | null } = {},
|
|
): WorkstreamInventory | null {
|
|
const wsDir = join(workstreamsRoot(projectDir), name);
|
|
if (!existsSync(wsDir)) return null;
|
|
|
|
const activeWorkstreamName = options.active === undefined ? readActiveWorkstream(projectDir) : options.active;
|
|
const p = wsPlanningPaths(projectDir, name);
|
|
const phaseDirNames = readSubdirectories(p.phases);
|
|
|
|
// Collect per-phase file counts
|
|
const phaseFilesCounts = phaseDirNames.map(dir => {
|
|
const counts = countPhaseFiles(join(p.phases, dir));
|
|
return { directory: dir, planCount: counts.planCount, summaryCount: counts.summaryCount };
|
|
});
|
|
|
|
return buildWorkstreamInventory({
|
|
name,
|
|
projectDir,
|
|
workstreamDir: wsDir,
|
|
phaseDirNames,
|
|
activeWorkstreamName,
|
|
phaseFilesCounts,
|
|
roadmapPhaseCount: countRoadmapPhases(p.roadmap, phaseDirNames.length),
|
|
stateProjection: readStateProjection(p.state),
|
|
filesExist: {
|
|
roadmap: existsSync(p.roadmap),
|
|
state: existsSync(p.state),
|
|
requirements: existsSync(p.requirements),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function listWorkstreamInventories(projectDir: string): WorkstreamInventoryList {
|
|
const wsRoot = workstreamsRoot(projectDir);
|
|
if (!existsSync(wsRoot)) {
|
|
return {
|
|
mode: 'flat',
|
|
active: null,
|
|
workstreams: [],
|
|
count: 0,
|
|
message: 'No workstreams — operating in flat mode',
|
|
};
|
|
}
|
|
|
|
const active = readActiveWorkstream(projectDir);
|
|
const entries = readdirSync(wsRoot, { withFileTypes: true });
|
|
const workstreams: WorkstreamInventory[] = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
const inventory = inspectWorkstream(projectDir, entry.name, { active });
|
|
if (inventory) workstreams.push(inventory);
|
|
}
|
|
|
|
return {
|
|
mode: 'workstream',
|
|
active,
|
|
workstreams,
|
|
count: workstreams.length,
|
|
};
|
|
}
|