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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user