@fission-ai/openspec 1.13.1 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +15 -3
  2. package/dist/commands/workflow/instructions.js +36 -8
  3. package/dist/commands/workflow/shared.d.ts +5 -0
  4. package/dist/core/archive.js +243 -76
  5. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  6. package/dist/core/artifact-graph/outputs.js +38 -8
  7. package/dist/core/command-generation/adapters/continue.js +4 -0
  8. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  9. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  10. package/dist/core/init.js +9 -3
  11. package/dist/core/legacy-cleanup.d.ts +1 -1
  12. package/dist/core/legacy-cleanup.js +29 -4
  13. package/dist/core/parsers/requirement-blocks.d.ts +34 -3
  14. package/dist/core/parsers/requirement-blocks.js +65 -23
  15. package/dist/core/specs-apply.d.ts +20 -0
  16. package/dist/core/specs-apply.js +19 -6
  17. package/dist/core/templates/workflows/archive-change.js +30 -16
  18. package/dist/core/templates/workflows/bulk-archive-change.js +28 -14
  19. package/dist/core/templates/workflows/continue-change.js +0 -2
  20. package/dist/core/templates/workflows/explore.js +2 -2
  21. package/dist/core/templates/workflows/ff-change.js +2 -2
  22. package/dist/core/templates/workflows/onboard.js +9 -4
  23. package/dist/core/templates/workflows/update-change.js +21 -9
  24. package/dist/core/templates/workflows/verify-change.js +115 -52
  25. package/dist/core/update.js +20 -7
  26. package/dist/core/validation/purpose-placeholder.js +24 -9
  27. package/dist/core/validation/validator.js +6 -5
  28. package/dist/utils/file-system.js +20 -3
  29. package/dist/utils/line-endings.d.ts +34 -0
  30. package/dist/utils/line-endings.js +46 -0
  31. package/package.json +2 -2
  32. package/schemas/spec-driven/schema.yaml +8 -0
@@ -7,7 +7,7 @@
7
7
  import type { ToolCommandAdapter } from '../types.js';
8
8
  /**
9
9
  * Kilo Code adapter for command generation.
10
- * File path: .kilocode/workflows/opsx-<id>.md
10
+ * File path: .kilo/command/opsx-<id>.md
11
11
  * Format: Plain markdown without frontmatter
12
12
  */
13
13
  export declare const kilocodeAdapter: ToolCommandAdapter;
@@ -7,13 +7,13 @@
7
7
  import path from 'path';
8
8
  /**
9
9
  * Kilo Code adapter for command generation.
10
- * File path: .kilocode/workflows/opsx-<id>.md
10
+ * File path: .kilo/command/opsx-<id>.md
11
11
  * Format: Plain markdown without frontmatter
12
12
  */
