@fission-ai/openspec 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/cli/index.d.ts +32 -0
  2. package/dist/cli/index.js +80 -9
  3. package/dist/commands/change.js +1 -0
  4. package/dist/commands/config.d.ts +5 -0
  5. package/dist/commands/config.js +19 -4
  6. package/dist/commands/feedback.js +28 -7
  7. package/dist/commands/schema.js +193 -28
  8. package/dist/commands/validate.d.ts +23 -0
  9. package/dist/commands/validate.js +148 -4
  10. package/dist/commands/workflow/schemas.d.ts +2 -0
  11. package/dist/commands/workflow/schemas.js +9 -2
  12. package/dist/core/archive.js +74 -10
  13. package/dist/core/artifact-graph/instruction-loader.js +2 -7
  14. package/dist/core/artifact-graph/outputs.d.ts +4 -0
  15. package/dist/core/artifact-graph/outputs.js +7 -0
  16. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  17. package/dist/core/artifact-graph/resolver.js +14 -0
  18. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  19. package/dist/core/command-generation/adapters/command-code.js +34 -0
  20. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  21. package/dist/core/command-generation/adapters/index.js +1 -0
  22. package/dist/core/command-generation/adapters/opencode.d.ts +2 -1
  23. package/dist/core/command-generation/adapters/opencode.js +13 -2
  24. package/dist/core/command-generation/registry.js +2 -0
  25. package/dist/core/completion-tip.d.ts +15 -0
  26. package/dist/core/completion-tip.js +133 -0
  27. package/dist/core/completions/command-registry.js +10 -0
  28. package/dist/core/completions/factory.d.ts +11 -0
  29. package/dist/core/completions/installers/bash-installer.d.ts +7 -0
  30. package/dist/core/completions/installers/bash-installer.js +15 -0
  31. package/dist/core/completions/installers/fish-installer.d.ts +7 -0
  32. package/dist/core/completions/installers/fish-installer.js +15 -0
  33. package/dist/core/completions/installers/powershell-installer.d.ts +7 -0
  34. package/dist/core/completions/installers/powershell-installer.js +15 -0
  35. package/dist/core/config-prompts.js +19 -10
  36. package/dist/core/config-schema.d.ts +1 -0
  37. package/dist/core/config-schema.js +2 -0
  38. package/dist/core/config.d.ts +1 -0
  39. package/dist/core/config.js +17 -15
  40. package/dist/core/global-config.d.ts +2 -0
  41. package/dist/core/init.d.ts +5 -0
  42. package/dist/core/init.js +110 -14
  43. package/dist/core/legacy-cleanup.d.ts +10 -0
  44. package/dist/core/legacy-cleanup.js +48 -26
  45. package/dist/core/parsers/requirement-blocks.js +41 -9
  46. package/dist/core/parsers/requirement-text.d.ts +8 -0
  47. package/dist/core/parsers/requirement-text.js +4 -3
  48. package/dist/core/profiles.d.ts +1 -1
  49. package/dist/core/profiles.js +11 -2
  50. package/dist/core/root-selection.d.ts +1 -1
  51. package/dist/core/shared-skill-target.d.ts +15 -0
  52. package/dist/core/shared-skill-target.js +30 -0
  53. package/dist/core/specs-apply.js +11 -4
  54. package/dist/core/templates/workflows/apply-change.js +3 -0
  55. package/dist/core/templates/workflows/feedback.js +1 -0
  56. package/dist/core/templates/workflows/onboard.js +4 -4
  57. package/dist/core/templates/workflows/propose.js +2 -2
  58. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  59. package/dist/core/templates/workflows/store-selection.js +1 -1
  60. package/dist/core/templates/workflows/update-change.js +4 -4
  61. package/dist/core/update.js +49 -11
  62. package/dist/core/validation/task-numbering.d.ts +16 -0
  63. package/dist/core/validation/task-numbering.js +57 -0
  64. package/dist/core/validation/validator.d.ts +5 -2
  65. package/dist/core/validation/validator.js +57 -3
  66. package/dist/telemetry/index.d.ts +3 -1
  67. package/dist/telemetry/index.js +10 -3
  68. package/dist/utils/change-metadata.js +17 -13
  69. package/dist/utils/change-utils.js +5 -0
  70. package/dist/utils/interactive.d.ts +30 -1
  71. package/dist/utils/interactive.js +105 -2
  72. package/dist/utils/task-progress.d.ts +34 -1
  73. package/dist/utils/task-progress.js +52 -28
  74. package/package.json +3 -6
  75. package/schemas/spec-driven/schema.yaml +17 -7
  76. package/scripts/postinstall.js +0 -83
@@ -7,15 +7,33 @@ import { isInteractive, resolveNoInteractive } from '../utils/interactive.js';
7
7
  import { getSpecIds } from '../utils/item-discovery.js';
