@fission-ai/openspec 1.13.1 → 1.14.0

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 (85) hide show
  1. package/README.md +15 -3
  2. package/dist/cli/index.js +39 -1
  3. package/dist/commands/spec.js +1 -0
  4. package/dist/commands/workflow/instructions.js +66 -18
  5. package/dist/commands/workflow/shared.d.ts +7 -0
  6. package/dist/commands/workflow/status.js +15 -2
  7. package/dist/core/archive.js +258 -78
  8. package/dist/core/artifact-graph/instruction-loader.d.ts +7 -0
  9. package/dist/core/artifact-graph/instruction-loader.js +18 -1
  10. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  11. package/dist/core/artifact-graph/outputs.js +38 -8
  12. package/dist/core/change-metadata/schema.d.ts +3 -0
  13. package/dist/core/change-metadata/schema.js +10 -0
  14. package/dist/core/change-status-policy.d.ts +8 -0
  15. package/dist/core/change-status-policy.js +33 -2
  16. package/dist/core/command-generation/adapters/atomcode.d.ts +24 -0
  17. package/dist/core/command-generation/adapters/atomcode.js +46 -0
  18. package/dist/core/command-generation/adapters/codestudio.d.ts +13 -0
  19. package/dist/core/command-generation/adapters/codestudio.js +27 -0
  20. package/dist/core/command-generation/adapters/continue.js +4 -0
  21. package/dist/core/command-generation/adapters/easycode.d.ts +17 -0
  22. package/dist/core/command-generation/adapters/easycode.js +28 -0
  23. package/dist/core/command-generation/adapters/gemini.js +1 -35
  24. package/dist/core/command-generation/adapters/gigacode.d.ts +17 -0
  25. package/dist/core/command-generation/adapters/gigacode.js +31 -0
  26. package/dist/core/command-generation/adapters/index.d.ts +4 -0
  27. package/dist/core/command-generation/adapters/index.js +4 -0
  28. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  29. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  30. package/dist/core/command-generation/registry.js +8 -0
  31. package/dist/core/command-generation/toml.d.ts +19 -0
  32. package/dist/core/command-generation/toml.js +39 -0
  33. package/dist/core/command-surface.js +1 -1
  34. package/dist/core/completions/command-registry.js +19 -0
  35. package/dist/core/completions/installers/zsh-installer.js +4 -2
  36. package/dist/core/config-prompts.js +6 -6
  37. package/dist/core/config.js +11 -1
  38. package/dist/core/init.js +44 -14
  39. package/dist/core/legacy-cleanup.d.ts +1 -1
  40. package/dist/core/legacy-cleanup.js +40 -11
  41. package/dist/core/list.d.ts +2 -0
  42. package/dist/core/list.js +49 -23
  43. package/dist/core/parsers/markdown-parser.js +5 -0
  44. package/dist/core/parsers/requirement-blocks.d.ts +40 -3
  45. package/dist/core/parsers/requirement-blocks.js +74 -25
  46. package/dist/core/project-config.js +45 -1
  47. package/dist/core/references.js +3 -1
  48. package/dist/core/root-selection.d.ts +6 -0
  49. package/dist/core/root-selection.js +16 -0
  50. package/dist/core/schemas/base.schema.d.ts +3 -0
  51. package/dist/core/schemas/base.schema.js +6 -0
  52. package/dist/core/schemas/change.schema.d.ts +8 -0
  53. package/dist/core/schemas/spec.schema.d.ts +2 -0
  54. package/dist/core/specs-apply.d.ts +20 -0
  55. package/dist/core/specs-apply.js +19 -6
  56. package/dist/core/templates/workflows/apply-change.js +5 -2
  57. package/dist/core/templates/workflows/archive-change.js +56 -20
  58. package/dist/core/templates/workflows/bulk-archive-change.js +34 -16
  59. package/dist/core/templates/workflows/continue-change.js +0 -2
  60. package/dist/core/templates/workflows/explore.js +2 -2
  61. package/dist/core/templates/workflows/ff-change.js +2 -2
  62. package/dist/core/templates/workflows/onboard.js +9 -4
  63. package/dist/core/templates/workflows/update-change.js +21 -9
  64. package/dist/core/templates/workflows/verify-change.js +115 -52
  65. package/dist/core/update.js +20 -7
  66. package/dist/core/validation/constants.d.ts +1 -1
  67. package/dist/core/validation/constants.js +1 -1
  68. package/dist/core/validation/purpose-placeholder.js +24 -9
  69. package/dist/core/validation/validator.js +15 -6
  70. package/dist/core/version-check.d.ts +23 -1
  71. package/dist/core/version-check.js +112 -26
  72. package/dist/core/view.d.ts +2 -0
  73. package/dist/core/view.js +62 -4
  74. package/dist/utils/change-metadata.d.ts +20 -1
  75. package/dist/utils/change-metadata.js +57 -1
  76. package/dist/utils/command-references.d.ts +1 -1
  77. package/dist/utils/command-references.js +2 -2
  78. package/dist/utils/file-system.js +20 -3
  79. package/dist/utils/index.d.ts +1 -1
  80. package/dist/utils/index.js +1 -1
  81. package/dist/utils/line-endings.d.ts +34 -0
  82. package/dist/utils/line-endings.js +46 -0
  83. package/package.json +2 -2
  84. package/schemas/spec-driven/schema.yaml +10 -1
  85. package/schemas/spec-driven/templates/proposal.md +6 -3
