Files
msd-core/sdk/src/research-gate.ts
Tom Boucher cc6689aca8 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>
2026-04-03 14:19:34 -04:00

95 lines
3.5 KiB
TypeScript

/**
* Research gate — validates RESEARCH.md for unresolved open questions
* before allowing plan-phase to proceed (#1602).
*
* Pure functions: no I/O, no side effects. The caller reads the file
* and passes the content string.
*/
// ─── Types ──────────────────────────────────────────────────────────────────
export interface ResearchGateResult {
/** Whether research is clear to proceed to planning */
pass: boolean;
/** Unresolved questions found (empty if pass=true) */
unresolvedQuestions: string[];
}
// ─── Open questions detection ───────────────────────────────────────────────
/**
* Check RESEARCH.md content for unresolved open questions.
*
* Rules:
* - If no "## Open Questions" section exists → pass
* - If section header has "(RESOLVED)" suffix → pass
* - If section exists but is empty (only whitespace before next heading) → pass
* - Otherwise → fail with list of unresolved questions
*/
export function checkResearchGate(researchContent: string): ResearchGateResult {
// Find "## Open Questions" section (case-insensitive)
const sectionMatch = researchContent.match(
/^##\s+Open\s+Questions\b([^\n]*)/im,
);
if (!sectionMatch) {
return { pass: true, unresolvedQuestions: [] };
}
// Check for (RESOLVED) suffix on the heading
const headingSuffix = sectionMatch[1].trim();
if (/\(resolved\)/i.test(headingSuffix)) {
return { pass: true, unresolvedQuestions: [] };
}
// Extract section content until next heading or EOF
const headingIndex = researchContent.indexOf(sectionMatch[0]);
const afterHeading = researchContent.slice(headingIndex + sectionMatch[0].length);
// Find next heading at same or higher level
const nextHeadingMatch = afterHeading.match(/\n##\s+[^\n]/);
const sectionBody = nextHeadingMatch
? afterHeading.slice(0, nextHeadingMatch.index)
: afterHeading;
// Extract question items (numbered list or bullet points)
const unresolvedQuestions: string[] = [];
let totalQuestionLines = 0;
const lines = sectionBody.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Match: "1. **Question**", "- **Question**", "* **Question**", "1. Question"
const questionMatch = trimmed.match(
/^(?:\d+[.)]\s*|\*\s+|-\s+)\*{0,2}([^*\n]+)\*{0,2}/,
);
if (questionMatch) {
totalQuestionLines++;
const questionText = questionMatch[1].trim();
// Skip questions marked as resolved inline (handles — RESOLVED, - RESOLVED, RESOLVED:, etc.)
if (!/\bresolved\b/i.test(trimmed)) {
unresolvedQuestions.push(questionText);
}
}
}
// Empty section body → pass
if (sectionBody.trim() === '') {
return { pass: true, unresolvedQuestions: [] };
}
// All question lines were resolved → pass
if (totalQuestionLines > 0 && unresolvedQuestions.length === 0) {
return { pass: true, unresolvedQuestions: [] };
}
// Unresolved questions found → fail
if (unresolvedQuestions.length > 0) {
return { pass: false, unresolvedQuestions };
}
// Section has content but no parseable question lines → fail conservatively
// (e.g., prose-style questions without list formatting)
return { pass: false, unresolvedQuestions: ['(unstructured open questions detected — review ## Open Questions section)'] };
}