@fission-ai/openspec 1.14.1 → 1.14.3

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
@@ -10,6 +10,7 @@ import { getActiveChangeIds } from '../utils/item-discovery.js';
10
10
  import { describeNestedChange, findNestedChangesIn, } from '../utils/nested-change.js';
11
11
  import { getTaskProgressForChange } from '../utils/task-progress.js';
12
12
  import { FileSystemUtils } from '../utils/file-system.js';
13
+ import { assertDiscoveredSpecFile } from '../utils/spec-path-containment.js';
13
14
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
14
15
  import { foldRequirementName, parseDeltaSpec, } from '../core/parsers/requirement-blocks.js';
15
16
  import { extractRequirementBlock, diffRequirementBlock, buildRenameMap, } from '../utils/requirement-diff.js';
@@ -168,7 +169,7 @@ export class ChangeCommand {
168
169
  const mainSpecPath = path.join(mainSpecsDir, ...capability.split('/'), 'spec.md');
169
170
  let mainContent = null;
170
171
  try {
171
- FileSystemUtils.assertPathWithin(mainSpecsDir, mainSpecPath);
172
+ assertDiscoveredSpecFile(mainSpecsDir, mainSpecPath);
172
173
  mainContent = await fs.readFile(mainSpecPath, 'utf-8');
173
174
  }
174
175
  catch (error) {
@@ -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();
@@ -5,7 +5,7 @@ import { Validator } from '../core/validation/validator.js';
5
5
  import { isInteractive } from '../utils/interactive.js';
6
6
  import { getSpecIds } from '../utils/item-discovery.js';
7
7
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
8
- import { FileSystemUtils } from '../utils/file-system.js';
8
+ import { assertDiscoveredSpecFile } from '../utils/spec-path-containment.js';
9
9
  const SPECS_DIR = 'openspec/specs';
10
10
  function assertSpecPath(specsDir, specPath) {
11
11
  const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath));
@@ -14,15 +14,7 @@ function assertSpecPath(specsDir, specPath) {
14
14
  path.isAbsolute(relativePath)) {
15
15
  throw new Error(`Path is outside the allowed directory: ${specPath}`);
16
16
  }
17
- try {
18
- // Preserve confined spec.md links, including links to a sibling capability.
19
- FileSystemUtils.assertPathWithin(specsDir, specPath);
20
- }
21
- catch {
22
- // A capability directory may intentionally be a monorepo symlink. Treat it
23
- // as the trust root while still rejecting a link outside that capability.
24
- FileSystemUtils.assertPathWithin(path.dirname(specPath), specPath);
25
- }
17
+ assertDiscoveredSpecFile(specsDir, specPath);
26
18
  }
