Refactor SDK-first architecture seams (#3316)

* refactor: tighten sdk-first architecture seams

Refs #3312

* refactor: finish state document seam cleanup

Refs #3312

* test: harden minimal install cleanup assertion

* ci: support sdk-scoped package lock

* fix(3316): restore root package-lock.json and align changeset pr ref

Reverts dec57a83 ("ci: support sdk-scoped package lock") and restores
the root package-lock.json that c249d34d deleted. The deletion was the
wrong direction:

- The root package.json declares its own runtime and dev deps
  (@anthropic-ai/claude-agent-sdk, ws, c8). Without a root lockfile,
  `npm install --no-package-lock` resolves whatever satisfies semver at
  install time — CI today and CI in six months can install different
  transitive trees, defeating reproducibility.
- The lockfile has been part of every release on this repo (long
  history on main); removing it loses the npm audit / Dependabot
  target without compensating benefit.
- The CI workaround pattern (cache-dependency-path: sdk/package-lock.json
  + `npm install --no-package-lock`) papered over the symptom rather
  than fix the cause.

Also fix the changeset pr: from 3312 (issue) to 3316 (PR). CONTEXT.md
flags this exact failure mode as a recurring CodeRabbit finding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address coderabbit review findings

* fix: close remaining coderabbit threads

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-09 14:21:42 -04:00
committed by GitHub
parent 2e87c60afc
commit 26dcdb1ad0
34 changed files with 1693 additions and 738 deletions

View File

@@ -109,7 +109,7 @@ Handlers for `**state.signal-waiting`**, `**state.signal-resume**`, `**state.val
- **`state.json`** / `state json` — port of **`cmdStateJson`** (`state.ts` `stateJson`): rebuilt STATE.md frontmatter JSON. Read-only golden: `read-only-parity.integration.test.ts` compares to CJS `state json` with **`last_updated`** stripped.
- **`state.load`** / `state load` — port of **`cmdStateLoad`** (`state-project-load.ts` `stateProjectLoad`): `{ config, state_raw, state_exists, roadmap_exists, config_exists }`; **`config`** comes from **`get-shit-done/bin/lib/core.cjs`** `loadConfig`, but discovery now routes through the **SDK Package Seam Module** (`sdk-package-compatibility.ts`) so install-layout probing stays behind one compatibility Adapter. Read-only golden: full `toEqual` vs `state load`. If `core.cjs` cannot be resolved, dispatch throws **`GSDError`** with the checked probe list (document for minimal `@gsd-build/sdk`-only installs).
`stateExtractField` in `helpers.ts` uses **horizontal whitespace only** after `Field:` so YAML keys such as lowercase `progress:` in frontmatter are not mistaken for the body `Progress:` line (see `get-shit-done/bin/lib/state.cjs` — same rule).
`stateExtractField` in `state-document.ts` (re-exported by `helpers.ts`) uses **horizontal whitespace only** after `Field:` so YAML keys such as lowercase `progress:` in frontmatter are not mistaken for the body `Progress:` line (see `get-shit-done/bin/lib/state-document.cjs` — same rule).
## Golden parity: coverage and exceptions
@@ -347,4 +347,3 @@ Disposition: **Registered** = handled in `createRegistry()` under the listed SDK
- `**detect-custom-files`**: requires `--config-dir <path>`; scans installer manifest vs GSD-managed dirs (`detect-custom-files.ts`).
- `**docs-init**`: docs-update workflow payload (`docs-init.ts`), aligned with `docs.cjs`. Golden tests omit `**agents_installed**` / `**missing_agents**` when comparing SDK vs CLI because the subprocess may resolve `~/.claude/...` differently than in-process checks.

View File

@@ -25,6 +25,7 @@ import { GSDError, ErrorClassification } from '../errors.js';
export { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
import { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js';
import { workspacePlanningPaths, resolveWorkspaceContext, type PlanningPaths } from './workspace.js';
export { stateExtractField } from './state-document.js';
import { relPlanningPath, validateWorkstreamName } from '../workstream-utils.js';
// ─── Runtime-aware agents directory resolution ─────────────────────────────
@@ -317,29 +318,6 @@ export function toPosixPath(p: string): string {
return p.split('\\').join('/');
}
// ─── stateExtractField ──────────────────────────────────────────────────────
/**
* Extract a field value from STATE.md content.
*
* Supports both **bold:** and plain: formats, case-insensitive.
*
* @param content - STATE.md content string
* @param fieldName - Field name to extract
* @returns The field value, or null if not found
*/
export function stateExtractField(content: string, fieldName: string): string | null {
const escaped = escapeRegex(fieldName);
// Horizontal whitespace only after ':' so YAML blocks like `progress:\n total:` do not
// match as `Progress:` with a multi-line "value" (parity with STATE.md body fields).
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
// ─── normalizeMd ───────────────────────────────────────────────────────────
/**

View File

@@ -30,7 +30,6 @@ import {
phaseTokenMatches,
toPosixPath,
planningPaths,
stateExtractField,
} from './helpers.js';
import { extractFrontmatter } from './frontmatter.js';
import { extractCurrentMilestone } from './roadmap.js';
@@ -41,6 +40,7 @@ import {
releaseStateLock,
stateReplaceField,
} from './state-mutation.js';
import { stateExtractField, stateReplaceFieldWithFallback } from './state-document.js';
import type { QueryHandler } from './utils.js';
import {
assertNoNullBytes,
@@ -959,29 +959,6 @@ export const phaseRemove: QueryHandler = async (args, projectDir, workstream) =>
};
};
// ─── stateReplaceFieldWithFallback (inline) ────────────────────────────────
/**
* Replace a field with fallback field name support.
*
* Tries primary first, then fallback. Returns content unchanged if neither matches.
* Reimplemented here because state-mutation.ts keeps it module-private.
*/
function stateReplaceFieldWithFallback(
content: string,
primary: string,
fallback: string | null,
value: string,
): string {
let result = stateReplaceField(content, primary, value);
if (result) return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result) return result;
}
return content;
}
// ─── updatePerformanceMetricsSection ───────────────────────────────────────
/**

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { scanPhasePlans } from './plan-scan.js';
describe('scanPhasePlans', () => {
it('counts flat and nested plan files while excluding derivative files', async () => {
const tmpDir = await mkdtemp(join(tmpdir(), 'gsd-plan-scan-'));
try {
const phaseDir = join(tmpDir, 'phases', '1');
const nestedDir = join(phaseDir, 'plans');
await mkdir(nestedDir, { recursive: true });
await writeFile(join(phaseDir, '01-01-PLAN.md'), '# Plan');
await writeFile(join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
await writeFile(join(phaseDir, '01-01-PLAN-OUTLINE.md'), '# Outline');
await writeFile(join(nestedDir, 'PLAN-02-next.md'), '# Plan');
await writeFile(join(nestedDir, 'SUMMARY-02-next.md'), '# Summary');
await writeFile(join(nestedDir, 'PLAN-03-draft.pre-bounce.md'), '# Draft');
await writeFile(join(nestedDir, 'PLAN-04-OUTLINE.md'), '# Outline');
expect(scanPhasePlans(phaseDir)).toMatchObject({
planCount: 2,
summaryCount: 2,
completed: true,
hasNestedPlans: true,
planFiles: ['01-01-PLAN.md', 'PLAN-02-next.md'],
summaryFiles: ['01-01-SUMMARY.md', 'SUMMARY-02-next.md'],
});
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,82 @@
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const PLAN_OUTLINE_RE = /-OUTLINE\.md$/i;
const PLAN_PRE_BOUNCE_RE = /\.pre-bounce\.md$/i;
export interface PhasePlanScan {
planCount: number;
summaryCount: number;
completed: boolean;
hasNestedPlans: boolean;
planFiles: string[];
summaryFiles: string[];
}
export function isRootPlanFile(fileName: string): boolean {
if (PLAN_OUTLINE_RE.test(fileName)) return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName)) return false;
if (fileName.endsWith('-PLAN.md') || fileName === 'PLAN.md') return true;
return /\.md$/i.test(fileName) && /PLAN/i.test(fileName);
}
export function isNestedPlanFile(fileName: string): boolean {
if (PLAN_OUTLINE_RE.test(fileName)) return false;
if (PLAN_PRE_BOUNCE_RE.test(fileName)) return false;
return /^PLAN-\d+.*\.md$/i.test(fileName) || /-PLAN-\d+.*\.md$/i.test(fileName);
}
export function isRootSummaryFile(fileName: string): boolean {
return fileName.endsWith('-SUMMARY.md') || fileName === 'SUMMARY.md';
}
export function isNestedSummaryFile(fileName: string): boolean {
return /^SUMMARY-\d+.*\.md$/i.test(fileName) || /-SUMMARY-\d+.*\.md$/i.test(fileName);
}
export function scanPhasePlans(phaseDir: string): PhasePlanScan {
let rootFiles: string[];
try {
rootFiles = readdirSync(phaseDir);
} catch {
return {
planCount: 0,
summaryCount: 0,
completed: false,
hasNestedPlans: false,
planFiles: [],
summaryFiles: [],
};
}
const rootPlanFiles = rootFiles.filter(isRootPlanFile);
const rootSummaryFiles = rootFiles.filter(isRootSummaryFile);
let nestedPlanFiles: string[] = [];
let nestedSummaryFiles: string[] = [];
let hasNestedPlans = false;
const nestedDir = join(phaseDir, 'plans');
if (existsSync(nestedDir)) {
try {
const nestedFiles = readdirSync(nestedDir);
nestedPlanFiles = nestedFiles.filter(isNestedPlanFile);
nestedSummaryFiles = nestedFiles.filter(isNestedSummaryFile);
hasNestedPlans = nestedPlanFiles.length > 0;
} catch { /* ignore unreadable nested layout */ }
}
const planFiles = rootPlanFiles.concat(nestedPlanFiles);
const summaryFiles = rootSummaryFiles.concat(nestedSummaryFiles);
const planCount = planFiles.length;
const summaryCount = summaryFiles.length;
return {
planCount,
summaryCount,
completed: planCount > 0 && summaryCount >= planCount,
hasNestedPlans,
planFiles,
summaryFiles,
};
}

View File

@@ -0,0 +1,129 @@
/**
* STATE.md Document Module.
*
* Pure transforms for STATE.md text. This module does not read the filesystem
* and does not own persistence or locking.
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function stateExtractField(content: string, fieldName: string): string | null {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(boldPattern, (_match, prefix: string) => `${prefix}${newValue}`);
}
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(plainPattern, (_match, prefix: string) => `${prefix}${newValue}`);
}
return null;
}
export function stateReplaceFieldWithFallback(
content: string,
primary: string,
fallback: string | null,
value: string,
): string {
let result = stateReplaceField(content, primary, value);
if (result) return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result) return result;
}
return content;
}
export function normalizeStateStatus(status: string | null | undefined, pausedAt?: string | null): string {
let normalizedStatus = status || 'unknown';
const statusLower = (status || '').toLowerCase();
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
normalizedStatus = 'paused';
} else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
normalizedStatus = 'executing';
} else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
normalizedStatus = 'planning';
} else if (statusLower.includes('discussing')) {
normalizedStatus = 'discussing';
} else if (statusLower.includes('verif')) {
normalizedStatus = 'verifying';
} else if (statusLower.includes('complete') || statusLower.includes('done')) {
normalizedStatus = 'completed';
} else if (statusLower.includes('ready to execute')) {
normalizedStatus = 'executing';
}
return normalizedStatus;
}
export function computeProgressPercent(
completedPlans: number | null,
totalPlans: number | null,
completedPhases: number | null,
totalPhases: number | null,
): number | null {
const hasPlanData = totalPlans !== null && totalPlans > 0 && completedPlans !== null;
const hasPhaseData = totalPhases !== null && totalPhases > 0 && completedPhases !== null;
if (!hasPlanData && !hasPhaseData) return null;
const planFraction = hasPlanData ? completedPlans / totalPlans : 1;
const phaseFraction = hasPhaseData ? completedPhases / totalPhases : 1;
return Math.min(100, Math.round(Math.min(planFraction, phaseFraction) * 100));
}
function toFiniteNumber(value: unknown): number | null {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function existingProgressExceedsDerived(
existingProgress: Record<string, unknown>,
derivedProgress: Record<string, unknown>,
key: string,
): boolean {
const existing = toFiniteNumber(existingProgress[key]);
const derived = toFiniteNumber(derivedProgress[key]);
return existing !== null && derived !== null && existing > derived;
}
export function shouldPreserveExistingProgress(
existingProgress: unknown,
derivedProgress: unknown,
): existingProgress is Record<string, unknown> {
if (!existingProgress || typeof existingProgress !== 'object') return false;
if (!derivedProgress || typeof derivedProgress !== 'object') return false;
const existing = existingProgress as Record<string, unknown>;
const derived = derivedProgress as Record<string, unknown>;
return (
existingProgressExceedsDerived(existing, derived, 'total_phases') ||
existingProgressExceedsDerived(existing, derived, 'completed_phases') ||
existingProgressExceedsDerived(existing, derived, 'total_plans') ||
existingProgressExceedsDerived(existing, derived, 'completed_plans')
);
}
export function normalizeProgressNumbers(progress: unknown): unknown {
if (!progress || typeof progress !== 'object') return progress;
const normalized: Record<string, unknown> = { ...(progress as Record<string, unknown>) };
for (const key of ['total_phases', 'completed_phases', 'total_plans', 'completed_plans', 'percent']) {
const number = toFiniteNumber(normalized[key]);
if (number !== null) normalized[key] = number;
}
return normalized;
}

View File

@@ -244,6 +244,131 @@ describe('stateUpdate', () => {
expect(data.updated).toBe(false);
});
it('preserves curated progress frontmatter during body-only updates', async () => {
const stateContent = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
progress:
total_phases: 12
completed_phases: 6
total_plans: 22
completed_plans: 22
percent: 50
---
# Project State
## Current Position
Status: Executing
Last Activity: 2026-01-01
Progress: [█████░░░░░] 50%
`;
await setupTestProject(tmpDir, stateContent);
const { stateUpdate } = await import('./state-mutation.js');
const { stateJson } = await import('./state.js');
const result = await stateUpdate(['Last Activity', '2026-05-07'], tmpDir);
expect((result.data as Record<string, unknown>).updated).toBe(true);
const loaded = await stateJson([], tmpDir);
const progress = (loaded.data as Record<string, unknown>).progress as Record<string, unknown>;
expect(Number(progress.total_phases)).toBe(12);
expect(Number(progress.completed_phases)).toBe(6);
expect(Number(progress.total_plans)).toBe(22);
expect(Number(progress.completed_plans)).toBe(22);
expect(Number(progress.percent)).toBe(50);
const after = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(after).toContain('Last Activity: 2026-05-07');
});
it('resyncs progress frontmatter when updating the Progress body field', async () => {
const stateContent = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
progress:
total_phases: 12
completed_phases: 6
total_plans: 22
completed_plans: 22
percent: 50
---
# Project State
## Current Position
Status: Executing
Progress: [█████░░░░░] 50%
`;
await setupTestProject(tmpDir, stateContent);
await rm(join(tmpDir, '.planning', 'ROADMAP.md'), { force: true });
const { stateUpdate } = await import('./state-mutation.js');
await stateUpdate(['Progress', '[████████░░] 80%'], tmpDir);
const after = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const { extractFrontmatter } = await import('./frontmatter.js');
const fm = extractFrontmatter(after);
const progress = fm.progress as Record<string, unknown>;
expect(Number(progress.percent)).toBe(80);
});
it('syncs full-file workstream STATE.md frontmatter from the selected workstream', async () => {
const planningDir = join(tmpDir, '.planning');
const wsDir = join(planningDir, 'workstreams', 'feature');
await mkdir(join(wsDir, 'phases'), { recursive: true });
await writeFile(
join(planningDir, 'STATE.md'),
[
'---',
'milestone: v0.1',
'milestone_name: Root Milestone',
'---',
'',
'# Root State',
'',
'Status: Root',
'',
].join('\n'),
'utf-8',
);
await writeFile(join(planningDir, 'ROADMAP.md'), '# Roadmap\n\n## v0.1 Root Milestone\n', 'utf-8');
await writeFile(
join(wsDir, 'STATE.md'),
[
'---',
'milestone: v2.0',
'milestone_name: Feature Milestone',
'---',
'',
'# Workstream State',
'',
'Status: Executing',
'',
].join('\n'),
'utf-8',
);
await writeFile(join(wsDir, 'ROADMAP.md'), '# Roadmap\n\n## v2.0 Feature Milestone\n', 'utf-8');
const { readModifyWriteStateMdFull } = await import('./state-mutation.js');
await readModifyWriteStateMdFull(tmpDir, content => content.replace('Status: Executing', 'Status: Complete'), 'feature');
const after = await readFile(join(wsDir, 'STATE.md'), 'utf-8');
const { extractFrontmatter } = await import('./frontmatter.js');
const fm = extractFrontmatter(after);
expect(fm.milestone).toBe('v2.0');
expect(fm.milestone_name).toBe('Feature Milestone');
expect(fm.status).toBe('completed');
});
it('throws on missing args', async () => {
const { stateUpdate } = await import('./state-mutation.js');
@@ -277,6 +402,45 @@ describe('statePatch', () => {
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(content).toContain('done');
});
it('preserves curated progress frontmatter when patching body-only fields', async () => {
const stateContent = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
progress:
total_phases: 12
completed_phases: 6
total_plans: 22
completed_plans: 22
percent: 50
---
# Project State
## Current Position
Status: Executing
Last Activity: 2026-01-01
Progress: [█████░░░░░] 50%
`;
await setupTestProject(tmpDir, stateContent);
const { statePatch } = await import('./state-mutation.js');
await statePatch([JSON.stringify({ 'Last Activity': '2026-05-07' })], tmpDir);
const after = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const { extractFrontmatter } = await import('./frontmatter.js');
const fm = extractFrontmatter(after);
const progress = fm.progress as Record<string, unknown>;
expect(Number(progress.total_phases)).toBe(12);
expect(Number(progress.completed_phases)).toBe(6);
expect(Number(progress.total_plans)).toBe(22);
expect(Number(progress.completed_plans)).toBe(22);
expect(Number(progress.percent)).toBe(50);
expect(after).toContain('Last Activity: 2026-05-07');
});
});
// ─── stateBeginPhase ────────────────────────────────────────────────────────

