@fission-ai/openspec 1.14.1 → 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.
package/README.md CHANGED
@@ -33,11 +33,6 @@ Our philosophy:
33
33
  → scalable from personal projects to enterprises
34
34
  ```
35
35
 
36
- > [!TIP]
37
- > **New workflow now available!** We've rebuilt OpenSpec with a new artifact-guided workflow.
38
- >
39
- > Run `/opsx:propose "your idea"` to get started. → [Learn more here](docs/opsx.md)
40
-
41
36
  <p align="center">
42
37
  Follow <a href="https://x.com/0xTab">@0xTab on X</a> for updates · Join the <a href="https://discord.gg/YctCnvvshC">OpenSpec Discord</a> for help and questions.
43
38
  </p>
@@ -112,13 +107,13 @@ OpenSpec is built with OpenSpec — browse this repo's live [specs](openspec/spe
112
107
 
113
108
  Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the hard part moves: a feature spans the API server, the web app, and a shared library; requirements are owned by one team and consumed by others; planning starts before any code exists.
114
109
 
115
- **[Stores](docs/stores-beta/user-guide.md)** are the answer — planning in a repo of its own. The same `openspec/` shape you already know (specs and changes), shared by `git push` like anything else. One source of truth your whole team and every coding agent can read, across every repo.
110
+ **[Stores](https://openspec.dev/docs/stores)** are the answer — planning in a repo of its own. The same `openspec/` shape you already know (specs and changes), shared by `git push` like anything else. One source of truth your whole team and every coding agent can read, across every repo.
116
111
 
117
112
  - **Cross-repo features** — one change, one plan, even when the code lands in three repos.
118
113
  - **Shared requirements** — a platform team owns the specs; product teams reference them read-only, right where their coding agent can read them. No drifting wiki.
119
114
  - **Plan before code** — capture the plan in the store now; the code repos catch up later.
120
115
 
121
- > Stores are in **beta**. Start with the [Stores User Guide](docs/stores-beta/user-guide.md).
116
+ > Stores are in **beta**. Start with the [Stores guide](https://openspec.dev/docs/stores).
122
117
 
123
118
  ## Quick Start
124
119
 
@@ -143,44 +138,33 @@ cd your-project
143
138
  openspec init
144
139
  ```
145
140
 
