Files
msd-core/sdk/src/query/workstream.ts
Tom Boucher bdba40cc3d fix(#2618): thread --ws through query dispatch and sync root STATE.md on workstream.set (#2627)
* fix(#2618): thread --ws through query dispatch for state and init handlers

Gap 1 of #2618: the query dispatcher already accepts a workstream via
registry.dispatch(cmd, args, projectDir, ws), but several handlers drop it
before reaching planningPaths() / getMilestoneInfo() / findPhase() — so
stateJson and the init.* handlers return root-scoped results even when --ws
is provided.

Changes:

- sdk/src/query/state.ts: forward workstream into getMilestoneInfo() and
  extractCurrentMilestone() so buildStateFrontmatter resolves milestone data
  from the workstream ROADMAP/STATE instead of the root mirror.
- sdk/src/query/init.ts: thread workstream through initExecutePhase,
  initPlanPhase, initPhaseOp, and getPhaseInfoWithFallback (which fans out
  to findPhase() and roadmapGetPhase()). Also switch hardcoded
  join(projectDir, '.planning') to relPlanningPath(workstream) so returned
  state_path/roadmap_path/config_path reflect the workstream layout.

Regression test: stateJson with --ws workstream reads STATE.md from
.planning/workstreams/<name>/ when workstream is provided.

Closes #2618 (gap 1)

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

* fix(#2618): sync root .planning/STATE.md mirror on workstream.set

Gap 2 of #2618: setActiveWorkstream only flips the active-workstream
pointer file; the root .planning/STATE.md mirror stays stale. Downstream
consumers (statusline, gsd-sdk query progress, any tool that reads the
root STATE.md) continue to see the previous workstream's state.

After setActiveWorkstream(), copy .planning/workstreams/<name>/STATE.md
verbatim to .planning/STATE.md via writeFileSync. The workstream STATE.md
is authoritative; the root file is a pass-through mirror. Missing source
STATE.md is a no-op rather than an error — a freshly created workstream
with no STATE.md yet should still activate cleanly.

The response now includes `mirror_synced: boolean` so callers can
observe whether the root mirror was updated.

Regression test: workstreamSet root STATE.md mirror sync — switches
from a stale root mirror to a workstream STATE.md with different
frontmatter and asserts the root file now matches.

Closes #2618 (gap 2)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 11:54:34 -04:00

455 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Workstream query handlers — list, get, create, set, status, complete, progress.
*
* Ported from get-shit-done/bin/lib/workstream.cjs.
* Manages .planning/workstreams/ directory for multi-workstream projects.
*
* @example
* ```typescript
* import { workstreamList, workstreamCreate } from './workstream.js';
*
* await workstreamList([], '/project');
* // { data: { workstreams: ['backend', 'frontend'], count: 2 } }
*
* await workstreamCreate(['api'], '/project');
* // { data: { created: true, name: 'api', path: '.planning/workstreams/api' } }
* ```
*/
import {
existsSync, readdirSync, readFileSync, writeFileSync,
mkdirSync, renameSync, rmdirSync, unlinkSync,
} from 'node:fs';
import { join, relative } from 'node:path';
import { toPosixPath, stateExtractField } from './helpers.js';
import { GSDError, ErrorClassification } from '../errors.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');
}
function getActiveWorkstream(projectDir: string): string | null {
const filePath = join(planningRoot(projectDir), 'active-workstream');
try {
const name = readFileSync(filePath, 'utf-8').trim();
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return null;
}
const wsDir = join(workstreamsDir(projectDir), name);
if (!existsSync(wsDir)) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return null;
}
return name;
} catch {
return null;
}
}
function setActiveWorkstream(projectDir: string, name: string | null): void {
const filePath = join(planningRoot(projectDir), 'active-workstream');
if (!name) {
try { unlinkSync(filePath); } catch { /* already gone */ }
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error('Invalid workstream name: must be alphanumeric, hyphens, and underscores only');
}
writeFileSync(filePath, name + '\n', 'utf-8');
}
// ─── Handlers ─────────────────────────────────────────────────────────────
/**
* Current active workstream and mode (flat vs workstream).
*
* Port of `cmdWorkstreamGet` from `workstream.cjs` lines 367–371.
*/
export const workstreamGet: QueryHandler = async (_args, projectDir) => {
const active = getActiveWorkstream(projectDir);
const wsRoot = workstreamsDir(projectDir);
return {
data: {
active,
mode: existsSync(wsRoot) ? 'workstream' : 'flat',
},
};
};
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 } };
}
};
export const workstreamCreate: QueryHandler = async (args, projectDir) => {
const rawName = args[0];
if (!rawName) return { data: { created: false, reason: 'name required' } };
if (rawName.includes('/') || rawName.includes('\\') || rawName.includes('..')) {
return { data: { created: false, reason: 'invalid workstream name — path separators not allowed' } };
}
const slug = rawName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!slug) return { data: { created: false, reason: 'invalid workstream name — must contain at least one alphanumeric character' } };
const baseDir = planningRoot(projectDir);
if (!existsSync(baseDir)) {
return { data: { created: false, reason: '.planning/ directory not found — run /gsd-new-project first' } };
}
const wsRoot = workstreamsDir(projectDir);
const wsDir = join(wsRoot, slug);
if (existsSync(wsDir) && existsSync(join(wsDir, 'STATE.md'))) {
return { data: { created: false, error: 'already_exists', workstream: slug, path: toPosixPath(relative(projectDir, wsDir)) } };
}
mkdirSync(wsDir, { recursive: true });
mkdirSync(join(wsDir, 'phases'), { recursive: true });
const today = new Date().toISOString().split('T')[0];
const stateContent = [
'---',
`workstream: ${slug}`,
`created: ${today}`,
'---',
'',
'# Project State',
'',
'## Current Position',
'**Status:** Not started',
'**Current Phase:** None',
`**Last Activity:** ${today}`,
'**Last Activity Description:** Workstream created',
'',
'## Progress',
'**Phases Complete:** 0',
'**Current Plan:** N/A',
'',
'## Session Continuity',
'**Stopped At:** N/A',
'**Resume File:** None',
'',
].join('\n');
const statePath = join(wsDir, 'STATE.md');
if (!existsSync(statePath)) {
writeFileSync(statePath, stateContent, 'utf-8');
}
setActiveWorkstream(projectDir, slug);
const relPath = toPosixPath(relative(projectDir, wsDir));
return {
data: {
created: true,
workstream: slug,
path: relPath,
state_path: relPath + '/STATE.md',
phases_path: relPath + '/phases',
active: true,
},
};
};
/**
* Rewrite the root `.planning/STATE.md` to mirror the active workstream's STATE.md.
*
* Fixes #2618 gap 2 — downstream consumers (statusline, progress, any tool that
* reads the root mirror) must see the new workstream's state immediately after a
* switch. The workstream STATE.md is authoritative; the root file is a
* pass-through copy. We write content verbatim (atomic write via writeFileSync)
* 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 rootStatePath = join(planningRoot(projectDir), 'STATE.md');
if (!existsSync(wsStatePath)) return;
try {
const content = readFileSync(wsStatePath, 'utf-8');
writeFileSync(rootStatePath, content, 'utf-8');
} catch { /* best-effort mirror; do not fail the switch */ }
}
export const workstreamSet: QueryHandler = async (args, projectDir) => {
const name = args[0];
if (!name || name === '--clear') {
if (name !== '--clear') {
return { data: { set: false, reason: 'name required. Usage: workstream set <name> (or workstream set --clear to unset)' } };
}
const previous = getActiveWorkstream(projectDir);
setActiveWorkstream(projectDir, null);
return { data: { active: null, cleared: true, previous: previous || null } };
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
return { data: { active: null, error: 'invalid_name', message: 'Workstream name must be alphanumeric, hyphens, and underscores only' } };
}
const wsDir = join(workstreamsDir(projectDir), name);
if (!existsSync(wsDir)) {
return { data: { active: null, error: 'not_found', workstream: name } };
}
setActiveWorkstream(projectDir, name);
syncRootStateMirror(projectDir, name);
return { data: { active: name, set: true, mirror_synced: existsSync(join(wsDir, 'STATE.md')) } };
};
export const workstreamStatus: QueryHandler = async (args, projectDir) => {
const name = args[0];
if (!name) {
throw new GSDError('workstream name required. Usage: workstream status <name>', ErrorClassification.Validation);
}
if (/[/\\]/.test(name) || name === '.' || name === '..') {
throw new GSDError('Invalid workstream name', ErrorClassification.Validation);
}
const wsDir = join(workstreamsDir(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 */ }
return {
data: {
found: true,
workstream: name,
path: relPath,
files,
phases,
phase_count: phases.length,
completed_phases: phases.filter(ph => ph.status === 'complete').length,
...stateInfo,
},
};
};
export const workstreamComplete: QueryHandler = async (args, projectDir) => {
const name = args[0];
if (!name) return { data: { completed: false, reason: 'workstream name required' } };
if (/[/\\]/.test(name) || name === '.' || name === '..') {
return { data: { completed: false, reason: 'invalid workstream name' } };
}
const root = planningRoot(projectDir);
const wsRoot = workstreamsDir(projectDir);
const wsDir = join(wsRoot, name);
if (!existsSync(wsDir)) {
return { data: { completed: false, error: 'not_found', workstream: name } };
}
const active = getActiveWorkstream(projectDir);
if (active === name) setActiveWorkstream(projectDir, null);
const archiveDir = join(root, 'milestones');
const today = new Date().toISOString().split('T')[0];
let archivePath = join(archiveDir, `ws-${name}-${today}`);
let suffix = 1;
while (existsSync(archivePath)) {
archivePath = join(archiveDir, `ws-${name}-${today}-${suffix++}`);
}
mkdirSync(archivePath, { recursive: true });
const filesMoved: string[] = [];
try {
const entries = readdirSync(wsDir, { withFileTypes: true });
for (const entry of entries) {
renameSync(join(wsDir, entry.name), join(archivePath, entry.name));
filesMoved.push(entry.name);
}
} catch (err) {
for (const fname of filesMoved) {
try { renameSync(join(archivePath, fname), join(wsDir, fname)); } catch { /* rollback */ }
}
try { rmdirSync(archivePath); } catch { /* cleanup */ }
if (active === name) setActiveWorkstream(projectDir, name);
return { data: { completed: false, error: 'archive_failed', message: String(err), workstream: name } };
}
try { rmdirSync(wsDir); } catch { /* may not be empty */ }
let remainingWs = 0;
try {
remainingWs = readdirSync(wsRoot, { withFileTypes: true })
.filter(e => e.isDirectory()).length;
if (remainingWs === 0) rmdirSync(wsRoot);
} catch { /* best-effort */ }
return {
data: {
completed: true,
workstream: name,
archived_to: toPosixPath(relative(projectDir, archivePath)),
remaining_workstreams: remainingWs,
reverted_to_flat: remainingWs === 0,
},
};
};
/**
* Port of `cmdWorkstreamProgress` from `workstream.cjs` — aggregate status for each workstream.
* (Not the same as roadmap `progress` / `progressBar`.)
*/
export const workstreamProgress: QueryHandler = async (_args, projectDir) => {
const wsRoot = workstreamsDir(projectDir);
if (!existsSync(wsRoot)) {
return {
data: {
mode: 'flat',
workstreams: [],
message: 'No workstreams — operating in flat mode',
},
};
}
const active = getActiveWorkstream(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,
},
};
};