From 5676e2e4efaf228514be32b3202523bf29606e08 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 21 Apr 2026 20:33:24 -0400 Subject: [PATCH] fix(sdk): forward --ws workstream flag through query dispatch (#2546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sdk): forward --ws workstream flag through query dispatch (closes #2524) - cli.ts: pass args.ws as workstream to registry.dispatch() - registry.ts: add workstream? param to dispatch(), thread to handler - utils.ts: add optional workstream? to QueryHandler type signature - helpers.ts: planningPaths() accepts workstream? and uses relPlanningPath() - All ~26 query handlers updated to receive and pass workstream to planningPaths() - Config/commit/intel handlers use _workstream (project-global, not scoped) - Add failing-then-passing test: tests/bug-2524-sdk-query-ws-flag.test.cjs Co-Authored-By: Claude Sonnet 4.6 * fix(sdk): forward workstream to all downstream query helpers Co-Authored-By: Claude Sonnet 4.6 * fix(test): rewrite #2524 test as static source assertions — no sdk/dist build in CI Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- sdk/src/cli.ts | 2 +- sdk/src/query/audit-open.ts | 8 +- sdk/src/query/check-gates.ts | 6 +- sdk/src/query/commit.ts | 6 +- sdk/src/query/config-mutation.ts | 8 +- sdk/src/query/config-query.ts | 4 +- sdk/src/query/detect-phase-type.ts | 12 +- sdk/src/query/helpers.ts | 9 +- sdk/src/query/init-complex.ts | 6 +- sdk/src/query/intel.ts | 16 +-- sdk/src/query/phase-lifecycle.ts | 91 ++++++++------- sdk/src/query/phase-list-queries.ts | 12 +- sdk/src/query/phase-ready.ts | 13 ++- sdk/src/query/phase.ts | 11 +- sdk/src/query/profile.ts | 4 +- sdk/src/query/progress.ts | 34 +++--- sdk/src/query/registry.ts | 5 +- .../query/requirements-extract-from-plans.ts | 8 +- sdk/src/query/roadmap-update-plan-progress.ts | 8 +- sdk/src/query/roadmap.ts | 34 +++--- sdk/src/query/route-next-action.ts | 16 +-- sdk/src/query/state-mutation.ts | 78 ++++++------- sdk/src/query/state-project-load.ts | 4 +- sdk/src/query/state.ts | 24 ++-- sdk/src/query/summary.ts | 4 +- sdk/src/query/template.ts | 4 +- sdk/src/query/uat.ts | 6 +- sdk/src/query/utils.ts | 2 +- sdk/src/query/validate.ts | 6 +- sdk/src/query/verify.ts | 8 +- tests/bug-2524-sdk-query-ws-flag.test.cjs | 107 ++++++++++++++++++ 31 files changed, 337 insertions(+), 219 deletions(-) create mode 100644 tests/bug-2524-sdk-query-ws-flag.test.cjs diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts index a809e5e46..d68f2ed38 100644 --- a/sdk/src/cli.ts +++ b/sdk/src/cli.ts @@ -408,7 +408,7 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true }]; } @@ -707,9 +707,9 @@ export function formatAuditReport(auditResult: AuditOpenResult): string { /** * `audit-open` / `audit.open` — optional `--json` for structured JSON only (default adds formatted report string). */ -export const auditOpen: QueryHandler = async (args, projectDir) => { +export const auditOpen: QueryHandler = async (args, projectDir, workstream) => { const jsonOnly = args.includes('--json'); - const result = auditOpenArtifacts(projectDir); + const result = auditOpenArtifacts(projectDir, workstream); if (jsonOnly) { return { data: result }; } diff --git a/sdk/src/query/check-gates.ts b/sdk/src/query/check-gates.ts index e010fdbbd..db694ba84 100644 --- a/sdk/src/query/check-gates.ts +++ b/sdk/src/query/check-gates.ts @@ -36,7 +36,7 @@ async function readFileSafe(filePath: string): Promise { } } -export const checkGates: QueryHandler = async (args, projectDir) => { +export const checkGates: QueryHandler = async (args, projectDir, workstream) => { const workflow = args[0]; if (!workflow) { throw new GSDError('workflow name required for check gates', ErrorClassification.Validation); @@ -51,7 +51,7 @@ export const checkGates: QueryHandler = async (args, projectDir) => { const blockers: Blocker[] = []; const warnings: Warning[] = []; - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); // Gate 1: .continue-here.md in project root const continueHerePath = join(projectDir, '.continue-here.md'); @@ -82,7 +82,7 @@ export const checkGates: QueryHandler = async (args, projectDir) => { // Gate 3: Verification debt — check VERIFICATION.md in phase dir if phase provided if (phaseNum) { - const phaseRes = await findPhase([phaseNum], projectDir); + const phaseRes = await findPhase([phaseNum], projectDir, workstream); const pdata = phaseRes.data as Record; if (pdata.found && pdata.directory) { const phaseDirFull = join(projectDir, pdata.directory as string); diff --git a/sdk/src/query/commit.ts b/sdk/src/query/commit.ts index 7b66773ee..e477fe116 100644 --- a/sdk/src/query/commit.ts +++ b/sdk/src/query/commit.ts @@ -95,7 +95,7 @@ export function sanitizeCommitMessage(text: string): string { * @param projectDir - Project root directory * @returns QueryResult with commit result */ -export const commit: QueryHandler = async (args, projectDir) => { +export const commit: QueryHandler = async (args, projectDir, _workstream) => { const allArgs = [...args]; // Extract flags @@ -180,7 +180,7 @@ export const commit: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { can_commit, reason, commit_docs, staged_files } */ -export const checkCommit: QueryHandler = async (_args, projectDir) => { +export const checkCommit: QueryHandler = async (_args, projectDir, _workstream) => { const paths = planningPaths(projectDir); let commitDocs = true; @@ -227,7 +227,7 @@ export const checkCommit: QueryHandler = async (_args, projectDir) => { // ─── commitToSubrepo ───────────────────────────────────────────────────── -export const commitToSubrepo: QueryHandler = async (args, projectDir) => { +export const commitToSubrepo: QueryHandler = async (args, projectDir, _workstream) => { const filesIdx = args.indexOf('--files'); const endIdx = filesIdx >= 0 ? filesIdx : args.length; const knownFlags = new Set(['--force', '--amend', '--no-verify']); diff --git a/sdk/src/query/config-mutation.ts b/sdk/src/query/config-mutation.ts index 52e22edab..1a7df4319 100644 --- a/sdk/src/query/config-mutation.ts +++ b/sdk/src/query/config-mutation.ts @@ -215,7 +215,7 @@ function setConfigValue(obj: Record, dotPath: string, value: un * @returns QueryResult matching gsd-tools `config-set` JSON: `{ updated, key, value, previousValue }` * @throws GSDError with Validation if key is invalid or args missing */ -export const configSet: QueryHandler = async (args, projectDir) => { +export const configSet: QueryHandler = async (args, projectDir, _workstream) => { const keyPath = args[0]; const rawValue = args[1]; if (!keyPath) { @@ -284,7 +284,7 @@ export const configSet: QueryHandler = async (args, projectDir) => { * @returns QueryResult with { set: true, profile, agents } * @throws GSDError with Validation if profile is invalid */ -export const configSetModelProfile: QueryHandler = async (args, projectDir) => { +export const configSetModelProfile: QueryHandler = async (args, projectDir, _workstream) => { const profileName = args[0]; if (!profileName) { throw new GSDError( @@ -346,7 +346,7 @@ export const configSetModelProfile: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { created: true, path } or { created: false, reason } */ -export const configNewProject: QueryHandler = async (args, projectDir) => { +export const configNewProject: QueryHandler = async (args, projectDir, _workstream) => { const paths = planningPaths(projectDir); // Idempotent: don't overwrite existing config @@ -472,7 +472,7 @@ export const configNewProject: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { ensured: true, section } */ -export const configEnsureSection: QueryHandler = async (args, projectDir) => { +export const configEnsureSection: QueryHandler = async (args, projectDir, _workstream) => { const sectionName = args[0]; if (!sectionName) { throw new GSDError('Usage: config-ensure-section
', ErrorClassification.Validation); diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index fdcb8dbe6..f00b9a04c 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -79,7 +79,7 @@ export function getAgentToModelMapForProfile(normalizedProfile: string): Record< * @returns QueryResult with the config value at the given path * @throws GSDError with Validation classification if key missing or not found */ -export const configGet: QueryHandler = async (args, projectDir) => { +export const configGet: QueryHandler = async (args, projectDir, _workstream) => { const keyPath = args[0]; if (!keyPath) { throw new GSDError('Usage: config-get ', ErrorClassification.Validation); @@ -127,7 +127,7 @@ export const configGet: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with `{ path: string }` absolute or project-relative resolution via planningPaths */ -export const configPath: QueryHandler = async (_args, projectDir) => { +export const configPath: QueryHandler = async (_args, projectDir, _workstream) => { const paths = planningPaths(projectDir); return { data: { path: paths.config } }; }; diff --git a/sdk/src/query/detect-phase-type.ts b/sdk/src/query/detect-phase-type.ts index d882e47db..7b0bc0903 100644 --- a/sdk/src/query/detect-phase-type.ts +++ b/sdk/src/query/detect-phase-type.ts @@ -21,8 +21,8 @@ const API_INDICATOR_RE = /route\.ts|controller\.|api\//i; const API_HEADING_RE = /\bAPI\b|endpoint|REST|GraphQL/i; const INFRA_RE = /docker|terraform|k8s|helm|infra/i; -async function roadmapHeadingForPhase(projectDir: string, phaseNum: string): Promise { - const roadmapPath = planningPaths(projectDir).roadmap; +async function roadmapHeadingForPhase(projectDir: string, phaseNum: string, workstream?: string): Promise { + const roadmapPath = planningPaths(projectDir, workstream).roadmap; let content: string; try { content = await readFile(roadmapPath, 'utf-8'); @@ -34,14 +34,14 @@ async function roadmapHeadingForPhase(projectDir: string, phaseNum: string): Pro return m ? m[0] : null; } -export const detectPhaseType: QueryHandler = async (args, projectDir) => { +export const detectPhaseType: QueryHandler = async (args, projectDir, workstream) => { const raw = args[0]; if (!raw) { throw new GSDError('phase number required for detect phase-type', ErrorClassification.Validation); } const phaseArg = normalizePhaseName(raw); - const phaseRes = await findPhase([raw], projectDir); + const phaseRes = await findPhase([raw], projectDir, workstream); const pdata = phaseRes.data as Record; const found = Boolean(pdata.found); @@ -54,9 +54,9 @@ export const detectPhaseType: QueryHandler = async (args, projectDir) => { const phaseNumForRoadmap = (pdata.phase_number as string) || phaseArg; // Read ROADMAP heading — try both normalized forms - let heading = await roadmapHeadingForPhase(projectDir, phaseNumForRoadmap); + let heading = await roadmapHeadingForPhase(projectDir, phaseNumForRoadmap, workstream); if (!heading && phaseNumForRoadmap !== phaseArg) { - heading = await roadmapHeadingForPhase(projectDir, phaseArg); + heading = await roadmapHeadingForPhase(projectDir, phaseArg, workstream); } // Frontend detection diff --git a/sdk/src/query/helpers.ts b/sdk/src/query/helpers.ts index a67af2a2e..3e5272c8d 100644 --- a/sdk/src/query/helpers.ts +++ b/sdk/src/query/helpers.ts @@ -21,6 +21,7 @@ import { join, dirname, relative, resolve, isAbsolute, normalize } from 'node:pa import { realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; import { GSDError, ErrorClassification } from '../errors.js'; +import { relPlanningPath } from '../workstream-utils.js'; // ─── Runtime-aware agents directory resolution ───────────────────────────── @@ -406,14 +407,16 @@ export function normalizeMd(content: string): string { /** * Get common .planning file paths for a project directory. * - * Simplified version (no workstream/project env vars). + * When `workstream` is provided, all paths are rooted under + * `.planning/workstreams/` instead of `.planning`. * All paths returned in POSIX format. * * @param projectDir - Root project directory + * @param workstream - Optional workstream name (see relPlanningPath) * @returns Object with paths to common .planning files */ -export function planningPaths(projectDir: string): PlanningPaths { - const base = join(projectDir, '.planning'); +export function planningPaths(projectDir: string, workstream?: string): PlanningPaths { + const base = join(projectDir, relPlanningPath(workstream)); return { planning: toPosixPath(base), state: toPosixPath(join(base, 'STATE.md')), diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index a15112ad7..5676e3422 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -58,7 +58,7 @@ function pathExists(base: string, relPath: string): boolean { * * Port of cmdInitNewProject from init.cjs lines 296-399. */ -export const initNewProject: QueryHandler = async (_args, projectDir) => { +export const initNewProject: QueryHandler = async (_args, projectDir, _workstream) => { const config = await loadConfig(projectDir); // Detect search API key availability from env vars and ~/.gsd/ files @@ -174,7 +174,7 @@ export const initNewProject: QueryHandler = async (_args, projectDir) => { * * Port of cmdInitProgress from init.cjs lines 1139-1284. */ -export const initProgress: QueryHandler = async (_args, projectDir) => { +export const initProgress: QueryHandler = async (_args, projectDir, _workstream) => { const config = await loadConfig(projectDir); const milestone = await getMilestoneInfo(projectDir); const paths = planningPaths(projectDir); @@ -322,7 +322,7 @@ export const initProgress: QueryHandler = async (_args, projectDir) => { * * Port of cmdInitManager from init.cjs lines 854-1137. */ -export const initManager: QueryHandler = async (_args, projectDir) => { +export const initManager: QueryHandler = async (_args, projectDir, _workstream) => { const config = await loadConfig(projectDir); const milestone = await getMilestoneInfo(projectDir); const paths = planningPaths(projectDir); diff --git a/sdk/src/query/intel.ts b/sdk/src/query/intel.ts index 99905e3fc..5c24ad69c 100644 --- a/sdk/src/query/intel.ts +++ b/sdk/src/query/intel.ts @@ -118,7 +118,7 @@ function searchArchMd(filePath: string, term: string): string[] { const INTEL_DISABLED_MSG = 'Intel system disabled. Set intel.enabled=true in config.json to activate.'; -export const intelStatus: QueryHandler = async (_args, projectDir) => { +export const intelStatus: QueryHandler = async (_args, projectDir, _workstream) => { if (!isIntelEnabled(projectDir)) { return { data: { disabled: true, message: INTEL_DISABLED_MSG } }; } @@ -149,7 +149,7 @@ export const intelStatus: QueryHandler = async (_args, projectDir) => { return { data: { files, overall_stale: overallStale } }; }; -export const intelDiff: QueryHandler = async (_args, projectDir) => { +export const intelDiff: QueryHandler = async (_args, projectDir, _workstream) => { if (!isIntelEnabled(projectDir)) { return { data: { disabled: true, message: INTEL_DISABLED_MSG } }; } @@ -172,7 +172,7 @@ export const intelDiff: QueryHandler = async (_args, projectDir) => { return { data: { changed, added, removed } }; }; -export const intelSnapshot: QueryHandler = async (_args, projectDir) => { +export const intelSnapshot: QueryHandler = async (_args, projectDir, _workstream) => { if (!isIntelEnabled(projectDir)) { return { data: { disabled: true, message: INTEL_DISABLED_MSG } }; } @@ -192,7 +192,7 @@ export const intelSnapshot: QueryHandler = async (_args, projectDir) => { return { data: { saved: true, timestamp, files: fileCount } }; }; -export const intelValidate: QueryHandler = async (_args, projectDir) => { +export const intelValidate: QueryHandler = async (_args, projectDir, _workstream) => { if (!isIntelEnabled(projectDir)) { return { data: { disabled: true, message: INTEL_DISABLED_MSG } }; } @@ -219,7 +219,7 @@ export const intelValidate: QueryHandler = async (_args, projectDir) => { return { data: { valid: errors.length === 0, errors, warnings } }; }; -export const intelQuery: QueryHandler = async (args, projectDir) => { +export const intelQuery: QueryHandler = async (args, projectDir, _workstream) => { const term = args[0] || ''; if (!isIntelEnabled(projectDir)) { return { data: { disabled: true, message: INTEL_DISABLED_MSG } }; @@ -247,7 +247,7 @@ export const intelQuery: QueryHandler = async (args, projectDir) => { * Extract exports from a JS/CJS/ESM file — port of `intelExtractExports` in `intel.cjs` (lines 502–614). * Returns `{ file, exports, method }` with `file` as a resolved absolute path (matches `gsd-tools.cjs`). */ -export const intelExtractExports: QueryHandler = async (args, projectDir) => { +export const intelExtractExports: QueryHandler = async (args, projectDir, _workstream) => { const raw = args[0]; if (!raw) { return { data: { file: '', exports: [], method: 'none' } }; @@ -351,7 +351,7 @@ export const intelExtractExports: QueryHandler = async (args, projectDir) => { return { data: { file: filePath, exports, method } }; }; -export const intelPatchMeta: QueryHandler = async (args, projectDir) => { +export const intelPatchMeta: QueryHandler = async (args, projectDir, _workstream) => { const raw = args[0]; if (!raw) { return { data: { patched: false, error: 'File not found' } }; @@ -391,7 +391,7 @@ export const intelPatchMeta: QueryHandler = async (args, projectDir) => { * * Port of `intelUpdate` from `intel.cjs` lines 314–321. */ -export const intelUpdate: QueryHandler = async (_args, projectDir) => { +export const intelUpdate: QueryHandler = async (_args, projectDir, _workstream) => { if (!isIntelEnabled(projectDir)) { return { data: { disabled: true, message: INTEL_DISABLED_MSG } }; } diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index 3d8649489..0e4ebb377 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -121,8 +121,9 @@ export function replaceInCurrentMilestone( export async function readModifyWriteRoadmapMd( projectDir: string, modifier: (content: string) => string | Promise, + workstream?: string, ): Promise { - const roadmapPath = planningPaths(projectDir).roadmap; + const roadmapPath = planningPaths(projectDir, workstream).roadmap; const lockPath = await acquireStateLock(roadmapPath); try { let content: string; @@ -152,14 +153,14 @@ export async function readModifyWriteRoadmapMd( * @param projectDir - Project root directory * @returns QueryResult with { phase_number, padded, name, slug, directory, naming_mode } */ -export const phaseAdd: QueryHandler = async (args, projectDir) => { +export const phaseAdd: QueryHandler = async (args, projectDir, workstream) => { const description = args[0]; if (!description) { throw new GSDError('description required for phase add', ErrorClassification.Validation); } assertNoNullBytes(description, 'description'); - const configPath = planningPaths(projectDir).config; + const configPath = planningPaths(projectDir, workstream).config; let config: Record = {}; try { config = JSON.parse(await readFile(configPath, 'utf-8')); @@ -206,7 +207,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => { assertSafePhaseDirName(dirName); - const dirPath = join(planningPaths(projectDir).phases, dirName); + const dirPath = join(planningPaths(projectDir, workstream).phases, dirName); // Create directory with .gitkeep so git tracks empty folders await mkdir(dirPath, { recursive: true }); @@ -224,7 +225,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => { return rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator); } return rawContent + phaseEntry; - }); + }, workstream); if (!dirName) { throw new GSDError('Phase directory name was not computed', ErrorClassification.Execution); @@ -238,7 +239,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => { padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId), name: description, slug, - directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir).phases, dirName))), + directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir, workstream).phases, dirName))), naming_mode: config.phase_naming || 'sequential', }; @@ -255,7 +256,7 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => { * * @param args - Either `--descriptions` followed by a JSON array string, or one description per arg (`--raw` ignored) */ -export const phaseAddBatch: QueryHandler = async (args, projectDir) => { +export const phaseAddBatch: QueryHandler = async (args, projectDir, workstream) => { let descriptions: string[]; const descIdx = args.indexOf('--descriptions'); if (descIdx !== -1 && args[descIdx + 1] !== undefined) { @@ -284,14 +285,14 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir) => { } } - const roadmapPath = planningPaths(projectDir).roadmap; + const roadmapPath = planningPaths(projectDir, workstream).roadmap; if (!existsSync(roadmapPath)) { throw new GSDError('ROADMAP.md not found', ErrorClassification.Validation); } let config: Record = {}; try { - config = JSON.parse(await readFile(planningPaths(projectDir).config, 'utf-8')); + config = JSON.parse(await readFile(planningPaths(projectDir, workstream).config, 'utf-8')); } catch { /* use defaults */ } const projectCode = (config.project_code as string) || ''; @@ -321,7 +322,7 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir) => { if (num > maxPhase) maxPhase = num; } - const phasesOnDisk = planningPaths(projectDir).phases; + const phasesOnDisk = planningPaths(projectDir, workstream).phases; if (existsSync(phasesOnDisk)) { const entries = await readdir(phasesOnDisk, { withFileTypes: true }); const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/; @@ -352,7 +353,7 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir) => { } assertSafePhaseDirName(dirName); - const dirPath = join(planningPaths(projectDir).phases, dirName); + const dirPath = join(planningPaths(projectDir, workstream).phases, dirName); await mkdir(dirPath, { recursive: true }); await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8'); @@ -373,13 +374,13 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir) => { padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId), name: description, slug, - directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir).phases, dirName))), + directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir, workstream).phases, dirName))), naming_mode: config.phase_naming || 'sequential', }); } return rawContent; - }); + }, workstream); return { data: { phases: added, count: added.length } }; }; @@ -397,7 +398,7 @@ export const phaseAddBatch: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { phase_number, after_phase, name, slug, directory } */ -export const phaseInsert: QueryHandler = async (args, projectDir) => { +export const phaseInsert: QueryHandler = async (args, projectDir, workstream) => { const afterPhase = args[0]; const description = args[1]; @@ -424,7 +425,7 @@ export const phaseInsert: QueryHandler = async (args, projectDir) => { } // Calculate next decimal by scanning both directories AND ROADMAP.md entries - const phasesDir = planningPaths(projectDir).phases; + const phasesDir = planningPaths(projectDir, workstream).phases; const normalizedBase = normalizePhaseName(afterPhase); const decimalSet = new Set(); @@ -453,7 +454,7 @@ export const phaseInsert: QueryHandler = async (args, projectDir) => { // Optional project code prefix let insertConfig: Record = {}; try { - insertConfig = JSON.parse(await readFile(planningPaths(projectDir).config, 'utf-8')); + insertConfig = JSON.parse(await readFile(planningPaths(projectDir, workstream).config, 'utf-8')); } catch { /* use defaults */ } const projectCode = (insertConfig.project_code as string) || ''; assertSafeProjectCode(projectCode); @@ -488,7 +489,7 @@ export const phaseInsert: QueryHandler = async (args, projectDir) => { } return rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx); - }); + }, workstream); if (!decimalPhase) { throw new GSDError('Decimal phase was not computed', ErrorClassification.Execution); @@ -502,7 +503,7 @@ export const phaseInsert: QueryHandler = async (args, projectDir) => { after_phase: afterPhase, name: description, slug, - directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir).phases, dirName))), + directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir, workstream).phases, dirName))), }; return { data: result }; @@ -518,8 +519,9 @@ export const phaseInsert: QueryHandler = async (args, projectDir) => { async function findPhaseDir( projectDir: string, phase: string, + workstream?: string, ): Promise<{ dirPath: string; dirName: string; phaseName: string | null } | null> { - const phasesDir = planningPaths(projectDir).phases; + const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); try { @@ -573,7 +575,7 @@ function normalizeScaffoldArgs(args: string[]): string[] { return [type, phase, ...(name !== undefined && name !== '' ? [name] : [])]; } -export const phaseScaffold: QueryHandler = async (args, projectDir) => { +export const phaseScaffold: QueryHandler = async (args, projectDir, workstream) => { const normalized = normalizeScaffoldArgs(args); const type = normalized[0]; const phase = normalized[1]; @@ -609,7 +611,7 @@ export const phaseScaffold: QueryHandler = async (args, projectDir) => { const slug = generateSlugInternal(name); const dirNameNew = `${padded}-${slug}`; assertSafePhaseDirName(dirNameNew, 'scaffold phase directory'); - const phasesParent = planningPaths(projectDir).phases; + const phasesParent = planningPaths(projectDir, workstream).phases; await mkdir(phasesParent, { recursive: true }); const dirPath = join(phasesParent, dirNameNew); await mkdir(dirPath, { recursive: true }); @@ -624,7 +626,7 @@ export const phaseScaffold: QueryHandler = async (args, projectDir) => { } // For context/uat/verification types, find the phase directory - const phaseInfo = phase ? await findPhaseDir(projectDir, phase) : null; + const phaseInfo = phase ? await findPhaseDir(projectDir, phase, workstream) : null; if (phase && !phaseInfo) { throw new GSDError(`Phase ${phase} directory not found`, ErrorClassification.Blocked); } @@ -819,6 +821,7 @@ async function updateRoadmapAfterPhaseRemoval( targetPhase: string, isDecimal: boolean, removedInt: number, + workstream?: string, ): Promise { await readModifyWriteRoadmapMd(projectDir, (content) => { const escaped = escapeRegex(targetPhase); @@ -884,7 +887,7 @@ async function updateRoadmapAfterPhaseRemoval( } return content; - }); + }, workstream); } // ─── phaseRemove handler ─────────────────────────────────────────────── @@ -901,14 +904,14 @@ async function updateRoadmapAfterPhaseRemoval( * @param projectDir - Project root directory * @returns QueryResult with { removed, directory_deleted, renamed_directories, renamed_files, roadmap_updated, state_updated } */ -export const phaseRemove: QueryHandler = async (args, projectDir) => { +export const phaseRemove: QueryHandler = async (args, projectDir, workstream) => { const targetPhase = args[0]; if (!targetPhase) { throw new GSDError('phase number required for phase remove', ErrorClassification.Validation); } assertNoNullBytes(targetPhase, 'targetPhase'); - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const phasesDir = paths.phases; if (!existsSync(paths.roadmap)) { @@ -964,7 +967,7 @@ export const phaseRemove: QueryHandler = async (args, projectDir) => { } catch { /* intentionally empty — renaming is best-effort */ } // Update ROADMAP.md - await updateRoadmapAfterPhaseRemoval(projectDir, targetPhase, isDecimal, parseInt(normalized, 10)); + await updateRoadmapAfterPhaseRemoval(projectDir, targetPhase, isDecimal, parseInt(normalized, 10), workstream); // Update STATE.md: decrement total_phases let stateUpdated = false; @@ -1109,18 +1112,18 @@ function updatePerformanceMetricsSection( * @param projectDir - Project root directory * @returns QueryResult with completion details and warnings */ -export const phaseComplete: QueryHandler = async (args, projectDir) => { +export const phaseComplete: QueryHandler = async (args, projectDir, workstream) => { const phaseNum = args[0]; if (!phaseNum) { throw new GSDError('phase number required for phase complete', ErrorClassification.Validation); } assertNoNullBytes(phaseNum, 'phaseNum'); - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const today = new Date().toISOString().split('T')[0]; // Step A: Validate phase exists and get info - const phaseInfo = await findPhaseDir(projectDir, phaseNum); + const phaseInfo = await findPhaseDir(projectDir, phaseNum, workstream); if (!phaseInfo) { throw new GSDError(`Phase ${phaseNum} not found`, ErrorClassification.Validation); } @@ -1246,7 +1249,7 @@ export const phaseComplete: QueryHandler = async (args, projectDir) => { } return roadmapContent; - }); + }, workstream); } // Step E: Find next phase — filesystem first, then ROADMAP.md fallback @@ -1255,7 +1258,7 @@ export const phaseComplete: QueryHandler = async (args, projectDir) => { let isLastPhase = true; try { - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream); const entries = await readdir(paths.phases, { withFileTypes: true }); const dirs = entries.filter(e => e.isDirectory()).map(e => e.name) .filter(isDirInMilestone) @@ -1408,8 +1411,8 @@ export const phaseComplete: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { cleared: count } */ -export const phasesClear: QueryHandler = async (args, projectDir) => { - const phasesDir = planningPaths(projectDir).phases; +export const phasesClear: QueryHandler = async (args, projectDir, workstream) => { + const phasesDir = planningPaths(projectDir, workstream).phases; const confirm = Array.isArray(args) && args.includes('--confirm'); let cleared = 0; @@ -1446,8 +1449,8 @@ export const phasesClear: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { archived: count, version, archive_directory } */ -export const phasesList: QueryHandler = async (args, projectDir) => { - const paths = planningPaths(projectDir); +export const phasesList: QueryHandler = async (args, projectDir, workstream) => { + const paths = planningPaths(projectDir, workstream); const phasesDir = paths.phases; const typeIdx = args.indexOf('--type'); @@ -1510,14 +1513,14 @@ export const phasesList: QueryHandler = async (args, projectDir) => { return { data: { directories: dirs, count: dirs.length } }; }; -export const phaseNextDecimal: QueryHandler = async (args, projectDir) => { +export const phaseNextDecimal: QueryHandler = async (args, projectDir, workstream) => { const basePhase = args[0]; if (!basePhase) { throw new GSDError('base phase number required', ErrorClassification.Validation); } assertNoNullBytes(basePhase, 'basePhase'); - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const phasesDir = paths.phases; const normalized = normalizePhaseName(basePhase); const decimalSet = new Set(); @@ -1567,16 +1570,16 @@ export const phaseNextDecimal: QueryHandler = async (args, projectDir) => { }; }; -export const phasesArchive: QueryHandler = async (args, projectDir) => { +export const phasesArchive: QueryHandler = async (args, projectDir, workstream) => { const version = args[0]; if (!version) { throw new GSDError('version required for phases archive', ErrorClassification.Validation); } assertNoNullBytes(version, 'version'); - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const phasesDir = paths.phases; - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream); const archiveDir = join(paths.planning, 'milestones', `${version}-phases`); await mkdir(archiveDir, { recursive: true }); @@ -1627,7 +1630,7 @@ function extractOneLinerFromBody(content: string): string | null { /** * Query handler for `milestone.complete` — port of `cmdMilestoneComplete` from `milestone.cjs`. */ -export const milestoneComplete: QueryHandler = async (args, projectDir) => { +export const milestoneComplete: QueryHandler = async (args, projectDir, workstream) => { const version = args[0]; if (!version) { throw new GSDError('version required for milestone complete (e.g., v1.0)', ErrorClassification.Validation); @@ -1637,7 +1640,7 @@ export const milestoneComplete: QueryHandler = async (args, projectDir) => { const nameOpt = parseMultiwordArg(args, 'name'); const archivePhases = args.includes('--archive-phases'); - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const roadmapPath = paths.roadmap; const reqPath = paths.requirements; const statePath = paths.state; @@ -1649,7 +1652,7 @@ export const milestoneComplete: QueryHandler = async (args, projectDir) => { await mkdir(archiveDir, { recursive: true }); - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream); let phaseCount = 0; let totalPlans = 0; @@ -1754,7 +1757,7 @@ export const milestoneComplete: QueryHandler = async (args, projectDir) => { `${version} milestone completed and archived`, ); return next; - }); + }, workstream); } let phasesArchived = false; diff --git a/sdk/src/query/phase-list-queries.ts b/sdk/src/query/phase-list-queries.ts index 992149356..17335cfc0 100644 --- a/sdk/src/query/phase-list-queries.ts +++ b/sdk/src/query/phase-list-queries.ts @@ -17,8 +17,8 @@ import { import type { QueryHandler } from './utils.js'; /** Resolve `.planning/phases/` for a phase token, or null. */ -async function resolvePhaseDir(phase: string, projectDir: string): Promise { - const phasesDir = planningPaths(projectDir).phases; +async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise { + const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); try { const entries = await readdir(phasesDir, { withFileTypes: true }); @@ -40,7 +40,7 @@ type ArtifactType = 'context' | 'summary' | 'verification' | 'research'; * * Args: `` `--type` `` */ -export const phaseListArtifacts: QueryHandler = async (args, projectDir) => { +export const phaseListArtifacts: QueryHandler = async (args, projectDir, workstream) => { if (!args[0]) { throw new GSDError('phase required', ErrorClassification.Validation); } @@ -56,7 +56,7 @@ export const phaseListArtifacts: QueryHandler = async (args, projectDir) => { } const artifactType = rawType as ArtifactType; - const phaseDir = await resolvePhaseDir(phase, projectDir); + const phaseDir = await resolvePhaseDir(phase, projectDir, workstream); if (!phaseDir) { return { data: { phase: normalizePhaseName(phase), type: artifactType, artifacts: [], error: 'Phase not found' } }; } @@ -93,7 +93,7 @@ export const phaseListArtifacts: QueryHandler = async (args, projectDir) => { * * Args: `` [`--with-schema` ``] */ -export const phaseListPlans: QueryHandler = async (args, projectDir) => { +export const phaseListPlans: QueryHandler = async (args, projectDir, workstream) => { if (!args[0]) { throw new GSDError('phase required', ErrorClassification.Validation); } @@ -108,7 +108,7 @@ export const phaseListPlans: QueryHandler = async (args, projectDir) => { const phase = args[0]; const normalized = normalizePhaseName(phase); - const phaseDir = await resolvePhaseDir(phase, projectDir); + const phaseDir = await resolvePhaseDir(phase, projectDir, workstream); if (!phaseDir) { return { data: { diff --git a/sdk/src/query/phase-ready.ts b/sdk/src/query/phase-ready.ts index 2218033df..399cc9f43 100644 --- a/sdk/src/query/phase-ready.ts +++ b/sdk/src/query/phase-ready.ts @@ -22,8 +22,9 @@ const UI_INDICATOR_RE = /UI|interface|frontend|component|layout|page|screen|view async function roadmapPhaseLineHasUiIndicators( projectDir: string, phaseNum: string, + workstream?: string, ): Promise { - const roadmapPath = planningPaths(projectDir).roadmap; + const roadmapPath = planningPaths(projectDir, workstream).roadmap; let content: string; try { content = await readFile(roadmapPath, 'utf-8'); @@ -89,14 +90,14 @@ function inferNextStep(params: { return 'complete'; } -export const checkPhaseReady: QueryHandler = async (args, projectDir) => { +export const checkPhaseReady: QueryHandler = async (args, projectDir, workstream) => { const raw = args[0]; if (!raw) { throw new GSDError('phase number required for check phase-ready', ErrorClassification.Validation); } const phaseArg = normalizePhaseName(raw); - const phaseRes = await findPhase([raw], projectDir); + const phaseRes = await findPhase([raw], projectDir, workstream); const pdata = phaseRes.data as Record; const found = Boolean(pdata.found); @@ -115,10 +116,10 @@ export const checkPhaseReady: QueryHandler = async (args, projectDir) => { const phaseNumForRoadmap = (pdata.phase_number as string) || phaseArg; const has_ui_indicators = - (await roadmapPhaseLineHasUiIndicators(projectDir, phaseNumForRoadmap)) || - (phaseNumForRoadmap !== phaseArg ? await roadmapPhaseLineHasUiIndicators(projectDir, phaseArg) : false); + (await roadmapPhaseLineHasUiIndicators(projectDir, phaseNumForRoadmap, workstream)) || + (phaseNumForRoadmap !== phaseArg ? await roadmapPhaseLineHasUiIndicators(projectDir, phaseArg, workstream) : false); - const analysis = await roadmapAnalyze([], projectDir); + const analysis = await roadmapAnalyze([], projectDir, workstream); const adata = analysis.data as { phases?: Array> }; const phases = adata.phases ?? []; const deps = dependenciesMet(phases, phaseArg); diff --git a/sdk/src/query/phase.ts b/sdk/src/query/phase.ts index 944f13262..dc2ef7bae 100644 --- a/sdk/src/query/phase.ts +++ b/sdk/src/query/phase.ts @@ -28,6 +28,7 @@ import { toPosixPath, planningPaths, } from './helpers.js'; +import { relPlanningPath } from '../workstream-utils.js'; import type { QueryHandler } from './utils.js'; // ─── Types ───────────────────────────────────────────────────────────────── @@ -154,13 +155,13 @@ function extractObjective(content: string): string | null { * @returns QueryResult with PhaseInfo * @throws GSDError with Validation classification if phase identifier missing */ -export const findPhase: QueryHandler = async (args, projectDir) => { +export const findPhase: QueryHandler = async (args, projectDir, workstream) => { const phase = args[0]; if (!phase) { throw new GSDError('phase identifier required', ErrorClassification.Validation); } - const phasesDir = planningPaths(projectDir).phases; + const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); const notFound: PhaseInfo = { @@ -179,7 +180,7 @@ export const findPhase: QueryHandler = async (args, projectDir) => { }; // Search current phases first - const relPhasesDir = '.planning/phases'; + const relPhasesDir = relPlanningPath(workstream) + '/phases'; const current = await searchPhaseInDir(phasesDir, relPhasesDir, normalized); if (current) return { data: current }; @@ -221,13 +222,13 @@ export const findPhase: QueryHandler = async (args, projectDir) => { * @returns QueryResult with { phase, plans[], waves{}, incomplete[], has_checkpoints } * @throws GSDError with Validation classification if phase identifier missing */ -export const phasePlanIndex: QueryHandler = async (args, projectDir) => { +export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream) => { const phase = args[0]; if (!phase) { throw new GSDError('phase required for phase-plan-index', ErrorClassification.Validation); } - const phasesDir = planningPaths(projectDir).phases; + const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); // Find phase directory diff --git a/sdk/src/query/profile.ts b/sdk/src/query/profile.ts index 9698d3e2a..1701e9f29 100644 --- a/sdk/src/query/profile.ts +++ b/sdk/src/query/profile.ts @@ -104,8 +104,8 @@ export const learningsQuery: QueryHandler = async (args) => { return { data: { learnings: results, count: results.length, tag } }; }; -export const learningsCopy: QueryHandler = async (_args, projectDir) => { - const paths = planningPaths(projectDir); +export const learningsCopy: QueryHandler = async (_args, projectDir, workstream) => { + const paths = planningPaths(projectDir, workstream); const learningsPath = join(paths.planning, 'LEARNINGS.md'); if (!existsSync(learningsPath)) { return { data: { copied: false, total: 0, created: 0, skipped: 0, reason: 'No LEARNINGS.md found' } }; diff --git a/sdk/src/query/progress.ts b/sdk/src/query/progress.ts index 9340b060f..4d90c9321 100644 --- a/sdk/src/query/progress.ts +++ b/sdk/src/query/progress.ts @@ -76,9 +76,9 @@ export async function determinePhaseStatus( * @param projectDir - Project root directory * @returns QueryResult with milestone progress data */ -export const progressJson: QueryHandler = async (_args, projectDir) => { - const phasesDir = planningPaths(projectDir).phases; - const milestone = await getMilestoneInfo(projectDir); +export const progressJson: QueryHandler = async (_args, projectDir, workstream) => { + const phasesDir = planningPaths(projectDir, workstream).phases; + const milestone = await getMilestoneInfo(projectDir, workstream); const phases: Array> = []; let totalPlans = 0; @@ -128,8 +128,8 @@ export const progressJson: QueryHandler = async (_args, projectDir) => { * Progress bar line — port of `cmdProgressRender` `format === 'bar'` from commands.cjs (lines 588–593). * Uses the same plan/summary counts as `progressJson` / CJS (not `roadmap.analyze` percent). */ -export const progressBar: QueryHandler = async (_args, projectDir) => { - const json = await progressJson([], projectDir); +export const progressBar: QueryHandler = async (_args, projectDir, workstream) => { + const json = await progressJson([], projectDir, workstream); const d = json.data as { total_plans: number; total_summaries: number; @@ -148,8 +148,8 @@ export const progressBar: QueryHandler = async (_args, projectDir) => { /** * Markdown progress table — port of `cmdProgressRender` `format === 'table'` from commands.cjs (lines 575–587). */ -export const progressTable: QueryHandler = async (_args, projectDir) => { - const json = await progressJson([], projectDir); +export const progressTable: QueryHandler = async (_args, projectDir, workstream) => { + const json = await progressJson([], projectDir, workstream); const d = json.data as { milestone_version: string; milestone_name: string; @@ -183,14 +183,14 @@ export const progressTable: QueryHandler = async (_args, projectDir) => { /** * Statistics aggregate — port of `cmdStats` JSON/table output from commands.cjs lines 816–971. */ -export const statsJson: QueryHandler = async (args, projectDir) => { +export const statsJson: QueryHandler = async (args, projectDir, workstream) => { const format = args[0] || 'json'; - const phasesDir = planningPaths(projectDir).phases; - const roadmapPath = planningPaths(projectDir).roadmap; - const reqPath = planningPaths(projectDir).requirements; - const statePath = planningPaths(projectDir).state; - const milestone = await getMilestoneInfo(projectDir); - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const phasesDir = planningPaths(projectDir, workstream).phases; + const roadmapPath = planningPaths(projectDir, workstream).roadmap; + const reqPath = planningPaths(projectDir, workstream).requirements; + const statePath = planningPaths(projectDir, workstream).state; + const milestone = await getMilestoneInfo(projectDir, workstream); + const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream); const phasesByNumber = new Map< string, @@ -201,7 +201,7 @@ export const statsJson: QueryHandler = async (args, projectDir) => { let totalSummaries = 0; try { - const roadmapContent = await extractCurrentMilestone(await readFile(roadmapPath, 'utf-8'), projectDir); + const roadmapContent = await extractCurrentMilestone(await readFile(roadmapPath, 'utf-8'), projectDir, workstream); const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; let match: RegExpExecArray | null; while ((match = headingPattern.exec(roadmapContent)) !== null) { @@ -348,8 +348,8 @@ export const statsJson: QueryHandler = async (args, projectDir) => { * Markdown statistics table — port of `cmdStats` `format === 'table'` from commands.cjs (lines 942–967). * Delegates to `statsJson` with `['table']` (same `rendered` string as CJS). */ -export const statsTable: QueryHandler = async (_args, projectDir) => { - return statsJson(['table'], projectDir); +export const statsTable: QueryHandler = async (_args, projectDir, workstream) => { + return statsJson(['table'], projectDir, workstream); }; // ─── todoMatchPhase ────────────────────────────────────────────────────── diff --git a/sdk/src/query/registry.ts b/sdk/src/query/registry.ts index 4ea71947d..f31080fa4 100644 --- a/sdk/src/query/registry.ts +++ b/sdk/src/query/registry.ts @@ -110,10 +110,11 @@ export class QueryRegistry { * @param command - The command name to dispatch * @param args - Arguments to pass to the handler * @param projectDir - The project directory for context + * @param workstream - Optional workstream name to scope .planning paths * @returns The query result from the handler * @throws GSDError if no handler is registered for the command */ - async dispatch(command: string, args: string[], projectDir: string): Promise { + async dispatch(command: string, args: string[], projectDir: string, workstream?: string): Promise { const handler = this.handlers.get(command); if (!handler) { throw new GSDError( @@ -121,7 +122,7 @@ export class QueryRegistry { ErrorClassification.Validation, ); } - return handler(args, projectDir); + return handler(args, projectDir, workstream); } } diff --git a/sdk/src/query/requirements-extract-from-plans.ts b/sdk/src/query/requirements-extract-from-plans.ts index dc68f2b47..3e9bb3b69 100644 --- a/sdk/src/query/requirements-extract-from-plans.ts +++ b/sdk/src/query/requirements-extract-from-plans.ts @@ -14,8 +14,8 @@ import { } from './helpers.js'; import type { QueryHandler } from './utils.js'; -async function resolvePhaseDir(phase: string, projectDir: string): Promise { - const phasesDir = planningPaths(projectDir).phases; +async function resolvePhaseDir(phase: string, projectDir: string, workstream?: string): Promise { + const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); try { const entries = await readdir(phasesDir, { withFileTypes: true }); @@ -40,14 +40,14 @@ function normalizeReqList(v: unknown): string[] { /** * Args: `` */ -export const requirementsExtractFromPlans: QueryHandler = async (args, projectDir) => { +export const requirementsExtractFromPlans: QueryHandler = async (args, projectDir, workstream) => { const phase = args[0]; if (!phase) { throw new GSDError('phase required', ErrorClassification.Validation); } const normalized = normalizePhaseName(phase); - const phaseDir = await resolvePhaseDir(phase, projectDir); + const phaseDir = await resolvePhaseDir(phase, projectDir, workstream); if (!phaseDir) { return { data: { diff --git a/sdk/src/query/roadmap-update-plan-progress.ts b/sdk/src/query/roadmap-update-plan-progress.ts index 58d9d4956..8ced729d4 100644 --- a/sdk/src/query/roadmap-update-plan-progress.ts +++ b/sdk/src/query/roadmap-update-plan-progress.ts @@ -14,13 +14,13 @@ import { escapeRegex, planningPaths } from './helpers.js'; import { GSDError, ErrorClassification } from '../errors.js'; import type { QueryHandler } from './utils.js'; -export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir) => { +export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir, workstream) => { const phaseNum = args[0]; if (!phaseNum) { throw new GSDError('phase number required for roadmap update-plan-progress', ErrorClassification.Validation); } - const phaseResult = await findPhase([phaseNum], projectDir); + const phaseResult = await findPhase([phaseNum], projectDir, workstream); const info = phaseResult.data as { found: boolean; plans: string[]; @@ -49,7 +49,7 @@ export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir) const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned'; const today = new Date().toISOString().split('T')[0]!; - const roadmapPath = planningPaths(projectDir).roadmap; + const roadmapPath = planningPaths(projectDir, workstream).roadmap; if (!existsSync(roadmapPath)) { return { data: { @@ -117,7 +117,7 @@ export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir) } return roadmapContent; - }); + }, workstream); return { data: { diff --git a/sdk/src/query/roadmap.ts b/sdk/src/query/roadmap.ts index c18e5e0a5..3c8ec3de9 100644 --- a/sdk/src/query/roadmap.ts +++ b/sdk/src/query/roadmap.ts @@ -62,9 +62,9 @@ export function stripShippedMilestones(content: string): string { /** * Read milestone + name from STATE.md frontmatter when ROADMAP does not encode them. */ -async function parseMilestoneFromState(projectDir: string): Promise<{ version: string; name: string } | null> { +async function parseMilestoneFromState(projectDir: string, workstream?: string): Promise<{ version: string; name: string } | null> { try { - const stateRaw = await readFile(planningPaths(projectDir).state, 'utf-8'); + const stateRaw = await readFile(planningPaths(projectDir, workstream).state, 'utf-8'); const vm = stateRaw.match(/^milestone:\s*(.+)$/m); if (!vm) return null; const version = vm[1].trim().replace(/^["']|["']$/g, ''); @@ -88,10 +88,10 @@ async function parseMilestoneFromState(projectDir: string): Promise<{ version: s * @param projectDir - Project root directory * @returns Object with version and name */ -export async function getMilestoneInfo(projectDir: string): Promise<{ version: string; name: string }> { +export async function getMilestoneInfo(projectDir: string, workstream?: string): Promise<{ version: string; name: string }> { try { // Priority 1: STATE.md frontmatter (authoritative for version; name only when real) - const fromState = await parseMilestoneFromState(projectDir); + const fromState = await parseMilestoneFromState(projectDir, workstream); const stateVersion = fromState?.version ?? null; const stateName = fromState && fromState.name !== 'milestone' ? fromState.name : null; if (stateVersion && stateName) { @@ -100,7 +100,7 @@ export async function getMilestoneInfo(projectDir: string): Promise<{ version: s // STATE.md has a version but no real name — fall through to ROADMAP for the name, // then override the version with the authoritative STATE.md value. - const roadmap = await readFile(planningPaths(projectDir).roadmap, 'utf-8'); + const roadmap = await readFile(planningPaths(projectDir, workstream).roadmap, 'utf-8'); // List-format: construction / blocked (legacy emoji) const barricadeMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/); @@ -137,7 +137,7 @@ export async function getMilestoneInfo(projectDir: string): Promise<{ version: s return { version: stateVersion ?? 'v1.0', name: 'milestone' }; } catch { - const fromState = await parseMilestoneFromState(projectDir); + const fromState = await parseMilestoneFromState(projectDir, workstream); if (fromState) return fromState; return { version: 'v1.0', name: 'milestone' }; } @@ -152,11 +152,11 @@ export async function getMilestoneInfo(projectDir: string): Promise<{ version: s * @param projectDir - Working directory for reading STATE.md * @returns Content scoped to current milestone */ -export async function extractCurrentMilestone(content: string, projectDir: string): Promise { +export async function extractCurrentMilestone(content: string, projectDir: string, workstream?: string): Promise { // Get version from STATE.md frontmatter let version: string | null = null; try { - const stateRaw = await readFile(planningPaths(projectDir).state, 'utf-8'); + const stateRaw = await readFile(planningPaths(projectDir, workstream).state, 'utf-8'); const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m); if (milestoneMatch) { version = milestoneMatch[1].trim(); @@ -294,7 +294,7 @@ function searchPhaseInContent(content: string, escapedPhase: string, phaseNum: s * @param projectDir - Project root directory * @returns QueryResult with phase section info or { found: false } */ -export const roadmapGetPhase: QueryHandler = async (args, projectDir) => { +export const roadmapGetPhase: QueryHandler = async (args, projectDir, workstream) => { const phaseNum = args[0]; if (!phaseNum) { throw new GSDError( @@ -303,7 +303,7 @@ export const roadmapGetPhase: QueryHandler = async (args, projectDir) => { ); } - const roadmapPath = planningPaths(projectDir).roadmap; + const roadmapPath = planningPaths(projectDir, workstream).roadmap; let rawContent: string; try { @@ -312,7 +312,7 @@ export const roadmapGetPhase: QueryHandler = async (args, projectDir) => { return { data: { found: false, error: 'ROADMAP.md not found' } }; } - const milestoneContent = await extractCurrentMilestone(rawContent, projectDir); + const milestoneContent = await extractCurrentMilestone(rawContent, projectDir, workstream); const escapedPhase = escapeRegex(phaseNum); // Search the current milestone slice first, then fall back to full roadmap. @@ -339,8 +339,8 @@ export const roadmapGetPhase: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with full roadmap analysis */ -export const roadmapAnalyze: QueryHandler = async (_args, projectDir) => { - const roadmapPath = planningPaths(projectDir).roadmap; +export const roadmapAnalyze: QueryHandler = async (_args, projectDir, workstream) => { + const roadmapPath = planningPaths(projectDir, workstream).roadmap; let rawContent: string; try { @@ -349,8 +349,8 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir) => { return { data: { error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null } }; } - const content = await extractCurrentMilestone(rawContent, projectDir); - const phasesDir = planningPaths(projectDir).phases; + const content = await extractCurrentMilestone(rawContent, projectDir, workstream); + const phasesDir = planningPaths(projectDir, workstream).phases; // IMPORTANT: Create regex INSIDE the function to avoid /g lastIndex persistence const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; @@ -526,7 +526,7 @@ export const roadmapAnnotateDependencies: QueryHandler = async (args, projectDir * Mark requirement IDs complete in REQUIREMENTS.md (checkbox + traceability table). * Port of `cmdRequirementsMarkComplete` from milestone.cjs lines 11–87. */ -export const requirementsMarkComplete: QueryHandler = async (args, projectDir) => { +export const requirementsMarkComplete: QueryHandler = async (args, projectDir, workstream) => { if (args.length === 0) { throw new GSDError( 'requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02', @@ -545,7 +545,7 @@ export const requirementsMarkComplete: QueryHandler = async (args, projectDir) = throw new GSDError('no valid requirement IDs found', ErrorClassification.Validation); } - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); if (!existsSync(paths.requirements)) { return { data: { updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds } }; } diff --git a/sdk/src/query/route-next-action.ts b/sdk/src/query/route-next-action.ts index 231895c9f..19166a4fa 100644 --- a/sdk/src/query/route-next-action.ts +++ b/sdk/src/query/route-next-action.ts @@ -52,11 +52,11 @@ async function verificationPassed(phaseDirAbs: string): Promise { } } -export const routeNextAction: QueryHandler = async (_args, projectDir) => { - const planning = planningPaths(projectDir).planning; +export const routeNextAction: QueryHandler = async (_args, projectDir, workstream) => { + const planning = planningPaths(projectDir, workstream).planning; const continueHere = existsSync(join(planning, '.continue-here.md')); - const sj = await stateJson([], projectDir); + const sj = await stateJson([], projectDir, workstream); const sjd = sj.data as Record; if (sjd.error) { return { @@ -85,11 +85,11 @@ export const routeNextAction: QueryHandler = async (_args, projectDir) => { const consecutiveCalls = readConsecutiveCallCount(planning); - const ra = await roadmapAnalyze([], projectDir); + const ra = await roadmapAnalyze([], projectDir, workstream); const raData = ra.data as { phases?: Array> }; const phases = raData.phases ?? []; - const phasesDir = planningPaths(projectDir).phases; + const phasesDir = planningPaths(projectDir, workstream).phases; let dirCount = 0; try { dirCount = readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()).length; @@ -97,7 +97,7 @@ export const routeNextAction: QueryHandler = async (_args, projectDir) => { let unresolvedVerification = false; if (currentPhase) { - const fp = await findPhase([currentPhase], projectDir); + const fp = await findPhase([currentPhase], projectDir, workstream); const fd = fp.data as Record; if (fd.found && fd.directory) { unresolvedVerification = await hasUnresolvedVerificationFails( @@ -126,7 +126,7 @@ export const routeNextAction: QueryHandler = async (_args, projectDir) => { uat_gaps: 0, }; } - const fp = await findPhase([cp], projectDir); + const fp = await findPhase([cp], projectDir, workstream); const d = fp.data as Record; const plans = (d.plans as string[]) ?? []; const summaries = (d.summaries as string[]) ?? []; @@ -212,7 +212,7 @@ export const routeNextAction: QueryHandler = async (_args, projectDir) => { }; } - const fp = await findPhase([currentPhase], projectDir); + const fp = await findPhase([currentPhase], projectDir, workstream); const pd = fp.data as Record; const found = Boolean(pd.found); const cp = normalizePhaseName(currentPhase); diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts index f624995a6..a107b5461 100644 --- a/sdk/src/query/state-mutation.ts +++ b/sdk/src/query/state-mutation.ts @@ -259,9 +259,10 @@ async function syncStateFrontmatter(content: string, projectDir: string): Promis */ async function readModifyWriteStateMd( projectDir: string, - modifier: (content: string) => string | Promise + modifier: (content: string) => string | Promise, + workstream?: string, ): Promise { - const statePath = planningPaths(projectDir).state; + const statePath = planningPaths(projectDir, workstream).state; const lockPath = await acquireStateLock(statePath); try { let content: string; @@ -292,8 +293,9 @@ async function readModifyWriteStateMd( export async function readModifyWriteStateMdFull( projectDir: string, modifier: (content: string) => string | Promise, + workstream?: string, ): Promise { - const statePath = planningPaths(projectDir).state; + const statePath = planningPaths(projectDir, workstream).state; const lockPath = await acquireStateLock(statePath); try { let content = ''; @@ -321,7 +323,7 @@ export async function readModifyWriteStateMdFull( * @param projectDir - Project root directory * @returns QueryResult with { updated: true/false, field, value } */ -export const stateUpdate: QueryHandler = async (args, projectDir) => { +export const stateUpdate: QueryHandler = async (args, projectDir, workstream) => { const field = args[0]; const value = args[1]; @@ -337,7 +339,7 @@ export const stateUpdate: QueryHandler = async (args, projectDir) => { return result; } return content; - }); + }, workstream); return { data: { updated, field, value: updated ? value : undefined } }; }; @@ -351,7 +353,7 @@ export const stateUpdate: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with `{ updated, failed }` matching `cmdStatePatch` in `state.cjs` */ -export const statePatch: QueryHandler = async (args, projectDir) => { +export const statePatch: QueryHandler = async (args, projectDir, workstream) => { let patches: Record; if (args.length >= 2 && args[0]?.startsWith('--')) { @@ -386,7 +388,7 @@ export const statePatch: QueryHandler = async (args, projectDir) => { } } return content; - }); + }, workstream); return { data: { updated, failed } }; }; @@ -404,7 +406,7 @@ export const statePatch: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with phase metadata and `updated` field names (for raw parity) */ -export const stateBeginPhase: QueryHandler = async (args, projectDir) => { +export const stateBeginPhase: QueryHandler = async (args, projectDir, workstream) => { const named = parseNamedArgs(args, ['phase', 'name', 'plans']); let phaseNumber = (named.phase as string | null) || ''; let phaseName = (named.name as string | null) || ''; @@ -527,7 +529,7 @@ export const stateBeginPhase: QueryHandler = async (args, projectDir) => { } return content; - }); + }, workstream); return { data: { @@ -548,7 +550,7 @@ export const stateBeginPhase: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { advanced, current_plan, total_plans } */ -export const stateAdvancePlan: QueryHandler = async (_args, projectDir) => { +export const stateAdvancePlan: QueryHandler = async (_args, projectDir, workstream) => { const today = new Date().toISOString().split('T')[0]; let result: Record = { error: 'STATE.md not found' }; @@ -619,7 +621,7 @@ export const stateAdvancePlan: QueryHandler = async (_args, projectDir) => { }); result = { advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }; return content; - }); + }, workstream); return { data: result }; }; @@ -633,7 +635,7 @@ export const stateAdvancePlan: QueryHandler = async (_args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { recorded: true/false } */ -export const stateRecordMetric: QueryHandler = async (args, projectDir) => { +export const stateRecordMetric: QueryHandler = async (args, projectDir, workstream) => { const parsed = parseNamedArgs(args, ['phase', 'plan', 'duration', 'tasks', 'files']); const phase = parsed.phase as string | null; const plan = parsed.plan as string | null; @@ -664,7 +666,7 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir) => { recorded = true; } return content; - }); + }, workstream); if (recorded) { return { data: { recorded: true, phase, plan, duration } }; @@ -681,13 +683,13 @@ export const stateRecordMetric: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { updated, percent, completed, total } */ -export const stateUpdateProgress: QueryHandler = async (_args, projectDir) => { - const phasesDir = planningPaths(projectDir).phases; +export const stateUpdateProgress: QueryHandler = async (_args, projectDir, workstream) => { + const phasesDir = planningPaths(projectDir, workstream).phases; let totalPlans = 0; let totalSummaries = 0; try { - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream); const entries = await readdir(phasesDir, { withFileTypes: true }); const phaseDirs = entries .filter(e => e.isDirectory()) @@ -720,7 +722,7 @@ export const stateUpdateProgress: QueryHandler = async (_args, projectDir) => { return content.replace(plainProgressPattern, (_match, prefix: string) => `${prefix}${progressStr}`); } return content; - }); + }, workstream); if (updated) { return { data: { updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr } }; @@ -734,7 +736,7 @@ export const stateUpdateProgress: QueryHandler = async (_args, projectDir) => { * Appends a decision to the Decisions section. Removes placeholder text. * argv matches `gsd-tools.cjs`: `--phase`, `--summary`, `--rationale`, etc. */ -export const stateAddDecision: QueryHandler = async (args, projectDir) => { +export const stateAddDecision: QueryHandler = async (args, projectDir, workstream) => { const parsed = parseNamedArgs(args, ['phase', 'summary', 'summary-file', 'rationale', 'rationale-file']); const phase = parsed.phase as string | null; let summaryText: string | null = null; @@ -777,7 +779,7 @@ export const stateAddDecision: QueryHandler = async (args, projectDir) => { added = true; } return content; - }); + }, workstream); if (added) { return { data: { added: true, decision: entry } }; @@ -789,7 +791,7 @@ export const stateAddDecision: QueryHandler = async (args, projectDir) => { * Query handler for state.add-blocker command. * argv: `--text`, `--text-file` (see `gsd-tools.cjs`). */ -export const stateAddBlocker: QueryHandler = async (args, projectDir) => { +export const stateAddBlocker: QueryHandler = async (args, projectDir, workstream) => { const parsed = parseNamedArgs(args, ['text', 'text-file']); let blockerText: string | null = null; @@ -824,7 +826,7 @@ export const stateAddBlocker: QueryHandler = async (args, projectDir) => { added = true; } return content; - }); + }, workstream); if (added) { return { data: { added: true, blocker: blockerText } }; @@ -836,7 +838,7 @@ export const stateAddBlocker: QueryHandler = async (args, projectDir) => { * Query handler for state.resolve-blocker command. * argv: `--text` (see `gsd-tools.cjs`). */ -export const stateResolveBlocker: QueryHandler = async (args, projectDir) => { +export const stateResolveBlocker: QueryHandler = async (args, projectDir, workstream) => { const parsed = parseNamedArgs(args, ['text']); const searchText = parsed.text as string | null; if (!searchText) { @@ -873,7 +875,7 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir) => { content = content.replace(sectionPattern, (_match, header: string) => `${header}${newBody}`); } return content; - }); + }, workstream); if (removedMatchingLine) { return { data: { resolved: true, blocker: searchText } }; @@ -888,7 +890,7 @@ export const stateResolveBlocker: QueryHandler = async (args, projectDir) => { * Query handler for state.record-session command. * argv: `--stopped-at`, `--resume-file` (see `cmdStateRecordSession` in `state.cjs`). */ -export const stateRecordSession: QueryHandler = async (args, projectDir) => { +export const stateRecordSession: QueryHandler = async (args, projectDir, workstream) => { const parsed = parseNamedArgs(args, ['stopped-at', 'resume-file']); const stoppedAt = parsed['stopped-at'] as string | null | undefined; const resumeFile = ((parsed['resume-file'] as string | null) ?? 'None'); @@ -913,7 +915,7 @@ export const stateRecordSession: QueryHandler = async (args, projectDir) => { if (result) { content = result; updated.push('Resume File'); } return content; - }); + }, workstream); if (updated.length > 0) { return { data: { recorded: true, updated } }; @@ -924,7 +926,7 @@ export const stateRecordSession: QueryHandler = async (args, projectDir) => { /** * Query handler for state.planned-phase — port of `cmdStatePlannedPhase` from `state.cjs`. */ -export const statePlannedPhase: QueryHandler = async (args, projectDir) => { +export const statePlannedPhase: QueryHandler = async (args, projectDir, workstream) => { const parsed = parseNamedArgs(args, ['phase', 'name', 'plans']); const phaseNumber = parsed.phase as string | null; const plansRaw = parsed.plans as string | null; @@ -943,7 +945,7 @@ export const statePlannedPhase: QueryHandler = async (args, projectDir) => { const phaseLabel = String(phaseNumber).trim(); - const statePath = planningPaths(projectDir).state; + const statePath = planningPaths(projectDir, workstream).state; if (!existsSync(statePath)) { return { data: { error: 'STATE.md not found' } }; } @@ -975,7 +977,7 @@ export const statePlannedPhase: QueryHandler = async (args, projectDir) => { lastActivity: `${today} -- Phase ${phaseLabel} planning complete`, }); return content; - }); + }, workstream); return { data: { updated, phase: phaseNumber, plan_count: planCount } }; }; @@ -1009,7 +1011,7 @@ function parseNamedArgs( * Writes `WAITING.json` under both `.gsd/` and `.planning/` so readers that only * watch one location (e.g. init workflows) still observe the signal. */ -export const stateSignalWaiting: QueryHandler = async (args, projectDir) => { +export const stateSignalWaiting: QueryHandler = async (args, projectDir, _workstream) => { const parsed = parseNamedArgs(args, ['type', 'question', 'options', 'phase']); const type = (parsed.type as string | null) || 'decision_point'; const question = (parsed.question as string | null) || null; @@ -1047,7 +1049,7 @@ export const stateSignalWaiting: QueryHandler = async (args, projectDir) => { /** * Port of `cmdSignalResume` from state.cjs. */ -export const stateSignalResume: QueryHandler = async (_args, projectDir) => { +export const stateSignalResume: QueryHandler = async (_args, projectDir, _workstream) => { const paths = [ join(projectDir, '.gsd', 'WAITING.json'), join(projectDir, '.planning', 'WAITING.json'), @@ -1069,8 +1071,8 @@ export const stateSignalResume: QueryHandler = async (_args, projectDir) => { /** * Port of `cmdStateValidate` from state.cjs. */ -export const stateValidate: QueryHandler = async (_args, projectDir) => { - const paths = planningPaths(projectDir); +export const stateValidate: QueryHandler = async (_args, projectDir, workstream) => { + const paths = planningPaths(projectDir, workstream); const statePath = paths.state; if (!existsSync(statePath)) { return { data: { error: 'STATE.md not found' } }; @@ -1140,9 +1142,9 @@ export const stateValidate: QueryHandler = async (_args, projectDir) => { /** * Port of `cmdStateSync` from state.cjs. Supports `--verify` dry-run. */ -export const stateSync: QueryHandler = async (args, projectDir) => { +export const stateSync: QueryHandler = async (args, projectDir, workstream) => { const verify = args.includes('--verify'); - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const statePath = paths.state; if (!existsSync(statePath)) { return { data: { error: 'STATE.md not found' } }; @@ -1230,7 +1232,7 @@ export const stateSync: QueryHandler = async (args, projectDir) => { return { data: { synced: false, changes, dry_run: true } }; } - await readModifyWriteStateMd(projectDir, (body) => runModifier(body)); + await readModifyWriteStateMd(projectDir, (body) => runModifier(body), workstream); return { data: { synced: true, changes, dry_run: false } }; }; @@ -1355,7 +1357,7 @@ function prunePass(content: string, cutoff: number): { newContent: string; archi * Port of `cmdStatePrune` from state.cjs. * Args: `--keep-recent N` (default 3), `--dry-run`, `--silent` (omit extra logging fields — no-op in SDK JSON). */ -export const statePrune: QueryHandler = async (args, projectDir) => { +export const statePrune: QueryHandler = async (args, projectDir, workstream) => { const parsed = parseNamedArgs(args, ['keep-recent'], ['dry-run', 'silent']); const parsedKeepRecent = Number.parseInt(String(parsed['keep-recent'] ?? '3'), 10); if (!Number.isInteger(parsedKeepRecent) || parsedKeepRecent < 0) { @@ -1364,7 +1366,7 @@ export const statePrune: QueryHandler = async (args, projectDir) => { const keepRecent = parsedKeepRecent; const dryRun = parsed['dry-run'] === true; - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const statePath = paths.state; if (!existsSync(statePath)) { return { data: { error: 'STATE.md not found' } }; @@ -1411,7 +1413,7 @@ export const statePrune: QueryHandler = async (args, projectDir) => { const result = prunePass(b, cutoff); archived.push(...result.archivedSections); return result.newContent; - }); + }, workstream); const archivePath = join(paths.planning, 'STATE-ARCHIVE.md'); const totalPruned = archived.reduce((sum, s) => sum + s.count, 0); diff --git a/sdk/src/query/state-project-load.ts b/sdk/src/query/state-project-load.ts index 39ffa203f..1ec29f7b7 100644 --- a/sdk/src/query/state-project-load.ts +++ b/sdk/src/query/state-project-load.ts @@ -51,9 +51,9 @@ function loadConfigCjs(projectDir: string): Record { * * Port of `cmdStateLoad` from `get-shit-done/bin/lib/state.cjs` lines 44–86. */ -export const stateProjectLoad: QueryHandler = async (_args, projectDir) => { +export const stateProjectLoad: QueryHandler = async (_args, projectDir, workstream) => { const config = loadConfigCjs(projectDir); - const planDir = planningPaths(projectDir).planning; + const planDir = planningPaths(projectDir, workstream).planning; let stateRaw = ''; try { diff --git a/sdk/src/query/state.ts b/sdk/src/query/state.ts index fdd2a576e..e683a5269 100644 --- a/sdk/src/query/state.ts +++ b/sdk/src/query/state.ts @@ -34,10 +34,10 @@ import type { QueryHandler } from './utils.js'; * * Port of getMilestonePhaseFilter from core.cjs lines 1409-1442. */ -export async function getMilestonePhaseFilter(projectDir: string): Promise<((dirName: string) => boolean) & { phaseCount: number }> { +export async function getMilestonePhaseFilter(projectDir: string, workstream?: string): Promise<((dirName: string) => boolean) & { phaseCount: number }> { const milestonePhaseNums = new Set(); try { - const roadmapContent = await readFile(planningPaths(projectDir).roadmap, 'utf-8'); + const roadmapContent = await readFile(planningPaths(projectDir, workstream).roadmap, 'utf-8'); const roadmap = await extractCurrentMilestone(roadmapContent, projectDir); const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi; let m: RegExpExecArray | null; @@ -77,7 +77,7 @@ export async function getMilestonePhaseFilter(projectDir: string): Promise<((dir * 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): Promise> { +export async function buildStateFrontmatter(bodyContent: string, projectDir: string, workstream?: string): Promise> { const currentPhase = stateExtractField(bodyContent, 'Current Phase'); const currentPhaseName = stateExtractField(bodyContent, 'Current Phase Name'); const currentPlan = stateExtractField(bodyContent, 'Current Plan'); @@ -103,8 +103,8 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str let completedPlans: number | null = null; try { - const phasesDir = planningPaths(projectDir).phases; - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const phasesDir = planningPaths(projectDir, workstream).phases; + const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream); const entries = await readdir(phasesDir, { withFileTypes: true }); const phaseDirs = entries .filter(e => e.isDirectory()) @@ -198,8 +198,8 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str * @param projectDir - Project root directory * @returns QueryResult with rebuilt state frontmatter */ -export const stateJson: QueryHandler = async (_args, projectDir) => { - const statePath = planningPaths(projectDir).state; +export const stateJson: QueryHandler = async (_args, projectDir, workstream) => { + const statePath = planningPaths(projectDir, workstream).state; let content: string; try { @@ -212,7 +212,7 @@ export const stateJson: QueryHandler = async (_args, projectDir) => { const body = stripFrontmatter(content); // Always rebuild from body + disk so progress reflects current state - const built = await buildStateFrontmatter(body, projectDir); + const built = await buildStateFrontmatter(body, projectDir, workstream); // Preserve frontmatter-only fields that cannot be recovered from body if (existingFm && existingFm.stopped_at && !built.stopped_at) { @@ -242,8 +242,8 @@ export const stateJson: QueryHandler = async (_args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with field value or full content */ -export const stateGet: QueryHandler = async (args, projectDir) => { - const statePath = planningPaths(projectDir).state; +export const stateGet: QueryHandler = async (args, projectDir, workstream) => { + const statePath = planningPaths(projectDir, workstream).state; let content: string; try { @@ -294,8 +294,8 @@ export const stateGet: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with structured snapshot */ -export const stateSnapshot: QueryHandler = async (_args, projectDir) => { - const statePath = planningPaths(projectDir).state; +export const stateSnapshot: QueryHandler = async (_args, projectDir, workstream) => { + const statePath = planningPaths(projectDir, workstream).state; let content: string; try { diff --git a/sdk/src/query/summary.ts b/sdk/src/query/summary.ts index c3756587e..6eabc8019 100644 --- a/sdk/src/query/summary.ts +++ b/sdk/src/query/summary.ts @@ -163,8 +163,8 @@ export const summaryExtract: QueryHandler = async (args, projectDir) => { return { data: fullResult }; }; -export const historyDigest: QueryHandler = async (_args, projectDir) => { - const phasesDir = planningPaths(projectDir).phases; +export const historyDigest: QueryHandler = async (_args, projectDir, workstream) => { + const phasesDir = planningPaths(projectDir, workstream).phases; const digest: { phases: Record< string, diff --git a/sdk/src/query/template.ts b/sdk/src/query/template.ts index 09c7d70f5..edea41baf 100644 --- a/sdk/src/query/template.ts +++ b/sdk/src/query/template.ts @@ -38,13 +38,13 @@ import type { QueryHandler } from './utils.js'; * @param projectDir - Project root directory * @returns QueryResult with { template: 'plan' | 'summary' | 'verification' } */ -export const templateSelect: QueryHandler = async (args, projectDir) => { +export const templateSelect: QueryHandler = async (args, projectDir, workstream) => { const phaseNum = args[0]; if (!phaseNum) { return { data: { template: 'plan' } }; } - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const normalized = normalizePhaseName(phaseNum); // Find the phase directory diff --git a/sdk/src/query/uat.ts b/sdk/src/query/uat.ts index 5db478802..ef3ce12d4 100644 --- a/sdk/src/query/uat.ts +++ b/sdk/src/query/uat.ts @@ -230,13 +230,13 @@ function parseVerificationItems(content: string, status: string): Record { - const paths = planningPaths(projectDir); +export const auditUat: QueryHandler = async (_args, projectDir, workstream) => { + const paths = planningPaths(projectDir, workstream); if (!existsSync(paths.phases)) { throw new GSDError('No phases directory found in planning directory', ErrorClassification.Blocked); } - const isDirInMilestone = await getMilestonePhaseFilter(projectDir); + const isDirInMilestone = await getMilestonePhaseFilter(projectDir, workstream); const results: Record[] = []; const dirs = readdirSync(paths.phases, { withFileTypes: true }) diff --git a/sdk/src/query/utils.ts b/sdk/src/query/utils.ts index d28b70b01..99468562e 100644 --- a/sdk/src/query/utils.ts +++ b/sdk/src/query/utils.ts @@ -27,7 +27,7 @@ export interface QueryResult { } /** Signature for a query handler function. */ -export type QueryHandler = (args: string[], projectDir: string) => Promise; +export type QueryHandler = (args: string[], projectDir: string, workstream?: string) => Promise; // ─── generateSlug ─────────────────────────────────────────────────────────── diff --git a/sdk/src/query/validate.ts b/sdk/src/query/validate.ts index 3887e1612..a9a5bb546 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -186,8 +186,8 @@ export const verifyKeyLinks: QueryHandler = async (args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { passed, errors, warnings, warning_count } */ -export const validateConsistency: QueryHandler = async (_args, projectDir) => { - const paths = planningPaths(projectDir); +export const validateConsistency: QueryHandler = async (_args, projectDir, workstream) => { + const paths = planningPaths(projectDir, workstream); const errors: string[] = []; const warnings: string[] = []; @@ -344,7 +344,7 @@ export const validateConsistency: QueryHandler = async (_args, projectDir) => { * @param projectDir - Project root directory * @returns QueryResult with { status, errors, warnings, info, repairable_count, repairs_performed? } */ -export const validateHealth: QueryHandler = async (args, projectDir) => { +export const validateHealth: QueryHandler = async (args, projectDir, _workstream) => { const doRepair = args.includes('--repair'); // T-12-09: Home directory guard diff --git a/sdk/src/query/verify.ts b/sdk/src/query/verify.ts index 41f36d93e..f28fd595d 100644 --- a/sdk/src/query/verify.ts +++ b/sdk/src/query/verify.ts @@ -133,13 +133,13 @@ export const verifyPlanStructure: QueryHandler = async (args, projectDir) => { * @returns QueryResult with { complete, phase, plan_count, summary_count, incomplete_plans, orphan_summaries, errors, warnings } * @throws GSDError with Validation classification if phase number missing */ -export const verifyPhaseCompleteness: QueryHandler = async (args, projectDir) => { +export const verifyPhaseCompleteness: QueryHandler = async (args, projectDir, workstream) => { const phase = args[0]; if (!phase) { throw new GSDError('phase required', ErrorClassification.Validation); } - const phasesDir = planningPaths(projectDir).phases; + const phasesDir = planningPaths(projectDir, workstream).phases; const normalized = normalizePhaseName(phase); // Find phase directory (mirror findPhase pattern from phase.ts) @@ -554,7 +554,7 @@ export const verifyPathExists: QueryHandler = async (args, projectDir) => { /** * Detect schema drift for a phase — port of `cmdVerifySchemaDrift` from verify.cjs lines 1013–1086. */ -export const verifySchemaDrift: QueryHandler = async (args, projectDir) => { +export const verifySchemaDrift: QueryHandler = async (args, projectDir, workstream) => { const phaseArg = args[0]; const skipFlag = args.includes('--skip'); @@ -565,7 +565,7 @@ export const verifySchemaDrift: QueryHandler = async (args, projectDir) => { const { checkSchemaDrift } = await import('./schema-detect.js'); const { execGit } = await import('./commit.js'); - const phasesDir = planningPaths(projectDir).phases; + const phasesDir = planningPaths(projectDir, workstream).phases; if (!existsSync(phasesDir)) { return { data: { diff --git a/tests/bug-2524-sdk-query-ws-flag.test.cjs b/tests/bug-2524-sdk-query-ws-flag.test.cjs new file mode 100644 index 000000000..924f88e34 --- /dev/null +++ b/tests/bug-2524-sdk-query-ws-flag.test.cjs @@ -0,0 +1,107 @@ +'use strict'; + +/** + * Bug #2524: gsd-sdk query --ws silently ignores the workstream flag. + * Tests that --ws is forwarded through the call chain: + * cli.ts -> registry.dispatch() -> planningPaths() + * + * Uses static source-file text assertions (no sdk/dist/ build required in CI). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const helpersTs = fs.readFileSync( + path.join(__dirname, '../sdk/src/query/helpers.ts'), + 'utf-8', +); +const registryTs = fs.readFileSync( + path.join(__dirname, '../sdk/src/query/registry.ts'), + 'utf-8', +); +const cliTs = fs.readFileSync( + path.join(__dirname, '../sdk/src/cli.ts'), + 'utf-8', +); + +// ─── Layer 3: planningPaths() accepts workstream ─────────────────────────── + +describe('planningPaths() workstream support', () => { + test('planningPaths signature includes optional workstream parameter', () => { + assert.ok( + helpersTs.includes('planningPaths(projectDir: string, workstream?: string)'), + 'planningPaths must accept an optional workstream parameter', + ); + }); + + test('planningPaths uses relPlanningPath(workstream) to compute the base path', () => { + assert.ok( + helpersTs.includes('relPlanningPath(workstream)'), + 'planningPaths must call relPlanningPath(workstream) to scope the base path', + ); + }); + + test('planningPaths imports relPlanningPath from workstream-utils', () => { + assert.ok( + helpersTs.includes('relPlanningPath'), + 'helpers.ts must import/use relPlanningPath from workstream-utils', + ); + }); +}); + +// ─── Layer 2: QueryRegistry.dispatch() accepts workstream ───────────────── + +describe('QueryRegistry.dispatch() workstream threading', () => { + test('dispatch method signature includes workstream parameter', () => { + assert.ok( + registryTs.includes('workstream?: string'), + 'dispatch() must accept an optional workstream parameter', + ); + }); + + test('dispatch forwards workstream to the handler as third argument', () => { + assert.ok( + registryTs.includes('handler(args, projectDir, workstream)'), + 'dispatch() must pass workstream as the third argument to the handler', + ); + }); + + test('QueryHandler type accepts a third workstream argument', () => { + // QueryHandler type is defined in utils.ts, but registry.ts imports and uses it + const utilsTs = fs.readFileSync( + path.join(__dirname, '../sdk/src/query/utils.ts'), + 'utf-8', + ); + assert.ok( + utilsTs.includes('workstream?: string') && utilsTs.includes('QueryHandler'), + 'QueryHandler type must include an optional workstream parameter', + ); + }); +}); + +// ─── Layer 1: CLI forwards args.ws to registry.dispatch() ───────────────── + +describe('CLI forwards --ws to registry.dispatch()', () => { + test('cli.ts passes args.ws as the workstream argument to registry.dispatch()', () => { + assert.ok( + cliTs.includes('registry.dispatch(matched.cmd, matched.args, args.projectDir, args.ws)'), + 'cli.ts must forward args.ws to registry.dispatch() as the workstream argument', + ); + }); + + test('cli.ts defines a ws field in ParsedCliArgs', () => { + assert.ok( + cliTs.includes('ws: string | undefined'), + 'ParsedCliArgs must have a ws field typed as string | undefined', + ); + }); + + test('cli.ts parses --ws flag from query argv', () => { + assert.ok( + cliTs.includes("if (a === '--ws' && argv[i + 1])"), + 'cli.ts query permissive parser must handle the --ws flag', + ); + }); +});