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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
35
sdk/src/query/plan-scan.test.ts
Normal file
35
sdk/src/query/plan-scan.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
82
sdk/src/query/plan-scan.ts
Normal file
82
sdk/src/query/plan-scan.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
129
sdk/src/query/state-document.ts
Normal file
129
sdk/src/query/state-document.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
195
sdk/src/query/workstream-inventory.ts
Normal file
195
sdk/src/query/workstream-inventory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user