Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/count-unrecognised-task-markers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Stop dropping checkbox lines whose marker the task parser does not recognise. A `tasks.md` whose remaining work used a marker other than `[ ]`/`[x]`/`[X]` — for example `- [~] 1.2 Deferred` — reported `✓ Complete` in `openspec list`/`status` and archived with no incomplete-task warning, because unmatched lines counted toward neither the numerator nor the denominator. An empty `[]` and a padded `[ x]` were lost the same way. Only `[x]`/`[X]` means done, so every other marker now reads as unfinished, across progress, the apply task list, archive's gate and validate's task-numbering check. The archive, bulk-archive and verify workflows now tell agents the same rule, so a hand-counted tally cannot disagree with the CLI.
6 changes: 5 additions & 1 deletion skills/openspec-archive-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ Archive a completed change in the experimental workflow.

Read the tasks file (typically `tasks.md`) to check for incomplete tasks.

Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
A checkbox is complete when its only content is `x` or `X`; spacing inside
the brackets does not matter, so `- [ x]` counts as complete too. Every
other marker is incomplete - `- [ ]`, an empty `- []`, and markers OpenSpec
assigns no meaning to such as `- [~]` or `- [-]`. Never read an unfamiliar
marker as complete.

**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
Expand Down
4 changes: 3 additions & 1 deletion skills/openspec-bulk-archive-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ This skill allows you to batch-archive changes, handling spec conflicts intellig
- Note which artifacts are `done` vs other states

b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
- Complete means the checkbox holds only `x`/`X`, ignoring spacing
(`- [ x]` is complete); every other marker is incomplete (`- [ ]`,
`- []`, and unfamiliar ones such as `- [~]` or `- [-]`)
- If no tasks file exists, note as "No tasks"