27
19
  function parseSpecFromFile(specsDir, specPath, specId) {
28
20
  assertSpecPath(specsDir, specPath);
@@ -4,7 +4,7 @@ import { describeNestedChange, findNestedChangesIn, NESTED_CHANGE_ISSUE_MARKER,
4
4
  import { Validator } from '../core/validation/validator.js';
5
5
  import { VALIDATION_MESSAGES } from '../core/validation/constants.js';
6
6
  import { resolveRootForCommand, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from '../core/root-selection.js';
7
- import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
7
+ import { canRenderSpinner, isInteractive, resolveNoInteractive } from '../utils/interactive.js';
8
8
  import { getSpecIds } from '../utils/item-discovery.js';
9
9
  import { getAvailableChanges } from './workflow/shared.js';
10
10
  import { nearestMatches } from '../utils/match.js';
@@ -356,7 +356,7 @@ export class ValidateCommand {
356
356
  }
357
357
  }
358
358
  async runBulkValidation(root, scope, opts) {
359
- const spinner = !opts.json && !opts.noInteractive ? ora('Validating...').start() : undefined;
359
+ const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating...').start() : undefined;
360
360
  const [changeIds, specIds] = await Promise.all([
361
361
  scope.changes ? this.listChangeIds(root) : Promise.resolve([]),
362
362
  scope.specs ? getSpecIds(root.path) : Promise.resolve([]),
@@ -527,7 +527,7 @@ export class ValidateCommand {
527
527
  // List first (may throw on a real archive-read failure), then start the
528
528
  // spinner so a thrown error never leaves a spinner spinning.
529
529
  const ids = await this.listArchivedChangeIds(root);
530
- const spinner = !opts.json && !opts.noInteractive ? ora('Validating archived changes...').start() : undefined;
530
+ const spinner = !opts.json && isInteractive(opts) && canRenderSpinner() ? ora('Validating archived changes...').start() : undefined;
531
531
  // The archive is append-only and can hold thousands of changes; a single
532
532
  // run resolves them all under one constant projectRoot (root.path), so
533
533
  // memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
@@ -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.`);
@@ -1,9 +1,221 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
- import fg from 'fast-glob';
3
+ import { expand } from 'brace-expansion';
4
+ import picomatch from 'picomatch';
5
+ import * as globby from 'tinyglobby';
4
6
  import { FileSystemUtils } from '../../utils/file-system.js';
5
7
  const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
6
8
  const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
9
+ const DUPLICATE_SLASH_RE = /(?!^)\/{2,}/gu;
10
+ // Deeply nested braces can overflow a recursive expander. Reject nesting no real
11
+ // artifact pattern needs before brace-expansion or tinyglobby see the pattern.
12
+ const MAX_BRACE_NESTING = 16;
13
+ // Nesting depth alone does not bound the work: `{a,b}` repeated 20 times is
14
+ // only one level deep and expands to 1,048,576 patterns, which exhausts the
15
+ // heap. fast-glob never expanded that far because micromatch compiled the
16
+ // braces to a regex. No real artifact pattern needs more than a few hundred.
17
+ const MAX_BRACE_EXPANSIONS = 1024;
18
+ const ARTIFACT_GLOB_OPTIONS = {
19
+ absolute: true,
20
+ // Braces are expanded first so `{draft}` left behind stays a literal name.
21
+ braceExpansion: false,
22
+ // fast-glob migration: directory patterns must not grow a trailing `/**`.
23
+ expandDirectories: false,
24
+ followSymbolicLinks: true,
25
+ onlyFiles: true,
26
+ };
27
+ function assertBraceNesting(pattern) {
28
+ // Mirror the braces parser: `[...]` and quoted runs are literal, `(` nests like
29
+ // `{`, and a closer only closes the innermost group of its own kind.
30
+ const open = [];
31
+ for (let index = 0; index < pattern.length; index += 1) {
32
+ const char = pattern[index];
33
+ if (char === '[') {
34
+ for (let brackets = 1; brackets > 0 && index + 1 < pattern.length;) {
35
+ index += 1;
36
+ if (pattern[index] === '[')
37
+ brackets += 1;
38
+ else if (pattern[index] === ']')
39
+ brackets -= 1;
40
+ }
41
+ }
42
+ else if (char === '"' || char === "'" || char === '`') {
43
+ const end = pattern.indexOf(char, index + 1);
44
+ index = end === -1 ? pattern.length : end;
45
+ }
46
+ else if (char === '{' || char === '(') {
47
+ open.push(char);
48
+ if (open.length > MAX_BRACE_NESTING) {
49
+ // A pattern this deep can run to thousands of characters; quote only its start.
50
+ const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
51
+ throw new Error(`Artifact output pattern nests braces more than ${MAX_BRACE_NESTING} levels deep: ${shown}`);
52
+ }
53
+ }
54
+ else if ((char === '}' && open.at(-1) === '{') || (char === ')' && open.at(-1) === '(')) {
55
+ open.pop();
56
+ }
57
+ }
58
+ }
59
+ function isDynamicSegment(segment) {
60
+ return segment.includes('*') || segment.includes('?') || segment.includes('[')
61
+ || EXTGLOB_RE.test(segment);
62
+ }
63
+ /**
64
+ * Static directory prefix of an expanded pattern, equivalent to the `base`
65
+ * fast-glob's generateTasks used to reject `../` before walking.
66
+ */
67
+ function globBase(pattern) {
68
+ const segments = pattern.split('/');
69
+ if (segments.length <= 1)
70
+ return '.';
71
+ const base = [];
72
+ for (const segment of segments.slice(0, -1)) {
73
+ if (segment === '')
74
+ continue;
75
+ if (isDynamicSegment(segment))
76
+ break;
77
+ base.push(segment);
78
+ }
79
+ // An absolute pattern must stay absolute, or `path.resolve(changeDir, base)`
80
+ // rebases `/etc/*` inside the change directory and the containment check
81
+ // passes for a pattern that globs the filesystem root.
82
+ if (pattern.startsWith('/'))
83
+ return `/${base.join('/')}`;
84
+ return base.length > 0 ? base.join('/') : '.';
85
+ }
86
+ // brace-expansion 5 leaves the rest of a pattern literal when a non-expanding
87
+ // group such as `{draft}` comes before a range. Expand one real group at a time.
88
+ // NUL-prefixed so no `generates` value can forge them: `resolveArtifactOutputPath`
89
+ // runs `path.join` first, which rejects a NUL byte. A single-character sentinel
90
+ // such as U+0001 is a legal filename character, and a pattern containing one
91
+ // would come back out of the expander as a brace.
92
+ const LITERAL_BRACE_OPEN = '\u0000<';
93
+ const LITERAL_BRACE_CLOSE = '\u0000>';
94
+ function hasTopLevelExpansion(body) {
95
+ let depth = 0;
96
+ for (let index = 0; index < body.length; index += 1) {
97
+ const char = body[index];
98
+ if (char === '{')
99
+ depth += 1;
100
+ else if (char === '}')
101
+ depth = Math.max(0, depth - 1);
102
+ else if (depth === 0) {
103
+ if (char === ',')
104
+ return true;
105
+ if (char === '.' && body[index + 1] === '.')
106
+ return true;
107
+ }
108
+ }
109
+ return false;
110
+ }
111
+ function findTopLevelExpansion(pattern) {
112
+ const stack = [];
113
+ for (let index = 0; index < pattern.length; index += 1) {
114
+ const char = pattern[index];
115
+ if (char === '{') {
116
+ stack.push(index);
117
+ }
118
+ else if (char === '}' && stack.length > 0) {
119
+ const open = stack.pop();
120
+ if (open === undefined)
121
+ continue;
122
+ // Only the outermost group that actually expands. brace-expansion expands
123
+ // its nested groups in one linear pass. Peeling the innermost `{a,b}` first
124
+ // turns `{a,{a,{a,b}}}` into a binary tree, so a 16-deep pattern does not
125
+ // finish before the test timeout.
126
+ if (stack.length > 0)
127
+ continue;
128
+ const body = pattern.slice(open + 1, index);
129
+ if (!hasTopLevelExpansion(body)) {
130
+ // `{prefix{a,b}}` has no separator of its own, so the outer braces stay
131
+ // literal. Keep scanning inside them; a nested group may still expand.
132
+ const nested = findTopLevelExpansion(body);
133
+ if (!nested)
134
+ continue;
135
+ return {
136
+ pre: `${pattern.slice(0, open + 1)}${nested.pre}`,
137
+ body: nested.body,
138
+ post: `${nested.post}${pattern.slice(index)}`,
139
+ };
140
+ }
141
+ return { pre: pattern.slice(0, open), body, post: pattern.slice(index + 1) };
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ function assertExpansionBudget(pattern, count) {
147
+ if (count <= MAX_BRACE_EXPANSIONS)
148
+ return;
149
+ // A pattern this wide can run to thousands of characters; quote only its start.
150
+ const shown = pattern.length > 80 ? `${pattern.slice(0, 80)}...` : pattern;
151
+ throw new Error(`Artifact output pattern expands to more than ${MAX_BRACE_EXPANSIONS} patterns: ${shown}`);
152
+ }
153
+ function expandArtifactBraces(pattern) {
154
+ if (pattern.includes('\u0000')) {
155
+ throw new Error('Artifact output pattern contains a NUL byte');
156
+ }
157
+ const pending = [pattern];
158
+ const finished = [];
159
+ while (pending.length > 0) {
160
+ const current = pending.pop();
161
+ if (current === undefined)
162
+ break;
163
+ const group = findTopLevelExpansion(current);
164
+ if (!group) {
165
+ finished.push(current.replaceAll(LITERAL_BRACE_OPEN, '{').replaceAll(LITERAL_BRACE_CLOSE, '}'));
166
+ continue;
167
+ }
168
+ const wrapped = `{${group.body}}`;
169
+ // `max` makes this budget the binding one. Without it `{1..1000000000}`
170
+ // allocates brace-expansion's own 100,000-result default before the check
171
+ // below rejects it. `max` truncates silently, so ask for one result more
172
+ // than the budget allows: a result that long means the pattern is over
173
+ // budget rather than fully expanded. A truncated group never looks literal,
174
+ // because its first alternative is not the group itself.
175
+ const remaining = Math.max(MAX_BRACE_EXPANSIONS - (pending.length + finished.length), 0);
176
+ const alternatives = expand(wrapped, { max: remaining + 1 });
177
+ if (alternatives.length === 1 && alternatives[0] === wrapped) {
178
+ pending.push(`${group.pre}${LITERAL_BRACE_OPEN}${group.body}${LITERAL_BRACE_CLOSE}${group.post}`);
179
+ continue;
180
+ }
181
+ // Checked before the push so the budget bounds memory, not just the result.
182
+ assertExpansionBudget(pattern, pending.length + finished.length + alternatives.length);
183
+ for (const alternative of alternatives) {
184
+ pending.push(`${group.pre}${alternative}${group.post}`);
185
+ }
186
+ }
187
+ const normalized = finished
188
+ .map((value) => value.replace(DUPLICATE_SLASH_RE, '/'))
189
+ .filter((value) => value.length > 0);
190
+ return normalized.length > 0 ? normalized : [pattern];
191
+ }
192
+ /**
193
+ * One directory level. picomatch matches the segment; readdir keeps symlink
194
+ * entries tinyglobby would drop when followSymbolicLinks is false.
195
+ */
196
+ function listDirectoryMatches(directory, segment) {
197
+ let entries;
198
+ try {
199
+ entries = fs.readdirSync(directory, { withFileTypes: true });
200
+ }
201
+ catch (error) {
202
+ if (error.code === 'ENOENT')
203
+ return [];
204
+ throw error;
205
+ }
206
+ const isMatch = picomatch(segment, {
207
+ dot: false,
208
+ nobrace: true,
209
+ noglobstar: true,
210
+ posix: true,
211
+ });
212
+ const names = [];
213
+ for (const entry of entries) {
214
+ if (isMatch(entry.name))
215
+ names.push(entry.name);
216
+ }
217
+ return names;
218
+ }
7
219
  function hasBraceExpansion(pattern) {
8
220
  const openings = [];
9
221
  for (let index = 0; index < pattern.length; index += 1) {
@@ -59,12 +271,16 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
59
271
  // `**` may consume no directory at all.
60
272
  assertGlobDirectoryTraversal(changeDir, canonicalDir, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
61
273
  }
62
- const matches = fg.sync(segment === '**' ? '*' : segment, {
63
- cwd: canonicalDir,
64
- onlyFiles: false,
65
- followSymbolicLinks: false,
66
- deep: 1,
67
- });
274
+ // `.` and `..` are not directory entries. Resolve them directly so a
275
+ // `**/../` pattern is confined before any glob runs.
276
+ if (segment === '' || segment === '.' || segment === '..') {
277
+ const candidate = path.resolve(canonicalDir, segment === '' ? '.' : segment);
278
+ const canonicalCandidate = FileSystemUtils.canonicalizeExistingPath(candidate);
279
+ FileSystemUtils.assertPathWithin(canonicalChangeDir, canonicalCandidate);
280
+ assertGlobDirectoryTraversal(changeDir, canonicalCandidate, directorySegments, segmentIndex + 1, visited, canonicalChangeDir, ancestors);
281
+ return;
282
+ }
283
+ const matches = listDirectoryMatches(canonicalDir, segment === '**' ? '*' : segment);
68
284
  for (const match of matches) {
69
285
  const candidate = path.join(canonicalDir, match);
70
286
  try {
@@ -90,8 +306,11 @@ function assertGlobDirectoryTraversal(changeDir, currentDir, directorySegments,
90
306
  * Returns absolute file paths. Glob matches are sorted for deterministic output.
91
307
  */
92
308
  export function resolveArtifactOutputs(changeDir, generates) {
309
+ const isGlob = isGlobPattern(generates);
310
+ if (isGlob)
311
+ assertBraceNesting(FileSystemUtils.toPosixPath(generates));
93
312
  const outputPath = resolveArtifactOutputPath(changeDir, generates);
94
- if (!isGlobPattern(generates)) {
313
+ if (!isGlob) {
95
314
  try {
96
315
  return fs.statSync(outputPath).isFile()
97
316
  ? [FileSystemUtils.canonicalizeExistingPath(outputPath)]
@@ -101,27 +320,21 @@ export function resolveArtifactOutputs(changeDir, generates) {
101
320
  return [];
102
321
  }
103
322
  }
104
- const normalizedPattern = FileSystemUtils.toPosixPath(generates);
105
- const globOptions = {
323
+ const normalizedPattern = FileSystemUtils.toPosixPath(generates).replace(DUPLICATE_SLASH_RE, '/');
324
+ // Expand braces before any filesystem access. tinyglobby has no generateTasks,
325
+ // and a `../` alternative is invisible until the braces are expanded.
326
+ const patterns = expandArtifactBraces(normalizedPattern);
327
+ for (const pattern of patterns) {
328
+ FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, globBase(pattern)));
329
+ }
330
+ for (const pattern of patterns) {
331
+ assertGlobDirectoryTraversal(changeDir, changeDir, pattern.split('/').slice(0, -1));
332
+ }
333
+ const matches = globby
334
+ .globSync(patterns, {
106
335
  cwd: changeDir,
107
- onlyFiles: true,
108
- absolute: true,
109
- // Preserve linked artifact directories; confine traversal and concrete matches.
110
- followSymbolicLinks: true,
111
- };
112
- // Task generation expands braces without accessing the filesystem. Validate
113
- // every task base before globbing, including paths introduced by expansion.
114
- const tasks = fg.generateTasks(normalizedPattern, globOptions);
115
- for (const task of tasks) {
116
- FileSystemUtils.assertPathWithin(changeDir, path.resolve(changeDir, task.base));
117
- }
118
- for (const task of tasks) {
119
- for (const positivePattern of task.positive) {
120
- assertGlobDirectoryTraversal(changeDir, changeDir, positivePattern.split('/').slice(0, -1));
121
- }
122
- }
123
- const matches = fg
124
- .sync(normalizedPattern, globOptions)
336
+ ...ARTIFACT_GLOB_OPTIONS,
337
+ })
125
338
  .map((match) => {
126
339
  const normalizedMatch = path.normalize(match);
127
340
  FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
@@ -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 tinyglobby.
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. */