View File

@@ -28,16 +28,17 @@ import { extractFrontmatter, stripFrontmatter } from './frontmatter.js';
import { reconstructFrontmatter, spliceFrontmatter } from './frontmatter-mutation.js';
import {
comparePhaseNum,
escapeRegex,
normalizePhaseName,
phaseTokenMatches,
planningPaths,
normalizeMd,
stateExtractField,
} from './helpers.js';
import { buildStateFrontmatter, getMilestonePhaseFilter } from './state.js';
import { stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } from './state-document.js';
import type { QueryHandler } from './utils.js';
const PROGRESS_FRONTMATTER_FIELDS = new Set(['Progress', 'Total Plans in Phase', 'Total Phases']);
// ─── Process exit lock cleanup (D2 — match CJS state.cjs:16-23) ─────────
/**
@@ -52,48 +53,7 @@ process.on('exit', () => {
}
});
// ─── stateReplaceField ────────────────────────────────────────────────────
/**
* Replace a field value in STATE.md content.
*
* Uses separate regex instances (no g flag) to avoid lastIndex persistence.
* Supports both **bold:** and plain: formats.
*
* @param content - STATE.md content
* @param fieldName - Field name to replace
* @param newValue - New value to set
* @returns Updated content, or null if field not found
*/
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
const escaped = escapeRegex(fieldName);
// Try **Field:** bold format first
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
if (boldPattern.test(content)) {
return content.replace(new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i'), (_match, prefix: string) => `${prefix}${newValue}`);
}
// Try plain Field: format
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
if (plainPattern.test(content)) {
return content.replace(new RegExp(`(^${escaped}:\\s*)(.*)`, 'im'), (_match, prefix: string) => `${prefix}${newValue}`);
}
return null;
}
/**
* Replace a field with fallback field name support.
*
* Tries primary first, then fallback. Returns content unchanged if neither matches.
*/
function stateReplaceFieldWithFallback(content: string, primary: string, fallback: string | null, value: string): string {
let result = stateReplaceField(content, primary, value);
if (result) return result;
if (fallback) {
result = stateReplaceField(content, fallback, value);
if (result) return result;
}
return content;
}
export { stateReplaceField };
/**
* Update fields within the ## Current Position section.
@@ -234,10 +194,15 @@ export async function releaseStateLock(lockPath: string): Promise<void> {
* Strips existing frontmatter, rebuilds from body + disk, and splices back.
* Preserves existing status when body-derived status is 'unknown'.
*/
async function syncStateFrontmatter(content: string, projectDir: string): Promise<string> {
async function syncStateFrontmatter(
content: string,
projectDir: string,
workstream?: string,
options: { preserveExistingProgress?: boolean } = {},
): Promise<string> {
const existingFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
const derivedFm = await buildStateFrontmatter(body, projectDir);
const derivedFm = await buildStateFrontmatter(body, projectDir, workstream, options);
// Preserve existing status when body-derived is 'unknown'
if (derivedFm.status === 'unknown' && existingFm.status && existingFm.status !== 'unknown') {
@@ -261,8 +226,10 @@ async function readModifyWriteStateMd(
projectDir: string,
modifier: (content: string) => string | Promise<string>,
workstream?: string,
options: { resync?: boolean; preserveExistingProgress?: boolean } = {},
): Promise<string> {
const statePath = planningPaths(projectDir, workstream).state;
const resync = options.resync !== false;
const lockPath = await acquireStateLock(statePath);
try {
let content: string;
@@ -274,9 +241,18 @@ async function readModifyWriteStateMd(
// Strip frontmatter before passing to modifier so that regex replacements
// operate on body fields only (not on YAML frontmatter keys like 'status:').
// syncStateFrontmatter rebuilds frontmatter from the modified body + disk.
const preFm = extractFrontmatter(content);
const body = stripFrontmatter(content);
const modified = await modifier(body);
const synced = await syncStateFrontmatter(modified, projectDir);
let synced = await syncStateFrontmatter(modified, projectDir, workstream, {
preserveExistingProgress: options.preserveExistingProgress,
});
if (!resync && preFm && preFm.progress) {
const postFm = extractFrontmatter(synced);
postFm.progress = preFm.progress;
const yamlStr = reconstructFrontmatter(postFm);
synced = `---\n${yamlStr}\n---\n\n${stripFrontmatter(synced)}`;
}
const normalized = normalizeMd(synced);
await writeFile(statePath, normalized, 'utf-8');
return normalized;
@@ -305,7 +281,7 @@ export async function readModifyWriteStateMdFull(
/* missing */
}
const modified = await modifier(content);
const synced = await syncStateFrontmatter(modified, projectDir);
const synced = await syncStateFrontmatter(modified, projectDir, workstream);
await writeFile(statePath, normalizeMd(synced), 'utf-8');
} finally {
await releaseStateLock(lockPath);
@@ -332,6 +308,7 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) =>
}
let updated = false;
const shouldResync = PROGRESS_FRONTMATTER_FIELDS.has(field);
await readModifyWriteStateMd(projectDir, (content) => {
const result = stateReplaceField(content, field, value);
if (result) {
@@ -339,7 +316,10 @@ export const stateUpdate: QueryHandler = async (args, projectDir, workstream) =>
return result;
}
return content;
}, workstream);
}, workstream, {
resync: shouldResync,
preserveExistingProgress: !shouldResync,
});
return { data: { updated } };
};
@@ -377,6 +357,7 @@ export const statePatch: QueryHandler = async (args, projectDir, workstream) =>
const updated: string[] = [];
const failed: string[] = [];
const shouldResync = Object.keys(patches).some(field => PROGRESS_FRONTMATTER_FIELDS.has(field));
await readModifyWriteStateMd(projectDir, (content) => {
for (const [field, value] of Object.entries(patches)) {
const result = stateReplaceField(content, field, String(value));
@@ -388,7 +369,10 @@ export const statePatch: QueryHandler = async (args, projectDir, workstream) =>
}
}
return content;
}, workstream);
}, workstream, {
resync: shouldResync,
preserveExistingProgress: !shouldResync,
});
return { data: { updated, failed } };
};
@@ -1707,4 +1691,4 @@ export const statePrune: QueryHandler = async (args, projectDir, workstream) =>
archive_file: totalPruned > 0 ? 'STATE-ARCHIVE.md' : null,
},
};
};
};

