@fission-ai/openspec 1.13.1 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +15 -3
  2. package/dist/commands/workflow/instructions.js +36 -8
  3. package/dist/commands/workflow/shared.d.ts +5 -0
  4. package/dist/core/archive.js +243 -76
  5. package/dist/core/artifact-graph/outputs.d.ts +1 -1
  6. package/dist/core/artifact-graph/outputs.js +38 -8
  7. package/dist/core/command-generation/adapters/continue.js +4 -0
  8. package/dist/core/command-generation/adapters/kilocode.d.ts +1 -1
  9. package/dist/core/command-generation/adapters/kilocode.js +2 -2
  10. package/dist/core/init.js +9 -3
  11. package/dist/core/legacy-cleanup.d.ts +1 -1
  12. package/dist/core/legacy-cleanup.js +29 -4
  13. package/dist/core/parsers/requirement-blocks.d.ts +34 -3
  14. package/dist/core/parsers/requirement-blocks.js +65 -23
  15. package/dist/core/specs-apply.d.ts +20 -0
  16. package/dist/core/specs-apply.js +19 -6
  17. package/dist/core/templates/workflows/archive-change.js +30 -16
  18. package/dist/core/templates/workflows/bulk-archive-change.js +28 -14
  19. package/dist/core/templates/workflows/continue-change.js +0 -2
  20. package/dist/core/templates/workflows/explore.js +2 -2
  21. package/dist/core/templates/workflows/ff-change.js +2 -2
  22. package/dist/core/templates/workflows/onboard.js +9 -4
  23. package/dist/core/templates/workflows/update-change.js +21 -9
  24. package/dist/core/templates/workflows/verify-change.js +115 -52
  25. package/dist/core/update.js +20 -7
  26. package/dist/core/validation/purpose-placeholder.js +24 -9
  27. package/dist/core/validation/validator.js +6 -5
  28. package/dist/utils/file-system.js +20 -3
  29. package/dist/utils/line-endings.d.ts +34 -0
  30. package/dist/utils/line-endings.js +46 -0
  31. package/package.json +2 -2
  32. package/schemas/spec-driven/schema.yaml +8 -0
package/README.md CHANGED
@@ -122,7 +122,7 @@ Solo, OpenSpec keeps you and your AI honest on a single repo. On a team, the har
122
122
 
123
123
  ## Quick Start
124
124
 
125
- **Requires Node.js 20.19.0 or higher.**
125
+ **Requires Node.js 20.19.0 or higher.** Homebrew installs it as a dependency.
126
126
 
127
127
  Install OpenSpec globally:
128
128
 
@@ -130,6 +130,12 @@ Install OpenSpec globally:
130
130
  npm install -g @fission-ai/openspec@latest
131
131
  ```
132
132
 
133
+ Or install the official [Homebrew formula](https://formulae.brew.sh/formula/openspec) on macOS or Linux:
134
+
135
+ ```bash
136
+ brew install openspec
137
+ ```
138
+
133
139
  Then navigate to your project directory and initialize:
134
140
 
135
141
  ```bash
@@ -137,7 +143,7 @@ cd your-project
137
143
  openspec init
138
144
  ```
139
145
 
140
- > **Want your AI to do it?** Paste the [setup prompt](docs/installation.md#install-with-your-ai-assistant) into your coding assistant — it installs the CLI, runs `openspec init`, and verifies the result.
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
147
 
142
148
  Now talk to your AI:
143
149
 
@@ -151,7 +157,7 @@ Both are in the default profile. If you want the expanded workflow (`/opsx:new`,
151
157
  > [!NOTE]
152
158
  > Not sure if your tool is supported? [View the full list](docs/supported-tools.md) – we support 30+ tools and growing.
153
159
  >
154
- > Also works with pnpm, yarn, bun, and nix. [See installation options](docs/installation.md).
160
+ > Also works with Homebrew, pnpm, yarn, bun, and Nix. [See installation options](docs-lab/start/installation.md).
155
161
 
156
162
  ## Docs
157
163
 
@@ -208,6 +214,12 @@ AI coding assistants are powerful but unpredictable when requirements live only
208
214
  npm install -g @fission-ai/openspec@latest
209
215
  ```
