@fission-ai/openspec 1.14.0 → 1.14.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 (58) hide show
  1. package/README.md +15 -31
  2. package/dist/cli/commands/config.d.ts +8 -0
  3. package/dist/cli/commands/config.js +88 -0
  4. package/dist/cli/commands/context.d.ts +3 -0
  5. package/dist/cli/commands/context.js +20 -0
  6. package/dist/cli/commands/doctor.d.ts +3 -0
  7. package/dist/cli/commands/doctor.js +18 -0
  8. package/dist/cli/commands/schema.d.ts +6 -0
  9. package/dist/cli/commands/schema.js +57 -0
  10. package/dist/cli/commands/spec.d.ts +3 -0
  11. package/dist/cli/commands/spec.js +42 -0
  12. package/dist/cli/commands/store.d.ts +3 -0
  13. package/dist/cli/commands/store.js +130 -0
  14. package/dist/cli/commands/workset.d.ts +3 -0
  15. package/dist/cli/commands/workset.js +96 -0
  16. package/dist/cli/index.d.ts +5 -0
  17. package/dist/cli/index.js +86 -43
  18. package/dist/commands/completion.js +4 -1
  19. package/dist/commands/config.d.ts +16 -7
  20. package/dist/commands/config.js +342 -395
  21. package/dist/commands/context.d.ts +8 -2
  22. package/dist/commands/context.js +22 -37
  23. package/dist/commands/doctor.d.ts +6 -7
  24. package/dist/commands/doctor.js +14 -27
  25. package/dist/commands/schema.d.ts +19 -5
  26. package/dist/commands/schema.js +662 -702
  27. package/dist/commands/spec.d.ts +13 -4
  28. package/dist/commands/spec.js +91 -120
  29. package/dist/commands/store.d.ts +27 -2
  30. package/dist/commands/store.js +1 -120
  31. package/dist/commands/workflow/default-schema.d.ts +2 -0
  32. package/dist/commands/workflow/default-schema.js +4 -0
  33. package/dist/commands/workflow/instructions.js +1 -1
  34. package/dist/commands/workflow/shared.d.ts +1 -1
  35. package/dist/commands/workflow/shared.js +1 -1
  36. package/dist/commands/workset.d.ts +22 -2
  37. package/dist/commands/workset.js +1 -88
  38. package/dist/core/archive.js +32 -12
  39. package/dist/core/artifact-graph/outputs.js +39 -1
  40. package/dist/core/completion-tip.js +3 -1
  41. package/dist/core/completions/completion-provider.js +4 -2
  42. package/dist/core/config-schema.d.ts +1 -1
  43. package/dist/core/parsers/requirement-blocks.d.ts +11 -0
  44. package/dist/core/parsers/requirement-blocks.js +57 -0
  45. package/dist/core/specs-apply.js +5 -1
  46. package/dist/core/templates/workflows/apply-change.js +4 -3
  47. package/dist/core/templates/workflows/continue-change.js +17 -0
  48. package/dist/core/templates/workflows/explore.js +8 -4
  49. package/dist/core/templates/workflows/update-change.js +4 -4
  50. package/dist/core/templates/workflows/verify-change.js +26 -20
  51. package/dist/core/validation/validator.js +23 -2
  52. package/dist/core/view.js +2 -26
  53. package/dist/utils/change-metadata.d.ts +17 -0
  54. package/dist/utils/change-metadata.js +27 -2
  55. package/dist/utils/file-system.d.ts +7 -0
  56. package/dist/utils/file-system.js +25 -0
  57. package/package.json +1 -1
  58. package/schemas/spec-driven/schema.yaml +28 -4
