diff --git a/.changeset/archive-schema-task-progress.md b/.changeset/archive-schema-task-progress.md new file mode 100644 index 0000000000..ccd5706d6b --- /dev/null +++ b/.changeset/archive-schema-task-progress.md @@ -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. diff --git a/openspec/specs/opsx-archive-skill/spec.md b/openspec/specs/opsx-archive-skill/spec.md index 26c1c66f60..0a6311f84e 100644 --- a/openspec/specs/opsx-archive-skill/spec.md +++ b/openspec/specs/opsx-archive-skill/spec.md @@ -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. diff --git a/skills/openspec-archive-change/SKILL.md b/skills/openspec-archive-change/SKILL.md index dd98c4499f..726e19c61c 100644 --- a/skills/openspec-archive-change/SKILL.md +++ b/skills/openspec-archive-change/SKILL.md @@ -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** diff --git a/skills/openspec-bulk-archive-change/SKILL.md b/skills/openspec-bulk-archive-change/SKILL.md index b388f0b802..d72aa7e499 100644 --- a/skills/openspec-bulk-archive-change/SKILL.md +++ b/skills/openspec-bulk-archive-change/SKILL.md @@ -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 "" --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 diff --git a/src/core/templates/workflows/archive-change.ts b/src/core/templates/workflows/archive-change.ts index 3c1c18c5e7..ee20ff1264 100644 --- a/src/core/templates/workflows/archive-change.ts +++ b/src/core/templates/workflows/archive-change.ts @@ -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** @@ -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** diff --git a/src/core/templates/workflows/bulk-archive-change.ts b/src/core/templates/workflows/bulk-archive-change.ts index 1235bba8e1..897ea14fd9 100644 --- a/src/core/templates/workflows/bulk-archive-change.ts +++ b/src/core/templates/workflows/bulk-archive-change.ts @@ -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 "" --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 @@ -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 "" --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 diff --git a/test/core/templates/archive-task-progress.test.ts b/test/core/templates/archive-task-progress.test.ts new file mode 100644 index 0000000000..9bd2df18ae --- /dev/null +++ b/test/core/templates/archive-task-progress.test.ts @@ -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) `/); + 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(); + }); + } +}); diff --git a/test/core/templates/skill-templates-parity.test.ts b/test/core/templates/skill-templates-parity.test.ts index 5bbcb847c3..8ee85789cf 100644 --- a/test/core/templates/skill-templates-parity.test.ts +++ b/test/core/templates/skill-templates-parity.test.ts @@ -88,13 +88,13 @@ const EXPECTED_FUNCTION_HASHES: Record = { getOpsxContinueCommandTemplate: 'ace5c9cc239c12b57dc86fd9a1c02a6ca467cb8e1245127340c07ab1b9d37c11', getOpsxApplyCommandTemplate: 'd70cecce3b7d1dd4dbd5fd1fc2bccb538f5e61f5b43d520e4beca896e3f9e6b3', getOpsxFfCommandTemplate: '743a7304c7efc84aa87f556154c034e1e0e561c276c51870a30ada58f33eb9af', - getArchiveChangeSkillTemplate: '8447a2489240bf0c27f863065d61453dd0264842d1dabafe27b577d6bff96eb3', - getBulkArchiveChangeSkillTemplate: 'f17399959921ff98c7798e4591c8888825b7c9a83b0a90f09d98c7e0984ab793', + getArchiveChangeSkillTemplate: '71715f9d5899498942af03e182e6d1ac2c95952dde967950c2a9161084a53a8b', + getBulkArchiveChangeSkillTemplate: '5997f8c5c33f8933b7b3e61a0e122222e27e36e0b713167c6254e0508d1b95bf', getOpsxSyncCommandTemplate: '60550b7bb9829421656d6324a9e4c951bc912f48f88882d1a07ce7f78397a5e7', getVerifyChangeSkillTemplate: '2e069a277dac23818b13bb50b66e806ab405bc3b7f535400e1ebf81b84153699', - getOpsxArchiveCommandTemplate: '980109e5f8362610872c70fe0a0f1d48d3d2692275b2b17e2f4c91c3de89c2fd', + getOpsxArchiveCommandTemplate: '3d2a330b46043fbb9f220831aa42ebbb62f411e9597b2bb491ad1ac1fa2d0873', getOpsxOnboardCommandTemplate: '0cf66e164c0e14c916c6d1ebb5d80ded07d7fb8e55d4eb34eba43e8ca9c28558', - getOpsxBulkArchiveCommandTemplate: '3db03eadb764abd74c8c180656c3f64a8b9a4971056c91624d38df3209d7b446', + getOpsxBulkArchiveCommandTemplate: '090913b517d62bf6dc093689ab44d1c1c00df22719b6824977c7cc092dbed38f', getOpsxVerifyCommandTemplate: '938f52f20fb9a3b811ea47314baac1034cd550e8ab363ae878ccba4b6329348f', getOpsxProposeSkillTemplate: '1aa2f2eb9c8cbc4dcab9d777bf8832b92ca04f9ef91d0494f1224a566aefdfe8', getOpsxProposeCommandTemplate: '3b7090ce5e79e879ab9b5bdaf4ff2b52e3c02211f71188838772d36ac337f96c', @@ -110,8 +110,8 @@ const EXPECTED_GENERATED_SKILL_CONTENT_HASHES: Record = { 'openspec-apply-change': 'f3e92c229fab8d77df9f0a77dcb117cf46279b53a208d53aed89bfe0bab2ac09', 'openspec-ff-change': 'a7ab656d46f04d45dff0c8888df4a126a2e62288b7336f7445bce4d1715055f5', 'openspec-sync-specs': '3909936a236a21a9a6d5bf495f90b396b3b68fc9220d7b2c1894668653beb2e4', - 'openspec-archive-change': '305a21a9c76a925055f3bdbaac504f208660ef6948d78f73928de166250609bf', - 'openspec-bulk-archive-change': '4bd638a50111d2ee3a667752a2355ed513f770695b137b93fc28848ca7bf60d2', + 'openspec-archive-change': 'd01d9eeb06223ee89708b7963e82c5ebc11719c5b2dc62d4abb268ee016fcb7b', + 'openspec-bulk-archive-change': '8018999e6444bdfe97b3b95ffdb5451fb06becf2cfb46bd8fd3277157bdde5d7', 'openspec-verify-change': 'ad8a3098bd27d852721687c47a12db7107ed8b8dfc7f071406bb19961652e7ee', 'openspec-onboard': '6993eff867d97d485e080078f9dfb80e968e242f3b17a924eeb077715fd548fa', 'openspec-propose': '66e3395adf9f2d93a09e8ef1d20e4efb010e5e8d4811f2d42a9316e4d1ca5a8b',