-
Notifications
You must be signed in to change notification settings - Fork 4.5k
fix(tasks): count indented sub-tasks so a change with unfinished work isn't reported complete #1486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@fission-ai/openspec': patch | ||
| --- | ||
|
|
||
| Task progress now counts indented sub-tasks. A `tasks.md` whose sub-tasks were unfinished reported `✓ Complete` in `openspec list` and `openspec view`, was missing those tasks from the `openspec instructions apply` list, and archived with no incomplete-task warning, because both checkbox parsers only matched checkboxes at column 0. | ||
|
|
||
| Progress counting and the apply task list now share one parser, so `list`, `view`, `archive` and `apply` agree about which lines of a tasks file are tasks. A checkbox with no text after it is left out of the apply list, which has nothing to act on, but still counts toward every progress number; a file of nothing but such checkboxes now asks to be rewritten rather than reporting itself done. The shared pattern matches every line the two it replaced matched, and more, so task counts can rise but never fall: no change starts reporting less work than before, and archive's incomplete-task warning can only become stricter. Checkboxes are still counted wherever they appear, including inside a code fence, an HTML comment or an indented block, so a `tasks.md` that shows a checklist as a format example can now count that example as work — remove it from the file, or pass `--yes` to archive. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import * as os from 'node:os'; | ||
| import { generateApplyInstructions } from '../../src/commands/workflow/instructions.js'; | ||
| import { getTaskProgressForChange } from '../../src/utils/task-progress.js'; | ||
|
|
||
| /** | ||
| * The apply task list and task progress read the same tasks file, so they must | ||
| * see the same tasks - including indented sub-tasks, which the apply parser | ||
| * used to drop. | ||
| */ | ||
| describe('generateApplyInstructions task list', () => { | ||
| let tempDir: string; | ||
| let changeDir: string; | ||
|
|
||
| beforeEach(() => { | ||
| tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-apply-tasks-')); | ||
| changeDir = path.join(tempDir, 'openspec', 'changes', 'my-change'); | ||
| fs.mkdirSync(path.join(changeDir, 'specs', 'demo'), { recursive: true }); | ||
| fs.writeFileSync(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\n'); | ||
| fs.writeFileSync(path.join(changeDir, 'proposal.md'), '## Why\nx\n'); | ||
| fs.writeFileSync( | ||
| path.join(changeDir, 'specs', 'demo', 'spec.md'), | ||
| '## ADDED Requirements\n\n### Requirement: Demo\nThe system SHALL demo.\n\n#### Scenario: Works\n- **WHEN** run\n- **THEN** works\n' | ||
| ); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| function writeTasks(content: string): void { | ||
| fs.writeFileSync(path.join(changeDir, 'tasks.md'), content); | ||
| } | ||
|
|
||
| it('lists indented sub-tasks alongside their parents', async () => { | ||
| writeTasks( | ||
| [ | ||
| '## 1. Implementation', | ||
| '- [x] 1.1 Parent task', | ||
| ' - [ ] 1.1.1 Unfinished sub-task', | ||
| '- [ ] 1.2 Second parent', | ||
| '', | ||
| ].join('\n') | ||
| ); | ||
|
|
||
| const instructions = await generateApplyInstructions(tempDir, 'my-change'); | ||
|
|
||
| expect(instructions.tasks.map((task) => task.description)).toEqual([ | ||
| '1.1 Parent task', | ||
| '1.1.1 Unfinished sub-task', | ||
| '1.2 Second parent', | ||
| ]); | ||
| expect(instructions.progress).toEqual({ total: 3, complete: 1, remaining: 2 }); | ||
| }); | ||
|
|
||
| it('reports the totals openspec list reports for the same change', async () => { | ||
| writeTasks( | ||
| ['## 1. Implementation', '- [x] 1.1 Parent task', ' - [ ] 1.1.1 Unfinished sub-task', ''].join( | ||
| '\n' | ||
| ) | ||
| ); | ||
|
|
||
| const instructions = await generateApplyInstructions(tempDir, 'my-change'); | ||
| // `openspec list` reads progress through getTaskProgressForChange, not the | ||
| // apply parser. The two must not disagree about the same file. | ||
| const listProgress = await getTaskProgressForChange( | ||
| path.join(tempDir, 'openspec', 'changes'), | ||
| 'my-change', | ||
| tempDir | ||
| ); | ||
|
|
||
| expect(listProgress).toEqual({ total: 2, completed: 1 }); | ||
| expect(instructions.progress.total).toBe(listProgress.total); | ||
| expect(instructions.progress.complete).toBe(listProgress.completed); | ||
| }); | ||
|
|
||
| it('reports a file of text-less checkboxes as having nothing to work on', async () => { | ||
| writeTasks('## 1. Implementation\n- [x]\n'); | ||
|
|
||
| const instructions = await generateApplyInstructions(tempDir, 'my-change'); | ||
|
|
||
| // As before the shared parser: apply points at regenerating the file | ||
| // rather than listing a blank row an agent cannot act on. | ||
| expect(instructions.tasks).toEqual([]); | ||
| expect(instructions.state).toBe('blocked'); | ||
| expect(instructions.instruction).toContain('contains no tasks'); | ||
| }); | ||
|
|
||
| it('counts a text-less checkbox toward progress even though it lists none', async () => { | ||
| // Progress must not disagree with `openspec list` or archive's gate just | ||
| // because a line carries no text an agent could act on: hiding the row is | ||
| // presentation, dropping it from the count would understate the work left. | ||
| writeTasks('## 1. Implementation\n- [x] 1.1 Real task\n- [ ] \n'); | ||
|
|
||
| const instructions = await generateApplyInstructions(tempDir, 'my-change'); | ||
| const listProgress = await getTaskProgressForChange( | ||
| path.join(tempDir, 'openspec', 'changes'), | ||
| 'my-change', | ||
| tempDir | ||
| ); | ||
|
|
||
| expect(instructions.tasks.map((task) => task.description)).toEqual(['1.1 Real task']); | ||
| expect(instructions.progress).toEqual({ total: 2, complete: 1, remaining: 1 }); | ||
| expect(instructions.state).toBe('ready'); | ||
| expect(listProgress).toEqual({ total: 2, completed: 1 }); | ||
| }); | ||
|
|
||
| it('does not call a change done while a bare checkbox is still unchecked', async () => { | ||
| writeTasks('## 1. Implementation\n- [x] 1.1 Real task\n- [ ]\n'); | ||
|
|
||
| const instructions = await generateApplyInstructions(tempDir, 'my-change'); | ||
|
|
||
| expect(instructions.progress).toEqual({ total: 2, complete: 1, remaining: 1 }); | ||
| expect(instructions.state).toBe('ready'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Align
--yesexpectations with the stated archive contract.The PR objective says incomplete-task warnings occur unless
--yesis used, but both tests pass{ yes: true }and require a warning. This locks in the opposite behavior.test/core/archive.test.ts#L257-L261: exercise the warning path withoutyes, or assert thatyesbypasses the warning.test/core/archive.test.ts#L309-L313: apply the same correction for the unterminated-fence case.📍 Affects 1 file
test/core/archive.test.ts#L257-L261(this comment)test/core/archive.test.ts#L309-L313🤖 Prompt for AI Agents