Merge pull request #2742 from gsd-build/fix/2731-workstream-query-handler-threadthrough

fix(query): thread workstream through all query handlers
This commit is contained in:
Tom Boucher
2026-04-26 11:49:25 -04:00
committed by GitHub
7 changed files with 175 additions and 39 deletions

View File

@@ -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<string, unknown>;
@@ -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<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');

View File

@@ -186,7 +186,7 @@ function setConfigValue(obj: Record<string, unknown>, 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 <section>', ErrorClassification.Validation);
}
const paths = planningPaths(projectDir);
const paths = planningPaths(projectDir, workstream);
let config: Record<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');

View File

@@ -165,6 +165,31 @@ describe('resolveModel', () => {
const data = result.data as Record<string, unknown>;
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<string, unknown>;
expect(rootData.profile).toBe('balanced');
expect(rootData.model).toBe('sonnet');
const wsResult = await resolveModel(['gsd-executor'], tmpDir, 'frontend');
const wsData = wsResult.data as Record<string, unknown>;
expect(wsData.profile).toBe('quality');
expect(wsData.model).toBe('opus');
});
});
// ─── MODEL_PROFILES ─────────────────────────────────────────────────────────

View File

@@ -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 <key.path>', 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

View File

@@ -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<string, unknown>;
const phases = data.phases as Record<string, unknown>[];
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<string, unknown>;
// 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<string, unknown>[];
expect(phases.length).toBeGreaterThan(0);
const phase1 = phases.find(p => p.number === '1');
expect(phase1).toBeDefined();
} finally {
await rm(tmp, { recursive: true, force: true });
}
});
});

View File

@@ -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<string, unknown>[] = [];
let currentPhase: Record<string, unknown> | 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[] = [];

View File

@@ -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;