View File

@@ -155,8 +155,36 @@ describe('stateJson', () => {
expect(progress.completed_plans).toBe(4);
// Phase 09 complete (3/3), phase 10 incomplete (1/3), phase 11 incomplete (0/1)
expect(progress.completed_phases).toBe(1);
// 4/7 = 57%
expect(progress.percent).toBe(57);
// min(plan fraction 4/7, phase fraction 1/3) = 33%
expect(progress.percent).toBe(33);
});
it('preserves wider curated progress when disk scan only sees a realized subset', async () => {
const stateContent = `---
gsd_state_version: 1.0
milestone: v3.0
milestone_name: SDK-First Migration
status: executing
progress:
total_phases: 12
completed_phases: 6
total_plans: 22
completed_plans: 22
percent: 50
---
${STATE_BODY}`;
await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
const progress = data.progress as Record<string, unknown>;
expect(progress.total_phases).toBe(12);
expect(progress.completed_phases).toBe(6);
expect(progress.total_plans).toBe(22);
expect(progress.completed_plans).toBe(22);
expect(progress.percent).toBe(50);
});
it('preserves stopped_at from existing frontmatter', async () => {
@@ -232,8 +260,8 @@ Progress: [░░░░░░░░░░] 0%
const data = result.data as Record<string, unknown>;
const progress = data.progress as Record<string, unknown>;
// Disk should override the body's 0%
expect(progress.percent).toBe(57);
// Disk should override the body's 0%; phase fraction caps plan-only progress.
expect(progress.percent).toBe(33);
});
});

