fix(phase-runner): add research gate to block planning on unresolved open questions (#1618)

* chore: add v1.31.0 npm known-issue notice to issue template config

Adds a top-priority contact link to the issue template chooser so users
are redirected to the Discussions announcement before opening a duplicate
issue about v1.31.0 not being on npm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(phase-runner): add research gate to block planning on unresolved open questions (#1602)

Plan-phase could proceed to planning even when RESEARCH.md had unresolved
open questions in its ## Open Questions section. This caused agents to
plan and execute with fundamental design decisions still undecided.

- Add `research-gate.ts` with pure `checkResearchGate()` function that
  parses RESEARCH.md for unresolved open questions
- Integrate gate into PhaseRunner between research (step 2) and plan
  (step 3) using existing `invokeBlockerCallback` pattern
- Add Dimension 11 (Research Resolution) to gsd-plan-checker.md agent
- Gate passes when: no Open Questions section, section has (RESOLVED)
  suffix, all individual questions marked RESOLVED, or section is empty
- Gate fires `onBlockerDecision` callback with PhaseStepType.Research
  and lists the unresolved questions in the error message
- Auto-approves (skip) when no callback registered (headless mode)
- 18 new tests: 13 unit tests for checkResearchGate, 5 integration
  tests for PhaseRunner research gate behavior

Closes #1602

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-03 14:19:34 -04:00
committed by GitHub
parent 6d24b597a0
commit cc6689aca8
6 changed files with 528 additions and 1 deletions

View File

@@ -26,6 +26,9 @@ import type { PromptFactory } from './phase-prompt.js';
import type { ContextEngine } from './context-engine.js';
import type { GSDLogger } from './logger.js';
import { runPhaseStepSession, runPlanSession } from './session-runner.js';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { checkResearchGate } from './research-gate.js';
// ─── Error type ──────────────────────────────────────────────────────────────
@@ -185,6 +188,21 @@ export class PhaseRunner {
}
}
// ── Step 2.5: Research gate (#1602) ──
// Check RESEARCH.md for unresolved open questions before planning
if (!halted && phaseOp.has_research) {
const gateResult = await this.checkResearchGate(phaseOp);
if (!gateResult.pass) {
const questionList = gateResult.unresolvedQuestions.join(', ');
const error = `RESEARCH.md has unresolved open questions: ${questionList}`;
this.logger?.warn(error, { phase: phaseNumber });
const decision = await this.invokeBlockerCallback(callbacks, phaseNumber, PhaseStepType.Research, error);
if (decision === 'stop') {
halted = true;
}
}
}
// ── Step 3: Plan ──
if (!halted) {
const result = await this.retryOnce('plan', () => this.runStep(PhaseStepType.Plan, phaseNumber, sessionOpts));
@@ -1076,6 +1094,22 @@ export class PhaseRunner {
return 'gaps_found';
}
/**
* Check RESEARCH.md for unresolved open questions (#1602).
* Returns the gate result — pass means safe to proceed to planning.
*/
private async checkResearchGate(phaseOp: PhaseOpInfo): Promise<{ pass: boolean; unresolvedQuestions: string[] }> {
try {
const researchPath = phaseOp.research_path ||
join(phaseOp.phase_dir, `${phaseOp.padded_phase}-RESEARCH.md`);
const content = await readFile(researchPath, 'utf-8');
return checkResearchGate(content);
} catch {
// File doesn't exist or can't be read — pass (nothing to gate on)
return { pass: true, unresolvedQuestions: [] };
}
}
/**
* Invoke the onBlockerDecision callback, falling back to auto-approve.
*/