diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index cdee42566..558185ac5 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -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; } diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index d7109df19..ff13ad6cb 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -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, }; diff --git a/get-shit-done/workflows/discuss-phase.md b/get-shit-done/workflows/discuss-phase.md index d8f7a6f40..d21c1fe4a 100644 --- a/get-shit-done/workflows/discuss-phase.md +++ b/get-shit-done/workflows/discuss-phase.md @@ -242,6 +242,47 @@ Structure the extracted information: **If no prior context exists:** Continue without — this is expected for early phases. + +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 `` for inclusion in CONTEXT.md `` section +- These become additional scope items that downstream agents (researcher, planner) will see + +**For unselected (reviewed but not folded) todos:** +- Store internally as `` for inclusion in CONTEXT.md `` 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. + + 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.] + @@ -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"] diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index b43d2827a..f84624e47 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -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 // ─────────────────────────────────────────────────────────────────────────────