210
216
 
217
+ If you installed OpenSpec with Homebrew:
218
+
219
+ ```bash
220
+ brew upgrade openspec
221
+ ```
222
+
211
223
  **Refresh agent instructions**
212
224
 
213
225
  Run this inside each project to regenerate AI guidance and ensure the latest slash commands are active:
@@ -7,7 +7,7 @@
7
7
  import ora from 'ora';
8
8
  import path from 'path';
9
9
  import * as fs from 'fs';
10
- import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputPath, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
10
+ import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
11
11
  import { isSpecsArtifactPath } from '../../core/artifact-graph/outputs.js';
12
12
  import { findUnreadDeltaFiles } from '../../utils/spec-discovery.js';
13
13
  import { getChangeDir, resolveCurrentPlanningHomeSync, } from '../../core/planning-home.js';
@@ -394,15 +394,28 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
394
394
  contextFiles[artifact.id] = outputs;
395
395
  }
396
396
  }
397
- // Parse tasks if tracking file exists
397
+ // Parse every concrete file matched by apply.tracks. A tracking path may be
398
+ // a glob owned by an artifact with any ID, so treating it as one literal
399
+ // path loses task evidence for valid custom schemas.
398
400
  let parsedTasks = [];
401
+ const unavailableTrackingFiles = [];
399
402
  let tracksFileExists = false;