@@ -181,6 +181,50 @@ function parseDeclarationList(raw) {
181
181
  return byId.size > 0 ? [...byId.values()] : undefined;
182
182
  }
183
183
  export const MAX_CONTEXT_SIZE = 50 * 1024; // 50KB hard limit, shared with the references index
184
+ /**
185
+ * Build the warning for an artifact whose `rules:` list is not an array of
186
+ * strings. Names the offending index and what YAML actually produced there, so
187
+ * a config that silently loses an entire rule set can be fixed without
188
+ * bisecting the list by hand. A bare `-` item containing an unquoted ": " is the
189
+ * common cause: YAML reads it as a mapping, so the hint points at quoting.
190
+ */
191
+ function describeRulesShapeError(artifactId, rules) {
192
+ const base = `Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`;
193
+ if (!Array.isArray(rules)) {
194
+ return `${base}. rules.${artifactId} is ${describeYamlType(rules)}`;
195
+ }
196
+ const bad = rules
197
+ .map((rule, index) => ({ rule, index }))
198
+ .filter(({ rule }) => typeof rule !== 'string');
199
+ if (bad.length === 0) {
200
+ return base;
201
+ }
202
+ // Name every offending index with its own shape, so a mixed list does not
203
+ // have to be re-bisected one item at a time.
204
+ const details = bad
205
+ .map(({ rule, index }) => `rules.${artifactId}[${index}] is ${describeYamlType(rule)}`)
206
+ .join('; ');
207
+ // A bare `-` item with an unquoted ": " is the common cause: YAML reads it as
208
+ // a mapping. Say so rather than leaving the reader to work out the quoting.
209
+ const hasMapping = bad.some(({ rule }) => rule !== null && typeof rule === 'object' && !Array.isArray(rule));
210
+ const hint = hasMapping
211
+ ? ' — an unquoted ": " makes YAML read the item as a key/value pair; quote the whole scalar to keep it a string.'
212
+ : '';
213
+ return `${base}. ${details}${hint}`;
214
+ }
215
+ /** Name a YAML value's shape in a warning, e.g. "a mapping" or "a number". */
216
+ function describeYamlType(value) {
217
+ if (value === null)
218
+ return 'null';
219
+ if (Array.isArray(value))
220
+ return 'a nested list';
221
+ const type = typeof value;
222
+ if (type === 'object')
223
+ return 'a mapping';
224
+ if (type === 'number' || type === 'boolean')
225
+ return `a ${type}`;
226
+ return `a ${type}`;
227
+ }
184
228
  /**
185
229
  * Read and parse openspec/config.yaml from project root.
186
230
  * Uses resilient parsing - validates each field independently using Zod safeParse.
@@ -265,7 +309,7 @@ export function readProjectConfig(projectRoot) {
265
309
  }
266
310
  }
267
311
  else {
268
- console.warn(`Rules for '${artifactId}' must be an array of strings, ignoring this artifact's rules`);
312
+ console.warn(describeRulesShapeError(artifactId, rules));
269
313
  }
270
314
  }
271
315
  if (hasValidRules) {
@@ -191,7 +191,9 @@ export function renderReferencedStoresSection(entries) {
191
191
  * let hostile content forge instruction lines (slice 6.1 hardening).
192
192
  */
