feat(workflows): link pending todos to roadmap phases in new-milestone (#2433) (#2485)

Adds step 10.5 to gsd-new-milestone that scans pending todos against the
approved roadmap and tags matches with `resolves_phase: N` in their YAML
frontmatter. Adds a `close_phase_todos` step to execute-phase that moves
tagged todos to `completed/` when the phase completes — closing the loop
automatically with no manual cleanup.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-20 18:25:24 -04:00
committed by GitHub
parent 280eed93bc
commit d0f4340807
3 changed files with 165 additions and 0 deletions

View File

@@ -1431,6 +1431,38 @@ gsd-sdk query learnings.copy 2>/dev/null || echo "⚠ Learnings copy failed —
Copy failure must NOT block phase completion.
</step>
<step name="close_phase_todos">
**Auto-close pending todos tagged for this phase (#2433).**
This step runs AFTER `update_roadmap` marks the phase complete. It moves any pending todos that carry `resolves_phase: <current-phase-number>` to the completed directory.
```bash
PHASE_NUM="${PHASE_NUMBER}"
PENDING_DIR=".planning/todos/pending"
COMPLETED_DIR=".planning/todos/completed"
mkdir -p "$COMPLETED_DIR"
CLOSED=()
for TODO_FILE in "$PENDING_DIR"/*.md; do
[ -f "$TODO_FILE" ] || continue
# Extract resolves_phase from YAML frontmatter (first --- block only)
RP=$(awk '/^---/{c++;next} c==1 && /^resolves_phase:/{print $2;exit} c==2{exit}' "$TODO_FILE" 2>/dev/null || true)
if [ "$RP" = "$PHASE_NUM" ] || [ "$RP" = "\"$PHASE_NUM\"" ]; then
mv "$TODO_FILE" "$COMPLETED_DIR/"
CLOSED+=("$(basename "$TODO_FILE")")
fi
done
if [ ${#CLOSED[@]} -gt 0 ]; then
gsd-sdk query commit "docs(phase-${PHASE_NUMBER}): auto-close ${#CLOSED[@]} todo(s) resolved by this phase" .planning/todos/completed/ .planning/STATE.md || true
echo "◆ Closed ${#CLOSED[@]} todo(s) resolved by Phase ${PHASE_NUMBER}:"
for f in "${CLOSED[@]}"; do echo " ✓ $f"; done
fi
```
**If no todos have `resolves_phase: <this-phase>`:** Skip silently — this step is always additive and never blocks phase completion.
</step>
<step name="update_project_md">
**Evolve PROJECT.md to reflect phase completion (prevents planning document drift — #956):**

View File

@@ -510,6 +510,56 @@ Success criteria:
gsd-sdk query commit "docs: create milestone v[X.Y] roadmap ([N] phases)" .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md
```
## 10.5. Link Pending Todos to Roadmap Phases
After roadmap approval, scan pending todos against the newly approved phases. For each todo whose scope matches a phase, tag it with `resolves_phase: N` in its YAML frontmatter.
**Check for pending todos:**
```bash
PENDING_TODOS=$(ls .planning/todos/pending/*.md 2>/dev/null | head -50)
```
**If no pending todos exist:** Skip this step silently.
**If pending todos exist:**
Read the approved ROADMAP.md and extract the phase list: phase number, phase name, goal, and requirement IDs.
For each pending todo, compare:
- The todo's `title` and `area` frontmatter fields
- The todo body (Problem and Solution sections)
Against each phase's:
- Phase goal
- Requirement IDs and descriptions
**Match criteria (best-effort — do not over-match):** A todo is considered resolved by a phase if the phase's goal or requirements directly describe implementing the same feature, area, or capability as the todo. Narrow, specific todos with concrete scopes are the best candidates. Vague or cross-cutting todos should be left unlinked.
**For each matched todo**, add `resolves_phase: [N]` to the YAML frontmatter block (after the existing fields):
```yaml
---
created: [existing]
title: [existing]
area: [existing]
resolves_phase: [N]
files: [existing]
---
```
**Only modify todos that have a clear, confident match.** Leave unmatched todos unmodified.
**If any todos were linked:**
```bash
gsd-sdk query commit "docs: tag [count] pending todos with resolves_phase after milestone v[X.Y] roadmap" .planning/todos/pending/*.md
```
Print a summary:
```
◆ Linked [N] pending todos to roadmap phases:
→ [todo title] → Phase [N]: [Phase Name]
(Leave [M] unmatched todos in pending/)
```
## 11. Done
```
@@ -553,6 +603,7 @@ Also: `/gsd-plan-phase [N] ${GSD_WS}` — skip discussion, plan directly
- [ ] User feedback incorporated (if any)
- [ ] Phase numbering mode respected (continued or reset)
- [ ] All commits made (if planning docs committed)
- [ ] Pending todos scanned for phase matches; matched todos tagged with `resolves_phase: N`
- [ ] User knows next step: `/gsd-discuss-phase [N] ${GSD_WS}`
**Atomic commits:** Each phase commits its artifacts immediately.

View File

@@ -0,0 +1,82 @@
'use strict';
/**
* Tests for gsd-new-milestone todo-to-phase linking (#2433).
* Verifies the workflow text contains the correct linking and auto-close steps.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const NEW_MILESTONE = fs.readFileSync(
path.join(ROOT, 'get-shit-done/workflows/new-milestone.md'), 'utf-8'
);
const EXECUTE_PHASE = fs.readFileSync(
path.join(ROOT, 'get-shit-done/workflows/execute-phase.md'), 'utf-8'
);
test('new-milestone.md: step 10.5 links pending todos to roadmap phases', () => {
assert.ok(NEW_MILESTONE.includes('10.5'), 'step 10.5 should exist');
assert.ok(NEW_MILESTONE.includes('resolves_phase'), 'should reference resolves_phase field');
assert.ok(NEW_MILESTONE.includes('.planning/todos/pending'), 'should scan pending todos directory');
});
test('new-milestone.md: todo linking runs after roadmap commit', () => {
const roadmapCommitIdx = NEW_MILESTONE.indexOf('docs: create milestone v[X.Y] roadmap');
const step105Idx = NEW_MILESTONE.indexOf('10.5. Link Pending Todos');
const step11Idx = NEW_MILESTONE.indexOf('## 11. Done');
assert.ok(roadmapCommitIdx < step105Idx, 'step 10.5 should come after roadmap commit');
assert.ok(step105Idx < step11Idx, 'step 10.5 should come before step 11');
});
test('new-milestone.md: todo linking is best-effort and leaves unmatched todos unmodified', () => {
assert.ok(NEW_MILESTONE.includes('best-effort'), 'should describe best-effort matching');
assert.ok(NEW_MILESTONE.includes('unmatched'), 'should mention leaving unmatched todos alone');
assert.ok(NEW_MILESTONE.includes('confident match'), 'should gate on confident match');
});
test('new-milestone.md: step 10.5 commits tagged todos', () => {
assert.ok(NEW_MILESTONE.includes('gsd-sdk query commit'), 'should commit tagged todos');
assert.ok(NEW_MILESTONE.includes('resolves_phase after milestone'), 'commit message should mention resolves_phase');
});
test('new-milestone.md: success_criteria includes todo linking', () => {
assert.ok(NEW_MILESTONE.includes('resolves_phase: N'), 'success_criteria should mention resolves_phase tagging');
});
test('execute-phase.md: close_phase_todos step exists', () => {
assert.ok(EXECUTE_PHASE.includes('close_phase_todos'), 'close_phase_todos step should exist');
assert.ok(EXECUTE_PHASE.includes('resolves_phase'), 'should check resolves_phase in todos');
});
test('execute-phase.md: auto-close moves todos to completed directory', () => {
assert.ok(EXECUTE_PHASE.includes('.planning/todos/completed'), 'should move to completed dir');
assert.ok(EXECUTE_PHASE.includes('.planning/todos/pending'), 'should scan pending dir');
assert.ok(EXECUTE_PHASE.includes('mv "$TODO_FILE" "$COMPLETED_DIR/"'), 'should use mv to move files');
});
test('execute-phase.md: close_phase_todos runs after update_roadmap', () => {
const updateRoadmapIdx = EXECUTE_PHASE.indexOf('name="update_roadmap"');
const closeTodosIdx = EXECUTE_PHASE.indexOf('name="close_phase_todos"');
assert.ok(updateRoadmapIdx < closeTodosIdx, 'close_phase_todos should run after update_roadmap');
});
test('execute-phase.md: auto-close never blocks phase completion', () => {
const closeTodosSection = EXECUTE_PHASE.slice(
EXECUTE_PHASE.indexOf('name="close_phase_todos"'),
EXECUTE_PHASE.indexOf('name="update_project_md"')
);
assert.ok(
closeTodosSection.includes('never blocks') || closeTodosSection.includes('additive'),
'close_phase_todos should be non-blocking'
);
});
test('execute-phase.md: awk extracts resolves_phase from YAML frontmatter', () => {
assert.ok(EXECUTE_PHASE.includes('awk'), 'should use awk for frontmatter extraction');
assert.ok(EXECUTE_PHASE.includes('resolves_phase:'), 'awk pattern should match resolves_phase key');
});