feat(discuss-phase): cross-reference pending todos against phase scope

Add a cross_reference_todos step to discuss-phase that surfaces relevant
backlog items before scope-setting decisions are made.

Implementation:
- New 'todo match-phase <N>' CLI command (commands.cjs) that scores
  pending todos against a phase's ROADMAP goal using three heuristics:
  keyword overlap, area match, and file path overlap
- New cross_reference_todos step in discuss-phase.md between
  load_prior_context and scout_codebase
- CONTEXT.md template gains 'Folded Todos' subsection in <decisions>
  and 'Reviewed Todos (not folded)' subsection in <deferred>

Design:
- No AI call for matching — pure keyword/area/file heuristics for speed
- Silent skip when todo_count is 0 or no matches (no workflow slowdown)
- Auto mode folds all todos with score >= 0.4 automatically
- Scoring: keywords (up to 0.6), area match (0.3), file overlap (0.4)

Tests: 5 new tests covering empty state, keyword matching, unrelated
todo exclusion, area matching, and score sorting.

Closes #1111
This commit is contained in:
Tom Boucher
2026-03-16 16:51:24 -04:00
parent fc468adb42
commit e84999663c
4 changed files with 273 additions and 2 deletions

View File

@@ -534,8 +534,10 @@ async function main() {
const subcommand = args[1];
if (subcommand === 'complete') {
commands.cmdTodoComplete(cwd, args[2], raw);
} else if (subcommand === 'match-phase') {
commands.cmdTodoMatchPhase(cwd, args[2], raw);
} else {
error('Unknown todo subcommand. Available: complete');
error('Unknown todo subcommand. Available: complete, match-phase');
}
break;
}

View File