193
193
  export function sanitizeInline(value, maxLength = 300) {
194
- const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
194
+ const flattened = value
195
+ .replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u206f]+/g, ' ')
196
+ .trim();
195
197
  return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
196
198
  }
197
199
  /**
@@ -94,6 +94,12 @@ export declare function toRootOutput(root: ResolvedOpenSpecRoot): RootOutput;
94
94
  export declare function isStoreSelectedRoot(root: ResolvedOpenSpecRoot): root is ResolvedOpenSpecRoot & {
95
95
  storeId: string;
96
96
  };
97
+ /**
98
+ * The project on the current path whose `store:` pointer names `storeId`,
99
+ * as a canonical path (the root walk resolves aliases). Null when the
100
+ * nearest root is a real planning root or points at a different store.
101
+ */
102
+ export declare function findDeclaringProjectRoot(storeId: string, startPath?: string): string | null;
97
103
  /**
98
104
  * Human-mode verification signal for a selected store. Written to stderr so
99
105
  * raw-Markdown and agent-consumed stdout payloads stay clean.
@@ -309,6 +309,22 @@ export function toRootOutput(root) {
309
309
  export function isStoreSelectedRoot(root) {
310
310
  return root.storeId !== undefined;
311
311
  }
312
+ /**
313
+ * The project on the current path whose `store:` pointer names `storeId`,
314
+ * as a canonical path (the root walk resolves aliases). Null when the
315
+ * nearest root is a real planning root or points at a different store.
316
+ */
317
+ export function findDeclaringProjectRoot(storeId, startPath = process.cwd()) {
318
+ const nearestRoot = findQualifyingRootSync(startPath);
319
+ if (!nearestRoot) {
320
+ return null;
321
+ }
322
+ const { hasPlanningShape, pointer } = classifyOpenSpecDir(nearestRoot);
323
+ if (hasPlanningShape || pointer.value !== storeId) {
324
+ return null;
325
+ }
326
+ return nearestRoot;
327
+ }
312
328
  /**
313
329
  * Human-mode verification signal for a selected store. Written to stderr so
314
330
  * raw-Markdown and agent-consumed stdout payloads stay clean.
@@ -1,10 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  export declare const ScenarioSchema: z.ZodObject<{
3
+ name: z.ZodOptional<z.ZodString>;
3
4
  rawText: z.ZodString;
4
5
  }, z.core.$strip>;
5
6
  export declare const RequirementSchema: z.ZodObject<{
7
+ name: z.ZodOptional<z.ZodString>;
6
8
  text: z.ZodString;
7
9
  scenarios: z.ZodArray<z.ZodObject<{
10
+ name: z.ZodOptional<z.ZodString>;
8
11
  rawText: z.ZodString;
9
12
  }, z.core.$strip>>;
10
13
  }, z.core.$strip>;
@@ -1,9 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { VALIDATION_MESSAGES } from '../validation/constants.js';
3
3
  export const ScenarioSchema = z.object({
4
+ // Header text without `####`, the closing `#` run, and the `Scenario:`
5
+ // prefix. Optional so objects built outside the parser still validate.
6
+ name: z.string().optional(),
4
7
  rawText: z.string().min(1, VALIDATION_MESSAGES.SCENARIO_EMPTY),
5
8
  });
6
9
  export const RequirementSchema = z.object({
10
+ // Header text without `###` and the `Requirement:` prefix: the name archive
11
+ // matches MODIFIED, REMOVED and RENAMED entries against.
12
+ name: z.string().optional(),
7
13
  // SHALL/MUST body-keyword enforcement lives in the imperative validator
8
14
  // (Validator.applySpecRules), not here: the parser collapses the requirement
9
15
  // header into `text`, so a Zod refine on `text` cannot tell "keyword in header
@@ -15,14 +15,18 @@ export declare const DeltaSchema: z.ZodObject<{
15
15
  }>;
16
16
  description: z.ZodString;
17
17
  requirement: z.ZodOptional<z.ZodObject<{
18
+ name: z.ZodOptional<z.ZodString>;
18
19
  text: z.ZodString;
19
20
  scenarios: z.ZodArray<z.ZodObject<{
21
+ name: z.ZodOptional<z.ZodString>;
20
22
  rawText: z.ZodString;
21
23
  }, z.core.$strip>>;
22
24
  }, z.core.$strip>>;
23
25
  requirements: z.ZodOptional<z.ZodArray<z.ZodObject<{
26
+ name: z.ZodOptional<z.ZodString>;
24
27
  text: z.ZodString;
25
28
  scenarios: z.ZodArray<z.ZodObject<{
29
+ name: z.ZodOptional<z.ZodString>;
26
30
  rawText: z.ZodString;
27
31
  }, z.core.$strip>>;
28
32
  }, z.core.$strip>>>;
@@ -45,14 +49,18 @@ export declare const ChangeSchema: z.ZodObject<{
45
49
  }>;
46
50
  description: z.ZodString;
47
51
  requirement: z.ZodOptional<z.ZodObject<{
52
+ name: z.ZodOptional<z.ZodString>;
48
53
  text: z.ZodString;
49
54
  scenarios: z.ZodArray<z.ZodObject<{
55
+ name: z.ZodOptional<z.ZodString>;
50
56
  rawText: z.ZodString;
51
57
  }, z.core.$strip>>;
52
58
  }, z.core.$strip>>;
53
59
  requirements: z.ZodOptional<z.ZodArray<z.ZodObject<{
60
+ name: z.ZodOptional<z.ZodString>;
54
61
  text: z.ZodString;
55
62
  scenarios: z.ZodArray<z.ZodObject<{
63
+ name: z.ZodOptional<z.ZodString>;
56
64
  rawText: z.ZodString;
57
65
  }, z.core.$strip>>;
58
66
  }, z.core.$strip>>>;
@@ -3,8 +3,10 @@ export declare const SpecSchema: z.ZodObject<{
3
3
  name: z.ZodString;
4
4
  overview: z.ZodString;
5
5
  requirements: z.ZodArray<z.ZodObject<{
6
+ name: z.ZodOptional<z.ZodString>;
6
7
  text: z.ZodString;
7
8
  scenarios: z.ZodArray<z.ZodObject<{
9
+ name: z.ZodOptional<z.ZodString>;
8
10
  rawText: z.ZodString;
9
11
  }, z.core.$strip>>;
10
12
  }, z.core.$strip>>;
@@ -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;
@@ -56,7 +56,7 @@ ${PROJECT_ROOT_GUARD}
56
56
  This returns:
57
57
  - \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
58
58
  - Progress (total, complete, remaining)
59
- - Task list with status
59
+ - Task list with status, source path, and source line
60
60
  - Dynamic instruction based on current state
61
61
  - Optional \`context\`: current required project instruction input from the selected root
62
62
  - Optional \`operationGuidance\`: current advisory guidance for apply
@@ -108,7 +108,9 @@ ${PROJECT_ROOT_GUARD}
108
108
  - Show which task is being worked on
109
109
  - Make the code changes required
110
110
  - Keep changes minimal and focused
111
- - Mark task complete in the tasks file: \`- [ ]\` → \`- [x]\`
111
+ - Before editing, confirm the checkbox at the returned \`sourcePath\` and \`line\` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location
112
+ - Mark the task complete at its returned \`sourcePath\` and \`line\`: \`- [ ]\` → \`- [x]\`
113
+ - Rerun the apply instructions and confirm that task is now done and progress changed
112
114
  - Continue to next task
113
115
 
114
116
  **Pause if:**
@@ -188,6 +190,7 @@ What would you like to do?
188
190
  - When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
189
191
  - Only mark a task \`- [x]\` when its specified behavior is fully implemented, not when it is partially done or deferred
190
192
  - Use contextFiles from CLI output, don't assume specific file names
193
+ - Use each task's sourcePath and line to update its exact checkbox
191
194
  - Do not use context or operation guidance as proof that a task is complete
192
195
  - Apply relevant project context; report conflicts with controlling workflow inputs
193
196
  - Consider every guidance entry; explain any inapplicable or conflicting advice
@@ -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
 
@@ -133,12 +140,23 @@ ${PROJECT_ROOT_GUARD}
133
140
  form of main specs produced by this merge; do not use them as archive guidance,
134
141
  change CLI behavior, or copy the rule text into any output file.
135
142
 
136
- Then run the \`openspec-sync-specs\` workflow inline (agent-driven intelligent merge) for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
143
+ Then ${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)')} for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
144
+
145
+ If the sync reports any stop or blocking condition, treat the sync as failed.
146
+ Stop the archive immediately. Do not perform the post-sync content comparison and do not move its \`changeRoot\`.
147
+ Nothing has moved, so the user can fix the blocking condition or re-run the sync.
148
+
149
+ After the sync writes each main spec, verify its structure against the canonical sync contract:
150
+ - A new main spec starts with a \`# <capability> Specification\` title. An existing main spec keeps its title exactly as it is.
151
+ - Preserve existing \`## Purpose\` sections completely untouched for established main specs.
152
+ - For a new main spec, copy the delta \`## Purpose\` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable \`## Purpose\` is provided, use the existing TBD Purpose behavior and warning.
153
+ - Verify that no delta-style section headers (\`## ADDED Requirements\`, \`## MODIFIED Requirements\`, \`## REMOVED Requirements\`, \`## RENAMED Requirements\`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
154
+ - Requirement blocks the sync wrote or changed use \`### Requirement:\` headings, and their scenarios use \`#### Scenario:\` headings, under the spec's \`## Requirements\` section. Leave content the delta does not mention exactly as it is.
137
155
 
138
156
  Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
139
157
  - ADDED requirements present
140
158
  - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
141
- - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
159
+ - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
142
160
  - RENAMED requirements present under the new name and absent under the old one
143
161
 
144
162
  If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and \`changeRoot\` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
@@ -188,7 +206,7 @@ ${PROJECT_ROOT_GUARD}
188
206
  - Don't block archive on warnings - just inform and confirm
189
207
  - Preserve .openspec.yaml when moving to archive (it moves with the directory)
190
208
  - Show clear summary of what happened
191
- - If sync is requested, run the \`openspec-sync-specs\` workflow inline (agent-driven)
209
+ - 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)')}
192
210
  - Never archive while a spec sync is still in flight — run the sync inline and verify the main specs before moving \`changeRoot\`
193
211
  - If delta specs exist, always run the sync assessment and show the combined summary before prompting
194
212
  - Apply relevant runtime context and report conflicts; operation guidance remains advisory
@@ -274,20 +292,27 @@ ${PROJECT_ROOT_GUARD}
274
292
 
275
293
  3. **Check task completion status**
276
294
 
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.
295
+ Run \`openspec list --json\` with the same selected-root flags and find the
296
+ entry in \`changes\` whose \`name\` exactly matches the selected change.
297
+ Require exactly one match and nonnegative integer \`totalTasks\` and
298
+ \`completedTasks\`, with \`completedTasks <= totalTasks\`. The CLI resolves
299
+ the schema's tracked task files, including custom artifact names, output
300
+ paths, and globs.
301
+ Incomplete tasks = \`totalTasks - completedTasks\`.
302
+
303
+ Do not infer task completion from artifact status or the absence of a
304
+ top-level \`tasks.md\`. If the lookup fails, returns invalid JSON, omits or
305
+ duplicates the selected change, or returns invalid counts, report the problem
306
+ and stop before syncing or archiving.
307
+ The CLI counts only \`x\`/\`X\` checkbox markers as complete;
308
+ other markers, including unfamiliar ones, remain incomplete.
284
309
 
285
310
  **If incomplete tasks found:**
286
311
  - Display warning showing count of incomplete tasks
287
312
  - Prompt user for confirmation to continue
288
313
  - Proceed if user confirms
289
314
 
290
- **If no tasks file exists:** Proceed without task-related warning.
315
+ **If \`totalTasks\` is zero:** Proceed without a task-related warning.
291
316
 
292
317
  4. **Assess delta spec sync state**
293
318
 
@@ -328,10 +353,21 @@ ${PROJECT_ROOT_GUARD}
328
353
 
329
354
  Then ${SYNC_INLINE_HANDOFF} for change '<name>', passing the delta spec analysis and the fetched specs-rule snapshot from above, and wait for it to finish. The inline sync must reuse that snapshot without fetching \`specs\` instructions again. Do not delegate it to a background task — step 5 would move \`changeRoot\` out from under a sync that is still reading it, leaving the change archived and the main specs never updated. If your agent can only run it by delegation, delegate synchronously and wait for the result.
330
355
 
356
+ If the sync reports any stop or blocking condition, treat the sync as failed.
357
+ Stop the archive immediately. Do not perform the post-sync content comparison and do not move its \`changeRoot\`.
358
+ Nothing has moved, so the user can fix the blocking condition or re-run the sync.
359
+
360
+ After the sync writes each main spec, verify its structure against the canonical sync contract:
361
+ - A new main spec starts with a \`# <capability> Specification\` title. An existing main spec keeps its title exactly as it is.
362
+ - Preserve existing \`## Purpose\` sections completely untouched for established main specs.
363
+ - For a new main spec, copy the delta \`## Purpose\` verbatim. Warn only if the purpose text is shorter than standard validation expects. Do not regenerate or rewrite existing authored purpose. If no usable \`## Purpose\` is provided, use the existing TBD Purpose behavior and warning.
364
+ - Verify that no delta-style section headers (\`## ADDED Requirements\`, \`## MODIFIED Requirements\`, \`## REMOVED Requirements\`, \`## RENAMED Requirements\`) remain in the main spec, adhering strictly to the sync workflow formatting rules.
365
+ - Requirement blocks the sync wrote or changed use \`### Requirement:\` headings, and their scenarios use \`#### Scenario:\` headings, under the spec's \`## Requirements\` section. Leave content the delta does not mention exactly as it is.
366
+
331
367
  Then re-run the comparison from the top of this step, including the explicitly retired, missing-spec case, against every capability that has a delta spec in \`artifactPaths.specs.existingOutputPaths\` — not only the ones the sync reports it touched. A successful sync leaves nothing left to apply, so each capability must now read as already synced:
332
368
  - ADDED requirements present
333
369
  - MODIFIED requirements carrying the scenario and description changes named in the delta, with their other scenarios intact
334
- - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
370
+ - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
335
371
  - RENAMED requirements present under the new name and absent under the old one
336
372
 
337
373
  If the sync failed, or any capability does not match, report what differs and stop — do not archive. Nothing has moved and \`changeRoot\` is intact, so the user can fix the mismatch or re-run the sync and start the archive again.
@@ -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,9 @@ ${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.
205
+ - If the sync reports any stop or blocking condition, treat the sync as failed. Stop processing that change immediately. Before continuing to the next change, record this change's outcome as Failed in the batch results, including the sync blocking/error condition.
206
+ - Do not perform the post-sync content comparison and do not move its \`changeRoot\`; leave the change intact.
198
207
  - For conflicts, apply in resolved order.
199
208
  - Pass that change's fetched specs-rule snapshot into inline sync; inline
200
209
  sync must reuse it without fetching instructions again
@@ -209,7 +218,7 @@ ${PROJECT_ROOT_GUARD}
209
218
  - Verify that main specs are updated:
210
219
  - ADDED requirements present
211
220
  - MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact
212
- - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
221
+ - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
213
222
  - RENAMED requirements present under the new name and absent under the old one
214
223
  - Do not verify delta specs in \`excludedDeltas\`; they are intentionally left unsynced.
215
224
  - If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's \`changeRoot\` — do not archive that change. \`changeRoot\` remains intact.
@@ -347,7 +356,7 @@ No active changes found. Create a new change to get started.
347
356
  - 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
357
  - If archive target exists, fail that change but continue with others
349
358
  - 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
359
+ - 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
360
  - Carry the per-delta \`includedDeltas\` and \`excludedDeltas\` decisions into execution; sync and verify only included deltas
352
361
  - Report every excluded delta as \`sync skipped\` without treating the archive itself as skipped
353
362
  - 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 +404,7 @@ ${PROJECT_ROOT_GUARD}
395
404
  2. **Prompt for change selection**
396
405
 
397
406
  Ask the user to choose changes (multi-select):
398
- - Show each change with its schema
407
+ - Show each change name and task status from the list output
399
408
  - Include an option for "All changes"
400
409
  - Allow any number of selections (1+ works, 2+ is the typical use case)
401
410
 
@@ -428,17 +437,24 @@ ${PROJECT_ROOT_GUARD}
428
437
 
429
438
  3. **Batch validation - gather status for all selected changes**
430
439
 
440
+ Run \`openspec list --json\` once with the same selected-root flags for task
441
+ progress. If the lookup fails, returns invalid JSON, or omits any selected
442
+ change, contains a duplicate selected change, or returns invalid counts,
443
+ report the problem and stop before syncing or archiving the batch.
444
+
431
445
  For each selected change, collect:
432
446
 
433
447
  a. **Artifact status** - Run \`openspec status --change "<name>" --json\`
434
448
  - Parse \`schemaName\`, \`artifacts\`, \`planningHome\`, \`changeRoot\`, \`artifactPaths\`, and \`actionContext\`
435
449
  - Note which artifacts are \`done\` vs other states
436
450
 
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"
451
+ b. **Task completion** - Find the \`changes\` entry from the list response whose \`name\` exactly matches this change
452
+ - Require nonnegative integer \`totalTasks\` and \`completedTasks\`, with \`completedTasks <= totalTasks\`
453
+ - Incomplete tasks = \`totalTasks - completedTasks\`
454
+ - The CLI resolves the schema's tracked task files, including custom artifact names, output paths, and globs
455
+ - Do not infer task completion from artifact status, an artifact id of \`tasks\`, or the absence of a top-level \`tasks.md\`
456
+ - The CLI counts only \`x\`/\`X\` checkbox markers as complete; other markers remain incomplete
457
+ - If \`totalTasks\` is zero, note as "No tasks"
442
458
 
443
459
  c. **Delta specs** - Check \`artifactPaths.specs.existingOutputPaths\` from status JSON
444
460
  - List which capability specs exist
@@ -553,6 +569,8 @@ ${PROJECT_ROOT_GUARD}
553
569
 
554
570
  a. **Sync included delta specs**:
555
571
  - ${SYNC_INLINE_HANDOFF} 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.
572
+ - If the sync reports any stop or blocking condition, treat the sync as failed. Stop processing that change immediately. Before continuing to the next change, record this change's outcome as Failed in the batch results, including the sync blocking/error condition.
573
+ - Do not perform the post-sync content comparison and do not move its \`changeRoot\`; leave the change intact.
556
574
  - For conflicts, apply in resolved order.
557
575
  - Pass that change's fetched specs-rule snapshot into inline sync; inline
558
576
  sync must reuse it without fetching instructions again
@@ -567,7 +585,7 @@ ${PROJECT_ROOT_GUARD}
567
585
  - Verify that main specs are updated:
568
586
  - ADDED requirements present
569
587
  - MODIFIED requirements carrying scenario and description changes named in the delta, with their other scenarios intact
570
- - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty; a spec the sync deliberately kept and reported is also a match
588
+ - REMOVED requirements gone — and where this sync retired a capability (removed its last requirement, leaving \`## Requirements\` empty), its main spec deleted rather than left empty.
571
589
  - RENAMED requirements present under the new name and absent under the old one
572
590
  - Do not verify delta specs in \`excludedDeltas\`; they are intentionally left unsynced.
573
591
  - If sync failed or any capability does not match verification, report what differs and fail/skip moving that change's \`changeRoot\` — do not archive that change. \`changeRoot\` remains intact.
@@ -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