diff --git a/src/utils/task-progress.ts b/src/utils/task-progress.ts index a14b866f0..bf13b0cf2 100644 --- a/src/utils/task-progress.ts +++ b/src/utils/task-progress.ts @@ -24,14 +24,45 @@ export function countTasksFromContent(content: string): TaskProgress { return { total, completed }; } -export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise { - const tasksPath = path.join(changesDir, changeName, 'tasks.md'); +async function collectTaskFiles(dir: string): Promise { + let entries; try { - const content = await fs.readFile(tasksPath, 'utf-8'); - return countTasksFromContent(content); + entries = await fs.readdir(dir, { withFileTypes: true }); } catch { - return { total: 0, completed: 0 }; + return []; + } + + const taskFiles: string[] = []; + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + taskFiles.push(...await collectTaskFiles(entryPath)); + } else if (entry.isFile() && entry.name === 'tasks.md') { + taskFiles.push(entryPath); + } + } + return taskFiles; +} + +export async function getTaskProgressForChange(changesDir: string, changeName: string): Promise { + const changeDir = path.join(changesDir, changeName); + const taskFiles = await collectTaskFiles(changeDir); + + let total = 0; + let completed = 0; + for (const tasksPath of taskFiles.sort()) { + let content; + try { + content = await fs.readFile(tasksPath, 'utf-8'); + } catch { + continue; + } + const progress = countTasksFromContent(content); + total += progress.total; + completed += progress.completed; } + + return { total, completed }; } export function formatTaskStatus(progress: TaskProgress): string { diff --git a/test/core/view.test.ts b/test/core/view.test.ts index b8b56df1e..1eb2af483 100644 --- a/test/core/view.test.ts +++ b/test/core/view.test.ts @@ -125,5 +125,31 @@ describe('ViewCommand', () => { 'gamma-change' ]); }); + + it('uses nested tasks.md files when classifying change progress', async () => { + const changesDir = path.join(tempDir, 'openspec', 'changes'); + const changeDir = path.join(changesDir, 'layered-change'); + await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true }); + await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true }); + await fs.writeFile( + path.join(changeDir, 'backend', 'tasks.md'), + '- [x] Backend task\n- [ ] Backend follow-up\n' + ); + await fs.writeFile( + path.join(changeDir, 'frontend', 'tasks.md'), + '- [ ] Frontend task\n' + ); + + const viewCommand = new ViewCommand(); + await viewCommand.execute(tempDir); + + const output = logOutput.map(stripAnsi).join('\n'); + expect(output).toContain('Active Changes'); + expect(output).toContain('layered-change'); + expect(output).toContain('33%'); + + const draftSection = output.split('Draft Changes')[1]?.split('Active Changes')[0] ?? ''; + expect(draftSection).not.toContain('layered-change'); + }); });