View File

@@ -23,7 +23,14 @@
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { extractFrontmatter, stripFrontmatter } from './frontmatter.js';
import { stateExtractField, planningPaths, escapeRegex } from './helpers.js';
import { planningPaths, escapeRegex } from './helpers.js';
import {
computeProgressPercent,
normalizeProgressNumbers,
normalizeStateStatus,
shouldPreserveExistingProgress,
stateExtractField,
} from './state-document.js';
import { getMilestoneInfo, extractCurrentMilestone } from './roadmap.js';
import type { QueryHandler } from './utils.js';
@@ -77,7 +84,12 @@ export async function getMilestonePhaseFilter(projectDir: string, workstream?: s
* Port of buildStateFrontmatter from state.cjs lines 650-760.
* HIGH complexity: extracts fields, scans disk, computes progress.
*/
export async function buildStateFrontmatter(bodyContent: string, projectDir: string, workstream?: string): Promise<Record<string, unknown>> {
export async function buildStateFrontmatter(
bodyContent: string,
projectDir: string,
workstream?: string,
options: { preserveExistingProgress?: boolean } = {},
): Promise<Record<string, unknown>> {
const currentPhase = stateExtractField(bodyContent, 'Current Phase');
const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name');
const currentPlan = stateExtractField(bodyContent, 'Current Plan');
@@ -146,32 +158,14 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str
} catch { /* intentionally empty */ }
// Derive percent from disk counts (ground truth)
let progressPercent: number | null = null;
if (totalPlans !== null && totalPlans > 0 && completedPlans !== null) {
progressPercent = Math.min(100, Math.round(completedPlans / totalPlans * 100));
} else if (progressRaw) {
let progressPercent = computeProgressPercent(completedPlans, totalPlans, completedPhases, totalPhases);
if (progressPercent === null && progressRaw) {
const pctMatch = progressRaw.match(/(\d+)%/);
if (pctMatch) progressPercent = parseInt(pctMatch[1], 10);
}
// Normalize status
let normalizedStatus = status || 'unknown';
const statusLower = (status || '').toLowerCase();
if (statusLower.includes('paused') || statusLower.includes('stopped') || pausedAt) {
normalizedStatus = 'paused';
} else if (statusLower.includes('executing') || statusLower.includes('in progress')) {
normalizedStatus = 'executing';
} else if (statusLower.includes('planning') || statusLower.includes('ready to plan')) {
normalizedStatus = 'planning';
} else if (statusLower.includes('discussing')) {
normalizedStatus = 'discussing';
} else if (statusLower.includes('verif')) {
normalizedStatus = 'verifying';
} else if (statusLower.includes('complete') || statusLower.includes('done')) {
normalizedStatus = 'completed';
} else if (statusLower.includes('ready to execute')) {
normalizedStatus = 'executing';
}
let normalizedStatus = normalizeStateStatus(status, pausedAt);
// Bug #2613: status preservation — if body has no Status field and existing
// frontmatter has a non-unknown status, prefer existing.
@@ -205,12 +199,12 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str
// prefer existing. Legitimate mid-milestone updates see non-zero disk counts
// and fall through, keeping disk as ground truth.
const existingProgress = existingFm.progress as Record<string, unknown> | undefined;
if (existingProgress && typeof existingProgress === 'object') {
if (options.preserveExistingProgress !== false && existingProgress && typeof existingProgress === 'object') {
const derivedTotalPlans = Number(progress.total_plans ?? 0);
const derivedCompletedPlans = Number(progress.completed_plans ?? 0);
const existingTotalPlans = Number(existingProgress.total_plans ?? 0);
if (derivedTotalPlans === 0 && derivedCompletedPlans === 0 && existingTotalPlans > 0) {
fm.progress = existingProgress;
fm.progress = normalizeProgressNumbers(existingProgress);
}
}
@@ -259,6 +253,12 @@ export const stateJson: QueryHandler = async (_args, projectDir, workstream) =>
if (built.status === 'unknown' && existingFm && existingFm.status && existingFm.status !== 'unknown') {
built.status = existingFm.status;
}
// Read-side projection: preserve curated cross-milestone aggregates when the
// disk scan sees only a narrower realized subset (#3242 Bug A). Mutation sync
// remains disk-authoritative when it sees non-zero counts.
if (existingFm && shouldPreserveExistingProgress(existingFm.progress, built.progress)) {
built.progress = normalizeProgressNumbers(existingFm.progress);
}
return { data: built };
};

View File

@@ -0,0 +1,195 @@
/**
* 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.
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { toPosixPath } from './helpers.js';
import { scanPhasePlans } from './plan-scan.js';
import { stateExtractField } from './state-document.js';
import { readActiveWorkstream } from './active-workstream-store.js';
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;
}
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): Pick<WorkstreamInventory, 'status' | 'current_phase' | 'last_activity'> {
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 active = 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;
for (const dir of [...phaseDirs].sort()) {
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';
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 {
name,
path: toPosixPath(relative(projectDir, wsDir)),
active: name === active,
files: {
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 {
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,
};
}

View File

@@ -8,7 +8,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { readFile } from 'node:fs/promises';
import { workstreamList, workstreamCreate, workstreamSet } from './workstream.js';
import { workstreamList, workstreamCreate, workstreamSet, workstreamProgress } from './workstream.js';
describe('workstreamList', () => {
let tmpDir: string;
@@ -31,6 +31,34 @@ describe('workstreamList', () => {
});
});
describe('workstreamProgress', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-ws-progress-'));
const wsDir = join(tmpDir, '.planning', 'workstreams', 'overflow');
await mkdir(join(wsDir, 'phases', '01-one'), { recursive: true });
await mkdir(join(wsDir, 'phases', '02-two'), { recursive: true });
await writeFile(join(wsDir, 'phases', '01-one', 'PLAN.md'), '# Plan\n');
await writeFile(join(wsDir, 'phases', '01-one', 'SUMMARY.md'), '# Summary\n');
await writeFile(join(wsDir, 'phases', '02-two', 'PLAN.md'), '# Plan\n');
await writeFile(join(wsDir, 'phases', '02-two', 'SUMMARY.md'), '# Summary\n');
await writeFile(join(wsDir, 'STATE.md'), '# State\nStatus: In progress\n');
await writeFile(join(wsDir, 'ROADMAP.md'), '# Roadmap\n### Phase 1: One\n');
await writeFile(join(tmpDir, '.planning', 'active-workstream'), 'overflow\n');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('clamps progress percent when completed phase dirs exceed roadmap count', async () => {
const result = await workstreamProgress([], tmpDir);
const data = result.data as { workstreams: Array<{ progress_percent: number }> };
expect(data.workstreams[0].progress_percent).toBe(100);
});
});
describe('workstreamCreate', () => {
let tmpDir: string;

View File

@@ -22,44 +22,20 @@ import {
} from 'node:fs';
import { join, relative } from 'node:path';
import { toPosixPath, stateExtractField } from './helpers.js';
import { toPosixPath } from './helpers.js';
import { GSDError, ErrorClassification } from '../errors.js';
import { validateWorkstreamName, toWorkstreamSlug } from '../workstream-name-policy.js';
import { readActiveWorkstream, writeActiveWorkstream } from './active-workstream-store.js';
import {
inspectWorkstream,
listWorkstreamInventories,
planningRoot,
workstreamsRoot,
} from './workstream-inventory.js';
import type { QueryHandler } from './utils.js';
// ─── Internal helpers ─────────────────────────────────────────────────────
const planningRoot = (projectDir: string) =>
join(projectDir, '.planning');
const workstreamsDir = (projectDir: string) =>
join(planningRoot(projectDir), 'workstreams');
function wsPlanningPaths(projectDir: string, name: string) {
const base = join(planningRoot(projectDir), 'workstreams', name);
return {
planning: base,
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);
}
function filterPlanFiles(files: string[]): string[] {
return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
}
function filterSummaryFiles(files: string[]): string[] {
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
}
// ─── Handlers ─────────────────────────────────────────────────────────────
/**
@@ -69,7 +45,7 @@ function filterSummaryFiles(files: string[]): string[] {
*/
export const workstreamGet: QueryHandler = async (_args, projectDir) => {
const active = readActiveWorkstream(projectDir);
const wsRoot = workstreamsDir(projectDir);
const wsRoot = workstreamsRoot(projectDir);
return {
data: {
active,
@@ -79,15 +55,26 @@ export const workstreamGet: QueryHandler = async (_args, projectDir) => {
};
export const workstreamList: QueryHandler = async (_args, projectDir) => {
const dir = workstreamsDir(projectDir);
if (!existsSync(dir)) return { data: { mode: 'flat', workstreams: [], message: 'No workstreams — operating in flat mode' } };
try {
const entries = readdirSync(dir, { withFileTypes: true });
const workstreams = entries.filter(e => e.isDirectory()).map(e => e.name);
return { data: { mode: 'workstream', workstreams, count: workstreams.length } };
} catch {
return { data: { mode: 'flat', workstreams: [], count: 0 } };
const inventory = listWorkstreamInventories(projectDir);
if (inventory.mode === 'flat') {
return { data: { mode: 'flat', workstreams: [], message: inventory.message } };
}
return {
data: {
mode: 'workstream',
workstreams: inventory.workstreams.map(ws => ({
name: ws.name,
path: ws.path,
has_roadmap: ws.files.roadmap,
has_state: ws.files.state,
status: ws.status,
current_phase: ws.current_phase,
phase_count: ws.phase_count,
completed_phases: ws.completed_phases,
})),
count: inventory.count,
},
};
};
export const workstreamCreate: QueryHandler = async (args, projectDir) => {
@@ -105,7 +92,7 @@ export const workstreamCreate: QueryHandler = async (args, projectDir) => {
return { data: { created: false, reason: '.planning/ directory not found — run /gsd-new-project first' } };
}
const wsRoot = workstreamsDir(projectDir);
const wsRoot = workstreamsRoot(projectDir);
const wsDir = join(wsRoot, slug);
if (existsSync(wsDir) && existsSync(join(wsDir, 'STATE.md'))) {
@@ -170,7 +157,7 @@ export const workstreamCreate: QueryHandler = async (args, projectDir) => {
* so frontmatter fields and body stay in lockstep with the source.
*/
function syncRootStateMirror(projectDir: string, name: string): void {
const wsStatePath = join(workstreamsDir(projectDir), name, 'STATE.md');
const wsStatePath = join(workstreamsRoot(projectDir), name, 'STATE.md');
const rootStatePath = join(planningRoot(projectDir), 'STATE.md');
if (!existsSync(wsStatePath)) return;
try {
@@ -195,7 +182,7 @@ export const workstreamSet: QueryHandler = async (args, projectDir) => {
return { data: { active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, underscores, or dots only' } };
}
const wsDir = join(workstreamsDir(projectDir), name);
const wsDir = join(workstreamsRoot(projectDir), name);
if (!existsSync(wsDir)) {
return { data: { active: null, error: 'not_found', workstream: name } };
}
@@ -214,60 +201,26 @@ export const workstreamStatus: QueryHandler = async (args, projectDir) => {
throw new GSDError('Invalid workstream name', ErrorClassification.Validation);
}
const wsDir = join(workstreamsDir(projectDir), name);
const wsDir = join(workstreamsRoot(projectDir), name);
if (!existsSync(wsDir)) {
return { data: { found: false, workstream: name } };
}
const p = wsPlanningPaths(projectDir, name);
const relPath = toPosixPath(relative(projectDir, wsDir));
const files = {
roadmap: existsSync(p.roadmap),
state: existsSync(p.state),
requirements: existsSync(p.requirements),
};
const phases: Array<{ directory: string; status: string; plan_count: number; summary_count: number }> = [];
for (const dir of readSubdirectories(p.phases).sort()) {
try {
const phaseFiles = readdirSync(join(p.phases, dir));
const plans = filterPlanFiles(phaseFiles);
const summaries = filterSummaryFiles(phaseFiles);
phases.push({
directory: dir,
status:
summaries.length >= plans.length && plans.length > 0
? 'complete'
: plans.length > 0
? 'in_progress'
: 'pending',
plan_count: plans.length,
summary_count: summaries.length,
});
} catch { /* skip */ }
}
let stateInfo: Record<string, string | null> = {};
try {
const stateContent = readFileSync(p.state, 'utf-8');
stateInfo = {
status: stateExtractField(stateContent, 'Status') || 'unknown',
current_phase: stateExtractField(stateContent, 'Current Phase'),
last_activity: stateExtractField(stateContent, 'Last Activity'),
};
} catch { /* skip */ }
const inventory = inspectWorkstream(projectDir, name);
if (!inventory) return { data: { found: false, workstream: name } };
return {
data: {
found: true,
workstream: name,
path: relPath,
files,
phases,
phase_count: phases.length,
completed_phases: phases.filter(ph => ph.status === 'complete').length,
...stateInfo,
path: inventory.path,
files: inventory.files,
phases: inventory.phases,
phase_count: inventory.phase_count,
completed_phases: inventory.completed_phases,
status: inventory.status,
current_phase: inventory.current_phase,
last_activity: inventory.last_activity,
},
};
};
@@ -280,7 +233,7 @@ export const workstreamComplete: QueryHandler = async (args, projectDir) => {
}
const root = planningRoot(projectDir);
const wsRoot = workstreamsDir(projectDir);
const wsRoot = workstreamsRoot(projectDir);
const wsDir = join(wsRoot, name);
if (!existsSync(wsDir)) {
@@ -341,85 +294,31 @@ export const workstreamComplete: QueryHandler = async (args, projectDir) => {
* (Not the same as roadmap `progress` / `progressBar`.)
*/
export const workstreamProgress: QueryHandler = async (_args, projectDir) => {
const wsRoot = workstreamsDir(projectDir);
if (!existsSync(wsRoot)) {
const inventory = listWorkstreamInventories(projectDir);
if (inventory.mode === 'flat') {
return {
data: {
mode: 'flat',
workstreams: [],
message: 'No workstreams — operating in flat mode',
message: inventory.message,
},
};
}
const active = readActiveWorkstream(projectDir);
const entries = readdirSync(wsRoot, { withFileTypes: true });
const workstreams: Array<{
name: string;
active: boolean;
status: string;
current_phase: string | null;
phases: string;
plans: string;
progress_percent: number;
}> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const wsDir = join(wsRoot, entry.name);
const phasesDir = join(wsDir, 'phases');
const phaseDirsProgress = readSubdirectories(phasesDir);
const phaseCount = phaseDirsProgress.length;
let completedCount = 0;
let totalPlans = 0;
let completedPlans = 0;
for (const d of phaseDirsProgress) {
try {
const phaseFiles = readdirSync(join(phasesDir, d));
const plans = filterPlanFiles(phaseFiles);
const summaries = filterSummaryFiles(phaseFiles);
totalPlans += plans.length;
completedPlans += Math.min(summaries.length, plans.length);
if (plans.length > 0 && summaries.length >= plans.length) completedCount++;
} catch { /* skip */ }
}
let roadmapPhaseCount = phaseCount;
try {
const roadmapContent = readFileSync(join(wsDir, 'ROADMAP.md'), 'utf-8');
const phaseMatches = roadmapContent.match(/^###?\s+Phase\s+\d/gm);
if (phaseMatches) roadmapPhaseCount = phaseMatches.length;
} catch { /* no roadmap */ }
let status = 'unknown';
let currentPhase: string | null = null;
try {
const stateContent = readFileSync(join(wsDir, 'STATE.md'), 'utf-8');
status = stateExtractField(stateContent, 'Status') || 'unknown';
currentPhase = stateExtractField(stateContent, 'Current Phase');
} catch { /* skip */ }
workstreams.push({
name: entry.name,
active: entry.name === active,
status,
current_phase: currentPhase,
phases: `${completedCount}/${roadmapPhaseCount}`,
plans: `${completedPlans}/${totalPlans}`,
progress_percent:
roadmapPhaseCount > 0 ? Math.round((completedCount / roadmapPhaseCount) * 100) : 0,
});
}
return {
data: {
mode: 'workstream',
active,
workstreams,
count: workstreams.length,
active: inventory.active,
workstreams: inventory.workstreams.map(ws => ({
name: ws.name,
active: ws.active,
status: ws.status,
current_phase: ws.current_phase,
phases: `${ws.completed_phases}/${ws.roadmap_phase_count}`,
plans: `${ws.completed_plans}/${ws.total_plans}`,
progress_percent: ws.progress_percent,
})),
count: inventory.count,
},
};
};