From 6deef7e7edbc2ff049432a8a7794b77bf5760218 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 17 Apr 2026 09:20:26 -0400 Subject: [PATCH] feat(init): allow parallel discuss across independent phases (#2268) (#2357) The sliding-window pattern serialized discuss to one phase at a time even when phases had no dependency relationship. Replaced it with a simple predicate: every undiscussed phase whose dependencies are satisfied is marked is_next_to_discuss, letting the user pick any of them from the manager's recommended_actions list. Closes #2268 Co-authored-by: Claude Sonnet 4.6 --- get-shit-done/bin/lib/init.cjs | 11 +-- tests/bug-2268-parallel-discuss.test.cjs | 112 +++++++++++++++++++++++ tests/init-manager.test.cjs | 35 +++---- 3 files changed, 133 insertions(+), 25 deletions(-) create mode 100644 tests/bug-2268-parallel-discuss.test.cjs diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 843b62800..7754be481 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -1080,15 +1080,10 @@ function cmdInitManager(cwd, raw) { : '—'; } - // Sliding window: discuss is sequential — only the first undiscussed phase is available - let foundNextToDiscuss = false; for (const phase of phases) { - if (!foundNextToDiscuss && (phase.disk_status === 'empty' || phase.disk_status === 'no_directory')) { - phase.is_next_to_discuss = true; - foundNextToDiscuss = true; - } else { - phase.is_next_to_discuss = false; - } + phase.is_next_to_discuss = + (phase.disk_status === 'empty' || phase.disk_status === 'no_directory') && + phase.deps_satisfied; } // Check for WAITING.json signal diff --git a/tests/bug-2268-parallel-discuss.test.cjs b/tests/bug-2268-parallel-discuss.test.cjs new file mode 100644 index 000000000..9ee613db9 --- /dev/null +++ b/tests/bug-2268-parallel-discuss.test.cjs @@ -0,0 +1,112 @@ +/** + * Regression test for bug #2268 + * + * cmdInitProgress used a sliding-window pattern that set is_next_to_discuss + * only on the FIRST undiscussed phase. Multiple independent undiscussed phases + * could not be discussed in parallel — the manager only ever recommended one + * discuss action at a time. + * + * Fix: mark ALL undiscussed phases as is_next_to_discuss = true so the user + * can pick any of them. + */ + +'use strict'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +function writeRoadmap(tmpDir, phases) { + const sections = phases.map(p => { + let section = `### Phase ${p.number}: ${p.name}\n\n**Goal:** Do the thing\n`; + return section; + }).join('\n'); + const checklist = phases.map(p => { + const mark = p.complete ? 'x' : ' '; + return `- [${mark}] **Phase ${p.number}: ${p.name}**`; + }).join('\n'); + fs.writeFileSync( + path.join(tmpDir, '.planning', 'ROADMAP.md'), + `# Roadmap\n\n## Progress\n\n${checklist}\n\n${sections}` + ); +} + +function writeState(tmpDir) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '---\nstatus: active\n---\n# State\n'); +} + +let tmpDir; + +describe('bug #2268: parallel discuss — all undiscussed phases marked is_next_to_discuss', () => { + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('two undiscussed phases: both marked is_next_to_discuss', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation' }, + { number: '2', name: 'Cloud Deployment' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.strictEqual(output.phases[0].is_next_to_discuss, true, 'phase 1 should be discussable'); + assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'phase 2 should also be discussable'); + }); + + test('two undiscussed phases: both get discuss recommendations', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation' }, + { number: '2', name: 'Cloud Deployment' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + const discussActions = output.recommended_actions.filter(a => a.action === 'discuss'); + assert.strictEqual(discussActions.length, 2, 'should recommend discuss for both undiscussed phases'); + + const phases = discussActions.map(a => a.phase).sort(); + assert.deepStrictEqual(phases, ['1', '2']); + }); + + test('five undiscussed phases: all five marked is_next_to_discuss', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Alpha' }, + { number: '2', name: 'Beta' }, + { number: '3', name: 'Gamma' }, + { number: '4', name: 'Delta' }, + { number: '5', name: 'Epsilon' }, + ]); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + for (const phase of output.phases) { + assert.strictEqual(phase.is_next_to_discuss, true, `phase ${phase.number} should be discussable`); + } + }); + + test('discussed phase stays false; undiscussed sibling is true', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [ + { number: '1', name: 'Foundation' }, + { number: '2', name: 'API Layer' }, + ]); + // scaffold CONTEXT.md to mark phase 1 as discussed + const dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, '01-CONTEXT.md'), '# Context'); + + const result = runGsdTools('init manager', tmpDir); + const output = JSON.parse(result.output); + + assert.strictEqual(output.phases[0].is_next_to_discuss, false, 'discussed phase must not be is_next_to_discuss'); + assert.strictEqual(output.phases[1].is_next_to_discuss, true, 'undiscussed sibling must be is_next_to_discuss'); + }); +}); diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs index 8f1d37794..de31a77f3 100644 --- a/tests/init-manager.test.cjs +++ b/tests/init-manager.test.cjs @@ -160,7 +160,7 @@ describe('init manager', () => { assert.strictEqual(output.phases[1].deps_satisfied, false); // phase 1 not complete }); - test('sliding window: only first undiscussed phase is next to discuss', () => { + test('parallel discuss: all undiscussed phases are marked is_next_to_discuss', () => { writeState(tmpDir); writeRoadmap(tmpDir, [ { number: '1', name: 'Foundation' }, @@ -171,18 +171,17 @@ describe('init manager', () => { const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); - // Only phase 1 should be discussable + // All three phases are undiscussed — all should be discussable assert.strictEqual(output.phases[0].is_next_to_discuss, true); - assert.strictEqual(output.phases[1].is_next_to_discuss, false); - assert.strictEqual(output.phases[2].is_next_to_discuss, false); + assert.strictEqual(output.phases[1].is_next_to_discuss, true); + assert.strictEqual(output.phases[2].is_next_to_discuss, true); - // Only recommendation should be discuss phase 1 - assert.strictEqual(output.recommended_actions.length, 1); - assert.strictEqual(output.recommended_actions[0].action, 'discuss'); - assert.strictEqual(output.recommended_actions[0].phase, '1'); + // All three should have discuss recommendations + const discussActions = output.recommended_actions.filter(a => a.action === 'discuss'); + assert.strictEqual(discussActions.length, 3); }); - test('sliding window: after discussing N, plan N + discuss N+1', () => { + test('discussed phase is not discussable; undiscussed siblings are', () => { writeState(tmpDir); writeRoadmap(tmpDir, [ { number: '1', name: 'Foundation' }, @@ -196,16 +195,18 @@ describe('init manager', () => { const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); - // Phase 1 is discussed, phase 2 is next to discuss + // Phase 1 is discussed; phases 2 and 3 are both undiscussed and discussable assert.strictEqual(output.phases[0].is_next_to_discuss, false); assert.strictEqual(output.phases[1].is_next_to_discuss, true); - assert.strictEqual(output.phases[2].is_next_to_discuss, false); + assert.strictEqual(output.phases[2].is_next_to_discuss, true); - // Should recommend plan phase 1 AND discuss phase 2 + // Should recommend plan phase 1 AND discuss phases 2 and 3 const phase1Rec = output.recommended_actions.find(r => r.phase === '1'); const phase2Rec = output.recommended_actions.find(r => r.phase === '2'); + const phase3Rec = output.recommended_actions.find(r => r.phase === '3'); assert.strictEqual(phase1Rec.action, 'plan'); assert.strictEqual(phase2Rec.action, 'discuss'); + assert.strictEqual(phase3Rec.action, 'discuss'); }); test('sliding window: full pipeline — execute N, plan N+1, discuss N+2', () => { @@ -225,17 +226,17 @@ describe('init manager', () => { const result = runGsdTools('init manager', tmpDir); const output = JSON.parse(result.output); - // Phase 4 is first undiscussed + // Phases 4 and 5 are both undiscussed — both discussable assert.strictEqual(output.phases[3].is_next_to_discuss, true); - assert.strictEqual(output.phases[4].is_next_to_discuss, false); + assert.strictEqual(output.phases[4].is_next_to_discuss, true); - // Recommendations: execute 2, plan 3, discuss 4 + // Recommendations: execute 2, plan 3, discuss 4, discuss 5 assert.strictEqual(output.recommended_actions[0].action, 'execute'); assert.strictEqual(output.recommended_actions[0].phase, '2'); assert.strictEqual(output.recommended_actions[1].action, 'plan'); assert.strictEqual(output.recommended_actions[1].phase, '3'); - assert.strictEqual(output.recommended_actions[2].action, 'discuss'); - assert.strictEqual(output.recommended_actions[2].phase, '4'); + const discussRecs = output.recommended_actions.filter(a => a.action === 'discuss').map(a => a.phase).sort(); + assert.deepStrictEqual(discussRecs, ['4', '5']); }); test('recommendation ordering: execute > plan > discuss', () => {