* 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>
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
/**
|
||
* 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,
|
||
},
|
||
};
|
||
};
|