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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-17 09:20:26 -04:00
committed by GitHub
parent 06c528be44
commit 6deef7e7ed
3 changed files with 133 additions and 25 deletions

View File

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

View File

@@ -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');
});
});

View File

@@ -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', () => {