diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index aaa6b341d..33cee0bb0 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -28,6 +28,7 @@ import { resolveModel, MODEL_PROFILES } from './config-query.js'; import { findPhase } from './phase.js'; import { roadmapGetPhase, getMilestoneInfo } from './roadmap.js'; import { planningPaths, normalizePhaseName, toPosixPath, resolveAgentsDir, detectRuntime } from './helpers.js'; +import { relPlanningPath } from '../workstream-utils.js'; import type { QueryHandler } from './utils.js'; // ─── Internal helpers ────────────────────────────────────────────────────── @@ -116,15 +117,16 @@ function checkAgentsInstalled(config?: { runtime?: unknown }): { agents_installe async function getPhaseInfoWithFallback( phase: string, projectDir: string, + workstream?: string, ): Promise<{ phaseInfo: Record | null; roadmapPhase: Record | null }> { - const phaseResult = await findPhase([phase], projectDir); + const phaseResult = await findPhase([phase], projectDir, workstream); let phaseInfo = phaseResult.data as Record | null; // findPhase returns { found: false } when missing; findPhaseInternal returns null — align for init parity. if (phaseInfo && phaseInfo.found === false) { phaseInfo = null; } - const roadmapResult = await roadmapGetPhase([phase], projectDir); + const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); const roadmapPhase = roadmapResult.data as Record | null; // Match init.cjs: drop archived disk match when the phase is listed in the current ROADMAP @@ -264,16 +266,16 @@ export function withProjectRoot( * Init handler for execute-phase workflow. * Port of cmdInitExecutePhase from init.cjs lines 50-171. */ -export const initExecutePhase: QueryHandler = async (args, projectDir) => { +export const initExecutePhase: QueryHandler = async (args, projectDir, workstream) => { const phase = args[0]; if (!phase) { return { data: { error: 'phase required for init execute-phase' } }; } const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); + const planningDir = join(projectDir, relPlanningPath(workstream)); - const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir); + const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream); const phase_req_ids = extractReqIds(roadmapPhase); const [executorModel, verifierModel] = await Promise.all([ @@ -281,7 +283,7 @@ export const initExecutePhase: QueryHandler = async (args, projectDir) => { getModelAlias('gsd-verifier', projectDir), ]); - const milestone = await getMilestoneInfo(projectDir); + const milestone = await getMilestoneInfo(projectDir, workstream); const phaseNumber = (phaseInfo?.phase_number as string) || null; const phaseSlug = (phaseInfo?.phase_slug as string) || null; @@ -343,16 +345,16 @@ export const initExecutePhase: QueryHandler = async (args, projectDir) => { * Init handler for plan-phase workflow. * Port of cmdInitPlanPhase from init.cjs lines 173-293. */ -export const initPlanPhase: QueryHandler = async (args, projectDir) => { +export const initPlanPhase: QueryHandler = async (args, projectDir, workstream) => { const phase = args[0]; if (!phase) { return { data: { error: 'phase required for init plan-phase' } }; } const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); + const planningDir = join(projectDir, relPlanningPath(workstream)); - const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir); + const { phaseInfo, roadmapPhase } = await getPhaseInfoWithFallback(phase, projectDir, workstream); const phase_req_ids = extractReqIds(roadmapPhase); const [researcherModel, plannerModel, checkerModel] = await Promise.all([ @@ -603,20 +605,20 @@ export const initVerifyWork: QueryHandler = async (args, projectDir) => { * Init handler for discuss-phase and similar phase operations. * Port of cmdInitPhaseOp from init.cjs lines 588-697. */ -export const initPhaseOp: QueryHandler = async (args, projectDir) => { +export const initPhaseOp: QueryHandler = async (args, projectDir, workstream) => { const phase = args[0]; if (!phase) { return { data: { error: 'phase required for init phase-op' } }; } const config = await loadConfig(projectDir); - const planningDir = join(projectDir, '.planning'); + const planningDir = join(projectDir, relPlanningPath(workstream)); // findPhase with archived override: if only match is archived, prefer ROADMAP - const phaseResult = await findPhase([phase], projectDir); + const phaseResult = await findPhase([phase], projectDir, workstream); let phaseInfo = phaseResult.data as Record | null; - const roadmapResult = await roadmapGetPhase([phase], projectDir); + const roadmapResult = await roadmapGetPhase([phase], projectDir, workstream); const roadmapPhase = roadmapResult.data as Record | null; // If the only match comes from an archived milestone, prefer current ROADMAP diff --git a/sdk/src/query/state.test.ts b/sdk/src/query/state.test.ts index 1f86f01d0..09c7eeb04 100644 --- a/sdk/src/query/state.test.ts +++ b/sdk/src/query/state.test.ts @@ -345,3 +345,37 @@ describe('stateSnapshot', () => { } }); }); + +// ─── Regression: --ws propagation (#2618 gap 1) ──────────────────────────── + +describe('stateJson with --ws workstream', () => { + it('reads STATE.md from .planning/workstreams// when workstream is provided', async () => { + // Build a workstream-scoped layout alongside the default .planning/STATE.md + const wsName = 'example-ws'; + const wsDir = join(tmpDir, '.planning', 'workstreams', wsName); + await mkdir(join(wsDir, 'phases'), { recursive: true }); + + const wsState = `--- +gsd_state_version: 1.0 +milestone: ws-1.0 +milestone_name: Workstream Marker +status: planning +--- + +# Project State + +Status: planning +`; + await writeFile(join(wsDir, 'STATE.md'), wsState); + await writeFile(join(wsDir, 'ROADMAP.md'), '# Roadmap\n'); + + // Root STATE.md still has the old values (SDK-First Migration). + // When --ws is threaded, stateJson must read the workstream STATE.md, not the root. + const result = await stateJson([], tmpDir, wsName); + const data = result.data as Record; + + expect(data.milestone).toBe('ws-1.0'); + expect(data.milestone_name).toBe('Workstream Marker'); + expect(data.status).toBe('planning'); + }); +}); diff --git a/sdk/src/query/state.ts b/sdk/src/query/state.ts index 2125841b1..7d31ac725 100644 --- a/sdk/src/query/state.ts +++ b/sdk/src/query/state.ts @@ -38,7 +38,7 @@ export async function getMilestonePhaseFilter(projectDir: string, workstream?: s const milestonePhaseNums = new Set(); try { const roadmapContent = await readFile(planningPaths(projectDir, workstream).roadmap, 'utf-8'); - const roadmap = await extractCurrentMilestone(roadmapContent, projectDir); + const roadmap = await extractCurrentMilestone(roadmapContent, projectDir, workstream); const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; let m: RegExpExecArray | null; while ((m = phasePattern.exec(roadmap)) !== null) { @@ -105,7 +105,7 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str let milestone: string | null = null; let milestoneName: string | null = null; try { - const info = await getMilestoneInfo(projectDir); + const info = await getMilestoneInfo(projectDir, workstream); milestone = info.version; milestoneName = info.name; } catch { /* intentionally empty */ } diff --git a/sdk/src/query/workstream.test.ts b/sdk/src/query/workstream.test.ts index 063f777d3..f92a072fa 100644 --- a/sdk/src/query/workstream.test.ts +++ b/sdk/src/query/workstream.test.ts @@ -7,7 +7,8 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { workstreamList, workstreamCreate } from './workstream.js'; +import { readFile } from 'node:fs/promises'; +import { workstreamList, workstreamCreate, workstreamSet } from './workstream.js'; describe('workstreamList', () => { let tmpDir: string; @@ -49,3 +50,76 @@ describe('workstreamCreate', () => { expect(data.created).toBe(true); }); }); + +describe('workstreamSet root STATE.md mirror sync (#2618 gap 2)', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'gsd-ws-set-')); + await mkdir(join(tmpDir, '.planning'), { recursive: true }); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + + // Root STATE.md with stale frontmatter (mirror of some prior workstream) + const rootState = [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v0.stale', + 'milestone_name: Stale Mirror', + 'active_workstream: old-ws', + 'current_phase: "99"', + 'status: completed', + 'last_updated: "2020-01-01T00:00:00.000Z"', + '---', + '', + '# Project State', + '', + ].join('\n'); + await writeFile(join(tmpDir, '.planning', 'STATE.md'), rootState); + + // Target workstream with different frontmatter + const wsDir = join(tmpDir, '.planning', 'workstreams', 'active-ws'); + await mkdir(wsDir, { recursive: true }); + const wsState = [ + '---', + 'gsd_state_version: 1.0', + 'milestone: v1.0', + 'milestone_name: Active Milestone', + 'active_workstream: active-ws', + 'current_phase: "3"', + 'status: executing', + 'last_updated: "2026-04-23T00:00:00.000Z"', + '---', + '', + '# Project State', + '', + 'Status: executing', + 'Current Phase: 3', + '', + ].join('\n'); + await writeFile(join(wsDir, 'STATE.md'), wsState); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('rewrites root .planning/STATE.md to mirror the new workstream STATE.md on switch', async () => { + const r = await workstreamSet(['active-ws'], tmpDir); + const data = r.data as Record; + expect(data.set).toBe(true); + expect(data.active).toBe('active-ws'); + + const rootStateAfter = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8'); + // The stale mirror fields must be gone; new workstream fields must be present. + expect(rootStateAfter).toContain('milestone: v1.0'); + expect(rootStateAfter).toContain('milestone_name: Active Milestone'); + expect(rootStateAfter).toContain('active_workstream: active-ws'); + expect(rootStateAfter).toContain('current_phase:'); + expect(rootStateAfter).toContain('status: executing'); + expect(rootStateAfter).not.toContain('milestone: v0.stale'); + expect(rootStateAfter).not.toContain('Stale Mirror'); + }); +}); diff --git a/sdk/src/query/workstream.ts b/sdk/src/query/workstream.ts index afe5861c8..0020a9264 100644 --- a/sdk/src/query/workstream.ts +++ b/sdk/src/query/workstream.ts @@ -189,6 +189,25 @@ export const workstreamCreate: QueryHandler = async (args, projectDir) => { }; }; +/** + * 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]; @@ -211,7 +230,8 @@ export const workstreamSet: QueryHandler = async (args, projectDir) => { } setActiveWorkstream(projectDir, name); - return { data: { active: name, set: true } }; + syncRootStateMirror(projectDir, name); + return { data: { active: name, set: true, mirror_synced: existsSync(join(wsDir, 'STATE.md')) } }; }; export const workstreamStatus: QueryHandler = async (args, projectDir) => {