Skip to content
5 changes: 5 additions & 0 deletions .changeset/archive-schema-task-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Archive workflows now use schema-aware task progress from `openspec list --json`, so custom task files and globs still trigger incomplete-task warnings.
24 changes: 17 additions & 7 deletions openspec/specs/opsx-archive-skill/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,37 @@ The skill SHALL check artifact completion status using the artifact graph before

### Requirement: Task Completion Check

The skill SHALL check task completion status from tasks.md before archiving.
The skill SHALL check the selected change's task completion using `totalTasks` and `completedTasks` from `openspec list --json`, with the same selected-root flags used for the rest of the workflow. It SHALL match the change by name and use the CLI's schema-aware task resolution in both single and bulk archive workflows.

#### Scenario: Incomplete tasks found

- **WHEN** agent reads tasks.md
- **AND** incomplete tasks are found (marked with `- [ ]`)
- **WHEN** the selected change has `totalTasks` greater than `completedTasks`
- **THEN** display warning showing count of incomplete tasks
- **AND** prompt user for confirmation to continue
- **AND** proceed if user confirms

#### Scenario: All tasks complete

- **WHEN** agent reads tasks.md
- **AND** all tasks are complete (marked with `- [x]`)
- **WHEN** the selected change has equal `totalTasks` and `completedTasks`
- **THEN** proceed without task-related warning

#### Scenario: No tasks file
#### Scenario: No tracked tasks

- **WHEN** tasks.md does not exist
- **WHEN** the CLI reports `totalTasks` as zero for the selected change
- **THEN** proceed without task-related warning

#### Scenario: Custom task artifact or output path

- **WHEN** the schema tracks tasks under a custom artifact name, output path, or glob
- **THEN** use the CLI totals across the schema-resolved files
- **AND** do not infer completion from artifact existence, an artifact id of `tasks`, or the absence of a top-level `tasks.md`

#### Scenario: Task progress lookup unavailable

- **WHEN** the list command fails, returns invalid JSON, omits or duplicates a selected change, or reports invalid task counts
- **THEN** report the lookup problem and stop before syncing or archiving
- **AND** do not treat the missing progress as zero tasks

### Requirement: Spec Sync Prompt

The skill SHALL prompt to sync delta specs before archiving if specs exist.
Expand Down
23 changes: 15 additions & 8 deletions skills/openspec-archive-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,27 @@ In both branches, never create the root as a side effect: do not run `openspec i

3. **Check task completion status**

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

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.
Run `openspec list --json` with the same selected-root flags and find the
entry in `changes` whose `name` exactly matches the selected change.
Require exactly one match and nonnegative integer `totalTasks` and
`completedTasks`, with `completedTasks <= totalTasks`. The CLI resolves
the schema's tracked task files, including custom artifact names, output
paths, and globs.
Incomplete tasks = `totalTasks - completedTasks`.

Do not infer task completion from artifact status or the absence of a
top-level `tasks.md`. If the lookup fails, returns invalid JSON, omits or
duplicates the selected change, or returns invalid counts, report the problem
and stop before syncing or archiving.
The CLI counts only `x`/`X` checkbox markers as complete;
other markers, including unfamiliar ones, remain incomplete.

**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Ask the user to confirm they want to proceed
- Proceed if user confirms

**If no tasks file exists:** Proceed without task-related warning.
**If `totalTasks` is zero:** Proceed without a task-related warning.

4. **Assess delta spec sync state**

Expand Down
17 changes: 12 additions & 5 deletions skills/openspec-bulk-archive-change/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,24 @@ In both branches, never create the root as a side effect: do not run `openspec i

3. **Batch validation - gather status for all selected changes**

Run `openspec list --json` once with the same selected-root flags for task
progress. If the lookup fails, returns invalid JSON, or omits any selected
change, contains a duplicate selected change, or returns invalid counts,
report the problem and stop before syncing or archiving the batch.

For each selected change, collect:

a. **Artifact status** - Run `openspec status --change "<name>" --json`
- Parse `schemaName`, `artifacts`, `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`
- Note which artifacts are `done` vs other states

b. **Task completion** - Read `artifactPaths.tasks.existingOutputPaths` from status JSON
- 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"
b. **Task completion** - Find the `changes` entry from the list response whose `name` exactly matches this change
- Require nonnegative integer `totalTasks` and `completedTasks`, with `completedTasks <= totalTasks`
- Incomplete tasks = `totalTasks - completedTasks`
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
- Do not infer task completion from artifact status, an artifact id of `tasks`, or the absence of a top-level `tasks.md`
- The CLI counts only `x`/`X` checkbox markers as complete; other markers remain incomplete
- If `totalTasks` is zero, note as "No tasks"