@@ -4,7 +4,7 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal } = require('./core.cjs');
const { safeReadFile, loadConfig, isGitIgnored, execGit, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, getRoadmapPhaseInternal } = require('./core.cjs');
const { extractFrontmatter } = require('./frontmatter.cjs');
const { MODEL_PROFILES } = require('./model-profiles.cjs');
@@ -448,6 +448,130 @@ function cmdProgressRender(cwd, format, raw) {
}
}
/**
* Match pending todos against a phase's goal/name/requirements.
* Returns todos with relevance scores based on keyword, area, and file overlap.
* Used by discuss-phase to surface relevant todos before scope-setting.
*/
function cmdTodoMatchPhase(cwd, phase, raw) {
if (!phase) { error('phase required for todo match-phase'); }
const pendingDir = path.join(cwd, '.planning', 'todos', 'pending');
const todos = [];
// Load pending todos
try {
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
for (const file of files) {
try {
const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
const filesMatch = content.match(/^files:\s*(.+)$/m);
const body = content.replace(/^(title|area|files|created|priority):.*$/gm, '').trim();
todos.push({
file,
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: areaMatch ? areaMatch[1].trim() : 'general',
files: filesMatch ? filesMatch[1].trim().split(/[,\s]+/).filter(Boolean) : [],
body: body.slice(0, 200), // first 200 chars for context
});
} catch {}
}
} catch {}
if (todos.length === 0) {
output({ phase, matches: [], todo_count: 0 }, raw);
return;
}
// Load phase goal/name from ROADMAP
const phaseInfo = getRoadmapPhaseInternal(cwd, phase);
const phaseName = phaseInfo ? (phaseInfo.phase_name || '') : '';
const phaseGoal = phaseInfo ? (phaseInfo.goal || '') : '';
const phaseSection = phaseInfo ? (phaseInfo.section || '') : '';
// Build keyword set from phase name + goal + section text
const phaseText = `${phaseName} ${phaseGoal} ${phaseSection}`.toLowerCase();
const stopWords = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'will', 'are', 'was', 'has', 'have', 'been', 'not', 'but', 'all', 'can', 'into', 'each', 'when', 'any', 'use', 'new']);
const phaseKeywords = new Set(
phaseText.split(/[\s\-_/.,;:()\[\]{}|]+/)
.map(w => w.replace(/[^a-z0-9]/g, ''))
.filter(w => w.length > 2 && !stopWords.has(w))
);
// Find phase directory to get expected file paths
const phaseInfoDisk = findPhaseInternal(cwd, phase);
const phasePlans = [];
if (phaseInfoDisk && phaseInfoDisk.found) {
try {
const phaseDir = path.join(cwd, phaseInfoDisk.directory);
const planFiles = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
for (const pf of planFiles) {
try {
const planContent = fs.readFileSync(path.join(phaseDir, pf), 'utf-8');
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
if (fmFiles) {
phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean));
}
} catch {}
}
} catch {}
}
// Score each todo for relevance
const matches = [];
for (const todo of todos) {
let score = 0;
const reasons = [];
// Keyword match: todo title/body terms in phase text
const todoWords = `${todo.title} ${todo.body}`.toLowerCase()
.split(/[\s\-_/.,;:()\[\]{}|]+/)
.map(w => w.replace(/[^a-z0-9]/g, ''))
.filter(w => w.length > 2 && !stopWords.has(w));
const matchedKeywords = todoWords.filter(w => phaseKeywords.has(w));
if (matchedKeywords.length > 0) {
score += Math.min(matchedKeywords.length * 0.2, 0.6);
reasons.push(`keywords: ${[...new Set(matchedKeywords)].slice(0, 5).join(', ')}`);
}
// Area match: todo area appears in phase text
if (todo.area !== 'general' && phaseText.includes(todo.area.toLowerCase())) {
score += 0.3;
reasons.push(`area: ${todo.area}`);
}
// File match: todo files overlap with phase plan files
if (todo.files.length > 0 && phasePlans.length > 0) {
const fileOverlap = todo.files.filter(f =>
phasePlans.some(pf => pf.includes(f) || f.includes(pf))
);
if (fileOverlap.length > 0) {
score += 0.4;
reasons.push(`files: ${fileOverlap.slice(0, 3).join(', ')}`);
}
}
if (score > 0) {
matches.push({
file: todo.file,
title: todo.title,
area: todo.area,
score: Math.round(score * 100) / 100,
reasons,
});
}
}
// Sort by score descending
matches.sort((a, b) => b.score - a.score);
output({ phase, matches, todo_count: todos.length }, raw);
}
function cmdTodoComplete(cwd, filename, raw) {
if (!filename) {
error('filename required for todo complete');
@@ -704,6 +828,7 @@ module.exports = {
cmdWebsearch,
cmdProgressRender,
cmdTodoComplete,
cmdTodoMatchPhase,
cmdScaffold,
cmdStats,
};

View File

@@ -242,6 +242,47 @@ Structure the extracted information:
**If no prior context exists:** Continue without — this is expected for early phases.
</step>
<step name="cross_reference_todos">
Check if any pending todos are relevant to this phase's scope. Surfaces backlog items that might otherwise be missed.
**Load and match todos:**
```bash
TODO_MATCHES=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" todo match-phase "${PHASE_NUMBER}")
```
Parse JSON for: `todo_count`, `matches[]` (each with `file`, `title`, `area`, `score`, `reasons`).
**If `todo_count` is 0 or `matches` is empty:** Skip silently — no workflow slowdown.
**If matches found:**
Present matched todos to the user. Show each match with its title, area, and why it matched:
```
📋 Found {N} pending todo(s) that may be relevant to Phase {X}:
{For each match:}
- **{title}** (area: {area}, relevance: {score}) — matched on {reasons}
```
Use AskUserQuestion (multiSelect) asking which todos to fold into this phase's scope:
```
Which of these todos should be folded into Phase {X} scope?
(Select any that apply, or none to skip)
```
**For selected (folded) todos:**
- Store internally as `<folded_todos>` for inclusion in CONTEXT.md `<decisions>` section
- These become additional scope items that downstream agents (researcher, planner) will see
**For unselected (reviewed but not folded) todos:**
- Store internally as `<reviewed_todos>` for inclusion in CONTEXT.md `<deferred>` section
- This prevents future phases from re-surfacing the same todos as "missed"
**Auto mode (`--auto`):** Fold all todos with score >= 0.4 automatically. Log the selection.
</step>
<step name="scout_codebase">
Lightweight scan of existing code to inform gray area identification and discussion. Uses ~10% context — acceptable for an interactive session.
@@ -544,6 +585,11 @@ mkdir -p ".planning/phases/${padded_phase}-${phase_slug}"
### Claude's Discretion
[Areas where user said "you decide" — note that Claude has flexibility here]
### Folded Todos
[If any todos were folded into scope from the cross_reference_todos step, list them here.
Each entry should include the todo title, original problem, and how it fits this phase's scope.
If no todos were folded: omit this subsection entirely.]
</decisions>
<canonical_refs>
@@ -595,6 +641,12 @@ Every entry needs a full relative path — not just a name.]
[Ideas that came up but belong in other phases. Don't lose them.]
### Reviewed Todos (not folded)
[If any todos were reviewed in cross_reference_todos but not folded into scope,
list them here so future phases know they were considered.
Each entry: todo title + reason it was deferred (out of scope, belongs in Phase Y, etc.)
If no reviewed-but-deferred todos: omit this subsection entirely.]
[If none: "None — discussion stayed within phase scope"]
</deferred>

View File

@@ -567,6 +567,98 @@ describe('todo complete command', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// todo match-phase command
// ─────────────────────────────────────────────────────────────────────────────
describe('todo match-phase command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => cleanup(tmpDir));
test('returns empty matches when no todos exist', () => {
const result = runGsdTools('todo match-phase 01', tmpDir);
assert.ok(result.success, 'should succeed');
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 0);
assert.deepStrictEqual(output.matches, []);
});
test('matches todo by keyword overlap with phase name', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nNeed to handle token expiry for OAuth flows.');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n');
const result = runGsdTools('todo match-phase 01', tmpDir);
assert.ok(result.success, 'should succeed');
const output = JSON.parse(result.output);
assert.strictEqual(output.todo_count, 1, 'should find 1 todo');
assert.ok(output.matches.length > 0, 'should have matches');
assert.strictEqual(output.matches[0].title, 'Add OAuth token refresh');
assert.ok(output.matches[0].score > 0, 'score should be positive');
assert.ok(output.matches[0].reasons.length > 0, 'should have reasons');
});
test('does not match unrelated todo', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token expiry.');
fs.writeFileSync(path.join(pendingDir, 'unrelated-todo.md'),
'title: Fix CSS grid layout in dashboard\narea: ui\ncreated: 2026-03-01\n\nGrid columns break on mobile.');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login and session handling\n');
const result = runGsdTools('todo match-phase 01', tmpDir);
assert.ok(result.success, 'should succeed');
const output = JSON.parse(result.output);
const matchTitles = output.matches.map(m => m.title);
assert.ok(matchTitles.includes('Add OAuth token refresh'), 'auth todo should match');
assert.ok(!matchTitles.includes('Fix CSS grid layout in dashboard'), 'unrelated todo should not match');
});
test('matches todo by area overlap', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'auth-todo.md'),
'title: Add OAuth token refresh\narea: auth\ncreated: 2026-03-01\n\nOAuth token handling.');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 01: Auth System\n\n**Goal:** Build auth module\n');
const result = runGsdTools('todo match-phase 01', tmpDir);
const output = JSON.parse(result.output);
const authMatch = output.matches.find(m => m.title === 'Add OAuth token refresh');
assert.ok(authMatch, 'should find auth todo');
const hasAreaReason = authMatch.reasons.some(r => r.startsWith('area:'));
assert.ok(hasAreaReason, 'should match on area');
});
test('sorts matches by score descending', () => {
const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending');
fs.mkdirSync(pendingDir, { recursive: true });
fs.writeFileSync(path.join(pendingDir, 'weak-match.md'),
'title: Check token format\narea: general\ncreated: 2026-03-01\n\nToken format validation.');
fs.writeFileSync(path.join(pendingDir, 'strong-match.md'),
'title: Session management authentication OAuth token handling\narea: auth\ncreated: 2026-03-01\n\nSession auth OAuth tokens.');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 01: Authentication and Session Management\n\n**Goal:** Implement OAuth login, session handling, and token management\n');
const result = runGsdTools('todo match-phase 01', tmpDir);
const output = JSON.parse(result.output);
assert.ok(output.matches.length >= 2, 'should have multiple matches');
for (let i = 1; i < output.matches.length; i++) {
assert.ok(output.matches[i - 1].score >= output.matches[i].score,
`match ${i-1} score (${output.matches[i-1].score}) should be >= match ${i} score (${output.matches[i].score})`);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// scaffold command
// ─────────────────────────────────────────────────────────────────────────────