diff --git a/sdk/src/query/commit.ts b/sdk/src/query/commit.ts index e477fe116..6a714cbb8 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, _workstream) => { +export const commit: QueryHandler = async (args, projectDir, workstream) => { const allArgs = [...args]; // Extract flags @@ -117,7 +117,7 @@ export const commit: QueryHandler = async (args, projectDir, _workstream) => { // Check commit_docs config unless --force if (!hasForce) { - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); try { const raw = await readFile(paths.config, 'utf-8'); const config = JSON.parse(raw) as Record; @@ -180,8 +180,8 @@ export const commit: QueryHandler = async (args, projectDir, _workstream) => { * @param projectDir - Project root directory * @returns QueryResult with { can_commit, reason, commit_docs, staged_files } */ -export const checkCommit: QueryHandler = async (_args, projectDir, _workstream) => { - const paths = planningPaths(projectDir); +export const checkCommit: QueryHandler = async (_args, projectDir, workstream) => { + const paths = planningPaths(projectDir, workstream); let commitDocs = true; try { @@ -227,7 +227,7 @@ export const checkCommit: QueryHandler = async (_args, projectDir, _workstream) // ─── commitToSubrepo ───────────────────────────────────────────────────── -export const commitToSubrepo: QueryHandler = async (args, projectDir, _workstream) => { +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']); @@ -239,7 +239,7 @@ export const commitToSubrepo: QueryHandler = async (args, projectDir, _workstrea return { data: { committed: false, reason: 'commit message required' } }; } - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); let config: Record = {}; try { const raw = await readFile(paths.config, 'utf-8'); diff --git a/sdk/src/query/config-mutation.ts b/sdk/src/query/config-mutation.ts index 010f2de70..5dcbb3ab6 100644 --- a/sdk/src/query/config-mutation.ts +++ b/sdk/src/query/config-mutation.ts @@ -186,7 +186,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, _workstream) => { +export const configSet: QueryHandler = async (args, projectDir, workstream) => { const keyPath = args[0]; const rawValue = args[1]; if (!keyPath) { @@ -214,7 +214,7 @@ export const configSet: QueryHandler = async (args, projectDir, _workstream) => } // D6: Lock protection for read-modify-write (match CJS config.cjs:296) - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const lockPath = await acquireStateLock(paths.config); let previousValue: unknown; try { @@ -255,7 +255,7 @@ export const configSet: QueryHandler = async (args, projectDir, _workstream) => * @returns QueryResult with { set: true, profile, agents } * @throws GSDError with Validation if profile is invalid */ -export const configSetModelProfile: QueryHandler = async (args, projectDir, _workstream) => { +export const configSetModelProfile: QueryHandler = async (args, projectDir, workstream) => { const profileName = args[0]; if (!profileName) { throw new GSDError( @@ -273,7 +273,7 @@ export const configSetModelProfile: QueryHandler = async (args, projectDir, _wor } // D6: Lock protection for read-modify-write - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); const lockPath = await acquireStateLock(paths.config); let previousProfile = 'balanced'; try { @@ -317,8 +317,8 @@ export const configSetModelProfile: QueryHandler = async (args, projectDir, _wor * @param projectDir - Project root directory * @returns QueryResult with { created: true, path } or { created: false, reason } */ -export const configNewProject: QueryHandler = async (args, projectDir, _workstream) => { - const paths = planningPaths(projectDir); +export const configNewProject: QueryHandler = async (args, projectDir, workstream) => { + const paths = planningPaths(projectDir, workstream); // Idempotent: don't overwrite existing config if (existsSync(paths.config)) { @@ -443,13 +443,13 @@ export const configNewProject: QueryHandler = async (args, projectDir, _workstre * @param projectDir - Project root directory * @returns QueryResult with { ensured: true, section } */ -export const configEnsureSection: QueryHandler = async (args, projectDir, _workstream) => { +export const configEnsureSection: QueryHandler = async (args, projectDir, workstream) => { const sectionName = args[0]; if (!sectionName) { throw new GSDError('Usage: config-ensure-section
', ErrorClassification.Validation); } - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); let config: Record = {}; try { const raw = await readFile(paths.config, 'utf-8'); diff --git a/sdk/src/query/config-query.test.ts b/sdk/src/query/config-query.test.ts index 03cf0e98d..634363305 100644 --- a/sdk/src/query/config-query.test.ts +++ b/sdk/src/query/config-query.test.ts @@ -165,6 +165,31 @@ describe('resolveModel', () => { const data = result.data as Record; expect(data).toHaveProperty('model', ''); }); + + it('resolveModel uses workstream config when --ws is specified', async () => { + const { resolveModel } = await import('./config-query.js'); + // Root config: balanced profile → gsd-executor resolves to 'sonnet' + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'balanced' }), + ); + // Workstream config: quality profile → gsd-executor resolves to 'opus' + await mkdir(join(tmpDir, '.planning', 'workstreams', 'frontend'), { recursive: true }); + await writeFile( + join(tmpDir, '.planning', 'workstreams', 'frontend', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + + const rootResult = await resolveModel(['gsd-executor'], tmpDir); + const rootData = rootResult.data as Record; + expect(rootData.profile).toBe('balanced'); + expect(rootData.model).toBe('sonnet'); + + const wsResult = await resolveModel(['gsd-executor'], tmpDir, 'frontend'); + const wsData = wsResult.data as Record; + expect(wsData.profile).toBe('quality'); + expect(wsData.model).toBe('opus'); + }); }); // ─── MODEL_PROFILES ───────────────────────────────────────────────────────── diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index da2866d5c..bc22ba6be 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -79,13 +79,13 @@ 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, _workstream) => { +export const configGet: QueryHandler = async (args, projectDir, workstream) => { const keyPath = args[0]; if (!keyPath) { throw new GSDError('Usage: config-get ', ErrorClassification.Validation); } - const paths = planningPaths(projectDir); + const paths = planningPaths(projectDir, workstream); let raw: string; try { raw = await readFile(paths.config, 'utf-8'); @@ -129,8 +129,8 @@ export const configGet: QueryHandler = async (args, projectDir, _workstream) => * @param projectDir - Project root directory * @returns QueryResult with `{ path: string }` absolute or project-relative resolution via planningPaths */ -export const configPath: QueryHandler = async (_args, projectDir, _workstream) => { - const paths = planningPaths(projectDir); +export const configPath: QueryHandler = async (_args, projectDir, workstream) => { + const paths = planningPaths(projectDir, workstream); return { data: { path: paths.config } }; }; @@ -144,16 +144,18 @@ export const configPath: QueryHandler = async (_args, projectDir, _workstream) = * * @param args - args[0] is the agent type (e.g., 'gsd-planner') * @param projectDir - Project root directory + * @param workstream - Optional workstream name; forwarded to loadConfig so per-workstream + * model_profile settings are respected (mirrors configGet/configPath behavior) * @returns QueryResult with { model, profile } or { model, profile, unknown_agent: true } * @throws GSDError with Validation classification if agent type not provided */ -export const resolveModel: QueryHandler = async (args, projectDir) => { +export const resolveModel: QueryHandler = async (args, projectDir, workstream) => { const agentType = args[0]; if (!agentType) { throw new GSDError('agent-type required', ErrorClassification.Validation); } - const config = await loadConfig(projectDir); + const config = await loadConfig(projectDir, workstream); const profile = String(config.model_profile || 'balanced').toLowerCase(); // Check per-agent override first diff --git a/sdk/src/query/init-complex.test.ts b/sdk/src/query/init-complex.test.ts index 53256a77c..c5d2a854d 100644 --- a/sdk/src/query/init-complex.test.ts +++ b/sdk/src/query/init-complex.test.ts @@ -373,3 +373,112 @@ describe('initManager', () => { }); }); }); + +// ─── Workstream path threading tests (#2731) ───────────────────────────────── + +const WORKSTREAM_CONFIG = JSON.stringify({ + model_profile: 'balanced', + commit_docs: false, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, +}); + +const WORKSTREAM_STATE = [ + '---', + 'milestone: v1.0', + 'status: executing', + '---', + '', + '# Project State', +].join('\n'); + +const WORKSTREAM_ROADMAP = [ + '# Roadmap', + '', + '## v1.0: Ops Milestone', + '', + '### Phase 1: Weave Cron', + '', + '**Goal:** Set up cron jobs', + '', + '### Phase 2: Alerts', + '', + '**Goal:** Set up alerting', + '', +].join('\n'); + +describe('initProgress workstream (#2731)', () => { + it('scans phases from workstream subdirectory, not root', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'gsd-ws-progress-')); + try { + const wsBase = join(tmp, '.planning', 'workstreams', 'production-support'); + + // Root .planning has NO phases — if workstream ignored, result will be empty + await mkdir(join(tmp, '.planning'), { recursive: true }); + await writeFile(join(tmp, '.planning', 'config.json'), WORKSTREAM_CONFIG); + + // Workstream-scoped structure + await mkdir(join(wsBase, 'phases', '01-weave-cron'), { recursive: true }); + await mkdir(join(wsBase, 'phases', '02-alerts'), { recursive: true }); + await writeFile(join(wsBase, 'config.json'), WORKSTREAM_CONFIG); + await writeFile(join(wsBase, 'STATE.md'), WORKSTREAM_STATE); + await writeFile(join(wsBase, 'ROADMAP.md'), WORKSTREAM_ROADMAP); + + // Phase 01: plan + summary (complete) + await writeFile(join(wsBase, 'phases', '01-weave-cron', '01-01-PLAN.md'), '# Plan'); + await writeFile(join(wsBase, 'phases', '01-weave-cron', '01-01-SUMMARY.md'), '# Done'); + + // Phase 02: plan only (in_progress) + await writeFile(join(wsBase, 'phases', '02-alerts', '02-01-PLAN.md'), '# Plan'); + + const result = await initProgress([], tmp, 'production-support'); + const data = result.data as Record; + const phases = data.phases as Record[]; + + expect(phases.length).toBeGreaterThan(0); + const phase1 = phases.find(p => (p.number as string).startsWith('01') || p.number === '1'); + expect(phase1).toBeDefined(); + expect(phase1?.status).toBe('complete'); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); +}); + +describe('initManager workstream (#2731)', () => { + it('reads ROADMAP.md from workstream subdirectory, not root', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'gsd-ws-manager-')); + try { + const wsBase = join(tmp, '.planning', 'workstreams', 'production-support'); + + // Root .planning has no ROADMAP — if workstream ignored, initManager errors + await mkdir(join(tmp, '.planning'), { recursive: true }); + await writeFile(join(tmp, '.planning', 'config.json'), WORKSTREAM_CONFIG); + + // Workstream-scoped structure + await mkdir(join(wsBase, 'phases', '01-weave-cron'), { recursive: true }); + await writeFile(join(wsBase, 'config.json'), WORKSTREAM_CONFIG); + await writeFile(join(wsBase, 'STATE.md'), WORKSTREAM_STATE); + await writeFile(join(wsBase, 'ROADMAP.md'), WORKSTREAM_ROADMAP); + await writeFile(join(wsBase, 'phases', '01-weave-cron', '01-01-PLAN.md'), '# Plan'); + + const result = await initManager([], tmp, 'production-support'); + const data = result.data as Record; + + // Should NOT return error (no ROADMAP found at root) + expect(data.error).toBeUndefined(); + // Should find phases from the workstream ROADMAP + const phases = data.phases as Record[]; + expect(phases.length).toBeGreaterThan(0); + const phase1 = phases.find(p => p.number === '1'); + expect(phase1).toBeDefined(); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/sdk/src/query/init-complex.ts b/sdk/src/query/init-complex.ts index f3c2a869a..d85e19a03 100644 --- a/sdk/src/query/init-complex.ts +++ b/sdk/src/query/init-complex.ts @@ -93,8 +93,8 @@ function deriveStatusFromCheckbox( * * Port of cmdInitNewProject from init.cjs lines 296-399. */ -export const initNewProject: QueryHandler = async (_args, projectDir, _workstream) => { - const config = await loadConfig(projectDir); +export const initNewProject: QueryHandler = async (_args, projectDir, workstream) => { + const config = await loadConfig(projectDir, workstream); // Detect search API key availability from env vars and ~/.gsd/ files const gsdHome = join(homedir(), '.gsd'); @@ -209,10 +209,10 @@ export const initNewProject: QueryHandler = async (_args, projectDir, _workstrea * * Port of cmdInitProgress from init.cjs lines 1139-1284. */ -export const initProgress: QueryHandler = async (_args, projectDir, _workstream) => { - const config = await loadConfig(projectDir); - const milestone = await getMilestoneInfo(projectDir); - const paths = planningPaths(projectDir); +export const initProgress: QueryHandler = async (_args, projectDir, workstream) => { + const config = await loadConfig(projectDir, workstream); + const milestone = await getMilestoneInfo(projectDir, workstream); + const paths = planningPaths(projectDir, workstream); const phases: Record[] = []; let currentPhase: Record | null = null; @@ -225,7 +225,7 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream) try { const rawRoadmap = await readFile(paths.roadmap, 'utf-8'); - const roadmapContent = await extractCurrentMilestone(rawRoadmap, projectDir); + const roadmapContent = await extractCurrentMilestone(rawRoadmap, projectDir, workstream); const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi; let hm: RegExpExecArray | null; while ((hm = headingPattern.exec(roadmapContent)) !== null) { @@ -372,10 +372,10 @@ export const initProgress: QueryHandler = async (_args, projectDir, _workstream) * * Port of cmdInitManager from init.cjs lines 854-1137. */ -export const initManager: QueryHandler = async (_args, projectDir, _workstream) => { - const config = await loadConfig(projectDir); - const milestone = await getMilestoneInfo(projectDir); - const paths = planningPaths(projectDir); +export const initManager: QueryHandler = async (_args, projectDir, workstream) => { + const config = await loadConfig(projectDir, workstream); + const milestone = await getMilestoneInfo(projectDir, workstream); + const paths = planningPaths(projectDir, workstream); let rawContent: string; try { @@ -384,7 +384,7 @@ export const initManager: QueryHandler = async (_args, projectDir, _workstream) return { data: { error: 'No ROADMAP.md found. Run /gsd-new-milestone first.' } }; } - const content = await extractCurrentMilestone(rawContent, projectDir); + const content = await extractCurrentMilestone(rawContent, projectDir, workstream); // Pre-compute directory listing once let phaseDirEntries: string[] = []; diff --git a/sdk/src/query/validate.ts b/sdk/src/query/validate.ts index c770dff97..1c6e35de6 100644 --- a/sdk/src/query/validate.ts +++ b/sdk/src/query/validate.ts @@ -344,7 +344,7 @@ export const validateConsistency: QueryHandler = async (_args, projectDir, works * @param projectDir - Project root directory * @returns QueryResult with { status, errors, warnings, info, repairable_count, repairs_performed? } */ -export const validateHealth: QueryHandler = async (args, projectDir, _workstream) => { +export const validateHealth: QueryHandler = async (args, projectDir, workstream) => { const doRepair = args.includes('--repair'); // T-12-09: Home directory guard @@ -365,13 +365,13 @@ export const validateHealth: QueryHandler = async (args, projectDir, _workstream }; } - const paths = planningPaths(projectDir); - const planBase = join(projectDir, '.planning'); + const paths = planningPaths(projectDir, workstream); + const planBase = paths.planning; const projectPath = join(planBase, 'PROJECT.md'); - const roadmapPath = join(planBase, 'ROADMAP.md'); - const statePath = join(planBase, 'STATE.md'); - const configPath = join(planBase, 'config.json'); - const phasesDir = join(planBase, 'phases'); + const roadmapPath = paths.roadmap; + const statePath = paths.state; + const configPath = paths.config; + const phasesDir = paths.phases; interface Issue { code: string;