13
13
  export const kilocodeAdapter = {
14
14
  toolId: 'kilocode',
15
15
  getFilePath(commandId) {
16
- return path.join('.kilocode', 'workflows', `opsx-${commandId}.md`);
16
+ return path.join('.kilo', 'command', `opsx-${commandId}.md`);
17
17
  },
18
18
  formatFile(content) {
19
19
  return `${content.body}
package/dist/core/init.js CHANGED
@@ -992,9 +992,15 @@ export class InitCommand {
992
992
  // Tools with no slash surface (e.g. Rovo Dev) reference skills as
993
993
  // prose ("the openspec-propose skill"); phrase the hint so it reads
994
994
  // as an instruction rather than a dead command with an argument.
995
- hint = usesNaturalLanguageSkillReferences(tool.value)
996
- ? `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`
997
- : `Start your first change: ${skillReference} "your idea"`;
995
+ if (usesNaturalLanguageSkillReferences(tool.value)) {
996
+ hint = `Start your first change: ask ${tool.name} to use ${skillReference} with "your idea"`;
997
+ }
998
+ else if (tool.value === 'codex') {
999
+ hint = `Start your first change: ${skillReference} "your idea" (Codex CLI or IDE); in the Codex desktop app, select ${skillReference.slice(1)} from Skills in the sidebar`;
1000
+ }
1001
+ else {
1002
+ hint = `Start your first change: ${skillReference} "your idea"`;
1003
+ }
998
1004
  }
999
1005
  else {
1000
1006
  continue;
@@ -2,7 +2,7 @@
2
2
  * Legacy cleanup module for detecting and removing OpenSpec artifacts
3
3
  * from previous init versions during the migration to the skill-based workflow.
4
4
  */
5
- import type { WorkflowId } from './profiles.js';
5
+ import { type WorkflowId } from './profiles.js';
6
6
  /**
7
7
  * Legacy config file names from the old ToolRegistry.
8
8
  * These were config files created at project root with OpenSpec markers.
@@ -4,10 +4,11 @@
4
4
  */
5
5
  import path from 'path';
6
6
  import os from 'os';
7
- import { promises as fs } from 'fs';
7
+ import { promises as fs, constants as fsConstants } from 'fs';
8
8
  import chalk from 'chalk';
9
9
  import { FileSystemUtils, removeMarkerBlock as removeMarkerBlockUtil } from '../utils/file-system.js';
10
10
  import { OPENSPEC_MARKERS } from './config.js';
11
+ import { ALL_WORKFLOWS } from './profiles.js';
11
12
  /**
12
13
  * Legacy config file names from the old ToolRegistry.
13
14
  * These were config files created at project root with OpenSpec markers.
@@ -24,6 +25,13 @@ export const LEGACY_CONFIG_FILES = [
24
25
  ];
25
26
  /** The three commands the old SlashCommandRegistry wrote into each directory. */
26
27
  const LEGACY_DIRECTORY_COMMAND_FILES = ['proposal.md', 'apply.md', 'archive.md'];
28
+ /** Exact Kilo workflow files written by OpenSpec before the command path moved. */
29
+ const LEGACY_KILOCODE_COMMAND_FILES = [
30
+ ...ALL_WORKFLOWS.map(workflow => `.kilocode/workflows/opsx-${workflow}.md`),
31
+ '.kilocode/workflows/openspec-proposal.md',
32
+ '.kilocode/workflows/openspec-apply.md',
33
+ '.kilocode/workflows/openspec-archive.md',
34
+ ];
27
35
  /**
28
36
  * Legacy slash command patterns from the old SlashCommandRegistry.
29
37
  * These map toolId to the path pattern where legacy commands were created.
@@ -48,7 +56,12 @@ export const LEGACY_SLASH_COMMAND_PATHS = {
48
56
  // belong to `devin` — the id Windsurf became. Only `.windsurf/` is listed:
49
57
  // `.devin/` postdates the opsx rename and never held `openspec-*` files.
50
58
  'devin': { type: 'files', pattern: '.windsurf/workflows/openspec-*.md' },
51
- 'kilocode': { type: 'files', pattern: '.kilocode/workflows/openspec-*.md' },
59
+ // Kilo now writes commands under `.kilo/command/`. Clean up both generations
60
+ // of OpenSpec workflows from Kilo's legacy `.kilocode/workflows/` folder.
61
+ 'kilocode': {
62
+ type: 'files',
63
+ pattern: LEGACY_KILOCODE_COMMAND_FILES,
64
+ },
52
65
  'kiro': { type: 'files', pattern: '.kiro/prompts/openspec-*.prompt.md' },
53
66
  'github-copilot': { type: 'files', pattern: '.github/prompts/openspec-*.prompt.md' },
54
67
  'amazon-q': { type: 'files', pattern: '.amazonq/prompts/openspec-*.md' },
@@ -331,15 +344,27 @@ async function settleLegacyCommandDir(projectPath, dirPath, result) {
331
344
  * a same-named file without them is the user's.
332
345
  */
333
346
  async function isGeneratedLegacyCommand(filePath) {
347
+ // Judge the opened handle, not the path, so the file checked is the file
348
+ // read. O_NOFOLLOW refuses a link and O_NONBLOCK keeps a FIFO from hanging;
349
+ // Windows has neither flag, so a link is refused there by lstat instead.
350
+ const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = fsConstants;
351
+ let handle;
334
352
  try {
335
- if (!(await fs.lstat(filePath)).isFile()) {
353
+ handle = await fs.open(filePath, O_RDONLY | (O_NOFOLLOW ?? 0) | (O_NONBLOCK ?? 0));
354
+ if (O_NOFOLLOW === undefined && (await fs.lstat(filePath)).isSymbolicLink()) {
336
355
  return false;
337
356
  }
338
- return hasOpenSpecMarkers(await fs.readFile(filePath, 'utf-8'));
357
+ if (!(await handle.stat()).isFile()) {
358
+ return false;
359
+ }
360
+ return hasOpenSpecMarkers(await handle.readFile('utf-8'));
339
361
  }
340
362
  catch {
341
363
  return false;
342
364
  }
365
+ finally {
366
+ await handle?.close();
367
+ }
343
368
  }
344
369
  /**
345
370
  * Detects legacy global slash command files.
@@ -95,14 +95,45 @@ export interface DeltaPlan {
95
95
  * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
96
96
  */
97
97
  export declare function parseDeltaSpec(content: string): DeltaPlan;
98
+ /** Both directions of the scenario-name comparison, plus the two totals. */
99
+ export interface ScenarioNameDiff {
100
+ /** Names the current block has that the incoming block does not cover. */
101
+ missing: string[];
102
+ /** Names the incoming block introduces that the current block does not have. */
103
+ added: string[];
104
+ /** Level-4 headers in the current block. */
105
+ currentCount: number;
106
+ /** Level-4 headers in the incoming block. */
107
+ incomingCount: number;
108
+ }
109
+ /**
110
+ * Compare the scenario names of a current requirement block and an incoming
111
+ * (MODIFIED) one, in both directions.
112
+ *
113
+ * `missing` is the loss the guard exists to catch: a MODIFIED requirement
114
+ * replaces the whole block, so every name there would be dropped from the main
115
+ * spec. `added` and the two counts are reported alongside it, because they are
116
+ * the first thing a reader checks once it fires (#1697) - a block that omits
117
+ * two names and introduces two is shaped like a rename, one that omits two and
118
+ * introduces none is shaped like a truncation. Neither is proof, and intent is
119
+ * not recoverable from structure, so this decides nothing and only says what
120
+ * the two blocks contain.
121
+ */
122
+ export declare function diffScenarioNames(current: RequirementBlock, incoming: RequirementBlock): ScenarioNameDiff;
98
123
  /**
99
124
  * Scenario names the current requirement block has and the incoming
100
125
  * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
101
126
  * so every name reported here would be dropped from the main spec.
102
127
  *
103
- * Shared by archive (which refuses to apply the block) and validate (which
104
- * reports the same loss at authoring time, #1477), so the two cannot disagree
105
- * about what counts as a dropped scenario.
128
+ * The `missing` half of diffScenarioNames, which archive (refusing to apply
129
+ * the block) and validate (reporting the same loss at authoring time, #1477)
130
+ * both go through, so the two cannot disagree about what counts as a dropped
131
+ * scenario.
106
132
  */
107
133
  export declare function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[];
134
+ /**
135
+ * The one sentence archive and validate both append when the guard fires, so
136
+ * the counts a reader sees cannot differ between the two commands.
137
+ */
138
+ export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
108
139
  //# sourceMappingURL=requirement-blocks.d.ts.map
@@ -380,37 +380,79 @@ function parseRenamedPairs(sectionBody, unpaired) {
380
380
  drop('FROM', pending.name, pending.line);
381
381
  return pairs;
382
382
  }
383
+ /**
384
+ * Compare the scenario names of a current requirement block and an incoming
385
+ * (MODIFIED) one, in both directions.
386
+ *
387
+ * `missing` is the loss the guard exists to catch: a MODIFIED requirement
388
+ * replaces the whole block, so every name there would be dropped from the main
389
+ * spec. `added` and the two counts are reported alongside it, because they are
390
+ * the first thing a reader checks once it fires (#1697) - a block that omits
391
+ * two names and introduces two is shaped like a rename, one that omits two and
392
+ * introduces none is shaped like a truncation. Neither is proof, and intent is
393
+ * not recoverable from structure, so this decides nothing and only says what
394
+ * the two blocks contain.
395
+ */
396
+ export function diffScenarioNames(current, incoming) {
397
+ const currentNames = parseScenarioBlocks(current.raw).map((scenario) => scenario.name);
398
+ const incomingNames = parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name);
399
+ // Multiplicity-aware: a name present N times on one side and M times on the
400
+ // other leaves max(0, N - M) instances unmatched. Set membership would treat
401
+ // N>M as fully covered and let archive silently drop duplicates (residual
402
+ // #1246 / duplicate-scenario-name blind spot).
403
+ const unmatched = (names, against) => {
404
+ const remaining = new Map();
405
+ for (const name of against)
406
+ remaining.set(name, (remaining.get(name) ?? 0) + 1);
407
+ const out = [];
408
+ for (const name of names) {
409
+ const left = remaining.get(name) ?? 0;
410
+ if (left > 0)
411
+ remaining.set(name, left - 1);
412
+ else
413
+ out.push(name);
414
+ }
415
+ return out;
416
+ };
417
+ return {
418
+ missing: unmatched(currentNames, incomingNames),
419
+ added: unmatched(incomingNames, currentNames),
420
+ currentCount: currentNames.length,
421
+ incomingCount: incomingNames.length,
422
+ };
423
+ }
383
424
  /**
384
425
  * Scenario names the current requirement block has and the incoming
385
426
  * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
386
427
  * so every name reported here would be dropped from the main spec.
387
428
  *
388
- * Shared by archive (which refuses to apply the block) and validate (which
389
- * reports the same loss at authoring time, #1477), so the two cannot disagree
390
- * about what counts as a dropped scenario.
429
+ * The `missing` half of diffScenarioNames, which archive (refusing to apply
430
+ * the block) and validate (reporting the same loss at authoring time, #1477)
431
+ * both go through, so the two cannot disagree about what counts as a dropped
432
+ * scenario.
391
433
  */
392
434
  export function findMissingCurrentScenarios(current, incoming) {
393
- // Multiplicity-aware: a name present N times in current and M times in
394
- // incoming means max(0, N - M) instances are missing. Set membership would
395
- // treat N>M as fully covered and let archive silently drop duplicates
396
- // (residual #1246 / duplicate-scenario-name blind spot).
397
- const remainingIncoming = new Map();
398
- for (const scenario of parseScenarioBlocks(incoming.raw)) {
399
- const name = scenario.name;
400
- remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
401
- }
402
- const missing = [];
403
- for (const scenario of parseScenarioBlocks(current.raw)) {
404
- const name = scenario.name;
405
- const remaining = remainingIncoming.get(name) ?? 0;
406
- if (remaining > 0) {
407
- remainingIncoming.set(name, remaining - 1);
408
- }
409
- else {
410
- missing.push(name);
411
- }
435
+ return diffScenarioNames(current, incoming).missing;
436
+ }
437
+ /** At most this many added names are listed before the rest are counted. */
438
+ const MAX_LISTED_ADDED_SCENARIOS = 3;
439
+ /**
440
+ * The one sentence archive and validate both append when the guard fires, so
441
+ * the counts a reader sees cannot differ between the two commands.
442
+ */
443
+ export function describeScenarioBalance(diff) {
444
+ const count = (value) => `${value} ${value === 1 ? 'scenario' : 'scenarios'}`;
445
+ const scale = `The modified block has ${count(diff.incomingCount)}; the current spec has ${count(diff.currentCount)}.`;
446
+ if (diff.added.length === 0) {
447
+ return `${scale} It adds none.`;
412
448
  }
413
- return missing;
449
+ const listed = diff.added
450
+ .slice(0, MAX_LISTED_ADDED_SCENARIOS)
451
+ .map((name) => `"${name}"`)
452
+ .join(', ');
453
+ const rest = diff.added.length - MAX_LISTED_ADDED_SCENARIOS;
454
+ const names = rest > 0 ? `${listed} and ${rest} more` : listed;
455
+ return `${scale} It adds ${count(diff.added.length)} not in the current spec: ${names}.`;
414
456
  }
415
457
  /**
416
458
  * Any non-fenced level-4 header on the given (masked) line. Reuses the spec
@@ -102,6 +102,26 @@ export declare function retireSpec(update: SpecUpdate, mainSpecsDir: string, opt
102
102
  displacedPath?: string;
103
103
  }>;
104
104
  export declare function finalizeRetiredSpec(target: string, displacedPath: string, mainSpecsDir: string): Promise<void>;
105
+ /**
106
+ * Remove now-empty directories from `startDir` upward, never leaving the real
107
+ * `boundaryDir` and never removing that directory itself.
108
+ *
109
+ * The boundary is a parameter rather than the specs root directly so the walk's
110
+ * containment is stated at the call site, where the root it must not escape is
111
+ * the thing being reasoned about.
112
+ *
113
+ * The guard re-runs every iteration, so stepping to the LEXICAL parent is safe:
114
+ * a parent that is not the real one is simply re-resolved and rejected. Errors
115
+ * are swallowed and end the walk - ENOTEMPTY and ENOENT are correct outcomes (a
116
+ * file arriving mid-walk must win), and a permissions failure leaves an empty
117
+ * directory behind, which the next successful archive clears.
118
+ *
119
+ * Not race-free: an attacker who can swap an ancestor between the check and the
120
+ * `rmdir` could get an empty directory outside the root removed. Closing that
121
+ * needs fd-relative syscalls Node does not expose, and it requires local write
122
+ * access to `openspec/specs` during an archive.
123
+ */
124
+ export declare function pruneEmptyDirs(startDir: string, boundaryDir: string): Promise<void>;
105
125
  /**
106
126
  * Write an updated spec to disk.
107
127
  */
@@ -8,13 +8,14 @@ import { promises as fs } from 'fs';
8
8
  import { randomUUID } from 'crypto';
9
9
  import path from 'path';
10
10
  import chalk from 'chalk';
11
- import { extractRequirementsSection, findMissingCurrentScenarios, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
+ import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
12
12
  import { findMainSpecStructureIssues } from './parsers/spec-structure.js';
13
13
  import { buildCodeFenceMask } from './parsers/code-fence.js';
14
14
  import { MarkdownParser } from './parsers/markdown-parser.js';
15
15
  import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from './validation/constants.js';
16
16
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
17
17
  import { FileSystemUtils } from '../utils/file-system.js';
18
+ import { matchLineEnding } from '../utils/line-endings.js';
18
19
  function isLexicallyWithin(allowedDirectory, targetPath) {
19
20
  const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
20
21
  return (relative === '' ||
@@ -383,9 +384,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
383
384
  if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
384
385
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
385
386
  }
386
- const missingScenarios = findMissingCurrentScenarios(currentBlock, mod);
387
- if (missingScenarios.length > 0) {
388
- throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
387
+ const scenarioDiff = diffScenarioNames(currentBlock, mod);
388
+ if (scenarioDiff.missing.length > 0) {
389
+ throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${scenarioDiff.missing.map(name => `"${name}"`).join(', ')}. ${describeScenarioBalance(scenarioDiff)} Refresh the change spec before archiving to avoid dropping scenarios.`);
389
390
  }