8
8
  import { getAvailableChanges } from './workflow/shared.js';
9
9
  import { nearestMatches } from '../utils/match.js';
10
+ import { promises as fs } from 'fs';
11
+ import { getTaskProgressDetailForChange } from '../utils/task-progress.js';
12
+ import { FileSystemUtils } from '../utils/file-system.js';
10
13
  export class ValidateCommand {
11
14
  async execute(itemName, options = {}) {
12
- const root = await resolveRootForCommand(options, { json: options.json });
15
+ const bulk = options.all || options.changes || options.specs;
16
+ const root = await resolveRootForCommand(options, {
17
+ json: options.json,
18
+ ...(bulk ? { allowImplicitRoot: false } : {}),
19
+ });
13
20
  if (!root) {
14
21
  return;
15
22
  }
16
23
  const interactive = isInteractive(options);
24
+ // Archived-task linting is its own scope: it checks task completion of
25
+ // already-archived changes, not delta specs (whose operations are already
26
+ // applied). Handled before the other bulk flags so `--archived` is explicit
27
+ // and never alters an existing invocation's behavior (#205).
28
+ if (options.archived) {
29
+ await this.runArchivedTaskValidation(root, {
30
+ json: !!options.json,
31
+ noInteractive: resolveNoInteractive(options),
32
+ });
33
+ return;
34
+ }
17
35
  // Handle bulk flags first
18
- if (options.all || options.changes || options.specs) {
36
+ if (bulk) {
19
37
  await this.runBulkValidation(root, {
20
38
  changes: !!options.all || !!options.changes,
21
39
  specs: !!options.all || !!options.specs,
@@ -145,7 +163,10 @@ export class ValidateCommand {
145
163
  if (type === 'change') {
146
164
  const changeDir = path.join(root.changesDir, id);
147
165
  const start = Date.now();
148
- const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir });
166
+ const report = await validator.validateChangeDeltaSpecs(changeDir, {
167
+ mainSpecsDir: root.specsDir,
168
+ projectRoot: root.path,
169
+ });
149
170
  const durationMs = Date.now() - start;
150
171
  this.printReport('change', id, report, durationMs, opts.json, root);
151
172
  // Non-zero exit if invalid (keeps enriched output test semantics)
@@ -222,7 +243,10 @@ export class ValidateCommand {
222
243
  queue.push(async () => {
223
244
  const start = Date.now();
224
245
  const changeDir = path.join(root.changesDir, id);
225
- const report = await validator.validateChangeDeltaSpecs(changeDir, { mainSpecsDir: root.specsDir });
246
+ const report = await validator.validateChangeDeltaSpecs(changeDir, {
247
+ mainSpecsDir: root.specsDir,
248
+ projectRoot: root.path,
249
+ });
226
250
  const durationMs = Date.now() - start;
227
251
  return { id, type: 'change', valid: report.valid, issues: report.issues, durationMs };
228
252
  });
@@ -322,6 +346,126 @@ export class ValidateCommand {
322
346
  }
323
347
  process.exitCode = failed > 0 ? 1 : 0;
324
348
  }
349
+ /**
350
+ * Lists archived change ids from the resolved root's archive directory,
351
+ * mirroring `getArchivedChangeIds` but store-aware (uses `root.archiveDir`
352
+ * rather than a cwd-relative path). Directories only, hidden entries skipped.
353
+ *
354
+ * Only a missing archive directory (ENOENT) is an empty list; a permission
355
+ * error, an I/O error, or an `archive` path that is a file (ENOTDIR) is a real
356
+ * failure and must not read as "no archived changes" — that would let a
357
+ * pre-commit lint pass without inspecting anything (#205).
358
+ */
359
+ async listArchivedChangeIds(root) {
360
+ try {
361
+ const entries = await fs.readdir(root.archiveDir, { withFileTypes: true });
362
+ return entries
363
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
364
+ .map((entry) => entry.name)
365
+ .sort();
366
+ }
367
+ catch (error) {
368
+ if (error?.code === 'ENOENT')
369
+ return [];
370
+ throw error;
371
+ }
372
+ }
373
+ /**
374
+ * Validates that every archived change has all of its tasks completed.
375
+ *
376
+ * An archived change is expected to be finished; an archived change with
377
+ * unchecked tasks is a real integrity problem the normal validate flow never
378
+ * surfaces, because active-change discovery excludes the archive directory
379
+ * (#205). Reuses the same task-progress counting `status`, `list`, and
380
+ * `archive` rely on, so what counts as a task never forks. Changes with no
381
+ * tasks pass (nothing to complete).
382
+ */
383
+ async runArchivedTaskValidation(root, opts) {
384
+ // List first (may throw on a real archive-read failure), then start the
385
+ // spinner so a thrown error never leaves a spinner spinning.
386
+ const ids = await this.listArchivedChangeIds(root);
387
+ const spinner = !opts.json && !opts.noInteractive ? ora('Validating archived changes...').start() : undefined;
388
+ // The archive is append-only and can hold thousands of changes; a single
389
+ // run resolves them all under one constant projectRoot (root.path), so
390
+ // memoize the schema→glob lookup to avoid re-parsing the same schema.yaml
391
+ // once per change. The loop is intentionally sequential: the per-change work
392
+ // is dominated by synchronous schema/config resolution, which a promise pool
393
+ // cannot overlap on Node's single thread — a pool would add complexity for
394
+ // no real gain here.
395
+ const schemaGlobCache = new Map();
396
+ const results = [];
397
+ let passed = 0;
398
+ let failed = 0;
399
+ for (const id of ids) {
400
+ const start = Date.now();
401
+ const issues = [];
402
+ try {
403
+ // The explicit root.path override is load-bearing: an archived change
404
+ // lives one directory deeper (changes/archive/<id>), so the default
405
+ // "../../.." projectRoot derivation would be wrong without it.
406
+ const progress = await getTaskProgressDetailForChange(root.archiveDir, id, root.path, schemaGlobCache);
407
+ // A tasks file that exists but cannot be read must fail loudly, not be
408
+ // silently counted as "no tasks" and pass. Report one issue per file,
409
+ // pathed like every other validate issue (POSIX, root-relative).
410
+ for (const file of progress.unreadable) {
411
+ issues.push({
412
+ level: 'ERROR',
413
+ path: FileSystemUtils.toPosixPath(path.relative(root.path, file)),
414
+ message: 'could not read task file',
415
+ });
416
+ }
417
+ const incomplete = Math.max(progress.total - progress.completed, 0);
418
+ if (incomplete > 0) {
419
+ issues.push({
420
+ level: 'ERROR',
421
+ path: 'tasks.md',
422
+ message: `${incomplete} incomplete task${incomplete === 1 ? '' : 's'} (${progress.completed}/${progress.total} completed)`,
423
+ });
424
+ }
425
+ }
426
+ catch (error) {
427
+ issues.push({ level: 'ERROR', path: 'tasks.md', message: error?.message || 'Unknown error' });
428
+ }
429
+ const valid = issues.length === 0;
430
+ if (valid)
431
+ passed++;
432
+ else
433
+ failed++;
434
+ results.push({ id, type: 'change', valid, issues, durationMs: Date.now() - start });
435
+ }
436
+ spinner?.stop();
437
+ const summary = {
438
+ totals: { items: results.length, passed, failed },
439
+ byType: { change: summarizeType(results, 'change') },
440
+ };
441
+ if (opts.json) {
442
+ const out = { items: results, summary, version: '1.0', root: toRootOutput(root) };
443
+ console.log(JSON.stringify(out, null, 2));
444
+ process.exitCode = failed > 0 ? 1 : 0;
445
+ return;
446
+ }
447
+ if (results.length === 0) {
448
+ console.log('No archived changes found.');
449
+ process.exitCode = 0;
450
+ return;
451
+ }
452
+ // Use the same `<type>/<id>` prefix bulk validation prints, so the plain
453
+ // output maps to the JSON `type` ('change') and stays greppable the same way.
454
+ for (const res of results) {
455
+ if (res.valid) {
456
+ console.log(`✓ change/${res.id}`);
457
+ }
458
+ else {
459
+ console.error(`✗ change/${res.id}`);
460
+ for (const issue of res.issues) {
461
+ const prefix = issue.level === 'ERROR' ? '✗' : issue.level === 'WARNING' ? '⚠' : 'ℹ';
462
+ console.error(` ${prefix} ${issue.message}`);
463
+ }
464
+ }
465
+ }
466
+ console.log(`Totals: ${summary.totals.passed} passed, ${summary.totals.failed} failed (${summary.totals.items} items)`);
467
+ process.exitCode = failed > 0 ? 1 : 0;
468
+ }
325
469
  }
326
470
  function summarizeType(results, type) {
327
471
  const filtered = results.filter(r => r.type === type);
@@ -5,6 +5,8 @@
5
5
  */
6
6
  export interface SchemasOptions {
7
7
  json?: boolean;
8
+ store?: string;
9
+ storePath?: string;
8
10
  }
9
11
  export declare function schemasCommand(options: SchemasOptions): Promise<void>;
10
12
  //# sourceMappingURL=schemas.d.ts.map
@@ -5,12 +5,19 @@
5
5
  */
6
6
  import chalk from 'chalk';
7
7
  import { listSchemasWithInfo } from '../../core/artifact-graph/index.js';
8
+ import { resolveRootForCommand } from '../../core/root-selection.js';
8
9
  // -----------------------------------------------------------------------------
9
10
  // Command Implementation
10
11
  // -----------------------------------------------------------------------------
11
12
  export async function schemasCommand(options) {
12
- const projectRoot = process.cwd();
13
- const schemas = listSchemasWithInfo(projectRoot);
13
+ const root = await resolveRootForCommand(options, {
14
+ json: options.json,
15
+ failurePayload: { schemas: [], root: null },
16
+ });
17
+ if (!root) {
18
+ return;
19
+ }
20
+ const schemas = listSchemasWithInfo(root.path);
14
21
  if (options.json) {
15
22
  console.log(JSON.stringify(schemas, null, 2));
16
23
  return;
@@ -10,7 +10,7 @@ import { emitStoreRootBanner, isRootSelectionError, resolveOpenSpecRoot, toRootO
10
10
  import { findSpecUpdates, buildUpdatedSpec, writeUpdatedSpec, retireSpec, finalizeRetiredSpec, } from './specs-apply.js';
11
11
  import { discoverSpecFiles, hasAnyFileUnder } from '../utils/spec-discovery.js';
12
12
  import { METADATA_FILENAME, readRetireCapabilitiesMarker, readSkipSpecsMarker } from '../utils/change-metadata.js';
13
- import { isNonInteractivePromptError } from '../utils/interactive.js';
13
+ import { confirmPrompt, isNonInteractivePromptError } from '../utils/interactive.js';
14
14
  import { FileSystemUtils } from '../utils/file-system.js';
15
15
  import { folderStyleNameProblem } from './id.js';
16
16
  function isMissingPathError(error) {
@@ -46,6 +46,33 @@ export async function isRetirableSpec(specName, rebuilt) {
46
46
  return (errors.length > 0 &&
47
47
  errors.every((issue) => issue.message === VALIDATION_MESSAGES.SPEC_NO_REQUIREMENTS));
48
48
  }
49
+ /**
50
+ * How much of one blocking line the abort is willing to show. A line long
51
+ * enough to fill the screen would push the way out of the abort off it.
52
+ */
53
+ const UNACCOUNTED_LINE_MAX = 200;
54
+ /**
55
+ * The first few lines a retirement would delete without being able to name
56
+ * them, quoted, with a count for the rest. Capped so a long tail cannot bury
57
+ * the rest of the abort.
58
+ *
59
+ * The lines are authored spec content printed verbatim to a terminal, so they
60
+ * get the same treatment as a change directory name (`describeChangeName`): a
61
+ * raw CR could forge a line of its own, and an ESC could redraw the screen.
62
+ * Truncation counts code points so a cut never leaves half a surrogate pair.
63
+ */
64
+ function describeUnaccountedContent(lines) {
65
+ const shown = lines
66
+ .slice(0, 3)
67
+ .map((line) => {
68
+ const safe = [...line.replace(/[\u0000-\u001f\u007f]/g, '?')];
69
+ const clipped = safe.slice(0, UNACCOUNTED_LINE_MAX).join('');
70
+ return `"${safe.length > UNACCOUNTED_LINE_MAX ? `${clipped}\u2026` : clipped}"`;
71
+ })
72
+ .join(', ');
73
+ const rest = lines.length > 3 ? `, and ${lines.length - 3} more line(s)` : '';
74
+ return `${shown}${rest}`;
75
+ }
49
76
  async function isRetirementCandidate(update, built, skipValidation) {
50
77
  return (!skipValidation &&
51
78
  built.noRequirementBlocks &&
@@ -202,9 +229,8 @@ function rerunCommand(root, changeName, options) {
202
229
  * `User force closed the prompt` (#1479).
203
230
  */
204
231
  async function confirmOrBlock(prompt, blocked) {
205
- const { confirm } = await import('@inquirer/prompts');
206
232
  try {
207
- return await confirm(prompt);
233
+ return await confirmPrompt(prompt);
208
234
  }
209
235
  catch (error) {
210
236
  if (isNonInteractivePromptError(error)) {
@@ -1253,17 +1279,24 @@ export class ArchiveCommand {
1253
1279
  const specName = p.update.id;
1254
1280
  const report = await new Validator().validateSpecContent(specName, p.rebuilt);
1255
1281
  if (!report.valid) {
1282
+ // This run is what emptied the capability, and "no
1283
+ // requirements" is the only thing wrong with the spec that
1284
+ // would be written - so retiring it is what archive would do,
1285
+ // and what stands in the way of that is worth saying. Not
1286
+ // always the *only* fix: a live requirement can be hiding in a
1287
+ // second `## Requirements` section the validator never reaches,
1288
+ // and merging the sections fixes that spec without a deletion.
1289
+ const emptiedByThisRun = p.update.exists &&
1290
+ p.counts.removed > 0 &&
1291
+ p.noRequirementBlocks &&
1292
+ (await isRetirableSpec(specName, p.rebuilt));
1256
1293
  // The dead end #1302 describes: the rebuilt spec is unwritable
1257
1294
  // for exactly one reason, and retiring the capability is the
1258
1295
  // fix - but only the author can authorise deleting the spec, so
1259
1296
  // the abort names the marker instead of just rejecting. Says so
1260
1297
  // only when the marker is the ONLY thing missing, so it never
1261
1298
  // sends someone after a marker that would not have helped.
1262
- const retirementWouldFix = !retirementDeclared &&
1263
- p.update.exists &&
1264
- p.counts.removed > 0 &&
1265
- (await isRetirementCandidate(p.update, p, false));
1266
- const retirementHint = retirementWouldFix
1299
+ const retirementHint = !retirementDeclared && emptiedByThisRun && p.unaccountedContent.length === 0
1267
1300
  ? `This change removes the last requirement '${specName}' has. To retire the` +
1268
1301
  ` capability and delete its spec, add \`retire_capabilities: true\` to the` +
1269
1302
  ` change's ${METADATA_FILENAME} (alongside its \`schema:\`, which that file` +
@@ -1272,6 +1305,27 @@ export class ArchiveCommand {
1272
1305
  ? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
1273
1306
  : '')
1274
1307
  : undefined;
1308
+ // #1696: the marker is missing AND the file holds content a
1309
+ // retirement cannot account for, so this abort said nothing at
1310
+ // all - just "must have at least one requirement", with no way
1311
+ // forward. It names the content instead of the marker, on
1312
+ // purpose: the marker is only ever named when adding it would
1313
+ // really let the archive through, and here it would not. Once
1314
+ // the content is resolved the rerun names the marker.
1315
+ const blockedRetirementHint = !retirementDeclared && emptiedByThisRun && p.unaccountedContent.length > 0
1316
+ ? `This change removes the last requirement '${specName}' has, so the rebuilt ` +
1317
+ `spec has none left and cannot be written. Retiring the capability is what ` +
1318
+ `archive does instead, and it is refused while the spec holds content the ` +
1319
+ `merge cannot safely account for and deleting the file would take with it: ` +
1320
+ `${describeUnaccountedContent(p.unaccountedContent)}. ` +
1321
+ 'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand, then rerun.' +
1322
+ // Said here too, because an author looking at a marker they
1323
+ // believe authorises the deletion should not have to clear
1324
+ // the content first to find out it was never read.
1325
+ (retirementMarker.invalidReason
1326
+ ? ` The marker present now cannot be honored (${retirementMarker.invalidReason}).`
1327
+ : '')
1328
+ : undefined;
1275
1329
  // The marker was set and retirement was still refused. Saying
1276
1330
  // nothing left the author who did exactly what the docs asked
1277
1331
  // back in the original dead end with no signal that their
@@ -1283,13 +1337,13 @@ export class ArchiveCommand {
1283
1337
  (await isRetirableSpec(specName, p.rebuilt))
1284
1338
  ? `'${specName}' declares retire_capabilities, but the spec holds content the merge ` +
1285
1339
  `cannot safely account for and deleting the file would take with it: ` +
1286
- `${p.unaccountedContent.slice(0, 3).map((line) => `"${line}"`).join(', ')}` +
1287
- `${p.unaccountedContent.length > 3 ? `, and ${p.unaccountedContent.length - 3} more line(s)` : ''}. ` +
1340
+ `${describeUnaccountedContent(p.unaccountedContent)}. ` +
1288
1341
  'Move it into `## Purpose` or a canonical requirement, or delete the spec by hand.'
1289
1342
  : undefined;
1290
1343
  if (json) {
1291
1344
  throw new ArchiveBlockedError('archive_spec_validation_failed', `Rebuilt spec for '${specName}' failed validation. No files were changed.`, refusalReason ??
1292
1345
  retirementHint ??
1346
+ blockedRetirementHint ??
1293
1347
  `Run ${withStoreFlag(root, `openspec validate ${specName}`)} after fixing the change deltas.`);
1294
1348
  }
1295
1349
  console.log(chalk.red(`\nValidation errors in rebuilt spec for ${specName} (will not write changes):`));
@@ -1301,6 +1355,8 @@ export class ArchiveCommand {
1301
1355
  }
1302
1356
  if (retirementHint)
1303
1357
  console.log(chalk.yellow(` → ${retirementHint}`));
1358
+ if (blockedRetirementHint)
1359
+ console.log(chalk.yellow(` → ${blockedRetirementHint}`));
1304
1360
  if (refusalReason)
1305
1361
  console.log(chalk.yellow(` → ${refusalReason}`));
1306
1362
  console.log('Aborted. No files were changed.');
@@ -1603,6 +1659,14 @@ export class ArchiveCommand {
1603
1659
  console.log('No active changes found.');
1604
1660
  return null;
1605
1661
  }
1662
+ // A picker needs a real terminal, and @inquirer's `select` writes ANSI
1663
+ // cursor escapes to stdout even when it is redirected — the same #1526
1664
+ // mechanism the confirm prompts were fixed for. When either stream is not a
1665
+ // TTY, refuse up front with the guidance the caught ExitPromptError would
1666
+ // give, rather than render an escape-spewing menu into a pipe or file.
1667
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1668
+ throw new ArchiveBlockedError('archive_change_name_required', 'A change name is required: no terminal is available to choose one from a list.', withStoreFlag(root, `openspec archive <change-name> ${rerunFlags(options).join(' ')}`));
1669
+ }
1606
1670
  // Build choices with progress inline to avoid duplicate lists
1607
1671
  let choices = changeDirs.map(name => ({ name, value: name }));
1608
1672
  try {
@@ -3,7 +3,7 @@ import * as path from 'node:path';
3
3
  import { getSchemaDir, resolveSchema, listSchemasWithInfo } from './resolver.js';
4
4
  import { ArtifactGraph } from './graph.js';
5
5
  import { detectCompleted } from './state.js';
6
- import { resolveArtifactOutputPath, resolveArtifactOutputs } from './outputs.js';
6
+ import { isSpecsArtifactPath, resolveArtifactOutputPath, resolveArtifactOutputs, } from './outputs.js';
7
7
  import { readChangeMetadata, resolveSchemaForChange } from '../../utils/change-metadata.js';
8
8
  import { FileSystemUtils } from '../../utils/file-system.js';
9
9
  import { buildActionContext, buildNextSteps, summarizePlanningHome, } from '../change-status-policy.js';
@@ -92,12 +92,7 @@ export function loadChangeContext(projectRoot, changeName, schemaName, options =
92
92
  const skippedArtifacts = new Set();
93
93
  if (metadata?.skip_specs) {
94
94
  for (const artifact of graph.getAllArtifacts()) {
95
- // A schema may write generates as './specs/...' - the globs treat that
96
- // identically to 'specs/...', so the skip set must too, or validate
97
- // would honor the marker while instructions tell the agent to create
98
- // the very files the conflict gate polices.
99
- const generates = artifact.generates.replace(/^(?:\.\/)+/, '');
100
- if (generates.startsWith('specs/') && !completed.has(artifact.id)) {
95
+ if (isSpecsArtifactPath(artifact.generates) && !completed.has(artifact.id)) {
101
96
  completed.add(artifact.id);
102
97
  skippedArtifacts.add(artifact.id);
103
98
  }
@@ -2,6 +2,10 @@
2
2
  * Checks if a path contains glob pattern characters.
3
3
  */
4
4
  export declare function isGlobPattern(pattern: string): boolean;
5
+ /**
6
+ * Returns whether an artifact generates files under the change's specs/ tree.
7
+ */
8
+ export declare function isSpecsArtifactPath(generates: string): boolean;
5
9
  export declare function resolveArtifactOutputPath(changeDir: string, generates: string): string;
6
10
  /**
7
11
  * Resolves an artifact's output path(s) to concrete files that currently exist.
@@ -8,6 +8,13 @@ import { FileSystemUtils } from '../../utils/file-system.js';
8
8
  export function isGlobPattern(pattern) {
9
9
  return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
10
10
  }
11
+ /**
12
+ * Returns whether an artifact generates files under the change's specs/ tree.
13
+ */
14
+ export function isSpecsArtifactPath(generates) {
15
+ const normalized = path.posix.normalize(FileSystemUtils.toPosixPath(generates));
16
+ return normalized.startsWith('specs/');
17
+ }
11
18
  export function resolveArtifactOutputPath(changeDir, generates) {
12
19
  const outputPath = path.join(changeDir, generates);
13
20
  FileSystemUtils.assertPathWithin(changeDir, outputPath);
@@ -23,18 +23,6 @@ export declare function getUserSchemasDir(): string;
23
23
  * @returns The path to the project's schemas directory
24
24
  */
25
25
  export declare function getProjectSchemasDir(projectRoot: string): string;
26
- /**
27
- * Determines whether a directory entry represents a schema directory candidate.
28
- *
29
- * Returns true for real directories and for symlinks whose target is a
30
- * directory. `fs.Dirent.isDirectory()` reports the raw entry type, so a symlink
31
- * (even one pointing at a directory) has `isDirectory() === false`; we
32
- * dereference such entries via `fs.statSync` to admit symlinked schema dirs
33
- * while still rejecting symlinks-to-files and broken/dangling symlinks.
34
- *
35
- * @param parentDir - The directory containing the entry
36
- * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
37
- */
38
26
  export declare function isSchemaDir(parentDir: string, entry: fs.Dirent): boolean;
39
27
  /**
40
28
  * Resolves a schema name to its directory path.
@@ -52,7 +52,21 @@ export function getProjectSchemasDir(projectRoot) {
52
52
  * @param parentDir - The directory containing the entry
53
53
  * @param entry - The directory entry from `fs.readdirSync(..., { withFileTypes: true })`
54
54
  */
55
+ /**
56
+ * Directories `schema fork` creates transiently while swapping a fork into
57
+ * place: a staging copy (`.fork-staging-<rand>`, created via mkdtemp) and a
58
+ * backup of the previous destination (`<name>.fork-backup-<pid>-<ts>`). Either
59
+ * can briefly coexist with real schemas in the schemas dir, so discovery must
60
+ * never surface them. Real schema names are kebab-case (no dots), so excluding
61
+ * these dot-bearing temp names can never hide a legitimate schema.
62
+ */
63
+ function isOwnedForkTempDir(name) {
64
+ return name.startsWith('.fork-staging-') || name.includes('.fork-backup-');
65
+ }
55
66
  export function isSchemaDir(parentDir, entry) {
67
+ if (isOwnedForkTempDir(entry.name)) {
68
+ return false;
69
+ }
56
70
  if (entry.isDirectory()) {
57
71
  return true;
58
72
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Command Code Command Adapter
3
+ *
4
+ * Command Code reads custom slash commands from `.commandcode/commands/`. The
5
+ * command name is the markdown filename without its `.md` extension, so
6
+ * `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
7
+ * OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
8
+ */
9
+ import type { ToolCommandAdapter } from '../types.js';
10
+ /**
11
+ * Command Code adapter for command generation.
12
+ * File path: .commandcode/commands/opsx-<id>.md
13
+ * Format: plain Markdown with $ARGUMENTS injected after the input contract
14
+ *
15
+ * Command Code executes the full trimmed file body and substitutes invocation
16
+ * arguments only where the body includes one of its argument placeholders.
17
+ */
18
+ export declare const commandCodeAdapter: ToolCommandAdapter;
19
+ //# sourceMappingURL=command-code.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Command Code Command Adapter
3
+ *
4
+ * Command Code reads custom slash commands from `.commandcode/commands/`. The
5
+ * command name is the markdown filename without its `.md` extension, so
6
+ * `opsx-<id>.md` registers `/opsx-<id>` — the same flat naming Cursor and
7
+ * OpenCode use. See https://commandcode.ai/docs/reference/slash-commands.
8
+ */
9
+ import path from 'path';
10
+ const COMMAND_CODE_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;
11
+ function injectCommandCodeArgs(body) {
12
+ if (/^\*\*Provided arguments\*\*:\s*(?:\$(?:ARGUMENTS|@)|\$\{(?:ARGUMENTS|@)\})\s*$/m.test(body)) {
13
+ return body;
14
+ }
15
+ return body.replace(COMMAND_CODE_INPUT_HEADING, (heading) => `${heading}\n**Provided arguments**: $ARGUMENTS`);
16
+ }
17
+ /**
18
+ * Command Code adapter for command generation.
19
+ * File path: .commandcode/commands/opsx-<id>.md
20
+ * Format: plain Markdown with $ARGUMENTS injected after the input contract
21
+ *
22
+ * Command Code executes the full trimmed file body and substitutes invocation
23
+ * arguments only where the body includes one of its argument placeholders.
24
+ */
25
+ export const commandCodeAdapter = {
26
+ toolId: 'command-code',
27
+ getFilePath(commandId) {
28
+ return path.join('.commandcode', 'commands', `opsx-${commandId}.md`);
29
+ },
30
+ formatFile(content) {
31
+ return `${injectCommandCodeArgs(content.body)}\n`;
32
+ },
33
+ };
34
+ //# sourceMappingURL=command-code.js.map
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
9
9
  export { bobAdapter } from './bob.js';
10
10
  export { claudeAdapter } from './claude.js';
11
11
  export { clineAdapter } from './cline.js';
12
+ export { commandCodeAdapter } from './command-code.js';
12
13
  export { codebuddyAdapter } from './codebuddy.js';
13
14
  export { continueAdapter } from './continue.js';
14
15
  export { costrictAdapter } from './costrict.js';
@@ -9,6 +9,7 @@ export { auggieAdapter } from './auggie.js';
9
9
  export { bobAdapter } from './bob.js';
10
10
  export { claudeAdapter } from './claude.js';
11
11
  export { clineAdapter } from './cline.js';
12
+ export { commandCodeAdapter } from './command-code.js';
12
13
  export { codebuddyAdapter } from './codebuddy.js';
13
14
  export { continueAdapter } from './continue.js';
14
15
  export { costrictAdapter } from './costrict.js';
@@ -7,7 +7,8 @@ import type { ToolCommandAdapter } from '../types.js';
7
7
  /**
8
8
  * OpenCode adapter for command generation.
9
9
  * File path: .opencode/commands/opsx-<id>.md
10
- * Frontmatter: description
10
+ * Frontmatter: description. $ARGUMENTS is injected after the complete input
11
+ * contract because OpenCode only passes arguments through explicit placeholders.
11
12
  */
12
13
  export declare const opencodeAdapter: ToolCommandAdapter;
13
14
  //# sourceMappingURL=opencode.d.ts.map
@@ -5,10 +5,21 @@
5
5
  */
6
6
  import path from 'path';
7
7
  import { escapeYamlValue } from '../yaml.js';
8
+ const OPENCODE_INPUT_BLOCK = /^\*\*Input\*\*:[^\r\n]*(?:\r?\n(?!\r?\n)[^\r\n]*)*/m;
9
+ const OPENCODE_NO_INPUT = /^\*\*Input\*\*:\s*None required\b/im;
10
+ const OPENCODE_ARGUMENT_PLACEHOLDER = /\$(?:ARGUMENTS\b|[1-9]\d*\b)/;
11
+ function injectOpenCodeArgs(body) {
12
+ if (OPENCODE_ARGUMENT_PLACEHOLDER.test(body) || OPENCODE_NO_INPUT.test(body)) {
13
+ return body;
14
+ }
15
+ const eol = body.includes('\r\n') ? '\r\n' : '\n';
16
+ return body.replace(OPENCODE_INPUT_BLOCK, (input) => `${input}${eol}**Provided arguments**: $ARGUMENTS`);
17
+ }
8
18
  /**
9
19
  * OpenCode adapter for command generation.
10
20
  * File path: .opencode/commands/opsx-<id>.md
11
- * Frontmatter: description
21
+ * Frontmatter: description. $ARGUMENTS is injected after the complete input
22
+ * contract because OpenCode only passes arguments through explicit placeholders.
12
23
  */
13
24
  export const opencodeAdapter = {
14
25
  toolId: 'opencode',
@@ -20,7 +31,7 @@ export const opencodeAdapter = {
20
31
  description: ${escapeYamlValue(content.description)}
21
32
  ---
22
33
 
23
- ${content.body}
34
+ ${injectOpenCodeArgs(content.body)}
24
35
  `;
25
36
  },
26
37
  };
@@ -10,6 +10,7 @@ import { auggieAdapter } from './adapters/auggie.js';
10
10
  import { bobAdapter } from './adapters/bob.js';
11
11
  import { claudeAdapter } from './adapters/claude.js';
12
12
  import { clineAdapter } from './adapters/cline.js';
13
+ import { commandCodeAdapter } from './adapters/command-code.js';
13
14
  import { devinAdapter } from './adapters/devin.js';
14
15
  import { codebuddyAdapter } from './adapters/codebuddy.js';
15
16
  import { continueAdapter } from './adapters/continue.js';
@@ -45,6 +46,7 @@ export class CommandAdapterRegistry {
45
46
  CommandAdapterRegistry.register(bobAdapter);
46
47
  CommandAdapterRegistry.register(claudeAdapter);
47
48
  CommandAdapterRegistry.register(clineAdapter);
49
+ CommandAdapterRegistry.register(commandCodeAdapter);
48
50
  CommandAdapterRegistry.register(devinAdapter);
49
51
  CommandAdapterRegistry.register(codebuddyAdapter);
50
52
  CommandAdapterRegistry.register(continueAdapter);
@@ -0,0 +1,15 @@
1
+ export declare const COMPLETION_TIP_MESSAGE = "Tip: Run 'openspec completion install' for shell completions";
2
+ export interface CompletionTipOptions {
3
+ /**
4
+ * Skip printing without marking the tip as seen, so it still appears on the
5
+ * user's first later run that can safely carry it. Used for runs nobody would
6
+ * read the tip from: JSON output, and stderr that is not a terminal.
7
+ */
8
+ silent?: boolean;
9
+ }
10
+ /**
11
+ * Print the completion tip once, the first time the CLI runs.
12
+ * Never throws — a hint must not break a command.
13
+ */
14
+ export declare function maybeShowCompletionTip(options?: CompletionTipOptions): Promise<void>;
15
+ //# sourceMappingURL=completion-tip.d.ts.map