feat(3544): Workstream Inventory Builder/Reader split (Phase 3 of #3524)

Phase 3 of the CJS↔SDK hard-seam migration (parent #3524).
Introduces the Builder/Reader pattern for paired Modules with
mixed pure-and-I/O concerns — the template for Phase 4 and
follow-up enhancements that migrate other paired Modules.

Phase 1 and Phase 2 migrated Modules where both sides used
character-equivalent logic. Phase 3 introduces the case where
the pure logic is shareable but the I/O is legitimately per-side.
The Builder/Reader split resolves this:

- The Builder is pure — accepts pre-collected data
  (BuilderInputs struct), returns the typed projection. One
  source of truth; one generator-emitted CJS mirror. Drift
  is structurally impossible.
- The Readers are per-side hand-authored Adapters that do the
  fs reads in their native idiom (currently both sync; either
  side can go async later without touching the Builder), then
  delegate to the Builder.

- sdk/src/workstream-inventory/builder.ts — Builder source.
  170 lines. Pure. Exports buildWorkstreamInventory(inputs),
  isCompletedInventory(status), plus the three typed inventory
  interfaces (WorkstreamPhaseInventory, WorkstreamInventory,
  WorkstreamInventoryList).
- sdk/src/workstream-inventory/builder.test.ts — 18 vitest
  pinning fixtures across all status branches, progress-percent
  clamping, active-marker projection, and isCompletedInventory
  classifier.
- sdk/scripts/gen-workstream-inventory-builder.mjs — generator.
  Captures function bodies via Function.prototype.toString();
  emits with the standard GENERATED FILE banner. Includes a
  small `const relative = path.relative;` preamble in the
  output to handle ESM destructured imports in the compiled
  source.
- sdk/scripts/check-workstream-inventory-builder-fresh.mjs —
  freshness check. Imports the generator function directly
  (rather than duplicating logic) — a cleaner pattern than
  Phase 1/2's approach.
- get-shit-done/bin/lib/workstream-inventory-builder.generated.cjs —
  generator-emitted CJS mirror.
- tests/workstream-inventory-builder-generator.test.cjs — 16
  parity assertions confirming CJS-generated output ==
  SDK source output for every fixture.

- bin/lib/workstream-inventory.cjs: 159 → 132 lines.
  Projection logic gone. `inspectWorkstream` and
  `listWorkstreamInventories` collect BuilderInputs via the
  existing sync fs functions and delegate to the Builder.
  `isCompletedInventory` re-exported from the Builder (its
  signature changed from object→string, but no external
  callers exist so the change is safe).
- sdk/src/query/workstream-inventory.ts: 196 → 143 lines.
  Same shape, sync fs (the SDK was already sync — surprise from
  recon). Types re-exported from the Builder.

- sdk/package.json: gen:workstream-inventory-builder and
  check:workstream-inventory-builder-fresh scripts.
- package.json: proxy for the freshness check.
- .githooks/pre-commit: drift block.
- .github/workflows/test.yml: drift check step.
- CONTEXT.md: amended "Workstream Inventory Module" entry
  to document the Builder/Reader split.
- docs/INVENTORY.md, docs/INVENTORY-MANIFEST.json:
  +1 module count, +1 row for the generated builder.

- Full suite: 9229/9229 pass (baseline 9215 + 14 net new from
  the parity assertions).
- Vitest: 18 Builder fixtures pass.
- Reader shrink: -27 lines on CJS, -53 lines on SDK.
- Net diff (modified files only): +68 / -133 = 65-line
  reduction. New files (Builder, generator, freshness check,
  parity test) add ~600 lines of new structured code.

1. `isCompletedInventory` signature changed from
   isCompletedInventory(inventory: object) to
   isCompletedInventory(status: string). Original CJS exported
   the object form but no external caller passed an object —
   they all passed inventory.status. Verified by grep before
   committing.
2. Generator preamble. The compiled ESM uses
   `import { relative } from 'node:path'`, making `relative`
   a free variable in `buildWorkstreamInventory`. The generator
   emits `const relative = path.relative;` so the captured
   function body works in CJS.
3. Freshness check imports the generator. The freshness check
   imports the generator's buildWorkstreamInventoryBuilderCjs()
   function directly rather than duplicating generation logic.
   Cleaner than Phase 1/2; future generators should follow this.

Shareable via the Builder/Reader pattern in future enhancements:
- frontmatter (pure YAML/markdown parsing)
- plan-scan (pure PLAN.md structure parsing)
- decisions (pure decision-record parsing)
- secrets (regex-based detection in text)
- uat (UAT-criteria parsing)

Structural divergence — different approach needed:
- state — sync vs async file ops; mutation paths differ.
- workstream — lifecycle ops; per-side API surface differs.
- phase, roadmap, init, profile-output, template — large
  surfaces; each its own potential enhancement.

None of these is in scope for Phase 3.

Closes #3544.
This commit is contained in:
Tom Boucher
2026-05-15 08:40:44 -04:00
parent fa862c77ee
commit ed8f4c9a31
15 changed files with 861 additions and 131 deletions

View File

@@ -4,51 +4,26 @@
* 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-inventory/builder.ts.
* This module handles I/O orchestration only.
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { toPosixPath } from './helpers.js';
import { join } from 'node:path';
import { scanPhasePlans } from './plan-scan.js';
import { stateExtractField } from './state-document.js';
import { readActiveWorkstream } from './active-workstream-store.js';
import { buildWorkstreamInventory } from '../workstream-inventory/builder.js';
export interface WorkstreamPhaseInventory {
directory: string;
status: 'complete' | 'in_progress' | 'pending';
plan_count: number;
summary_count: number;
}
// Re-export types from the builder so downstream consumers can import from here.
export type {
WorkstreamPhaseInventory,
WorkstreamInventory,
WorkstreamInventoryList,
} from '../workstream-inventory/builder.js';
export interface WorkstreamInventory {
name: string;
path: string;
active: boolean;
files: {
roadmap: boolean;
state: boolean;
requirements: boolean;
};
status: string;
current_phase: string | null;
last_activity: string | null;
phases: WorkstreamPhaseInventory[];
phase_count: number;
completed_phases: number;
roadmap_phase_count: number;
total_plans: number;
completed_plans: number;
progress_percent: number;
}
export interface WorkstreamInventoryList {
mode: 'flat' | 'workstream';
active: string | null;
workstreams: WorkstreamInventory[];
count: number;
message?: string;
}
import type { WorkstreamInventory, WorkstreamInventoryList } from '../workstream-inventory/builder.js';
export const planningRoot = (projectDir: string): string =>
join(projectDir, '.planning');
@@ -86,7 +61,7 @@ export function countPhaseFiles(phaseDir: string): { planCount: number; summaryC
return { planCount: scan.planCount, summaryCount: scan.summaryCount };
}
function readStateProjection(statePath: string): Pick<WorkstreamInventory, 'status' | 'current_phase' | 'last_activity'> {
function readStateProjection(statePath: string): { status: string; current_phase: string | null; last_activity: string | null } {
try {
const stateContent = readFileSync(statePath, 'utf-8');
return {
@@ -111,58 +86,31 @@ export function inspectWorkstream(
const wsDir = join(workstreamsRoot(projectDir), name);
if (!existsSync(wsDir)) return null;
const active = options.active === undefined ? readActiveWorkstream(projectDir) : options.active;
const activeWorkstreamName = options.active === undefined ? readActiveWorkstream(projectDir) : options.active;
const p = wsPlanningPaths(projectDir, name);
const phaseDirs = readSubdirectories(p.phases);
const phases: WorkstreamPhaseInventory[] = [];
let completedPhases = 0;
let totalPlans = 0;
let completedPlans = 0;
const phaseDirNames = readSubdirectories(p.phases);
for (const dir of [...phaseDirs].sort()) {
// Collect per-phase file counts
const phaseFilesCounts = phaseDirNames.map(dir => {
const counts = countPhaseFiles(join(p.phases, dir));
const status: WorkstreamPhaseInventory['status'] =
counts.summaryCount >= counts.planCount && counts.planCount > 0
? 'complete'
: counts.planCount > 0
? 'in_progress'
: 'pending';
return { directory: dir, planCount: counts.planCount, summaryCount: counts.summaryCount };
});
totalPlans += counts.planCount;
completedPlans += Math.min(counts.summaryCount, counts.planCount);
if (status === 'complete') completedPhases++;
phases.push({
directory: dir,
status,
plan_count: counts.planCount,
summary_count: counts.summaryCount,
});
}
const roadmapPhaseCount = countRoadmapPhases(p.roadmap, phaseDirs.length);
const state = readStateProjection(p.state);
return {
return buildWorkstreamInventory({
name,
path: toPosixPath(relative(projectDir, wsDir)),
active: name === active,
files: {
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),
},
status: state.status,
current_phase: state.current_phase,
last_activity: state.last_activity,
phases,
phase_count: phases.length,
completed_phases: completedPhases,
roadmap_phase_count: roadmapPhaseCount,
total_plans: totalPlans,
completed_plans: completedPlans,
progress_percent: roadmapPhaseCount > 0 ? Math.min(100, Math.round((completedPhases / roadmapPhaseCount) * 100)) : 0,
};
});
}
export function listWorkstreamInventories(projectDir: string): WorkstreamInventoryList {

View File

@@ -0,0 +1,237 @@
/**
* Pinning tests for the Workstream Inventory Builder.
*
* The Builder is pure (no I/O). It takes pre-collected BuilderInputs and
* returns a WorkstreamInventory. These tests lock the projection logic against
* the canonical shape defined in sdk/src/query/workstream-inventory.ts.
*/
import { describe, it, expect } from 'vitest';
import { buildWorkstreamInventory, isCompletedInventory } from './builder.js';
import type { BuilderInputs } from './builder.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function minimalInputs(overrides: Partial<BuilderInputs> = {}): BuilderInputs {
return {
name: 'my-ws',
projectDir: '/project',
workstreamDir: '/project/.planning/workstreams/my-ws',
phaseDirNames: [],
activeWorkstreamName: null,
phaseFilesCounts: [],
roadmapPhaseCount: 0,
stateProjection: { status: 'unknown', current_phase: null, last_activity: null },
filesExist: { roadmap: false, state: false, requirements: false },
...overrides,
};
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('buildWorkstreamInventory', () => {
it('empty inventory: no phase dirs, no STATE.md', () => {
const result = buildWorkstreamInventory(minimalInputs());
expect(result).toEqual({
name: 'my-ws',
path: '.planning/workstreams/my-ws',
active: false,
files: { roadmap: false, state: false, requirements: false },
status: 'unknown',
current_phase: null,
last_activity: null,
phases: [],
phase_count: 0,
completed_phases: 0,
roadmap_phase_count: 0,
total_plans: 0,
completed_plans: 0,
progress_percent: 0,
});
});
it('one phase in_progress (partial plan completion)', () => {
const result = buildWorkstreamInventory(
minimalInputs({
phaseDirNames: ['01-alpha'],
phaseFilesCounts: [{ directory: '01-alpha', planCount: 3, summaryCount: 1 }],
roadmapPhaseCount: 1,
stateProjection: { status: 'executing', current_phase: '01-alpha', last_activity: '2026-05-01' },
filesExist: { roadmap: true, state: true, requirements: false },
}),
);
expect(result.phases).toHaveLength(1);
expect(result.phases[0]).toEqual({
directory: '01-alpha',
status: 'in_progress',
plan_count: 3,
summary_count: 1,
});
expect(result.phase_count).toBe(1);
expect(result.completed_phases).toBe(0);
expect(result.total_plans).toBe(3);
expect(result.completed_plans).toBe(1);
expect(result.progress_percent).toBe(0);
expect(result.status).toBe('executing');
expect(result.current_phase).toBe('01-alpha');
expect(result.last_activity).toBe('2026-05-01');
expect(result.files).toEqual({ roadmap: true, state: true, requirements: false });
});
it('one phase complete (summary_count >= plan_count)', () => {
const result = buildWorkstreamInventory(
minimalInputs({
phaseDirNames: ['01-alpha'],
phaseFilesCounts: [{ directory: '01-alpha', planCount: 2, summaryCount: 2 }],
roadmapPhaseCount: 1,
stateProjection: { status: 'milestone complete', current_phase: null, last_activity: '2026-04-01' },
filesExist: { roadmap: true, state: true, requirements: true },
}),
);
expect(result.phases[0].status).toBe('complete');
expect(result.completed_phases).toBe(1);
expect(result.progress_percent).toBe(100);
expect(result.completed_plans).toBe(2);
});
it('one phase pending (plan_count is 0)', () => {
const result = buildWorkstreamInventory(
minimalInputs({
phaseDirNames: ['01-alpha'],
phaseFilesCounts: [{ directory: '01-alpha', planCount: 0, summaryCount: 0 }],
roadmapPhaseCount: 1,
stateProjection: { status: 'planning', current_phase: null, last_activity: null },
}),
);
expect(result.phases[0].status).toBe('pending');
expect(result.completed_phases).toBe(0);
expect(result.progress_percent).toBe(0);
});
it('multiple phases with mixed statuses', () => {
const result = buildWorkstreamInventory(
minimalInputs({
phaseDirNames: ['01-alpha', '02-beta', '03-gamma'],
phaseFilesCounts: [
{ directory: '01-alpha', planCount: 2, summaryCount: 2 }, // complete
{ directory: '02-beta', planCount: 3, summaryCount: 1 }, // in_progress
{ directory: '03-gamma', planCount: 0, summaryCount: 0 }, // pending
],
roadmapPhaseCount: 3,
stateProjection: { status: 'executing', current_phase: '02-beta', last_activity: '2026-05-10' },
filesExist: { roadmap: true, state: true, requirements: false },
}),
);
expect(result.phases).toHaveLength(3);
expect(result.phases[0].status).toBe('complete');
expect(result.phases[1].status).toBe('in_progress');
expect(result.phases[2].status).toBe('pending');
expect(result.completed_phases).toBe(1);
expect(result.total_plans).toBe(5);
expect(result.completed_plans).toBe(3); // 2 from alpha + min(1,3)=1 from beta + 0 from gamma
expect(result.progress_percent).toBe(Math.round((1 / 3) * 100)); // 33
expect(result.roadmap_phase_count).toBe(3);
});
it('progress_percent clamps to 100 when completedPhases > roadmapPhaseCount', () => {
// 3 phase dirs all complete, but roadmap only has 1 entry
const result = buildWorkstreamInventory(
minimalInputs({
phaseDirNames: ['01-alpha', '02-beta', '03-gamma'],
phaseFilesCounts: [
{ directory: '01-alpha', planCount: 1, summaryCount: 1 },
{ directory: '02-beta', planCount: 1, summaryCount: 1 },
{ directory: '03-gamma', planCount: 1, summaryCount: 1 },
],
roadmapPhaseCount: 1,
stateProjection: { status: 'milestone complete', current_phase: null, last_activity: null },
filesExist: { roadmap: true, state: true, requirements: false },
}),
);
expect(result.completed_phases).toBe(3);
expect(result.roadmap_phase_count).toBe(1);
expect(result.progress_percent).toBe(100);
});
it('active workstream marker: active: true when activeWorkstreamName === name', () => {
const result = buildWorkstreamInventory(
minimalInputs({
name: 'my-ws',
activeWorkstreamName: 'my-ws',
}),
);
expect(result.active).toBe(true);
});
it('active: false when activeWorkstreamName is a different workstream', () => {
const result = buildWorkstreamInventory(
minimalInputs({
name: 'my-ws',
activeWorkstreamName: 'other-ws',
}),
);
expect(result.active).toBe(false);
});
it('phases are sorted by directory name', () => {
// Provide dirs in reverse order to verify sorting
const result = buildWorkstreamInventory(
minimalInputs({
phaseDirNames: ['03-gamma', '01-alpha', '02-beta'],
phaseFilesCounts: [
{ directory: '03-gamma', planCount: 1, summaryCount: 0 },
{ directory: '01-alpha', planCount: 1, summaryCount: 1 },
{ directory: '02-beta', planCount: 1, summaryCount: 0 },
],
roadmapPhaseCount: 3,
stateProjection: { status: 'executing', current_phase: null, last_activity: null },
filesExist: { roadmap: true, state: false, requirements: false },
}),
);
expect(result.phases.map((p) => p.directory)).toEqual(['01-alpha', '02-beta', '03-gamma']);
});
it('path is relative from projectDir to workstreamDir using posix separators', () => {
const result = buildWorkstreamInventory(
minimalInputs({
projectDir: '/home/user/project',
workstreamDir: '/home/user/project/.planning/workstreams/my-ws',
}),
);
expect(result.path).toBe('.planning/workstreams/my-ws');
});
});
describe('isCompletedInventory', () => {
it('returns true for "milestone complete"', () => {
expect(isCompletedInventory('milestone complete')).toBe(true);
});
it('returns true for "Milestone Complete" (case-insensitive)', () => {
expect(isCompletedInventory('Milestone Complete')).toBe(true);
});
it('returns true for "archived"', () => {
expect(isCompletedInventory('archived')).toBe(true);
});
it('returns true for "Archived"', () => {
expect(isCompletedInventory('Archived')).toBe(true);
});
it('returns false for "executing"', () => {
expect(isCompletedInventory('executing')).toBe(false);
});
it('returns false for "planning"', () => {
expect(isCompletedInventory('planning')).toBe(false);
});
it('returns false for "unknown"', () => {
expect(isCompletedInventory('unknown')).toBe(false);
});
it('returns false for empty string', () => {
expect(isCompletedInventory('')).toBe(false);
});
});

View File

@@ -0,0 +1,170 @@
/**
* Workstream Inventory Builder — pure projection from pre-collected
* filesystem data to typed WorkstreamInventory. No I/O. No async.
*
* The caller is responsible for collecting BuilderInputs from the filesystem
* (or from test fixtures). This module performs only the stateless transformation.
*/
import { join, relative } from 'node:path';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface WorkstreamPhaseInventory {
directory: string;
status: 'complete' | 'in_progress' | 'pending';
plan_count: number;
summary_count: number;
}
export interface WorkstreamInventory {
name: string;
path: string;
active: boolean;
files: {
roadmap: boolean;
state: boolean;
requirements: boolean;
};
status: string;
current_phase: string | null;
last_activity: string | null;
phases: WorkstreamPhaseInventory[];
phase_count: number;
completed_phases: number;
roadmap_phase_count: number;
total_plans: number;
completed_plans: number;
progress_percent: number;
}
export interface WorkstreamInventoryList {
mode: 'flat' | 'workstream';
active: string | null;
workstreams: WorkstreamInventory[];
count: number;
message?: string;
}
// ─── Inputs ───────────────────────────────────────────────────────────────────
export interface BuilderInputs {
/** The workstream name (directory basename). */
name: string;
/** Absolute path to the project root. */
projectDir: string;
/** Absolute path to the workstream directory. */
workstreamDir: string;
/** List of phase directory names (unsorted; builder will sort them). */
phaseDirNames: string[];
/** The currently active workstream name, or null if none. */
activeWorkstreamName: string | null;
/**
* Pre-collected plan/summary counts per phase directory.
* The `directory` field must match entries in `phaseDirNames`.
*/
phaseFilesCounts: Array<{ directory: string; planCount: number; summaryCount: number }>;
/** Phase count from the ROADMAP.md (already resolved, fallback applied). */
roadmapPhaseCount: number;
/** Projection from the workstream's STATE.md (already read). */
stateProjection: { status: string; current_phase: string | null; last_activity: string | null };
/** Whether each canonical file exists (already checked). */
filesExist: { roadmap: boolean; state: boolean; requirements: boolean };
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
* Convert a path to POSIX format (forward slashes only).
* Pure string transform — no filesystem access.
*/
function toPosixPath(p: string): string {
return p.split('\\').join('/');
}
// ─── Exports ──────────────────────────────────────────────────────────────────
/**
* Pure classifier: returns true if the given status string indicates a
* completed or archived workstream (case-insensitive substring match).
*/
export function isCompletedInventory(status: string): boolean {
const s = String(status ?? '').toLowerCase();
return s.includes('milestone complete') || s.includes('archived');
}
/**
* Build a WorkstreamInventory from pre-collected BuilderInputs.
*
* This is a pure function — it does not read the filesystem and does not
* produce side-effects. All I/O must be done by the caller before invoking.
*/
export function buildWorkstreamInventory(inputs: BuilderInputs): WorkstreamInventory {
const {
name,
projectDir,
workstreamDir,
phaseDirNames,
activeWorkstreamName,
phaseFilesCounts,
roadmapPhaseCount,
stateProjection,
filesExist,
} = inputs;
// Index counts by directory for O(1) lookup during sort/iteration
const countsMap = new Map<string, { planCount: number; summaryCount: number }>();
for (const entry of phaseFilesCounts) {
countsMap.set(entry.directory, { planCount: entry.planCount, summaryCount: entry.summaryCount });
}
const phases: WorkstreamPhaseInventory[] = [];
let completedPhases = 0;
let totalPlans = 0;
let completedPlans = 0;
for (const dir of [...phaseDirNames].sort()) {
const counts = countsMap.get(dir) ?? { planCount: 0, summaryCount: 0 };
const status: WorkstreamPhaseInventory['status'] =
counts.summaryCount >= counts.planCount && counts.planCount > 0
? 'complete'
: counts.planCount > 0
? 'in_progress'
: 'pending';
totalPlans += counts.planCount;
completedPlans += Math.min(counts.summaryCount, counts.planCount);
if (status === 'complete') completedPhases++;
phases.push({
directory: dir,
status,
plan_count: counts.planCount,
summary_count: counts.summaryCount,
});
}
return {
name,
path: toPosixPath(relative(projectDir, workstreamDir)),
active: name === activeWorkstreamName,
files: {
roadmap: filesExist.roadmap,
state: filesExist.state,
requirements: filesExist.requirements,
},
status: stateProjection.status,
current_phase: stateProjection.current_phase,
last_activity: stateProjection.last_activity,
phases,
phase_count: phases.length,
completed_phases: completedPhases,
roadmap_phase_count: roadmapPhaseCount,
total_plans: totalPlans,
completed_plans: completedPlans,
progress_percent:
roadmapPhaseCount > 0
? Math.min(100, Math.round((completedPhases / roadmapPhaseCount) * 100))
: 0,
};
}