c. **Delta specs** - Check `artifactPaths.specs.existingOutputPaths` from status JSON
- List which capability specs exist
Expand Down
46 changes: 30 additions & 16 deletions src/core/templates/workflows/archive-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,20 +99,27 @@ ${PROJECT_ROOT_GUARD}

3. **Check task completion status**

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

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.
Run \`openspec list --json\` with the same selected-root flags and find the
entry in \`changes\` whose \`name\` exactly matches the selected change.
Require exactly one match and nonnegative integer \`totalTasks\` and
\`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
the schema's tracked task files, including custom artifact names, output
paths, and globs.
Incomplete tasks = \`totalTasks - completedTasks\`.

Do not infer task completion from artifact status or the absence of a
top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
duplicates the selected change, or returns invalid counts, report the problem
and stop before syncing or archiving.
The CLI counts only \`x\`/\`X\` checkbox markers as complete;
other markers, including unfamiliar ones, remain incomplete.

**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Ask the user to confirm they want to proceed
- Proceed if user confirms

**If no tasks file exists:** Proceed without task-related warning.
**If \`totalTasks\` is zero:** Proceed without a task-related warning.

4. **Assess delta spec sync state**

Expand Down Expand Up @@ -293,20 +300,27 @@ ${PROJECT_ROOT_GUARD}

3. **Check task completion status**

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

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.
Run \`openspec list --json\` with the same selected-root flags and find the
entry in \`changes\` whose \`name\` exactly matches the selected change.
Require exactly one match and nonnegative integer \`totalTasks\` and
\`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
the schema's tracked task files, including custom artifact names, output
paths, and globs.
Incomplete tasks = \`totalTasks - completedTasks\`.

Do not infer task completion from artifact status or the absence of a
top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
duplicates the selected change, or returns invalid counts, report the problem
and stop before syncing or archiving.
The CLI counts only \`x\`/\`X\` checkbox markers as complete;
other markers, including unfamiliar ones, remain incomplete.

**If incomplete tasks found:**
- Display warning showing count of incomplete tasks
- Prompt user for confirmation to continue
- Proceed if user confirms

**If no tasks file exists:** Proceed without task-related warning.
**If \`totalTasks\` is zero:** Proceed without a task-related warning.

4. **Assess delta spec sync state**

Expand Down
34 changes: 24 additions & 10 deletions src/core/templates/workflows/bulk-archive-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,24 @@ ${PROJECT_ROOT_GUARD}

3. **Batch validation - gather status for all selected changes**

Run \`openspec list --json\` once with the same selected-root flags for task
progress. If the lookup fails, returns invalid JSON, or omits any selected
change, contains a duplicate selected change, or returns invalid counts,
report the problem and stop before syncing or archiving the batch.

For each selected change, collect:

a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
- Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
- Note which artifacts are \`done\` vs other states

b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON
- 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"
b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
- Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
- Incomplete tasks = \`totalTasks - completedTasks\`
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
- Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
- The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
- If \`totalTasks\` is zero, note as "No tasks"

c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
- List which capability specs exist
Expand Down Expand Up @@ -447,17 +454,24 @@ ${PROJECT_ROOT_GUARD}

3. **Batch validation - gather status for all selected changes**

Run \`openspec list --json\` once with the same selected-root flags for task
progress. If the lookup fails, returns invalid JSON, or omits any selected
change, contains a duplicate selected change, or returns invalid counts,
report the problem and stop before syncing or archiving the batch.

For each selected change, collect:

a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
- Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
- Note which artifacts are \`done\` vs other states

b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON
- 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"
b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
- Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
- Incomplete tasks = \`totalTasks - completedTasks\`
- The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
- Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
- The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
- If \`totalTasks\` is zero, note as "No tasks"

c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
- List which capability specs exist
Expand Down
124 changes: 124 additions & 0 deletions test/core/templates/archive-task-progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getArchiveChangeSkillTemplate,
getBulkArchiveChangeSkillTemplate,
getOpsxArchiveCommandTemplate,
getOpsxBulkArchiveCommandTemplate,
} from '../../../src/core/templates/skill-templates.js';
import { generateSkillContent } from '../../../src/core/shared/skill-generation.js';
import { getGlobalDataDir, registerStore } from '../../../src/core/index.js';
import { runCLI } from '../../helpers/run-cli.js';

const surfaces = [
['archive skill', generateSkillContent(getArchiveChangeSkillTemplate(), 'test')],
['archive command', getOpsxArchiveCommandTemplate().content],
['bulk archive skill', generateSkillContent(getBulkArchiveChangeSkillTemplate(), 'test')],
['bulk archive command', getOpsxBulkArchiveCommandTemplate().content],
] as const;

describe('archive task discovery uses schema-resolved CLI progress', () => {
let root: string;
let callerRoot: string;
let env: NodeJS.ProcessEnv;
const storeId = 'task-store';

async function write(relative: string, content: string) {
const file = path.join(root, relative);
await fs.mkdir(path.dirname(file), { recursive: true });
await fs.writeFile(file, content);
}

beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-archive-task-guidance-'));
env = {
XDG_DATA_HOME: path.join(root, 'data'),
XDG_CONFIG_HOME: path.join(root, 'config'),
};
await write('openspec/config.yaml', 'schema: custom\n');
await fs.mkdir(path.join(root, 'openspec/specs'), { recursive: true });
// Include another change so callers must match the selected name, not
// take the first list entry or aggregate progress across the whole root.
await write('openspec/changes/other/tasks.md', '- [x] Unrelated completed work\n');
await registerStore({ id: storeId, localPath: root, globalDataDir: getGlobalDataDir({ env }) });

// The caller's nearest root contains the same change name, but its tasks
// are all done. Omitting --store must therefore produce different progress.
callerRoot = path.join(root, 'caller');
await write('caller/openspec/config.yaml', 'schema: spec-driven\n');
await write('caller/openspec/changes/selected/tasks.md', '- [x] Local work is done\n');
});

afterEach(async () => {
await fs.rm(root, { recursive: true, force: true });
});

for (const [surface, content] of surfaces) {
it.each([
['custom output', 'planning/work-items.md', ['planning/work-items.md']],
['multiple outputs', 'work/*.md', ['work/backend.md', 'work/frontend.md']],
] as const)(`${surface}: counts unfinished tasks in %s`, async (_label, generates, files) => {
await write('openspec/schemas/custom/schema.yaml', [
'name: custom',
'version: 1',
'artifacts:',
' - id: implementation',
` generates: "${generates}"`,
' description: Implementation checklist',
' template: checklist.md',
' requires: []',
'apply:',
' requires: [implementation]',
` tracks: "${generates}"`,
].join('\n'));
await write('openspec/changes/selected/.openspec.yaml', 'schema: custom\n');
for (const file of files) {
await write(`openspec/changes/selected/${file}`, '- [ x ] Finished\n- [~] Pending\n- [ ] Pending\n');
}

// Execute the lookup actually taught in the task-checking step. The old
// single workflow read tasks.md, and bulk assumed an artifact id "tasks";
// neither can find this schema's implementation checklist.
const step = content.split('3. **')[1].split('4. **')[0];
const command = step.match(/`openspec (list[^`]*)`/);
expect(command, surface).not.toBeNull();
expect(step).toContain('same selected-root flags');
expect(step).toMatch(/name` exactly matches/);
expect(step).toContain('totalTasks - completedTasks');
expect(step).toContain('nonnegative integer');
expect(step).toContain('completedTasks <= totalTasks');
expect(step).toMatch(/other markers.*remain incomplete/s);
expect(step).not.toContain('artifactPaths.tasks');
expect(step).not.toContain('If no tasks file exists');

const args = command![1].trim().split(/\s+/);
expect(args).toEqual(['list', '--json']);
const storeFlag = content.match(/then pass `(--store) <id>`/);
expect(storeFlag).not.toBeNull();
expect(content).toContain('Every unscoped example of those commands below is shorthand: before running it, append the flag');
const scopedArgs = [...args, storeFlag![1], storeId];
expect(scopedArgs).toEqual(['list', '--json', '--store', storeId]);

const unscoped = await runCLI(args, { cwd: callerRoot, env });
expect(unscoped.exitCode, unscoped.stderr).toBe(0);
expect(JSON.parse(unscoped.stdout).changes).toEqual([
expect.objectContaining({ name: 'selected', totalTasks: 1, completedTasks: 1 }),
]);

const result = await runCLI(scopedArgs, { cwd: callerRoot, env });
expect(result.exitCode, result.stderr).toBe(0);
const report = JSON.parse(result.stdout);
expect(report.root).toMatchObject({ source: 'store', store_id: storeId });
const changes = report.changes;
expect(changes).toHaveLength(2);
expect(changes.find((change: { name: string }) => change.name === 'selected')).toMatchObject({
totalTasks: files.length * 3,
completedTasks: files.length,
status: 'in-progress',
});
await expect(fs.access(path.join(root, 'openspec/changes/selected/tasks.md'))).rejects.toThrow();
});
}
});
Loading
Loading