146
- > **Want your AI to do it?** Paste the [setup prompt](docs-lab/start/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
141
+ > **Want your AI to do it?** Paste the [setup prompt](https://openspec.dev/docs/installation#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
147
142
 
148
143
  Now talk to your AI:
149
144
 
150
- - **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Explore guide](docs/explore.md))
145
+ - **Not sure what to build yet?** Start with `/opsx:explore`, a no-stakes thinking partner that reads your code, weighs options, and shapes a plan before any code gets written. ([Quickstart](https://openspec.dev/docs/quickstart))
151
146
  - **Already know what you want?** Go straight to `/opsx:propose <what-you-want-to-build>`.
152
147
 
153
148
  Both are in the default profile. If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/opsx:verify`, `/opsx:bulk-archive`, `/opsx:onboard`), select it with `openspec config profile` and apply with `openspec update`.
154
149
 
155
- `/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [How To Invoke](docs/supported-tools.md#how-to-invoke).
150
+ `/opsx:propose` is the canonical name; your tool may spell it `/opsx-propose` (Cursor, GitHub Copilot), `@opsx-propose` (Amazon Q) or `$openspec-propose` (Codex). `openspec init` prints the right form for the tools you picked — see [Supported Tools](https://openspec.dev/docs/supported-tools).
156
151
 
157
152
  > [!NOTE]
158
- > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
153
+ > Not sure if your tool is supported? [View the full list](https://openspec.dev/docs/supported-tools) – we support 30+ tools and growing.
159
154
  >
160
- > Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](docs-lab/start/installation.md).
155
+ > Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](https://openspec.dev/docs/installation).
161
156
 
162
157
  ## Docs
163
158
 
164
- **Start here:** the **[Documentation Home](docs/README.md)** maps everything. New to OpenSpec? Read [Getting Started](docs/getting-started.md), then [How Commands Work](docs/how-commands-work.md) (where you actually type `/opsx:propose`).
165
-
166
- → **[Getting Started](docs/getting-started.md)**: first steps<br>
167
- → **[Explore First](docs/explore.md)**: think it through with `/opsx:explore` before you commit<br>
168
- → **[How Commands Work](docs/how-commands-work.md)**: where slash commands run vs the CLI<br>
169
- → **[Core Concepts at a Glance](docs/overview.md)**: the whole mental model, one page<br>
170
- → **[Examples & Recipes](docs/examples.md)**: real changes, start to finish<br>
171
- → **[Workflows](docs/workflows.md)**: combos and patterns<br>
172
- → **[Existing Projects](docs/existing-projects.md)**: adopt OpenSpec on a brownfield codebase<br>
173
- → **[Editing a Change](docs/editing-changes.md)**: update artifacts, go back, reconcile manual edits<br>
174
- → **[Commands](docs/commands.md)**: slash commands & skills<br>
175
- → **[CLI](docs/cli.md)**: terminal reference<br>
176
- → **[Stores](docs/stores-beta/user-guide.md)**: plan in a separate repo, shared across your team (beta)<br>
177
- → **[Supported Tools](docs/supported-tools.md)**: tool integrations & install paths<br>
178
- → **[Concepts](docs/concepts.md)**: how it all fits<br>
179
- → **[Multi-Language](docs/multi-language.md)**: multi-language support<br>
180
- → **[Customization](docs/customization.md)**: make it yours<br>
181
- → **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec<br>
182
- → **[FAQ](docs/faq.md)** · **[Troubleshooting](docs/troubleshooting.md)** · **[Glossary](docs/glossary.md)**: quick help
159
+ Full documentation lives at **[openspec.dev/docs](https://openspec.dev/docs)**.
183
160
 
161
+ → **[Quickstart](https://openspec.dev/docs/quickstart)**: your first change, start to finish<br>
162
+ → **[Set up your project](https://openspec.dev/docs/setup)**: what `openspec init` installs<br>
163
+ → **[CLI](https://openspec.dev/docs/cli)**: terminal reference<br>
164
+ → **[Supported Tools](https://openspec.dev/docs/supported-tools)**: tool integrations & install paths<br>
165
+ → **[Customize](https://openspec.dev/docs/customize)**: profiles, project config, and schemas<br>
166
+ → **[Stores](https://openspec.dev/docs/stores)**: plan in a separate repo, shared across your team (beta)<br>
167
+ → **[Community Showcase](docs/community.md)**: projects and resources built with and for OpenSpec
184
168
 
185
169
  ## Community schemas
186
170
 
@@ -27,6 +27,11 @@ export declare function isJsonRun(command: Command): boolean;
27
27
  * completions is noise, and `__complete` would burn the one-shot tip invisibly.
28
28
  */
29
29
  export declare function isCompletionRun(commandPath: string): boolean;
30
+ /**
31
+ * True for the hidden `__complete` resolver alone. It runs on every Tab press,
32
+ * so the hooks skip telemetry and the completion tip for it entirely.
33
+ */
34
+ export declare function isTabCompletionRun(commandPath: string): boolean;
30
35
  /**
31
36
  * True when the first-run completions tip must be deferred rather than shown.
32
37
  *
package/dist/cli/index.js CHANGED
@@ -90,6 +90,13 @@ export function isJsonRun(command) {
90
90
  export function isCompletionRun(commandPath) {
91
91
  return commandPath.split(':')[0] === 'completion' || commandPath === '__complete';
92
92
  }
93
+ /**
94
+ * True for the hidden `__complete` resolver alone. It runs on every Tab press,
95
+ * so the hooks skip telemetry and the completion tip for it entirely.
96
+ */
97
+ export function isTabCompletionRun(commandPath) {
98
+ return commandPath === '__complete';
99
+ }
93
100
  /**
94
101
  * True when the first-run completions tip must be deferred rather than shown.
95
102
  *
@@ -149,6 +156,12 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
149
156
  if (opts.color === false) {
150
157
  process.env.NO_COLOR = '1';
151
158
  }
159
+ // `__complete` runs on every Tab press, with stderr sent to /dev/null by the
160
+ // generated scripts. Tracking it would hold each press open for the request,
161
+ // and the first-run notice would be marked seen without anyone reading it.
162
+ if (isTabCompletionRun(getCommandPath(actionCommand))) {
163
+ return;
164
+ }
152
165
  // Show first-run telemetry notice (if not seen). It's written to stderr, so it
153
166
  // never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
154
167
  // very first invocation stays free of any incidental output on either stream.
@@ -160,6 +173,11 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
160
173
  });
161
174
  // Shutdown telemetry after command completes
162
175
  program.hook('postAction', async (_thisCommand, actionCommand) => {
176
+ // Nothing to flush (preAction tracked nothing), and the tip is always
177
+ // deferred for completion runs, so skip loading either module.
178
+ if (isTabCompletionRun(getCommandPath(actionCommand))) {
179
+ return;
180
+ }
163
181
  // Show the first-run shell-completions tip (on stderr, so piped stdout stays
164
182
  // clean). postAction, not preAction: the tip trails the command's own output
165
183
  // instead of pushing an error message or `init`'s setup summary down the
@@ -1,4 +1,3 @@
1
- import ora from 'ora';
2
1
  import { CompletionFactory } from '../core/completions/factory.js';
3
2
  import { COMMAND_REGISTRY } from '../core/completions/command-registry.js';
4
3
  import { detectShell } from '../utils/shell-detection.js';
@@ -93,6 +92,9 @@ export class CompletionCommand {
93
92
  async installForShell(shell, verbose) {
94
93
  const generator = CompletionFactory.createGenerator(shell);
95
94
  const installer = CompletionFactory.createInstaller(shell);
95
+ // Loaded here, not at the top: `__complete` shares this module and runs on
96
+ // every Tab press, so it must not pay for the spinner.
97
+ const { default: ora } = await import('ora');
96
98
  const spinner = ora(`Installing ${shell} completion script...`).start();
97
99
  try {
98
100
  // Generate the completion script
@@ -187,6 +189,7 @@ export class CompletionCommand {
187
189
  return;
188
190
  }
189
191
  }
192
+ const { default: ora } = await import('ora');
190
193
  const spinner = ora(`Uninstalling ${shell} completion script...`).start();
191
194
  try {
192
195
  const result = await installer.uninstall();
@@ -1078,13 +1078,22 @@ export class ArchiveCommand {
1078
1078
  const changesDir = root.changesDir;
1079
1079
  const archiveDir = root.archiveDir;
1080
1080
  const mainSpecsDir = root.specsDir;
1081
- for (const [allowedDirectory, managedDir] of [
1082
- [root.path, changesDir],
1083
- [changesDir, archiveDir],
1084
- [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],
1085
1089
  ]) {
1086
1090
  try {
1087
- FileSystemUtils.assertPathWithin(allowedDirectory, managedDir);
1091
+ if (allowLinkedLeaf) {
1092
+ FileSystemUtils.assertPathWithinAllowingLinkedLeaf(allowedDirectory, managedDir);
1093
+ }
1094
+ else {
1095
+ FileSystemUtils.assertPathWithin(allowedDirectory, managedDir);
1096
+ }
1088
1097
  }
1089
1098
  catch {
1090
1099
  throw new ArchiveBlockedError('archive_path_outside_root', `Refusing to archive through a path outside the OpenSpec root: ${managedDir}`);
@@ -1744,6 +1753,9 @@ export class ArchiveCommand {
1744
1753
  if (hasRetirements) {
1745
1754
  await assertRetirementAuthorization(changeDir, retirementAuthorizationFingerprint);
1746
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);
1747
1759
  const verifyArchivedDeltas = async (stagedSource) => {
1748
1760
  if (hasRetirements) {
1749
1761
  await assertRetirementAuthorization(archivePath, retirementAuthorizationFingerprint,
@@ -1757,13 +1769,13 @@ export class ArchiveCommand {
1757
1769
  }
1758
1770
  }
1759
1771
  for (const proposed of prepared) {
1760
- const archivedSource = path.join(archivePath, path.relative(changeDir, proposed.update.source));
1772
+ const archivedSource = path.join(archivePath, path.relative(canonicalChangeDir, proposed.update.source));
1761
1773
  if ((await fingerprintPortableContent(archivedSource)) !==
1762
1774
  proposed.sourceContentFingerprint) {
1763
1775
  throw new Error(`The archived delta for '${proposed.update.id}' changed during the final move.`);
1764
1776
  }
1765
1777
  if (stagedSource) {
1766
- const stagedDelta = path.join(stagedSource, path.relative(changeDir, proposed.update.source));
1778
+ const stagedDelta = path.join(stagedSource, path.relative(canonicalChangeDir, proposed.update.source));
1767
1779
  if ((await fingerprintPortableContent(stagedDelta)) !==
1768
1780
  proposed.sourceContentFingerprint) {
1769
1781
  throw new Error(`The active delta for '${proposed.update.id}' changed during the fallback copy.`);
@@ -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)]
@@ -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 = {
@@ -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) {
@@ -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}.`
@@ -87,8 +87,11 @@ ${PROJECT_ROOT_GUARD}
87
87
  - Add CRITICAL issue: "Requirement not found: <requirement name>"
88
88
  - Recommendation: "Implement requirement X: <description>"
89
89
  - For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
90
- - Search codebase for the removed behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
91
- - Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
90
+ - A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by normalized name, scanning that file's requirement headings rather than string-matching the delta's exact title. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
91
+ - If that file holds no such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - recover the baseline from the same file's history, in the planning repository rather than the current directory: \`git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md\`, or \`git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md\` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: \`git log\` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
92
+ - A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
93
+ - Search codebase for that behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
94
+ - Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
92
95
  - If the behavior is still present:
93
96
  - Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
94
97
  - Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
@@ -104,7 +107,7 @@ ${PROJECT_ROOT_GUARD}
104
107
 
105
108
  6. **Verify Correctness**
106
109
 
107
- If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
110
+ If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each REMOVED and RENAMED entry with a usable baseline is checked there against its baseline behavior, and an entry already marked not verified there stays not verified), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
108
111
 
109
112
  **Requirement Implementation Mapping**:
110
113
  - For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
@@ -158,7 +161,7 @@ ${PROJECT_ROOT_GUARD}
158
161
  | Coherence | Followed/Issues |
159
162
  \`\`\`
160
163
 
161
- In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
164
+ In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified", or "1 removal not verified (baseline unavailable)"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
162
165
 
163
166
  **Issues by Priority**:
164
167
 
@@ -296,8 +299,11 @@ ${PROJECT_ROOT_GUARD}
296
299
  - Add CRITICAL issue: "Requirement not found: <requirement name>"
297
300
  - Recommendation: "Implement requirement X: <description>"
298
301
  - For each REMOVED requirement, the change asks for the behavior to be gone, so invert the check:
299
- - Search codebase for the removed behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
300
- - Finding no implementation is the expected result. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
302
+ - A REMOVED block carries only the requirement name and any \`**Reason**\`/\`**Migration**\` notes, so the delta does not describe the behavior itself. Before searching, read the baseline requirement in the main spec at \`<planningHome.root>/openspec/specs/<capability-path>/spec.md\`, using the same capability path as the delta spec. Match it by normalized name, scanning that file's requirement headings rather than string-matching the delta's exact title. Its body and scenarios are the evidence for the behavior that must be gone; never search from the requirement name alone.
303
+ - If that file holds no such requirement under this or a near-equivalent name - for example because an earlier sync already applied the removal - recover the baseline from the same file's history, in the planning repository rather than the current directory: \`git -C "<planningHome.root>" log -p -- openspec/specs/<capability-path>/spec.md\`, or \`git -C "<planningHome.root>" show <commit>^:./openspec/specs/<capability-path>/spec.md\` once you have identified the commit that applied the removal. Empty output means the pathspec matched nothing, not that there is no baseline: \`git log\` reports an unmatched path as success, so re-check the path before drawing any conclusion from silence.
304
+ - A baseline that adds nothing to its own title - no body beyond a restatement of the name, no scenarios - is unusable evidence, the same as a missing one. If no usable baseline is available in either place, mark **Spec Coverage** as not verified for that entry with the reason and stop there: do not search, and never count an unsearched removal as passing.
305
+ - Search codebase for that behavior. Matches in \`openspec/\` artifacts or docs, or in code that serves only the Migration note or an ADDED requirement, are not evidence by themselves. Report any code path that still delivers the removed behavior, including one shared with an ADDED requirement.
306
+ - Finding no implementation is the expected result, but only once the baseline behavior was the thing you searched for. Never report a REMOVED requirement as "Requirement not found" or recommend implementing it.
301
307
  - If the behavior is still present:
302
308
  - Add CRITICAL issue: "Removed requirement still implemented: <requirement name>"
303
309
  - Recommendation: "Remove the remaining implementation at <file>:<lines>, following the requirement's Migration note if it has one"
@@ -313,7 +319,7 @@ ${PROJECT_ROOT_GUARD}
313
319
 
314
320
  6. **Verify Correctness**
315
321
 
316
- If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each RENAMED entry is checked there against its baseline behavior), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
322
+ If the delta specs are readable and contain at least one REMOVED or RENAMED requirement but no ADDED or MODIFIED requirements (the change only removes or renames requirements), report **Requirement Implementation Mapping** and **Scenario Coverage** as **Not applicable**. The REMOVED and RENAMED checks under Spec Coverage are the evidence for such a change (each REMOVED and RENAMED entry with a usable baseline is checked there against its baseline behavior, and an entry already marked not verified there stays not verified), so do not mark these two checks as not verified. A delta spec with no parseable requirements at all is unusable evidence, not a removal-only change: mark these checks as not verified.
317
323
 
318
324
  **Requirement Implementation Mapping**:
319
325
  - For each ADDED or MODIFIED requirement from delta specs (REMOVED entries, and RENAMED entries without a MODIFIED block, were settled under Spec Coverage):
@@ -367,7 +373,7 @@ ${PROJECT_ROOT_GUARD}
367
373
  | Coherence | Followed/Issues |
368
374
  \`\`\`
369
375
 
370
- In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
376
+ In each Status cell, report the results of checks that ran and \`Not verified (<reason>)\` for every skipped check. If all checks in a dimension were skipped, start the cell with \`Not verified\`. Never score a skipped check as passing. Treat every not verified or partially verified check as skipped in the final assessment. Count only ADDED and MODIFIED requirements in N, and report REMOVED and RENAMED requirements separately (for example, "1 removal confirmed, 1 rename verified", or "1 removal not verified (baseline unavailable)"). For a change that only removes or renames requirements, the Correctness cell reads \`Not applicable (no ADDED or MODIFIED requirements)\`.
371
377
 
372
378
  **Issues by Priority**:
373
379
 
@@ -5,7 +5,7 @@ import { MarkdownParser } from '../parsers/markdown-parser.js';
5
5
  import { ChangeParser } from '../parsers/change-parser.js';
6
6
  import { findSpecUpdates, buildUpdatedSpec } from '../specs-apply.js';
7
7
  import { MIN_PURPOSE_LENGTH, MAX_REQUIREMENT_TEXT_LENGTH, VALIDATION_MESSAGES } from './constants.js';
8
- import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, } from '../parsers/requirement-blocks.js';
8
+ import { parseDeltaSpec, foldRequirementName, normalizeRequirementName, extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, } from '../parsers/requirement-blocks.js';
9
9
  import { extractRequirementBody as extractRequirementBodyShared, containsShallOrMust as containsShallOrMustShared, countScenarios as countScenariosShared, countEmptyScenarios, } from '../parsers/requirement-text.js';
10
10
  import { findMainSpecStructureIssues } from '../parsers/spec-structure.js';
11
11
  import { FileSystemUtils } from '../../utils/file-system.js';
@@ -203,6 +203,16 @@ export class Validator {
203
203
  message: `Requirement "${orphan.name}" is ${where}, which is not a delta section, so it is ignored. Move it under "## ADDED Requirements", "## MODIFIED Requirements", "## REMOVED Requirements", or "## RENAMED Requirements".`,
204
204
  });
205
205
  }
206
+ // Archive carries requirement blocks only, by design. Warn so text
207
+ // written elsewhere is moved rather than lost without notice.
208
+ for (const lost of plan.unappliedContent) {
209
+ issues.push({
210
+ level: 'WARNING',
211
+ path: entryPath,
212
+ line: lost.line,
213
+ message: `${describeUnappliedContent(lost, true)} is not carried into the main spec by archive. Move it into a requirement, or into proposal.md or design.md.`,
214
+ });
215
+ }
206
216
  const sectionNames = [];
207
217
  if (plan.sectionPresence.added)
208
218
  sectionNames.push('## ADDED Requirements');
@@ -2,10 +2,22 @@ import { CHANGE_METADATA_KNOWN_KEYS, type ChangeMetadata } from '../core/change-
2
2
  import { type ProjectConfig } from '../core/project-config.js';
3
3
  export declare const METADATA_FILENAME = ".openspec.yaml";
4
4
  export { CHANGE_METADATA_KNOWN_KEYS };
5
+ /**
6
+ * Extension keys: a top-level key starting with a lowercase `x-` belongs to a
7
+ * tool or team that reads `.openspec.yaml` alongside OpenSpec (a tracker id, an
8
+ * owner). OpenSpec ignores it and does not report it as unknown. As with
9
+ * OpenAPI's `x-` extensions, the name after the prefix may use any case or
10
+ * spelling, but must be non-empty and free of whitespace, so `x-`, `x- foo`
11
+ * and `X-Foo` are still reported. Extensions are top-level only: a key inside
12
+ * `initiative:` is rejected by that object's strict schema.
13
+ */
14
+ export declare const EXTENSION_KEY_PATTERN: RegExp;
15
+ export declare function isExtensionMetadataKey(key: string): boolean;
5
16
  /**
6
17
  * Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
7
18
  * stripped by ChangeMetadataSchema rather than rejected, so callers that want
8
19
  * to tell the author a key did nothing have to look at the raw object.
20
+ * Extension keys (`x-*`) are not unknown: they are deliberately someone else's.
9
21
  */
10
22
  export declare function listUnknownChangeMetadataKeys(parsed: unknown): string[];
11
23
  /**
@@ -40,6 +52,11 @@ export declare function validateSchemaName(schemaName: string, projectRoot?: str
40
52
  /**
41
53
  * Writes change metadata to .openspec.yaml in the change directory.
42
54
  *
55
+ * Meant for a new file only (`createChange` refuses an existing change
56
+ * directory). `ChangeMetadataSchema` strips unknown keys, including `x-`
57
+ * extension keys, so a caller that rewrites an existing file must merge those
58
+ * keys back from the raw YAML first or it silently deletes them.
59
+ *
43
60
  * @param changeDir - The path to the change directory
44
61
  * @param metadata - The metadata to write
45
62
  * @param projectRoot - Optional project root for project-local schema resolution
@@ -7,10 +7,24 @@ import { readProjectConfig } from '../core/project-config.js';
7
7
  import { sanitizeInline } from '../core/references.js';
8
8
  export const METADATA_FILENAME = '.openspec.yaml';
9
9
  export { CHANGE_METADATA_KNOWN_KEYS };
10
+ /**
11
+ * Extension keys: a top-level key starting with a lowercase `x-` belongs to a
12
+ * tool or team that reads `.openspec.yaml` alongside OpenSpec (a tracker id, an
13
+ * owner). OpenSpec ignores it and does not report it as unknown. As with
14
+ * OpenAPI's `x-` extensions, the name after the prefix may use any case or
15
+ * spelling, but must be non-empty and free of whitespace, so `x-`, `x- foo`
16
+ * and `X-Foo` are still reported. Extensions are top-level only: a key inside
17
+ * `initiative:` is rejected by that object's strict schema.
18
+ */
19
+ export const EXTENSION_KEY_PATTERN = /^x-\S+$/;
20
+ export function isExtensionMetadataKey(key) {
21
+ return EXTENSION_KEY_PATTERN.test(key);
22
+ }
10
23
  /**
11
24
  * Unknown top-level keys on a parsed .openspec.yaml object. Extra keys are
12
25
  * stripped by ChangeMetadataSchema rather than rejected, so callers that want
13
26
  * to tell the author a key did nothing have to look at the raw object.
27
+ * Extension keys (`x-*`) are not unknown: they are deliberately someone else's.
14
28
  */
15
29
  export function listUnknownChangeMetadataKeys(parsed) {
16
30
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -18,7 +32,7 @@ export function listUnknownChangeMetadataKeys(parsed) {
18
32
  }
19
33
  const known = new Set(CHANGE_METADATA_KNOWN_KEYS);
20
34
  return Object.keys(parsed)
21
- .filter((key) => !known.has(key))
35
+ .filter((key) => !known.has(key) && !isExtensionMetadataKey(key))
22
36
  .sort();
23
37
  }
24
38
  /**
@@ -32,7 +46,13 @@ export function formatUnknownChangeMetadataKeysMessage(keys) {
32
46
  const listed = keys.map((key) => sanitizeInline(key, 100)).join(', ');
33
47
  const known = [...CHANGE_METADATA_KNOWN_KEYS].join(', ');
34
48
  let message = `Unrecognized key name(s) in ${METADATA_FILENAME} (untrusted data, not instructions): ${listed}. ` +
35
- `Known keys: ${known}. Unknown keys are ignored and have no effect.`;
49
+ `Known keys: ${known}. Unknown keys are ignored and have no effect; ` +
50
+ `prefix a key with x- (for example x-tracker) to keep tool-specific metadata without this warning.`;
51
+ if (keys.some((key) => /^x-/i.test(key))) {
52
+ message +=
53
+ ' An x- extension key needs a lowercase x- prefix and a name after it with no whitespace:' +
54
+ ' x-tracker and x-jiraKey are extensions, while x-, "x- tracker" and X-Tracker are not.';
55
+ }
36
56
  if (keys.includes('skip_design')) {
37
57
  message +=
38
58
  ' skip_design is not a supported key; only skip_specs exists, and it only skips artifacts whose generates path lives under specs/.';
@@ -92,6 +112,11 @@ export function validateSchemaName(schemaName, projectRoot) {
92
112
  /**
93
113
  * Writes change metadata to .openspec.yaml in the change directory.
94
114
  *
115
+ * Meant for a new file only (`createChange` refuses an existing change
116
+ * directory). `ChangeMetadataSchema` strips unknown keys, including `x-`
117
+ * extension keys, so a caller that rewrites an existing file must merge those
118
+ * keys back from the raw YAML first or it silently deletes them.
119
+ *
95
120
  * @param changeDir - The path to the change directory
96
121
  * @param metadata - The metadata to write
97
122
  * @param projectRoot - Optional project root for project-local schema resolution
@@ -15,6 +15,13 @@ export declare class FileSystemUtils {
15
15
  * Missing suffixes are resolved from their nearest existing ancestor.
16
16
  */
17
17
  static assertPathWithin(allowedDirectory: string, targetPath: string): void;
18
+ /**
19
+ * Like assertPathWithin, but a targetPath that is itself a symlink may
20
+ * resolve outside allowedDirectory, so a project can keep a managed
21
+ * directory (for example openspec/changes) in another repository. Its parent
22
+ * must still be confined and the link must resolve to an existing path.
23
+ */
24
+ static assertPathWithinAllowingLinkedLeaf(allowedDirectory: string, targetPath: string): void;
18
25
  static resolveProjectArtifactPath(projectPath: string, artifactPath: string): string;
19
26
  static assertProjectArtifactPath(projectPath: string, targetPath: string): void;
20
27
  private static isPathWithin;
@@ -105,6 +105,31 @@ export class FileSystemUtils {
105
105
  throw new Error(`Path is outside the allowed directory: ${targetPath}`);
106
106
  }
107
107
  }
108
+ /**
109
+ * Like assertPathWithin, but a targetPath that is itself a symlink may
110
+ * resolve outside allowedDirectory, so a project can keep a managed
111
+ * directory (for example openspec/changes) in another repository. Its parent
112
+ * must still be confined and the link must resolve to an existing path.
113
+ */
114
+ static assertPathWithinAllowingLinkedLeaf(allowedDirectory, targetPath) {
115
+ const resolvedTarget = path.resolve(targetPath);
116
+ let isLink = false;
117
+ try {
118
+ isLink = nodeFs.lstatSync(resolvedTarget).isSymbolicLink();
119
+ }
120
+ catch (error) {
121
+ if (error.code !== 'ENOENT') {
122
+ throw error;
123
+ }
124
+ }
125
+ if (!isLink) {
126
+ this.assertPathWithin(allowedDirectory, resolvedTarget);
127
+ return;
128
+ }
129
+ this.assertPathWithin(allowedDirectory, path.dirname(resolvedTarget));
130
+ // Throws for a dangling link, which cannot be proven to be a directory.
131
+ nodeFs.realpathSync.native(resolvedTarget);
132
+ }
108
133
  static resolveProjectArtifactPath(projectPath, artifactPath) {
109
134
  if (path.isAbsolute(artifactPath)) {
110
135
  throw new Error(`Refusing to manage an artifact outside the project: ${artifactPath}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fission-ai/openspec",
3
- "version": "1.14.1",
3
+ "version": "1.14.2",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "openspec",