c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON
Expand Down
4 changes: 3 additions & 1 deletion skills/openspec-verify-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ Verify that an implementation matches the change artifacts (specs, tasks, design

**Task Completion**:
- If `contextFiles.tasks` exists, read every file path in it
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
- Parse checkboxes: complete means the box holds only `x`/`X`, ignoring
spacing (`- [ x]` is complete); every other marker is incomplete
(`- [ ]`, `- []`, and unfamiliar ones such as `- [~]` or `- [-]`)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
Expand Down
12 changes: 10 additions & 2 deletions src/core/templates/workflows/archive-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,11 @@ ${STORE_SELECTION_GUIDANCE}

Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks.

Count tasks marked with \`- [ ]\` (incomplete) vs \`- [x]\` (complete).
A checkbox is complete when its only content is \`x\` or \`X\`; spacing inside
the brackets does not matter, so \`- [ x]\` counts as complete too. Every
other marker is incomplete - \`- [ ]\`, an empty \`- []\`, and markers OpenSpec
assigns no meaning to such as \`- [~]\` or \`- [-]\`. Never read an unfamiliar
marker as complete.

**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
Expand Down Expand Up @@ -260,7 +264,11 @@ ${STORE_SELECTION_GUIDANCE}

Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks.

Count tasks marked with \`- [ ]\` (incomplete) vs \`- [x]\` (complete).
A checkbox is complete when its only content is \`x\` or \`X\`; spacing inside
the brackets does not matter, so \`- [ x]\` counts as complete too. Every
other marker is incomplete - \`- [ ]\`, an empty \`- []\`, and markers OpenSpec
assigns no meaning to such as \`- [~]\` or \`- [-]\`. Never read an unfamiliar
marker as complete.

**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
Expand Down
8 changes: 6 additions & 2 deletions src/core/templates/workflows/bulk-archive-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ ${STORE_SELECTION_GUIDANCE}
- Note which artifacts are \`done\` vs other states

b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON
- Count \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
- Complete means the checkbox holds only \`x\`/\`X\`, ignoring spacing
(\`- [ x]\` is complete); every other marker is incomplete (\`- [ ]\`,
\`- []\`, and unfamiliar ones such as \`- [~]\` or \`- [-]\`)
- If no tasks file exists, note as "No tasks"

c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
Expand Down Expand Up @@ -411,7 +413,9 @@ ${STORE_SELECTION_GUIDANCE}
- Note which artifacts are \`done\` vs other states

b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON
- Count \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
- Complete means the checkbox holds only \`x\`/\`X\`, ignoring spacing
(\`- [ x]\` is complete); every other marker is incomplete (\`- [ ]\`,
\`- []\`, and unfamiliar ones such as \`- [~]\` or \`- [-]\`)
- If no tasks file exists, note as "No tasks"

c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
Expand Down
8 changes: 6 additions & 2 deletions src/core/templates/workflows/verify-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ ${STORE_SELECTION_GUIDANCE}

**Task Completion**:
- If \`contextFiles.tasks\` exists, read every file path in it
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
- Parse checkboxes: complete means the box holds only \`x\`/\`X\`, ignoring
spacing (\`- [ x]\` is complete); every other marker is incomplete
(\`- [ ]\`, \`- []\`, and unfamiliar ones such as \`- [~]\` or \`- [-]\`)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
Expand Down Expand Up @@ -237,7 +239,9 @@ ${STORE_SELECTION_GUIDANCE}

**Task Completion**:
- If \`contextFiles.tasks\` exists, read every file path in it
- Parse checkboxes: \`- [ ]\` (incomplete) vs \`- [x]\` (complete)
- Parse checkboxes: complete means the box holds only \`x\`/\`X\`, ignoring
spacing (\`- [ x]\` is complete); every other marker is incomplete
(\`- [ ]\`, \`- []\`, and unfamiliar ones such as \`- [~]\` or \`- [-]\`)
- Count complete vs total tasks
- If incomplete tasks exist:
- Add CRITICAL issue for each incomplete task
Expand Down
31 changes: 25 additions & 6 deletions src/utils/task-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,44 @@ import { resolveArtifactOutputs, resolveSchema } from '../core/artifact-graph/in
import { resolveSchemaForChange } from './change-metadata.js';

/**
* A Markdown task line: a `-`/`*` bullet carrying a `[ ]` or `[x]` checkbox.
* A Markdown task line: a `-`/`*` bullet carrying a checkbox that holds at most
* one non-whitespace marker - `[ ]`, `[x]`, `[]`, `[~]`, `[ x ]` all qualify.
*
* Leading whitespace is allowed so nested sub-tasks count like their parents.
* Anchoring at column 0 made ` - [ ] 1.1.1 ...` invisible to progress, to the
* apply task list, and to archive's incomplete-task check, so a change with
* unfinished sub-tasks reported "✓ Complete" and archived without a warning.
*
* The marker is no longer restricted to ` `/`x`/`X`, because a checkbox this
* pattern rejects is a line that counts toward neither the numerator nor the
* denominator: a tasks.md whose remaining work was written `- [~] ...`
* reported "✓ Complete" and archived with no incomplete-task warning, and
* marking items `[~]` *shrank* the denominator instead of leaving them counted
* as not-done (#1761). An empty `[]` and a padded `[ x]` were lost the same
* silent way. Only `x`/`X` means done, so every unrecognised marker reads as
* not-done - the conservative default, and no new concept: OpenSpec does not
* adopt `[~]` or any other marker's meaning, it just stops dropping the line.
*
* Permissive on purpose, and safe to keep that way: any character class
* tightened here - the `\s` inside the brackets, which lets a tab or
* non-breaking space stand for an empty box - drops lines that used to count,
* and a task this parser drops is a task `openspec archive` stops warning about.
* tightened here drops lines that used to count, and a task this parser drops
* is a task `openspec archive` stops warning about. The cost of the wide class
* is over-counting - `- [1] ...` in a tasks file now reads as one unfinished
* task - which is a loud, correctable false positive, unlike the silent loss.
*
* Where the width stops, and why: the marker is one token, so a *multi*
* character bracket stays unmatched. Widening to `[^\]]*` would swallow the
* commonest bullet in Markdown - `- [Some doc](./doc.md)`, whose `]` is
* followed by `(`, not by a space - and turn every link list into phantom
* unfinished work. A dropped `- [WIP] ...` is the accepted residue of keeping
* link bullets out; report it as a bug in this trade, not in the marker set.
*
* Deliberately unanchored at the end: `.` does not match `\r`, so writing the
* description group as `(.*)$` would reject every line of a CRLF tasks.md.
*/
const TASK_LINE_PATTERN = /^\s*[-*]\s*\[([\sxX])\]\s*(.*)/;
const TASK_LINE_PATTERN = /^\s*[-*]\s*\[\s*([^\]\s]?)\s*\]\s*(.*)/;

export interface ParsedTask {
/** Checkbox state: `[x]`/`[X]` is done, anything else is not. */
/** Checkbox state: `[x]`/`[X]` is done, every other marker (and none) is not. */
done: boolean;
/** Task text after the checkbox, trimmed (may be empty). */
description: string;
Expand Down
26 changes: 26 additions & 0 deletions test/commands/apply-instructions-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,32 @@ describe('generateApplyInstructions task list', () => {
expect(listProgress).toEqual({ total: 2, completed: 1 });
});

it('lists a task written with an unrecognised marker as remaining work (#1761)', async () => {
// Before the fix the apply parser dropped the line, so the agent was told
// every task was complete and the change was ready to archive.
writeTasks(
['## 1. Implementation', '- [x] 1.1 Done', '- [~] 1.2 Deferred', '- [] 1.3 Empty box', ''].join(
'\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 Done',
'1.2 Deferred',
'1.3 Empty box',
]);
expect(instructions.progress).toEqual({ total: 3, complete: 1, remaining: 2 });
expect(instructions.state).toBe('ready');
expect(listProgress).toEqual({ total: 3, 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');

Expand Down
26 changes: 26 additions & 0 deletions test/core/archive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,32 @@ describe('ArchiveCommand', () => {
);
});

it('detects tasks written with an unrecognised marker (#1761 data-safety gate)', async () => {
// Before the fix the marker had to be ` `, `x` or `X`; every other
// checkbox character was dropped from the count entirely, so a change
// whose remaining work was written `- [~] ...` archived with no warning.
const changeName = 'unknown-marker-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
await fs.mkdir(changeDir, { recursive: true });
await fs.writeFile(
path.join(changeDir, 'tasks.md'),
[
'## 1. Implementation',
'- [x] 1.1 Done',
'- [~] 1.2 Deferred, not done',
'- [-] 1.3 Cancelled, not done',
'- [] 1.4 Empty box, not done',
'',
].join('\n')
);

await archiveCommand.execute(changeName, { yes: true });

expect(console.log).toHaveBeenCalledWith(
expect.stringContaining('Warning: 3 incomplete task(s) found')
);
});

it('should update specs when archiving (delta-based ADDED) and include change name in skeleton', async () => {
const changeName = 'spec-feature';
const changeDir = path.join(tempDir, 'openspec', 'changes', changeName);
Expand Down
16 changes: 16 additions & 0 deletions test/core/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,22 @@ Regular text that should be ignored
expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(false);
});

it('does not report a change whose remaining work uses an unrecognised marker as complete (#1761)', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'deferred-change'), { recursive: true });

await fs.writeFile(
path.join(changesDir, 'deferred-change', 'tasks.md'),
'- [x] 1.1 Done\n- [~] 1.2 Deferred\n- [] 1.3 Empty box\n'
);

const listCommand = new ListCommand();
await listCommand.execute(tempDir, 'changes');

expect(logOutput.some(line => line.includes('1/3 tasks'))).toBe(true);
expect(logOutput.some(line => line.includes('✓ Complete'))).toBe(false);
});

it('should handle changes without tasks.md', async () => {
const changesDir = path.join(tempDir, 'openspec', 'changes');
await fs.mkdir(path.join(changesDir, 'no-tasks'), { recursive: true });
Expand Down
22 changes: 22 additions & 0 deletions test/core/task-numbering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,28 @@ describe('findTaskNumberingIssues', () => {
]);
});

it('checks lines written with an unrecognised marker too (#1761)', () => {
// The numbering check shares the task parser, so a marker it used to drop
// also escaped duplicate-ID and wrong-group detection.
const issues = findInSingleFile(
['## 3. Work', '- [x] 3.1 first', '- [~] 3.1 deferred duplicate', '- [] 4.1 wrong group', ''].join(
'\n'
)
);

expect(issues).toEqual([
{
line: 3,
message: 'Task ID "3.1" is duplicated; it was first declared on line 2.',
},
{
line: 4,
message:
'Task "4.1" is under group 3, but its leading number points to group 4. Move it to group 4 or renumber it.',
},
]);
});

it('accepts alphabetic suffixes and numbering gaps', () => {
const issues = findInSingleFile(
['## 4. Work', '- [ ] 4.2a inserted', '- [ ] 4.2b another', '- [ ] 4.7 gap'].join(
Expand Down
18 changes: 9 additions & 9 deletions test/core/templates/skill-templates-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ const EXPECTED_FUNCTION_HASHES: Record<string, string> = {
getOpsxContinueCommandTemplate: 'e50e50266efa1b8e64ff9b6274ee8254f0a240d6adc1b862d126e2f1c9d3a559',
getOpsxApplyCommandTemplate: 'e3579ac78f2e2c75fa3d3a7ac7dc3e49c395e96f7323398f0f041d94f8de9bb0',
getOpsxFfCommandTemplate: '21132fc9c6d3b3ab2d2295d6bbd72d1e0052eb35ea1be0258c8b1ab3e200c4db',
getArchiveChangeSkillTemplate: '56bfada1a5f35a127791b70de9d428a75b5aedd1584d6c9803a1ecb1fd1b4a23',
getBulkArchiveChangeSkillTemplate: '93875998cade5322d95b43299fba794bc1da754e917dd63a770406386a6d295d',
getArchiveChangeSkillTemplate: 'e0c21f54f1f027ee69f9855e4d65e68097d50e4e90cdba513fc1b03d45203857',
getBulkArchiveChangeSkillTemplate: '59c2a482fb534f2a3a26b59e260329eb2edfaaf7996f77a0db30914f77319ca7',
getOpsxSyncCommandTemplate: '0d2427efb79986e8fff3f96bd075a739c80d45eb29159fae717e950030da8202',
getVerifyChangeSkillTemplate: '223b7ffd99299a7d430e13092b9a0a3421b39f0d3217232f46c39d79b5f619ff',
getOpsxArchiveCommandTemplate: '9f973c819b11620985b03322945f0e0a92a02a2ef455b94e74482f5e6292ac5d',
getVerifyChangeSkillTemplate: '7c27431d32ece63f618d197a88375ffdd2f3979d5314ce3affacba923dc60c87',
getOpsxArchiveCommandTemplate: 'ec258209822f72d49252f0c0f825082b726634702e08179a88125ee88ce8f716',
getOpsxOnboardCommandTemplate: 'ee99aa99252c602720fbb8c63fb3ac438a5bd4e952fd961ddf1ae956cbfc2c8f',
getOpsxBulkArchiveCommandTemplate: '9fa8cdebe2f5667ebfc37bdc023396762c59d5b038c771dac2d8fd2c19e2627b',
getOpsxVerifyCommandTemplate: '1efcf7eff0671f48e9d9420f50865c563dd3079ee60f8c380bb7a90dd0102696',
getOpsxBulkArchiveCommandTemplate: '93b031ef69f80bc85a14271e1834d02082c87d9a79fe998a354baa69dd94fc3a',
getOpsxVerifyCommandTemplate: 'b7c4e065817144c3f1bb61fb6598e4b4ec2da80c36f98b112f95a5f83290b126',
getOpsxProposeSkillTemplate: '9c0fbf0137151bd03ec30c45180f83daec96e8976ceaf517c63147f84b803446',
getOpsxProposeCommandTemplate: 'b3c145f541dcc13d9859eae8f7bedbe4553371477ed2c5ac07a4a80f82c46f52',
getFeedbackSkillTemplate: 'dabeb5e825b9349abc8156c3e7b8608f27987912a6d9bf47ef29addde6138133',
Expand All @@ -72,9 +72,9 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record<string, string> = {
'openspec-apply-change': '81ea96d9fa6ec8536cd23c1fe561ed28e1cc1cad0a8ceb700588e08974cc0e49',
'openspec-ff-change': '31355250514bce51b16ff37ee2b833bc9d475cd0dbd4b1f68fe2041694575623',
'openspec-sync-specs': 'd933d8856584d6c1253de91e652e7aee9e85c77ad4d3531f6476f79d84e6e5e8',
'openspec-archive-change': '7c65053d674ba4e1e20e2bf73ba7e5a7f94baef2eaa9b33cee48d4cadea51b7a',
'openspec-bulk-archive-change': '2039b9ecf6e64339dffe0e16272507a386d9fe326f419ff758315aa736fdd96c',
'openspec-verify-change': 'af9be013dcbe8c6d8f6d9ab10c893fbd03f4c62933c384d82f63894dd0ceb84f',
'openspec-archive-change': 'b4dac563bd371b39e7635c1309789ec2108a2357868dd3fd5fdef66669393e15',
'openspec-bulk-archive-change': 'e1c0f1a0ef706222620a0c1b17606d746439506c19de2a9cf621c9478b1bc1a4',
'openspec-verify-change': '847bc57688cc4987828f9535559d74f2d1ebe192c33f084a2defa9d60cb8bc67',
'openspec-onboard': 'f6f59476acaf5e4d65dbb180da4cef62432612f3cecf207d471a951295e2003a',
'openspec-propose': 'e358b45102a88082cf20f5c4441cba02533724ad6eef8ed15ba174e3496cb6ed',
'openspec-update-change': '586547406aca94422dfeb3ffedce6c01049429b743f57ce829baa79ebc714d51',
Expand Down
Loading
Loading