400
403
  if (tracksFile) {
401
- const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
402
- tracksFileExists = fs.existsSync(tracksPath);
403
- if (tracksFileExists) {
404
- const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
405
- parsedTasks = parseTaskLines(tasksContent);
404
+ const tracksPaths = resolveArtifactOutputs(changeDir, tracksFile);
405
+ tracksFileExists = tracksPaths.length > 0;
406
+ for (const tracksPath of tracksPaths) {
407
+ try {
408
+ const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
409
+ parsedTasks.push(...parseTaskLines(tasksContent));
410
+ }
411
+ catch (error) {
412
+ const code = error?.code;
413
+ const message = error instanceof Error ? error.message : String(error);
414
+ unavailableTrackingFiles.push({
415
+ path: tracksPath,
416
+ reason: code && !message.includes(code) ? `${code}: ${message}` : message,
417
+ });
418
+ }
406
419
  }
407
420
  }
408
421
  const tasks = toTaskItems(parsedTasks);
@@ -435,6 +448,10 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
435
448
  `The ${tracksFilename} file is missing and must be created.` +
436
449
  `\n${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
437
450
  }
451
+ else if (tracksFile && unavailableTrackingFiles.length > 0 && tasks.length === 0) {
452
+ state = 'blocked';
453
+ instruction = 'No readable task descriptions are available.';
454
+ }
438
455
  else if (tracksFile && tracksFileExists && tasks.length === 0) {
439
456
  // Tracking file exists but lists nothing an agent can work on: either no
440
457
  // checkboxes at all, or only checkboxes with no text after them.
@@ -444,7 +461,10 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
444
461
  `The ${tracksFilename} file exists but contains no tasks to work on.` +
445
462
  `\nAdd tasks to ${tracksFilename}, or rebuild it: ${describeArtifactRemedy(changeName, findArtifactIdFor(schema, tracksFile))}`;
446
463
  }
447
- else if (tracksFile && remaining === 0 && total > 0) {
464
+ else if (tracksFile &&
465
+ unavailableTrackingFiles.length === 0 &&
466
+ remaining === 0 &&
467
+ total > 0) {
448
468
  state = 'all_done';
449
469
  instruction = 'All tasks are complete! This change is ready to be archived.\nConsider running tests and reviewing the changes before archiving.';
450
470
  }
@@ -457,6 +477,12 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
457
477
  state = 'ready';
458
478
  instruction = schemaInstruction?.trim() ?? 'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
459
479
  }
480
+ if (unavailableTrackingFiles.length > 0) {
481
+ const unavailableDetails = unavailableTrackingFiles
482
+ .map((file) => `- ${file.path}: ${file.reason}`)
483
+ .join('\n');
484
+ instruction += `\nTask completion is not verified because tracking evidence was unavailable:\n${unavailableDetails}`;
485
+ }
460
486
  const warnings = await collectApplyWarnings({
461
487
  state,
462
488
  schema,
@@ -471,6 +497,8 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
471
497
  contextFiles,
472
498
  progress: { total, complete, remaining },
473
499
  tasks,
500
+ taskTrackingConfigured: tracksFile !== null,
501
+ ...(unavailableTrackingFiles.length > 0 ? { unavailableTrackingFiles } : {}),
474
502
  state,
475
503
  missingArtifacts: missingArtifacts.length > 0 ? missingArtifacts : undefined,
476
504
  ...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
@@ -28,6 +28,11 @@ export interface ApplyInstructions {
28
28
  remaining: number;
29
29
  };
30
30
  tasks: TaskItem[];
31
+ taskTrackingConfigured: boolean;
32
+ unavailableTrackingFiles?: Array<{
33
+ path: string;
34
+ reason: string;
35
+ }>;
31
36
  state: 'blocked' | 'all_done' | 'ready';
32
37
  missingArtifacts?: string[];
33
38
  /**
@@ -7,7 +7,7 @@ import { Validator } from './validation/validator.js';
7
7
  import { VALIDATION_MESSAGES } from './validation/constants.js';
8
8
  import chalk from 'chalk';
9
9
  import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootOutput, withStoreFlag, isStoreSelectedRoot, } from './root-selection.js';
10
- import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
10
+ import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, pruneEmptyDirs, } from './specs-apply.js';
11
11
  import { discoverSpecFiles, findUnreadDeltaFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
12
12
  import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
13
13
  import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
@@ -289,6 +289,14 @@ async function copyDirContents(src, dest) {
289
289
  }
290
290
  await fs.chmod(dest, sourceStat.mode & 0o7777);
291
291
  }
292
+ /** SHA-256 of one file's bytes, streamed so a large file is never held whole. */
293
+ async function fingerprintFileContents(filePath) {
294
+ const fileHash = createHash('sha256');
295
+ for await (const chunk of createReadStream(filePath)) {
296
+ fileHash.update(chunk);
297
+ }
298
+ return fileHash.digest();
299
+ }
292
300
  async function fingerprintDirectoryContents(root) {
293
301
  const hash = createHash('sha256');
294
302
  const updateHashField = (label, value) => {
@@ -301,13 +309,7 @@ async function fingerprintDirectoryContents(root) {
301
309
  hash.update(labelBuffer);
302
310
  hash.update(valueBuffer);
303
311
  };
304
- const fingerprintFile = async (filePath) => {
305
- const fileHash = createHash('sha256');
306
- for await (const chunk of createReadStream(filePath)) {
307
- fileHash.update(chunk);
308
- }
309
- return fileHash.digest();
310
- };
312
+ const fingerprintFile = fingerprintFileContents;
311
313
  const visit = async (dir, relativeDir) => {
312
314
  const before = await fs.lstat(dir, { bigint: true });
313
315
  if (!before.isDirectory()) {
@@ -367,15 +369,198 @@ async function assertCopiedDirectoryUnchanged(stagedSource, destination, expecte
367
369
  }
368
370
  /**
369
371
  * Move a directory from src to dest. On Windows, fs.rename() can fail with
370
- * EPERM, and cross-device moves fail with EXDEV. When the source can first be
371
- * renamed to a private sibling, fall back to a verified copy-then-remove. A
372
- * source that cannot be staged is left untouched rather than copied and deleted
373
- * through a path another process may still be editing.
372
+ * EPERM, and cross-device moves fail with EXDEV. Prefer renaming the source
373
+ * to a private sibling first, then copy-then-remove. When that staging rename
374
+ * also fails with EPERM/EXDEV — the usual Windows case for a directory that
375
+ * still has children, because a watcher holds a directory-enumeration handle —
376
+ * copy from the original source instead. Fingerprints still abort if the tree
377
+ * changes mid-copy. A staging failure that is not EPERM/EXDEV still leaves
378
+ * the source untouched rather than copying through a path we could not claim.
374
379
  */
375
380
  class MoveDestinationRetainedError extends Error {
376
381
  }
377
382
  class RetirementBackupsRetainedError extends Error {
378
383
  }
384
+ function isFallbackRenameCode(code) {
385
+ return code === 'EPERM' || code === 'EXDEV';
386
+ }
387
+ /**
388
+ * Every entry under `root`, deepest first, as paths relative to it.
389
+ *
390
+ * The listing is what bounds the removal below. Anything that appears after it
391
+ * is simply not in the set, so it cannot be deleted by the cleanup.
392
+ */
393
+ async function listTreeEntriesDeepestFirst(root) {
394
+ const entries = [];
395
+ const visit = async (dir, relativeDir) => {
396
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
397
+ const relative = relativeDir === '' ? entry.name : path.join(relativeDir, entry.name);
398
+ if (entry.isDirectory()) {
399
+ await visit(path.join(dir, entry.name), relative);
400
+ entries.push({ relative, kind: 'directory' });
401
+ }
402
+ else {
403
+ // A symlink to a directory is not a directory here, and its content is
404
+ // its target, not bytes to read - reading one raises EISDIR. Record the
405
+ // kind so cleanup compares each entry the way the copy wrote it.
406
+ entries.push({ relative, kind: entry.isSymbolicLink() ? 'symlink' : 'file' });
407
+ }
408
+ }
409
+ };
410
+ await visit(root, '');
411
+ return entries;
412
+ }
413
+ /**
414
+ * Remove exactly the entries that were copied and verified, deepest first.
415
+ *
416
+ * The move is only safe to finish by deleting the source, and the source of the
417
+ * unstaged fallback is still the live change directory: the archive claim
418
+ * covers the destination, not it. A recursive remove would delete whatever is
419
+ * there at that moment, including a file a concurrent writer added after the
420
+ * final fingerprint - data that never reached the destination.
421
+ *
422
+ * Removing a named set instead means a late arrival is never in it. It is left
423
+ * on disk, and the `rmdir` of its parent fails with ENOTEMPTY, which the caller
424
+ * reports as a retained destination. The move does not complete silently.
425
+ *
426
+ * `entries` must be listed before the final fingerprint, so that an arrival is
427
+ * either caught by that fingerprint or absent from the set.
428
+ *
429
+ * An edit to a file that is already in the set is covered by claiming each file
430
+ * before reading it: `rename` is atomic, so once a file is under its claim name
431
+ * the bytes there are ours. A writer that rewrites the file by path after that
432
+ * point creates a new file at the original path, which is not in `entries`, is
433
+ * never deleted, and makes the parent `rmdir` fail with ENOTEMPTY - reported as
434
+ * a retained destination. A writer that got there first is caught by comparing
435
+ * the claimed bytes against the copy: on a mismatch the file is put back and
436
+ * the move is abandoned with both trees intact, because the destination holds
437
+ * the older content and deleting the source would lose the newer.
438
+ *
439
+ * What remains outside this, as for any copy, is a writer holding an open
440
+ * descriptor that writes through it after the comparison. Staging is still
441
+ * preferred whenever the rename is permitted at all.
442
+ */
443
+ /**
444
+ * A claim suffix no entry in this move can already carry.
445
+ *
446
+ * A fixed suffix collides with a source file that legitimately ends in it:
447
+ * claiming `x` would rename it over a real `x.openspec-claim`, and that file's
448
+ * own turn would then fail with ENOENT after part of the live source had
449
+ * already been removed. So draw a fresh suffix per move and prove it against
450
+ * the very set being removed - if no entry ends with the suffix, no claim of
451
+ * one entry can land on another.
452
+ */
453
+ function makeClaimSuffix(entries) {
454
+ const names = entries.map((entry) => entry.relative);
455
+ for (;;) {
456
+ const suffix = `.openspec-claim-${randomUUID()}`;
457
+ if (!names.some((name) => name.endsWith(suffix)))
458
+ return suffix;
459
+ }
460
+ }
461
+ /** What the entry holds now, for comparison against the copy. */
462
+ async function readEntryIdentity(entryPath, kind) {
463
+ return kind === 'symlink'
464
+ ? `symlink:${await fs.readlink(entryPath)}`
465
+ : `file:${(await fingerprintFileContents(entryPath)).toString('hex')}`;
466
+ }
467
+ async function removeVerifiedTree(root, entries, destination) {
468
+ const claimSuffix = makeClaimSuffix(entries);
469
+ for (const entry of entries) {
470
+ const target = path.join(root, entry.relative);
471
+ if (entry.kind === 'directory') {
472
+ await fs.rmdir(target);
473
+ continue;
474
+ }
475
+ const claimed = target + claimSuffix;
476
+ await fs.rename(target, claimed);
477
+ let claimedIdentity;
478
+ let copiedIdentity;
479
+ try {
480
+ claimedIdentity = await readEntryIdentity(claimed, entry.kind);
481
+ copiedIdentity = await readEntryIdentity(path.join(destination, entry.relative), entry.kind);
482
+ }
483
+ catch (error) {
484
+ await fs.rename(claimed, target).catch(() => undefined);
485
+ throw error;
486
+ }
487
+ if (claimedIdentity !== copiedIdentity) {
488
+ await fs.rename(claimed, target).catch(() => undefined);
489
+ throw new Error(`${target} changed after it was verified, so the copy at ${destination} ` +
490
+ 'does not hold its current content.');
491
+ }
492
+ await fs.rm(claimed, { force: true });
493
+ }
494
+ await fs.rmdir(root);
495
+ }
496
+ async function copyThenRemoveDirectory(source, dest, options, restoreSource) {
497
+ let destIsOurs = false;
498
+ let sourceFingerprint;
499
+ try {
500
+ sourceFingerprint = await fingerprintDirectoryContents(source);
501
+ await fs.mkdir(dest, { mode: 0o700 });
502
+ destIsOurs = true;
503
+ await copyDirContents(source, dest);
504
+ await options.verifyCopiedDestination?.(source);
505
+ await assertCopiedDirectoryUnchanged(source, dest, sourceFingerprint);
506
+ }
507
+ catch (copyError) {
508
+ if (destIsOurs) {
509
+ await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
510
+ }
511
+ if (restoreSource) {
512
+ try {
513
+ await restoreSource();
514
+ }
515
+ catch (restoreError) {
516
+ throw new Error(`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
517
+ `Could not restore the staged source at ${source} ` +
518
+ `(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
519
+ }
520
+ }
521
+ if (copyError.code === 'EEXIST') {
522
+ throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
523
+ }
524
+ throw copyError;
525
+ }
526
+ let verifiedEntries;
527
+ try {
528
+ // Listed before the verification, not after it. A file that arrives before
529
+ // the fingerprint changes it and aborts the move; one that arrives after is
530
+ // not in this set. Listing afterwards would leave a window in which an
531
+ // arrival is both unverified and deletable.
532
+ verifiedEntries = await listTreeEntriesDeepestFirst(source);
533
+ await options.verifyCopiedDestination?.(source);
534
+ await assertCopiedDirectoryUnchanged(source, dest, sourceFingerprint);
535
+ }
536
+ catch (verificationError) {
537
+ await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
538
+ if (restoreSource) {
539
+ try {
540
+ await restoreSource();
541
+ }
542
+ catch (restoreError) {
543
+ throw new Error(`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
544
+ `Could not restore the staged source at ${source} ` +
545
+ `(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
546
+ }
547
+ }
548
+ throw verificationError;
549
+ }
550
+ try {
551
+ await removeVerifiedTree(source, verifiedEntries, dest);
552
+ }
553
+ catch (cleanupError) {
554
+ // Removal may already have deleted part of the source, or stopped on an
555
+ // entry that appeared after verification. The destination is now the only
556
+ // complete copy, so never erase it while trying to make this failed move
557
+ // look atomic.
558
+ throw new MoveDestinationRetainedError(`Copied ${source} to ${dest}, but could not remove the source at ` +
559
+ `${source} completely ` +
560
+ `(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
561
+ 'The complete destination was retained for recovery.');
562
+ }
563
+ }
379
564
  async function moveDirectory(src, dest, options = {}) {
380
565
  try {
381
566
  await fs.rename(src, dest);
@@ -387,71 +572,24 @@ async function moveDirectory(src, dest, options = {}) {
387
572
  if (code === 'ENOTEMPTY' || code === 'EEXIST') {
388
573
  throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
389
574
  }
390
- if (code === 'EPERM' || code === 'EXDEV') {
575
+ if (isFallbackRenameCode(code)) {
391
576
  const stagedSource = path.join(path.dirname(src), `.openspec-move-${randomUUID()}`);
392
577
  try {
393
578
  await fs.rename(src, stagedSource);
394
579
  }
395
580
  catch (stageError) {
581
+ const stageCode = stageError?.code;
582
+ if (isFallbackRenameCode(stageCode)) {
583
+ await copyThenRemoveDirectory(src, dest, options);
584
+ return;
585
+ }
396
586
  throw new Error(`Could not safely stage ${src} before the fallback archive copy ` +
397
587
  `(${stageError instanceof Error ? stageError.message : String(stageError)}). ` +
398
588
  'No fallback copy was attempted.');
399
589
  }
400
- let destIsOurs = false;
401
- let stagedFingerprint;
402
- try {
403
- stagedFingerprint = await fingerprintDirectoryContents(stagedSource);
404
- await fs.mkdir(dest, { mode: 0o700 });
405
- destIsOurs = true;
406
- await copyDirContents(stagedSource, dest);
407
- await options.verifyCopiedDestination?.(stagedSource);
408
- await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
409
- }
410
- catch (copyError) {
411
- if (destIsOurs) {
412
- await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
413
- }
414
- try {
415
- await fs.rename(stagedSource, src);
416
- }
417
- catch (restoreError) {
418
- throw new Error(`${copyError instanceof Error ? copyError.message : String(copyError)} ` +
419
- `Could not restore the staged source at ${stagedSource} ` +
420
- `(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
421
- }
422
- if (copyError.code === 'EEXIST') {
423
- throw new ArchiveBlockedError('archive_target_exists', `Archive '${path.basename(dest)}' already exists.`);
424
- }
425
- throw copyError;
426
- }
427
- try {
428
- await options.verifyCopiedDestination?.(stagedSource);
429
- await assertCopiedDirectoryUnchanged(stagedSource, dest, stagedFingerprint);
430
- }
431
- catch (verificationError) {
432
- await fs.rm(dest, { recursive: true, force: true }).catch(() => undefined);
433
- try {
434
- await fs.rename(stagedSource, src);
435
- }
436
- catch (restoreError) {
437
- throw new Error(`${verificationError instanceof Error ? verificationError.message : String(verificationError)} ` +
438
- `Could not restore the staged source at ${stagedSource} ` +
439
- `(${restoreError instanceof Error ? restoreError.message : String(restoreError)}).`);
440
- }
441
- throw verificationError;
442
- }
443
- try {
444
- await fs.rm(stagedSource, { recursive: true, force: true });
445
- }
446
- catch (cleanupError) {
447
- // Recursive removal may already have deleted part of the source. The
448
- // destination is now the only complete copy, so never erase it while
449
- // trying to make this failed move look atomic.
450
- throw new MoveDestinationRetainedError(`Copied ${src} to ${dest}, but could not remove the staged source at ` +
451
- `${stagedSource} completely ` +
452
- `(${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}). ` +
453
- 'The complete destination was retained for recovery.');
454
- }
590
+ await copyThenRemoveDirectory(stagedSource, dest, options, async () => {
591
+ await fs.rename(stagedSource, src);
592
+ });
455
593
  }
456
594
  else {
457
595
  throw err;
@@ -473,6 +611,10 @@ async function assertArchiveDestinationAvailable(archivePath, archiveName) {
473
611
  function archiveClaimPath(archivePath, _archiveName) {
474
612
  return path.join(path.dirname(archivePath), '.openspec-archive.lock');
475
613
  }
614
+ function isSameArchiveClaimFile(first, second) {
615
+ return (first.ino === second.ino &&
616
+ (first.dev === second.dev || first.dev === 0n || second.dev === 0n));
617
+ }
476
618
  async function releaseArchiveClaim(claim, claimPath) {
477
619
  const owned = await claim.handle.stat({ bigint: true }).catch(() => undefined);
478
620
  await claim.handle.close().catch(() => undefined);
@@ -485,10 +627,8 @@ async function releaseArchiveClaim(claim, claimPath) {
485
627
  const current = await fs.lstat(claimPath, { bigint: true });
486
628
  const contents = await fs.readFile(claimPath, 'utf8');
487
629
  const currentAfterRead = await fs.lstat(claimPath, { bigint: true });
488
- if (current.dev === owned.dev &&
489
- current.ino === owned.ino &&
490
- current.dev === currentAfterRead.dev &&
491
- current.ino === currentAfterRead.ino &&
630
+ if (isSameArchiveClaimFile(current, owned) &&
631
+ isSameArchiveClaimFile(current, currentAfterRead) &&
492
632
  contents === claim.contents) {
493
633
  await fs.unlink(claimPath);
494
634
  }
@@ -666,7 +806,29 @@ async function assertDistinctMutationTargets(mutations) {
666
806
  owners.set(identity, mutation.update.id);
667
807
  }
668
808
  }
669
- async function captureSpecSnapshots(mutations) {
809
+ /**
810
+ * The deepest directory at or above `dir` that exists, never going above
811
+ * `boundaryDir`. Used as the floor for a rollback prune: everything below it
812
+ * was created by the write being undone, and it was not.
813
+ */
814
+ async function deepestExistingAncestor(dir, boundaryDir) {
815
+ let current = dir;
816
+ for (;;) {
817
+ if (current === boundaryDir || !current.startsWith(boundaryDir + path.sep)) {
818
+ return boundaryDir;
819
+ }
820
+ try {
821
+ await fs.lstat(current);
822
+ return current;
823
+ }
824
+ catch (error) {
825
+ if (error.code !== 'ENOENT')
826
+ throw error;
827
+ }
828
+ current = path.dirname(current);
829
+ }
830
+ }
831
+ async function captureSpecSnapshots(mutations, mainSpecsDir) {
670
832
  return Promise.all(mutations.map(async ({ update, outcome, rebuilt }) => {
671
833
  try {
672
834
  const stat = await fs.lstat(update.target);
@@ -714,6 +876,7 @@ async function captureSpecSnapshots(mutations) {
714
876
  target: update.target,
715
877
  existed: false,
716
878
  outcome,
879
+ pruneBoundary: await deepestExistingAncestor(path.dirname(update.target), mainSpecsDir),
717
880
  ...(outcome === 'write' ? { expectedContent: Buffer.from(rebuilt) } : {}),
718
881
  };
719
882
  }
@@ -721,7 +884,7 @@ async function captureSpecSnapshots(mutations) {
721
884
  }
722
885
  }));
723
886
  }
724
- async function restoreSpecSnapshots(snapshots) {
887
+ async function restoreSpecSnapshots(snapshots, mainSpecsDir) {
725
888
  const errors = [];
726
889
  for (const snapshot of [...snapshots].reverse()) {
727
890
  try {
@@ -799,6 +962,10 @@ async function restoreSpecSnapshots(snapshots) {
799
962
  }
800
963
  if (!snapshot.existed) {
801
964
  await fs.rm(snapshot.target, { force: true });
965
+ // Only a capability directory this write created is ours to take back.
966
+ // One the user already had stays, empty or not, with its own mode -
967
+ // pruneEmptyDirs never removes its boundary.
968
+ await pruneEmptyDirs(path.dirname(snapshot.target), snapshot.pruneBoundary ?? mainSpecsDir);
802
969
  continue;
803
970
  }
804
971
  if (snapshot.symlink !== undefined) {
@@ -1406,7 +1573,7 @@ export class ArchiveCommand {
1406
1573
  'No files were changed; review the new content and rerun.');
1407
1574
  }
1408
1575
  }
1409
- const specSnapshots = await captureSpecSnapshots(mutations);
1576
+ const specSnapshots = await captureSpecSnapshots(mutations, mainSpecsDir);
1410
1577
  const specSnapshotsByTarget = new Map(specSnapshots.map((snapshot) => [snapshot.target, snapshot]));
1411
1578
  const mutationAttempts = new Set();
1412
1579
  try {
@@ -1611,7 +1778,7 @@ export class ArchiveCommand {
1611
1778
  throw error;
1612
1779
  const rollbackErrors = [];
1613
1780
  try {
1614
- await restoreSpecSnapshots(specSnapshots.filter(({ target }) => mutationAttempts.has(target)));
1781
+ await restoreSpecSnapshots(specSnapshots.filter(({ target }) => mutationAttempts.has(target)), mainSpecsDir);
1615
1782
  }
1616
1783
  catch (rollbackError) {
1617
1784
  rollbackErrors.push(rollbackError instanceof Error
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Checks if a path contains glob pattern characters.
2
+ * Recognizes artifact globs while preserving literal output filenames.
3
3
  */
4
4
  export declare function isGlobPattern(pattern: string): boolean;
5
5
  /**
@@ -2,11 +2,31 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import fg from 'fast-glob';
4
4
  import { FileSystemUtils } from '../../utils/file-system.js';
5
+ const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
6
+ const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
7
+ function hasBraceExpansion(pattern) {
8
+ const openings = [];
9
+ for (let index = 0; index < pattern.length; index += 1) {
10
+ if (pattern[index] === '{')
11
+ openings.push(index);
12
+ if (pattern[index] !== '}')
13
+ continue;
14
+ const opening = openings.pop();
15
+ if (opening !== undefined && BRACE_EXPANSION_SEPARATORS_RE.test(pattern.slice(opening, index))) {
16
+ return true;
17
+ }
18
+ }
19
+ return false;
20
+ }
5
21
  /**
6
- * Checks if a path contains glob pattern characters.
22
+ * Recognizes artifact globs while preserving literal output filenames.
7
23
  */
8
24
  export function isGlobPattern(pattern) {
9
- return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
25
+ // Keep the original wildcard rules and recognize brace expansions and extglobs.
26
+ // Its full dynamic predicate also reinterprets literal !, parentheses, and backslashes.
27
+ const normalized = FileSystemUtils.toPosixPath(pattern);
28
+ return normalized.includes('*') || normalized.includes('?') || normalized.includes('[')
29
+ || EXTGLOB_RE.test(normalized) || hasBraceExpansion(normalized);
10
30
  }
11
31
  /**
12
32
  * Returns whether an artifact generates files under the change's specs/ tree.
@@ -82,16 +102,26 @@ export function resolveArtifactOutputs(changeDir, generates) {
82
102
  }
83
103
  }
84
104
  const normalizedPattern = FileSystemUtils.toPosixPath(generates);
85
- assertGlobDirectoryTraversal(changeDir, changeDir, normalizedPattern.split('/').slice(0, -1));
86
- const matches = fg
87
- .sync(normalizedPattern, {
105
+ const globOptions = {
88
106
  cwd: changeDir,
89
107
  onlyFiles: true,
90
108
  absolute: true,
91
- // Preserve existing support for linked artifact directories. Every
92
- // concrete match is canonically confined below before it is returned.
109
+ // Preserve linked artifact directories; confine traversal and concrete matches.
93
110
  followSymbolicLinks: true,
94
- })
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)
95
125
  .map((match) => {
96
126
  const normalizedMatch = path.normalize(match);
97
127
  FileSystemUtils.assertPathWithin(changeDir, normalizedMatch);
@@ -16,12 +16,16 @@ export const continueAdapter = {
16
16
  return path.join('.continue', 'prompts', `opsx-${commandId}.prompt`);
17
17
  },
18
18
  formatFile(content) {
19
+ // Continue injects an invoked prompt into the model context. Smaller local
20
+ // models can otherwise mistake the workflow name for a tool to call (#925).
19
21
  return `---
20
22
  name: ${escapeYamlValue(`opsx-${content.id}`)}
21
23
  description: ${escapeYamlValue(content.description)}
22
24
  invokable: true
23
25
  ---
24
26
 
27
+ This workflow prompt is already active. Follow its instructions directly. Do not call a tool named after this workflow.
28
+
25
29
  ${content.body}
26
30
  `;
27
31
  },