@@ -247,6 +247,14 @@ function toArchiveDiagnostic(error) {
247
247
  if (isRootSelectionError(error)) {
248
248
  return error.diagnostic;
249
249
  }
250
+ if (error instanceof RetirementCleanupError) {
251
+ return {
252
+ severity: 'error',
253
+ code: 'archive_retirement_cleanup_failed',
254
+ message: error.message,
255
+ fix: 'Inspect the archived change and all recovery paths in this diagnostic; preserve any needed content before cleanup.',
256
+ };
257
+ }
250
258
  return {
251
259
  severity: 'error',
252
260
  code: 'archive_error',
@@ -379,7 +387,7 @@ async function assertCopiedDirectoryUnchanged(stagedSource, destination, expecte
379
387
  */
380
388
  class MoveDestinationRetainedError extends Error {
381
389
  }
382
- class RetirementBackupsRetainedError extends Error {
390
+ class RetirementCleanupError extends Error {
383
391
  }
384
392
  function isFallbackRenameCode(code) {
385
393
  return code === 'EPERM' || code === 'EXDEV';
@@ -1012,12 +1020,12 @@ async function finalizeRetirementBackups(snapshots, mainSpecsDir) {
1012
1020
  snapshot.displacedPath = undefined;
1013
1021
  }
1014
1022
  catch (error) {
1015
- errors.push(`Could not remove the committed retirement backup at ${displacedPath} ` +
1023
+ errors.push(`Could not finalize the retirement backup at ${displacedPath} ` +
1016
1024
  `(${error instanceof Error ? error.message : String(error)}).`);
1017
1025
  }
1018
1026
  }
1019
1027
  if (errors.length > 0) {
1020
- throw new RetirementBackupsRetainedError(`${errors.join(' ')} The change remains archived and each listed backup was retained for recovery.`);
1028
+ throw new RetirementCleanupError(`${errors.join(' ')} The change was archived, but retirement cleanup did not complete. Inspect all reported recovery paths before recovery or cleanup.`);
1021
1029
  }
1022
1030
  }
1023
1031
  export class ArchiveCommand {
@@ -1070,13 +1078,22 @@ export class ArchiveCommand {
1070
1078
  const changesDir = root.changesDir;
1071
1079
  const archiveDir = root.archiveDir;
1072
1080
  const mainSpecsDir = root.specsDir;
1073
- for (const [allowedDirectory, managedDir] of [
1074
- [root.path, changesDir],
1075
- [changesDir, archiveDir],
1076
- [root.path, mainSpecsDir],
1081
+ const openspecDir = path.dirname(changesDir);
1082
+ // openspec/, changes/ and specs/ may be project-owned symlinks to another
1083
+ // directory; archive/ may not leave the changes directory it lives in.
1084
+ for (const [allowedDirectory, managedDir, allowLinkedLeaf] of [
1085
+ [root.path, openspecDir, true],
1086
+ [openspecDir, changesDir, true],
1087
+ [changesDir, archiveDir, false],
1088
+ [openspecDir, mainSpecsDir, true],
1077
1089
  ]) {
1078
1090
  try {
1079
- FileSystemUtils.assertPathWithin(allowedDirectory, managedDir);
1091
+ if (allowLinkedLeaf) {
1092
+ FileSystemUtils.assertPathWithinAllowingLinkedLeaf(allowedDirectory, managedDir);
1093
+ }
1094
+ else {
1095
+ FileSystemUtils.assertPathWithin(allowedDirectory, managedDir);
1096
+ }
1080
1097
  }
1081
1098
  catch {
1082
1099
  throw new ArchiveBlockedError('archive_path_outside_root', `Refusing to archive through a path outside the OpenSpec root: ${managedDir}`);
@@ -1736,6 +1753,9 @@ export class ArchiveCommand {
1736
1753
  if (hasRetirements) {
1737
1754
  await assertRetirementAuthorization(changeDir, retirementAuthorizationFingerprint);
1738
1755
  }
1756
+ // update.source is canonical (symlinks resolved), so measure it from
1757
+ // the canonical change directory; changes/ may itself be a symlink.
1758
+ const canonicalChangeDir = FileSystemUtils.canonicalizeExistingPath(changeDir);
1739
1759
  const verifyArchivedDeltas = async (stagedSource) => {
1740
1760
  if (hasRetirements) {
1741
1761
  await assertRetirementAuthorization(archivePath, retirementAuthorizationFingerprint,
@@ -1749,13 +1769,13 @@ export class ArchiveCommand {
1749
1769
  }
1750
1770
  }
1751
1771
  for (const proposed of prepared) {
1752
- const archivedSource = path.join(archivePath, path.relative(changeDir, proposed.update.source));
1772
+ const archivedSource = path.join(archivePath, path.relative(canonicalChangeDir, proposed.update.source));
1753
1773
  if ((await fingerprintPortableContent(archivedSource)) !==
1754
1774
  proposed.sourceContentFingerprint) {
1755
1775
  throw new Error(`The archived delta for '${proposed.update.id}' changed during the final move.`);
1756
1776
  }
1757
1777
  if (stagedSource) {
1758
- const stagedDelta = path.join(stagedSource, path.relative(changeDir, proposed.update.source));
1778
+ const stagedDelta = path.join(stagedSource, path.relative(canonicalChangeDir, proposed.update.source));
1759
1779
  if ((await fingerprintPortableContent(stagedDelta)) !==
1760
1780
  proposed.sourceContentFingerprint) {
1761
1781
  throw new Error(`The active delta for '${proposed.update.id}' changed during the fallback copy.`);
@@ -1777,11 +1797,11 @@ export class ArchiveCommand {
1777
1797
  await finalizeRetirementBackups(specSnapshots, mainSpecsDir);
1778
1798
  }
1779
1799
  catch (cleanupError) {
1780
- throw new RetirementBackupsRetainedError(`${error.message} ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
1800
+ throw new RetirementCleanupError(`${error.message} ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
1781
1801
  }
1782
1802
  throw error;
1783
1803
  }
1784
- if (error instanceof RetirementBackupsRetainedError)
1804
+ if (error instanceof RetirementCleanupError)
1785
1805
  throw error;
1786
1806
  const rollbackErrors = [];
1787
1807
  try {
@@ -4,6 +4,41 @@ import fg from 'fast-glob';
4
4
  import { FileSystemUtils } from '../../utils/file-system.js';
5
5
  const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
6
6
  const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
7
+ // braces <=3.0.3 (fast-glob > micromatch) has no depth guard (GHSA-vfj7-8cjw-p6xm),
8
+ // so reject nesting no real artifact pattern needs before it reaches the parser.
9
+ const MAX_BRACE_NESTING = 16;
10
+ function assertBraceNesting(pattern) {
11
+ // Mirror the braces parser: `[...]` and quoted runs are literal, `(` nests like
12
+ // `{`, and a closer only closes the innermost group of its own kind.
13
+ const open = [];
14
+ for (let index = 0; index < pattern.length; index += 1) {
15
+ const char = pattern[index];
16
+ if (char === '[') {
17
+ for (let brackets = 1; brackets > 0 && index + 1 < pattern.length;) {
18
+ index += 1;
19
+ if (pattern[index] === '[')
20
+ brackets += 1;
21
+ else if (pattern[index] === ']')
22
+ brackets -= 1;
23
+ }
24
+ }
25
+ else if (char === '"' || char === "'" || char === '`') {
26
+ const end = pattern.indexOf(char, index + 1);
27
+ index = end === -1 ? pattern.length : end;
28
+ }
29
+ else if (char === '{' || char === '(') {
30
+ open.push(char);
31
+ if (open.length > MAX_BRACE_NESTING) {
32
+ // A pattern this deep can run to thousands of characters; quote only its start.
33
+ const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
34
+ throw new Error(`Artifact output pattern nests braces more than ${MAX_BRACE_NESTING} levels deep: ${shown}`);
35
+ }
36
+ }
37
+ else if ((char === '}' && open.at(-1) === '{') || (char === ')' && open.at(-1) === '(')) {
38
+ open.pop();
39
+ }
40
+ }
41
+ }
7
42
  function hasBraceExpansion(pattern) {
8
43
  const openings = [];
9
44
  for (let index = 0; index < pattern.length; index += 1) {
@@ -90,8 +125,11 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
90
125
  * Returns absolute file paths. Glob matches are sorted for deterministic output.
91
126
  */
92
127
  export function resolveArtifactOutputs(changeDir, generates) {
128
+ const isGlob = isGlobPattern(generates);
129
+ if (isGlob)
130
+ assertBraceNesting(FileSystemUtils.toPosixPath(generates));
93
131
  const outputPath = resolveArtifactOutputPath(changeDir, generates);
94
- if (!isGlobPattern(generates)) {
132
+ if (!isGlob) {
95
133
  try {
96
134
  return fs.statSync(outputPath).isFile()
97
135
  ? [FileSystemUtils.canonicalizeExistingPath(outputPath)]
@@ -22,7 +22,6 @@ import { getGlobalConfigPath } from './global-config.js';
22
22
  import { writeFileAtomically } from './file-state.js';
23
23
  import { isCiEnvironment } from '../utils/ci.js';
24
24
  import { detectShell } from '../utils/shell-detection.js';
25
- import { CompletionFactory } from './completions/factory.js';
26
25
  export const COMPLETION_TIP_MESSAGE = "Tip: Run 'openspec completion install' for shell completions";
27
26
  function isSuppressedByEnv() {
28
27
  // isCiEnvironment, not a CI==='true' string check: providers set CI to "True",
@@ -54,6 +53,9 @@ async function decideTip() {
54
53
  if (!shell) {
55
54
  return 'retire';
56
55
  }
56
+ // Loaded only here: every shell's generator and installer, needed only on
57
+ // the rare run that still owes the tip.
58
+ const { CompletionFactory } = await import('./completions/factory.js');
57
59
  return (await CompletionFactory.createInstaller(shell).isInstalled())
58
60
  ? 'retire'
59
61
  : 'show';
@@ -1,5 +1,4 @@
1
1
  import { getActiveChangeIds, getSpecIds } from '../../utils/item-discovery.js';
2
- import { listSchemas } from '../artifact-graph/index.js';
3
2
  /**
4
3
  * Provides dynamic completion suggestions for OpenSpec items (changes and specs).
5
4
  * Implements a 2-second cache to avoid excessive file system operations during
@@ -74,7 +73,10 @@ export class CompletionProvider {
74
73
  if (this.schemaCache && now - this.schemaCache.timestamp < this.cacheTTL) {
75
74
  return this.schemaCache.data;
76
75
  }
77
- // Fetch fresh data
76
+ // Fetch fresh data. The resolver (and the yaml and zod it parses schemas
77
+ // with) loads only here: completing changes or specs never needs it, and
78
+ // the artifact-graph barrel would also pull in fast-glob.
79
+ const { listSchemas } = await import('../artifact-graph/resolver.js');
78
80
  const schemaNames = listSchemas(this.projectRoot);
79
81
  // Update cache
80
82
  this.schemaCache = {
@@ -10,9 +10,9 @@ export declare const GlobalConfigSchema: z.ZodObject<{
10
10
  core: "core";
11
11
  }>>>;
12
12
  delivery: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
13
- commands: "commands";
14
13
  both: "both";
15
14
  skills: "skills";
15
+ commands: "commands";
16
16
  }>>>;
17
17
  workflows: z.ZodOptional<z.ZodArray<z.ZodString>>;
18
18
  defaultStore: z.ZodOptional<z.ZodString>;
@@ -70,6 +70,15 @@ export interface OrphanedRequirement {
70
70
  section: string | null;
71
71
  line: number;
72
72
  }
73
+ /**
74
+ * Authored text archive does not carry into the main spec: a non-delta `## `
75
+ * section, text above the first `## `, or prose before the first requirement
76
+ * in ADDED/MODIFIED. `section` is null for text above the first `## `.
77
+ */
78
+ export interface UnappliedContent {
79
+ section: string | null;
80
+ line: number;
81
+ }
73
82
  export interface DeltaPlan {
74
83
  added: RequirementBlock[];
75
84
  modified: RequirementBlock[];
@@ -83,6 +92,7 @@ export interface DeltaPlan {
83
92
  unpairedRenames: UnpairedRename[];
84
93
  /** Canonical requirement blocks written outside every delta section. */
85
94
  orphanedRequirements: OrphanedRequirement[];
95
+ unappliedContent: UnappliedContent[];
86
96
  skippedHeaders: SkippedHeader[];
87
97
  sectionPresence: {
88
98
  added: boolean;
@@ -95,6 +105,7 @@ export interface DeltaPlan {
95
105
  * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
96
106
  */
97
107
  export declare function parseDeltaSpec(content: string): DeltaPlan;
108
+ export declare function describeUnappliedContent(content: UnappliedContent, sentenceStart?: boolean): string;
98
109
  /** Both directions of the scenario-name comparison, plus the two totals. */
99
110
  export interface ScenarioNameDiff {
100
111
  /** Names the current block has that the incoming block does not cover. */
@@ -136,6 +136,7 @@ export function parseDeltaSpec(content) {
136
136
  renamed: renamedPairs,
137
137
  unpairedRenames,
138
138
  orphanedRequirements: findOrphanedRequirements(lines, fenceMask),
139
+ unappliedContent: findUnappliedContent(lines, fenceMask),
139
140
  skippedHeaders,
140
141
  sectionPresence: {
141
142
  added: addedLookup.found,
@@ -187,6 +188,62 @@ function findOrphanedRequirements(lines, fenceMask) {
187
188
  }
188
189
  return orphans;
189
190
  }
191
+ export function describeUnappliedContent(content, sentenceStart = false) {
192
+ const text = content.section === null
193
+ ? 'text above the first "## " section'
194
+ : DELTA_SECTION_TITLES.has(content.section.toLowerCase())
195
+ ? `text before the first requirement in "## ${content.section}"`
196
+ : `section "## ${content.section}"`;
197
+ return sentenceStart ? text.charAt(0).toUpperCase() + text.slice(1) : text;
198
+ }
199
+ /**
200
+ * Where text the merge never reads begins. HTML comments, `# ` titles, Purpose
201
+ * (warned about separately), REMOVED/RENAMED bodies, and stray `###` headers
202
+ * before the first requirement (INFO since #498) are not reported.
203
+ */
204
+ function findUnappliedContent(lines, fenceMask) {
205
+ // Blank out comments outside fences, keeping line count; an unterminated one
206
+ // runs to EOF. Comments only decide whether a line holds text: sections come
207
+ // from the same split the merge reads, where a `## ` line inside a comment is
208
+ // still a boundary. Fenced lines keep their text, so a `<!--` in a code sample
209
+ // never hides what follows the fence.
210
+ const comments = lines
211
+ .map((line, i) => (fenceMask[i] ? '' : line))
212
+ .join('\n')
213
+ .replace(/<!--[\s\S]*?(?:--!?>|$)/g, (m) => m.replace(/[^\n]/g, ' '))
214
+ .split('\n');
215
+ const masked = lines.map((line, i) => (fenceMask[i] ? line : comments[i]));
216
+ const sections = splitTopLevelSections(lines, fenceMask);
217
+ const firstText = (from, to, skip) => {
218
+ for (let i = from; i < to; i++)
219
+ if (masked[i].trim() && !skip?.test(masked[i]))
220
+ return i;
221
+ return -1;
222
+ };
223
+ const found = [];
224
+ const preamble = firstText(0, sections.length ? sections[0].body.bodyStartLine - 2 : lines.length, /^#\s/);
225
+ if (preamble !== -1)
226
+ found.push({ section: null, line: preamble + 1 });
227
+ for (const { title, body } of sections) {
228
+ const folded = title.toLowerCase();
229
+ const start = body.bodyStartLine - 1;
230
+ if (folded === 'added requirements' || folded === 'modified requirements') {
231
+ const block = body.lines.findIndex((l, i) => !body.fenceMask[i] && REQUIREMENT_HEADER_REGEX.test(l));
232
+ const first = firstText(start, start + (block === -1 ? body.lines.length : block), /^###\s/);
233
+ if (first !== -1)
234
+ found.push({ section: title, line: first + 1 });
235
+ }
236
+ else if (!DELTA_SECTION_TITLES.has(folded)) {
237
+ // The Purpose carry-over reads past commented-out headers, so only a
238
+ // `## Purpose` outside a comment is the one archive handles.
239
+ if (folded === 'purpose' && masked[start - 1].trim())
240
+ continue;
241
+ if (firstText(start, start + body.lines.length) !== -1)
242
+ found.push({ section: title, line: start });
243
+ }
244
+ }
245
+ return found;
246
+ }
190
247
  /**
191
248
  * Every `## ` section, as a LIST rather than a title-keyed record.
192
249
  *
@@ -8,7 +8,7 @@ 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, diffScenarioNames, describeScenarioBalance, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from './parsers/requirement-blocks.js';
11
+ import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, 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';
@@ -145,6 +145,10 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
145
145
  `which is not a delta section, so it was not applied. ` +
146
146
  `Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
147
147
  }
148
+ // Archive carries requirement blocks only; name any other text it leaves behind.
149
+ for (const lost of plan.unappliedContent) {
150
+ warn(`${specName} - ${describeUnappliedContent(lost)} (line ${lost.line}) is not carried into the main spec.`);
151
+ }
148
152
  // Pre-validate duplicates within sections
149
153
  const addedNames = new Set();
150
154
  for (const add of plan.added) {
@@ -66,7 +66,7 @@ ${PROJECT_ROOT_GUARD}
66
66
  - If \`state: "blocked"\`: show the message and pause implementation.
67
67
  - If \`missingArtifacts\` is non-empty: ${BLOCKED_STATE_HANDOFF}
68
68
  - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
69
- - If \`state: "all_done"\`: congratulate, suggest archive
69
+ - If \`state: "all_done"\`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
70
70
  - Otherwise: proceed to implementation
71
71
 
72
72
  Treat \`context\` as a required prompt-level input. Read and consider it, and
@@ -125,7 +125,7 @@ ${PROJECT_ROOT_GUARD}
125
125
  Display:
126
126
  - Tasks completed this session
127
127
  - Overall progress: "N/M tasks complete"
128
- - If all done: suggest archive
128
+ - If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
129
129
  - If paused: explain why and wait for guidance
130
130
 
131
131
  **Output During Implementation**
@@ -156,7 +156,8 @@ Working on task 4/7: <task description>
156
156
  - [x] Task 2
157
157
  ...
158
158
 
159
- All tasks complete! ${ARCHIVE_HANDOFF}
159
+ All tracked tasks are complete. Review or verify the change as appropriate
160
+ before archiving. ${ARCHIVE_HANDOFF}
160
161
  \`\`\`
161
162
 
162
163
  **Output On Pause (Issue Encountered)**
@@ -7,6 +7,15 @@ import { PROJECT_ROOT_GUARD } from './project-root.js';
7
7
  * optional-workflow.ts).
8
8
  */
9
9
  const PLANNING_COMPLETE_HANDOFF = optionalWorkflow('apply', 'You can now implement this change with `/opsx:apply`.', 'You can now implement this change - `openspec instructions apply --change "<name>" --json` returns the tasks and how to work them.') + ' ' + optionalWorkflow('archive', 'Once implementation and any tracked work are complete, archive it with `/opsx:archive`.', 'Once implementation and any tracked work are complete, archive it with `openspec archive "<name>"`.');
10
+ /**
11
+ * A glob artifact reads `done` once one matching file exists, so a run
12
+ * interrupted while writing several files leaves it `done` with files missing,
13
+ * and the next run would move on without reading its instruction again (#2034).
14
+ */
15
+ const PARTIAL_ARTIFACT_CHECK = `**Before anything else, finish a partly written artifact**:
16
+ - An artifact whose output is a glob pattern (e.g. \`specs/**/*.md\`) reads \`done\` as soon as one matching file exists, so a run interrupted while writing its files leaves it \`done\` with some still missing
17
+ - For each such \`done\` artifact, first get its instructions and read any completed dependencies needed to determine the expected files. Then compare those files with \`artifactPaths.<id>.existingOutputPaths\`. For spec-driven's \`specs\`, read the proposal and check for one \`specs/<capability-path>/spec.md\` per capability it lists; resolve each expected path against \`changeRoot\` before comparing path identity
18
+ - If any are missing, write only the missing files, and STOP - that counts as this invocation's ONE artifact. If you cannot tell whether a file was left out on purpose, ask the user`;
10
19
  export function getContinueChangeSkillTemplate() {
11
20
  return {
12
21
  name: 'openspec-continue-change',
@@ -51,6 +60,10 @@ ${PROJECT_ROOT_GUARD}
51
60
 
52
61
  ---
53
62
 
63
+ ${PARTIAL_ARTIFACT_CHECK}
64
+
65
+ ---
66
+
54
67
  **If all planning artifacts are complete (\`isPlanningComplete: true\`, or legacy \`isComplete: true\`)**:
55
68
  - Congratulate the user
56
69
  - Show final status including the schema used
@@ -169,6 +182,10 @@ ${PROJECT_ROOT_GUARD}
169
182
 
170
183
  ---
171
184
 
185
+ ${PARTIAL_ARTIFACT_CHECK}
186
+
187
+ ---
188
+
172
189
  **If all planning artifacts are complete (\`isPlanningComplete: true\`, or legacy \`isComplete: true\`)**:
173
190
  - Congratulate the user
174
191
  - Show final status including the schema used
@@ -178,6 +178,8 @@ If the user mentions a change or you detect one is relevant:
178
178
 
179
179
  \`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
180
180
 
181
+ The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from \`artifactPaths\` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
182
+
181
183
  | Insight Type | Where to Capture |
182
184
  |----------------------------|-------------------------------------|
183
185
  | New requirement discovered | \`specs/<capability-path>/spec.md\` |
@@ -187,7 +189,7 @@ If the user mentions a change or you detect one is relevant:
187
189
  | New work identified | \`tasks.md\` |
188
190
  | Assumption invalidated | Relevant artifact |
189
191
 
190
- Example offers:
192
+ Example offers (default schema):
191
193
  - "That's a design decision. Capture it in design.md?"
192
194
  - "This is a new requirement. Add it to specs?"
193
195
  - "This changes scope. Update the proposal?"
@@ -316,7 +318,7 @@ You: That changes everything.
316
318
  There's no required ending. Discovery might:
317
319
 
318
320
  - **Flow into a proposal**: "${DISCOVERY_END_HANDOFF}"
319
- - **Result in artifact updates**: "Updated design.md with these decisions"
321
+ - **Result in artifact updates**: "Updated the design with these decisions"
320
322
  - **Just provide clarity**: User has what they need, moves on
321
323
  - **Continue later**: "We can pick this up anytime"
322
324
 
@@ -511,6 +513,8 @@ If the user mentions a change or you detect one is relevant:
511
513
 
512
514
  \`<capability-path>\` is the spec directory relative to \`specs/\` (for example, \`user-auth\` or \`identity/user-auth\`). Preserve an existing capability's full path and follow the project's established organization for new capabilities.
513
515
 
516
+ The files below are the default spec-driven schema's. With another schema, capture each insight in whichever artifact from \`artifactPaths\` fits it, and name that artifact in your offer; never create an artifact the schema does not define.
517
+
514
518
  | Insight Type | Where to Capture |
515
519
  |----------------------------|-------------------------------------|
516
520
  | New requirement discovered | \`specs/<capability-path>/spec.md\` |
@@ -520,7 +524,7 @@ If the user mentions a change or you detect one is relevant:
520
524
  | New work identified | \`tasks.md\` |
521
525
  | Assumption invalidated | Relevant artifact |
522
526
 
523
- Example offers:
527
+ Example offers (default schema):
524
528
  - "That's a design decision. Capture it in design.md?"
525
529
  - "This is a new requirement. Add it to specs?"
526
530
  - "This changes scope. Update the proposal?"
@@ -545,7 +549,7 @@ If the user mentions a change or you detect one is relevant:
545
549
  There's no required ending. Discovery might:
546
550
 
547
551
  - **Flow into a proposal**: "${DISCOVERY_END_HANDOFF}"
548
- - **Result in artifact updates**: "Updated design.md with these decisions"
552
+ - **Result in artifact updates**: "Updated the design with these decisions"
549
553
  - **Just provide clarity**: User has what they need, moves on
550
554
  - **Continue later**: "We can pick this up anytime"
551
555
 
@@ -64,7 +64,7 @@ ${CONTINUE_SCOPE_NOTE}
64
64
 
65
65
  The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
66
66
 
67
- The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
67
+ The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
68
68
 
69
69
  3. **Understand the request**
70
70
  - If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
@@ -108,7 +108,7 @@ After each invocation, show:
108
108
  **Guardrails**
109
109
  - Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}.
110
110
  - Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
111
- - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
111
+ - Edit only the concrete files in \`existingOutputPaths\`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob \`resolvedOutputPath\`.
112
112
  - Do not advance the build frontier: if an artifact has empty \`existingOutputPaths\` and status \`ready\` or \`blocked\`, ${CONTINUE_FRONTIER}. Leave \`skipped\` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose \`existingOutputPaths\` is non-empty.
113
113
  - Confirm every edit with the user before writing.
114
114
  - If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`,
@@ -163,7 +163,7 @@ ${CONTINUE_SCOPE_NOTE}
163
163
 
164
164
  The artifact ids and paths come from the active schema - do NOT assume them, and do NOT branch on hardcoded artifact names. Custom schemas must work unchanged.
165
165
 
166
- The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
166
+ The files to edit are \`artifactPaths.<id>.existingOutputPaths\` - the concrete files that exist on disk, already glob-expanded for glob artifacts (e.g. \`specs/**/*.md\`), plus any confirmed concrete path allowed under a partly populated glob artifact (step 4). Do NOT write to \`resolvedOutputPath\`: for a glob artifact it is still the glob pattern, not a real file.
167
167
 
168
168
  3. **Understand the request**
169
169
  - If the user asked for a specific revision ("the design now uses X"), that is the starting edit.
@@ -207,7 +207,7 @@ After each invocation, show:
207
207
  **Guardrails**
208
208
  - Planning artifacts only - NEVER edit implementation code. If the revised plan implies code changes, ${APPLY_GUARDRAIL}.
209
209
  - Use the artifact ids and paths reported by \`openspec status\`; never branch on hardcoded artifact names.
210
- - Edit only the concrete files in \`existingOutputPaths\`; never write to a glob \`resolvedOutputPath\`.
210
+ - Edit only the concrete files in \`existingOutputPaths\`, plus any confirmed concrete path allowed under a partly populated glob artifact (step 4); never write to a glob \`resolvedOutputPath\`.
211
211
  - Do not advance the build frontier: if an artifact has empty \`existingOutputPaths\` and status \`ready\` or \`blocked\`, ${CONTINUE_FRONTIER}. Leave \`skipped\` artifacts untouched. The only new-file scope is a confirmed concrete path under a glob artifact whose \`existingOutputPaths\` is non-empty.
212
212
  - Confirm every edit with the user before writing.
213
213
  - If the request changes the change's *intent* rather than refining it, ${INTENT_CHANGE_GUARDRAIL}.`