* 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>
191 lines
5.6 KiB
TypeScript
191 lines
5.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { checkResearchGate } from './research-gate.js';
|
|
|
|
describe('checkResearchGate', () => {
|
|
// ── Pass cases ──────────────────────────────────────────────────────────
|
|
|
|
it('passes when no Open Questions section exists', () => {
|
|
const content = `# Research
|
|
|
|
## Key Findings
|
|
Everything is clear.
|
|
|
|
## Recommendations
|
|
Use TypeScript.`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(true);
|
|
expect(result.unresolvedQuestions).toEqual([]);
|
|
});
|
|
|
|
it('passes when Open Questions section has (RESOLVED) suffix', () => {
|
|
const content = `# Research
|
|
|
|
## Open Questions (RESOLVED)
|
|
|
|
1. **Hash prefix** — RESOLVED: Use "guest_contract:"
|
|
2. **Cache TTL** — RESOLVED: 5 minutes`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(true);
|
|
expect(result.unresolvedQuestions).toEqual([]);
|
|
});
|
|
|
|
it('passes when Open Questions section is empty', () => {
|
|
const content = `# Research
|
|
|
|
## Open Questions
|
|
|
|
## Next Steps
|
|
Proceed to planning.`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(true);
|
|
expect(result.unresolvedQuestions).toEqual([]);
|
|
});
|
|
|
|
it('passes when all individual questions are marked RESOLVED', () => {
|
|
const content = `# Research
|
|
|
|
## Open Questions
|
|
|
|
1. **Hash prefix** — RESOLVED: Use "guest_contract:"
|
|
2. **Cache strategy** — RESOLVED: Use Redis with 5min TTL`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(true);
|
|
expect(result.unresolvedQuestions).toEqual([]);
|
|
});
|
|
|
|
it('passes with empty research content', () => {
|
|
const result = checkResearchGate('');
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
// ── Fail cases ──────────────────────────────────────────────────────────
|
|
|
|
it('fails when Open Questions section has unresolved numbered items', () => {
|
|
const content = `# Research
|
|
|
|
## Open Questions
|
|
|
|
1. **Hash prefix** — keep or change?
|
|
2. **Cache TTL** — what duration?
|
|
|
|
## Recommendations
|
|
Use TypeScript.`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.unresolvedQuestions).toHaveLength(2);
|
|
expect(result.unresolvedQuestions[0]).toContain('Hash prefix');
|
|
expect(result.unresolvedQuestions[1]).toContain('Cache TTL');
|
|
});
|
|
|
|
it('fails when Open Questions has bullet-point items', () => {
|
|
const content = `# Research
|
|
|
|
## Open Questions
|
|
|
|
- **Auth strategy** — OAuth vs API keys?
|
|
- **Database** — Postgres or SQLite?`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.unresolvedQuestions).toHaveLength(2);
|
|
});
|
|
|
|
it('fails with mix of resolved and unresolved questions', () => {
|
|
const content = `# Research
|
|
|
|
## Open Questions
|
|
|
|
1. **Hash prefix** — RESOLVED: Use "guest_contract:"
|
|
2. **Cache TTL** — what duration?
|
|
3. **Auth flow** — RESOLVED: OAuth2
|
|
4. **Rate limiting** — needs decision`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.unresolvedQuestions).toHaveLength(2);
|
|
expect(result.unresolvedQuestions[0]).toContain('Cache TTL');
|
|
expect(result.unresolvedQuestions[1]).toContain('Rate limiting');
|
|
});
|
|
|
|
it('fails with prose-style open questions (no list formatting)', () => {
|
|
const content = `# Research
|
|
|
|
## Open Questions
|
|
|
|
We still need to determine the hashing strategy and whether
|
|
the cache should be shared across instances.
|
|
|
|
## Recommendations
|
|
Use TypeScript.`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.unresolvedQuestions).toHaveLength(1);
|
|
expect(result.unresolvedQuestions[0]).toContain('unstructured');
|
|
});
|
|
|
|
it('fails when Open Questions is the last section (no next heading)', () => {
|
|
const content = `# Research
|
|
|
|
## Key Findings
|
|
Good stuff.
|
|
|
|
## Open Questions
|
|
|
|
1. **Deployment strategy** — containers vs serverless?`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.unresolvedQuestions).toHaveLength(1);
|
|
expect(result.unresolvedQuestions[0]).toContain('Deployment strategy');
|
|
});
|
|
|
|
// ── Edge cases ──────────────────────────────────────────────────────────
|
|
|
|
it('handles case-insensitive heading match', () => {
|
|
const content = `# Research
|
|
|
|
## open questions
|
|
|
|
1. **Something** — unclear`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(false);
|
|
});
|
|
|
|
it('does not match subsection headings (### Open Questions)', () => {
|
|
// Only ## level headings should trigger the gate
|
|
const content = `# Research
|
|
|
|
## Findings
|
|
|
|
### Open Questions
|
|
These are just notes, not blocking.
|
|
|
|
1. **Minor thing** — just a thought`;
|
|
|
|
// ### level = subsection under Findings, not the formal gate section
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(true);
|
|
});
|
|
|
|
it('handles asterisk-style bullet points', () => {
|
|
const content = `# Research
|
|
|
|
## Open Questions
|
|
|
|
* **Strategy A** — needs evaluation
|
|
* **Strategy B** — RESOLVED: go with B`;
|
|
|
|
const result = checkResearchGate(content);
|
|
expect(result.pass).toBe(false);
|
|
expect(result.unresolvedQuestions).toHaveLength(1);
|
|
expect(result.unresolvedQuestions[0]).toContain('Strategy A');
|
|
});
|
|
});
|