390
391
  // Identical content means the modification was already synced to the
391
392
  // baseline (early-sync pattern) — count only real replacements, so a
@@ -995,7 +996,7 @@ async function isInsideRealDir(realPath, dir) {
995
996
  * needs fd-relative syscalls Node does not expose, and it requires local write
996
997
  * access to `openspec/specs` during an archive.
997
998
  */
998
- async function pruneEmptyDirs(startDir, boundaryDir) {
999
+ export async function pruneEmptyDirs(startDir, boundaryDir) {
999
1000
  let boundary;
1000
1001
  try {
1001
1002
  boundary = await fs.realpath(boundaryDir);
@@ -1040,11 +1041,23 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
1040
1041
  // Create target directory if needed
1041
1042
  const targetDir = path.dirname(update.target);
1042
1043
  await fs.mkdir(targetDir, { recursive: true });
1044
+ // The parsers normalize CRLF to LF on read, so `rebuilt` is always LF. Write
1045
+ // it back with the convention the file already used, or a Windows checkout
1046
+ // (core.autocrlf=true) sees every line of the spec change when one
1047
+ // requirement moved. A spec that does not exist yet stays LF.
1048
+ // Only a missing file means "no convention to match". Swallowing every error
1049
+ // would read an existing but unreadable spec as absent and rewrite it as LF.
1050
+ const previous = await fs.readFile(update.target, 'utf-8').catch((error) => {
1051
+ if (error.code === 'ENOENT')
1052
+ return undefined;
1053
+ throw error;
1054
+ });
1055
+ const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
1043
1056
  await options.beforeMutate?.();
1044
1057
  // Preserve the established in-place write semantics: symlink referents,
1045
1058
  // hard-linked specs, ACLs, extended attributes, and filesystems without hard
1046
1059
  // links must continue to behave as they did before capability retirement.
1047
- await fs.writeFile(update.target, rebuilt);
1060
+ await fs.writeFile(update.target, toWrite);
1048
1061
  if (options.silent)
1049
1062
  return;
1050
1063
  const specName = update.id;
@@ -81,20 +81,27 @@ ${PROJECT_ROOT_GUARD}
81
81
 
82
82
  3. **Check task completion status**
83
83
 
84
- Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks.
85
-
86
- A checkbox is complete when its only content is \`x\` or \`X\`; spacing inside
87
- the brackets does not matter, so \`- [ x]\` counts as complete too. Every
88
- other marker is incomplete - \`- [ ]\`, an empty \`- []\`, and markers OpenSpec
89
- assigns no meaning to such as \`- [~]\` or \`- [-]\`. Never read an unfamiliar
90
- marker as complete.
84
+ Run \`openspec list --json\` with the same selected-root flags and find the
85
+ entry in \`changes\` whose \`name\` exactly matches the selected change.
86
+ Require exactly one match and nonnegative integer \`totalTasks\` and
87
+ \`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
88
+ the schema's tracked task files, including custom artifact names, output
89
+ paths, and globs.
90
+ Incomplete tasks = \`totalTasks - completedTasks\`.
91
+
92
+ Do not infer task completion from artifact status or the absence of a
93
+ top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
94
+ duplicates the selected change, or returns invalid counts, report the problem
95
+ and stop before syncing or archiving.
96
+ The CLI counts only \`x\`/\`X\` checkbox markers as complete;
97
+ other markers, including unfamiliar ones, remain incomplete.
91
98
 
92
99
  **If incomplete tasks found:**
93
100
  - Display warning showing count of incomplete tasks
94
101
  - Ask the user to confirm they want to proceed
95
102
  - Proceed if user confirms
96
103
 
97
- **If no tasks file exists:** Proceed without task-related warning.
104
+ **If \`totalTasks\` is zero:** Proceed without a task-related warning.
98
105
 
99
106
  4. **Assess delta spec sync state**
100
107
 
@@ -274,20 +281,27 @@ ${PROJECT_ROOT_GUARD}
274
281
 
275
282
  3. **Check task completion status**
276
283
 
277
- Read the tasks file (typically \`tasks.md\`) to check for incomplete tasks.
278
-
279
- A checkbox is complete when its only content is \`x\` or \`X\`; spacing inside
280
- the brackets does not matter, so \`- [ x]\` counts as complete too. Every
281
- other marker is incomplete - \`- [ ]\`, an empty \`- []\`, and markers OpenSpec
282
- assigns no meaning to such as \`- [~]\` or \`- [-]\`. Never read an unfamiliar
283
- marker as complete.
284
+ Run \`openspec list --json\` with the same selected-root flags and find the
285
+ entry in \`changes\` whose \`name\` exactly matches the selected change.
286
+ Require exactly one match and nonnegative integer \`totalTasks\` and
287
+ \`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
288
+ the schema's tracked task files, including custom artifact names, output
289
+ paths, and globs.
290
+ Incomplete tasks = \`totalTasks - completedTasks\`.
291
+
292
+ Do not infer task completion from artifact status or the absence of a
293
+ top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
294
+ duplicates the selected change, or returns invalid counts, report the problem
295
+ and stop before syncing or archiving.
296
+ The CLI counts only \`x\`/\`X\` checkbox markers as complete;
297
+ other markers, including unfamiliar ones, remain incomplete.
284
298
 
285
299
  **If incomplete tasks found:**
286
300
  - Display warning showing count of incomplete tasks
287
301
  - Prompt user for confirmation to continue
288
302
  - Proceed if user confirms
289
303
 
290
- **If no tasks file exists:** Proceed without task-related warning.
304
+ **If \`totalTasks\` is zero:** Proceed without a task-related warning.
291
305
 
292
306
  4. **Assess delta spec sync state**
293
307
 
@@ -37,7 +37,7 @@ ${PROJECT_ROOT_GUARD}
37
37
  2. **Prompt for change selection**
38
38
 
39
39
  Ask the user to choose changes (multi-select):
40
- - Show each change with its schema
40
+ - Show each change name and task status from the list output
41
41
  - Include an option for "All changes"
42
42
  - Allow any number of selections (1+ works, 2+ is the typical use case)
43
43
 
@@ -70,17 +70,24 @@ ${PROJECT_ROOT_GUARD}
70
70
 
71
71
  3. **Batch validation - gather status for all selected changes**
72
72
 
73
+ Run \`openspec list --json\` once with the same selected-root flags for task
74
+ progress. If the lookup fails, returns invalid JSON, or omits any selected
75
+ change, contains a duplicate selected change, or returns invalid counts,
76
+ report the problem and stop before syncing or archiving the batch.
77
+
73
78
  For each selected change, collect:
74
79
 
75
80
  a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
76
81
  - Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
77
82
  - Note which artifacts are \`done\` vs other states
78
83
 
79
- b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON
80
- - Complete means the checkbox holds only \`x\`/\`X\`, ignoring spacing
81
- (\`- [ x]\` is complete); every other marker is incomplete (\`- [ ]\`,
82
- \`- []\`, and unfamiliar ones such as \`- [~]\` or \`- [-]\`)
83
- - If no tasks file exists, note as "No tasks"
84
+ b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
85
+ - Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
86
+ - Incomplete tasks = \`totalTasks - completedTasks\`
87
+ - The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
88
+ - Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
89
+ - The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
90
+ - If \`totalTasks\` is zero, note as "No tasks"
84
91
 
85
92
  c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
86
93
  - List which capability specs exist
@@ -194,7 +201,7 @@ ${PROJECT_ROOT_GUARD}
194
201
  Process changes in the determined order (respecting conflict resolution):
195
202
 
196
203
  a. **Sync included delta specs**:
197
- - Run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
204
+ - ${optionalWorkflow('sync', 'Run the `openspec-sync-specs` workflow inline (agent-driven intelligent merge)', 'Perform the delta-to-main-spec merge inline yourself (agent-driven intelligent merge)')} only for changes with entries in \`includedDeltas\`, passing only the included delta paths and explicitly instructing it to ignore that change's \`excludedDeltas\`. Wait for it to finish.
198
205
  - For conflicts, apply in resolved order.
199
206
  - Pass that change's fetched specs-rule snapshot into inline sync; inline
200
207
  sync must reuse it without fetching instructions again
@@ -347,7 +354,7 @@ No active changes found. Create a new change to get started.
347
354
  - Archive directory target uses the current date, computed once in step 3d and reused at the move: YYYY-MM-DD-<name>; a name that already starts with a \`YYYY-MM-DD-\` prefix is used as-is (never stack a second date)
348
355
  - If archive target exists, fail that change but continue with others
349
356
  - Check every archive target in step 3, before the first main-spec write; a change whose target exists is never synced or moved
350
- - If sync is requested, run the \`openspec-sync-specs\` workflow inline (agent-driven) for each change with included delta specs
357
+ - If sync is requested, ${optionalWorkflow('sync', 'run the `openspec-sync-specs` workflow inline (agent-driven)', 'perform the delta-to-main-spec merge inline (agent-driven)')} for each change with included delta specs
351
358
  - Carry the per-delta \`includedDeltas\` and \`excludedDeltas\` decisions into execution; sync and verify only included deltas
352
359
  - Report every excluded delta as \`sync skipped\` without treating the archive itself as skipped
353
360
  - Never archive a change while a spec sync is still in flight — run the sync inline and verify main specs at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\` before moving \`changeRoot\`
@@ -395,7 +402,7 @@ ${PROJECT_ROOT_GUARD}
395
402
  2. **Prompt for change selection**
396
403
 
397
404
  Ask the user to choose changes (multi-select):
398
- - Show each change with its schema
405
+ - Show each change name and task status from the list output
399
406
  - Include an option for "All changes"
400
407
  - Allow any number of selections (1+ works, 2+ is the typical use case)
401
408
 
@@ -428,17 +435,24 @@ ${PROJECT_ROOT_GUARD}
428
435
 
429
436
  3. **Batch validation - gather status for all selected changes**
430
437
 
438
+ Run \`openspec list --json\` once with the same selected-root flags for task
439
+ progress. If the lookup fails, returns invalid JSON, or omits any selected
440
+ change, contains a duplicate selected change, or returns invalid counts,
441
+ report the problem and stop before syncing or archiving the batch.
442
+
431
443
  For each selected change, collect:
432
444
 
433
445
  a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
434
446
  - Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
435
447
  - Note which artifacts are \`done\` vs other states
436
448
 
437
- b. **Task completion** - Read \`artifactPaths.tasks.existingOutputPaths\` from status JSON
438
- - Complete means the checkbox holds only \`x\`/\`X\`, ignoring spacing
439
- (\`- [ x]\` is complete); every other marker is incomplete (\`- [ ]\`,
440
- \`- []\`, and unfamiliar ones such as \`- [~]\` or \`- [-]\`)
441
- - If no tasks file exists, note as "No tasks"
449
+ b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
450
+ - Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
451
+ - Incomplete tasks = \`totalTasks - completedTasks\`
452
+ - The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
453
+ - Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
454
+ - The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
455
+ - If \`totalTasks\` is zero, note as "No tasks"
442
456
 
443
457
  c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
444
458
  - List which capability specs exist
@@ -30,7 +30,6 @@ ${PROJECT_ROOT_GUARD}
30
30
 
31
31
  When prompting, present the top 3-4 most recently modified changes as options, showing:
32
32
  - Change name
33
- - Schema (from \`schema\` field if present, otherwise "spec-driven")
34
33
  - Status (e.g., "0/5 tasks", "complete", "no tasks")
35
34
  - How recently it was modified (from \`lastModified\` field)
36
35
 
@@ -149,7 +148,6 @@ ${PROJECT_ROOT_GUARD}
149
148
 
150
149
  When prompting, present the top 3-4 most recently modified changes as options, showing:
151
150
  - Change name
152
- - Schema (from \`schema\` field if present, otherwise "spec-driven")
153
151
  - Status (e.g., "0/5 tasks", "complete", "no tasks")
154
152
  - How recently it was modified (from \`lastModified\` field)
155
153
 
@@ -128,7 +128,7 @@ openspec list --json
128
128
 
129
129
  This tells you:
130
130
  - If there are active changes
131
- - Their names, schemas, and status
131
+ - Their names and task status
132
132
  - What the user might be working on
133
133
 
134
134
  That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
@@ -459,7 +459,7 @@ openspec list --json
459
459
 
460
460
  This tells you:
461
461
  - If there are active changes
462
- - Their names, schemas, and status
462
+ - Their names and task status
463
463
  - What the user might be working on
464
464
 
465
465
  That is the *change* list - work in flight. It does not include the project's durable capabilities, so list those too:
@@ -88,7 +88,7 @@ ${PROJECT_ROOT_GUARD}
88
88
  - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway
89
89
  - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped
90
90
 
91
- c. **If an artifact requires user input** (unclear context):
91
+ c. **If an artifact requires user input** (critically unclear context):
92
92
  - Ask the user to clarify
93
93
  - Then continue with creation
94
94
 
@@ -206,7 +206,7 @@ ${PROJECT_ROOT_GUARD}
206
206
  - Dependencies are enablers, not gates: if a required artifact is still \`blocked\` only because you skipped a conditional dependency, write it anyway
207
207
  - Stop when every artifact in the required set is \`done\`, \`skipped\`, or was deliberately skipped
208
208
 
209
- c. **If an artifact requires user input** (unclear context):
209
+ c. **If an artifact requires user input** (critically unclear context):
210
210
  - Ask the user to clarify
211
211
  - Then continue with creation
212
212
 
@@ -415,7 +415,7 @@ Save to the \`resolvedOutputPath\` from \`openspec instructions design --change
415
415
 
416
416
  Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
417
417
 
418
- These should be small, clear, and in logical order.
418
+ These should be small, clear, and in logical order. Each group carries the tests and documentation for its own work - the last group is only for integration checks.
419
419
  \`\`\`
420
420
 
421
421
  **DO:** Generate tasks based on specs and design:
@@ -438,12 +438,17 @@ Here are the implementation tasks:
438
438
 
439
439
  ---
440
440
 
441
- Each checkbox becomes a unit of work in the apply phase. Ready to implement?
441
+ Each checkbox becomes a unit of work in the apply phase. Does this task breakdown look right?
442
442
  \`\`\`
443
443
 
444
- **PAUSE** - Wait for user to confirm they're ready to implement.
444
+ **PAUSE** - Wait for user approval/feedback.
445
+
446
+ After approval, save to the \`resolvedOutputPath\` from \`openspec instructions tasks --change "<name>" --json\`.
447
+
448
+ Then ask:
449
+ > "Tasks are saved. Ready to implement?"
445
450
 
446
- Save to the \`resolvedOutputPath\` from \`openspec instructions tasks --change "<name>" --json\`.
451
+ **PAUSE** - Wait for user to confirm before implementation.
447
452
 
448
453
  ---
449
454