@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
@@ -5,6 +5,38 @@ declare const program: Command;
5
5
  * For example: 'change show' -> 'change:show'
6
6
  */
7
7
  export declare function getCommandPath(command: Command): string;
8
+ /**
9
+ * True when the executing command asked for JSON output — used to suppress the
10
+ * first-run telemetry notice so stdout stays a single valid JSON document.
11
+ *
12
+ * `--json` reaches commands three ways, so a single parsed option is not enough:
13
+ * - declared on the leaf (`openspec status --json`) → `opts().json`
14
+ * - declared on a parent group and read via globals (`openspec workset --json list`)
15
+ * → `optsWithGlobals().json`
16
+ * - a residual arg on a permissive group that never declares the option
17
+ * (`openspec store --json`, which detects it from `command.args`) → `args`
18
+ *
19
+ * Suppressing is always safe: the disclosure is only deferred to the next
20
+ * non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
21
+ */
22
+ export declare function isJsonRun(command: Command): boolean;
23
+ /**
24
+ * True for the commands that exist to serve shell completions: the user-facing
25
+ * `openspec completion ...` group and the hidden `__complete` resolver that
26
+ * generated completion scripts call on every Tab press. Tipping either about
27
+ * completions is noise, and `__complete` would burn the one-shot tip invisibly.
28
+ */
29
+ export declare function isCompletionRun(commandPath: string): boolean;
30
+ /**
31
+ * True when the first-run completions tip must be deferred rather than shown.
32
+ *
33
+ * Deferring keeps the tip unconsumed, so it still reaches the user on a later
34
+ * run that can actually carry it. All three cases are runs nobody would read a
35
+ * hint from: JSON output, the completion machinery itself, and a stderr that is
36
+ * not a terminal — pipes and the agent-driven runs that dominate this CLI's
37
+ * usage would otherwise burn the user's one-shot tip into a log nobody opens.
38
+ */
39
+ export declare function shouldDeferCompletionTip(command: Command, stderrIsTty: boolean): boolean;
8
40
  export { program };
9
41
  export declare function runCli(argv?: string[]): void;
10
42
  //# sourceMappingURL=index.d.ts.map
package/dist/cli/index.js CHANGED
@@ -4,7 +4,7 @@ import { createRequire } from 'module';
4
4
  import ora from 'ora';
5
5
  import path from 'path';
6
6
  import { fileURLToPath } from 'url';
7
- import { promises as fs } from 'fs';
7
+ import { existsSync, promises as fs } from 'fs';
8
8
  import { AI_TOOLS, TOOL_ID_ALIASES } from '../core/config.js';
9
9
  import { UpdateCommand } from '../core/update.js';
10
10
  import { getAvailableCliUpdate, displayCliUpdateNote, shouldOfferUpgrade, getInstallDir, offerCliUpgrade, rerunUpdateWithUpgradedCli, displayUpgradeCommand, isSourceCheckout, } from '../core/version-check.js';
@@ -26,6 +26,7 @@ import { registerContextCommand } from '../commands/context.js';
26
26
  import { registerWorksetCommand } from '../commands/workset.js';
27
27
  import { statusCommand, instructionsCommand, applyInstructionsCommand, archiveInstructionsCommand, templatesCommand, schemasCommand, newChangeCommand, DEFAULT_SCHEMA, } from '../commands/workflow/index.js';
28
28
  import { maybeShowTelemetryNotice, trackCommand, shutdown } from '../telemetry/index.js';
29
+ import { maybeShowCompletionTip } from '../core/completion-tip.js';
29
30
  import { COMMON_FLAGS } from '../core/completions/shared-flags.js';
30
31
  import { isInteractive } from '../utils/interactive.js';
31
32
  const STORE_OPTION_DESCRIPTION = COMMON_FLAGS.store.description;
@@ -72,6 +73,45 @@ export function getCommandPath(command) {
72
73
  }
73
74
  return names.join(':') || 'openspec';
74
75
  }
76
+ /**
77
+ * True when the executing command asked for JSON output — used to suppress the
78
+ * first-run telemetry notice so stdout stays a single valid JSON document.
79
+ *
80
+ * `--json` reaches commands three ways, so a single parsed option is not enough:
81
+ * - declared on the leaf (`openspec status --json`) → `opts().json`
82
+ * - declared on a parent group and read via globals (`openspec workset --json list`)
83
+ * → `optsWithGlobals().json`
84
+ * - a residual arg on a permissive group that never declares the option
85
+ * (`openspec store --json`, which detects it from `command.args`) → `args`
86
+ *
87
+ * Suppressing is always safe: the disclosure is only deferred to the next
88
+ * non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
89
+ */
90
+ export function isJsonRun(command) {
91
+ return (command.optsWithGlobals().json === true ||
92
+ command.args.includes('--json'));
93
+ }
94
+ /**
95
+ * True for the commands that exist to serve shell completions: the user-facing
96
+ * `openspec completion ...` group and the hidden `__complete` resolver that
97
+ * generated completion scripts call on every Tab press. Tipping either about
98
+ * completions is noise, and `__complete` would burn the one-shot tip invisibly.
99
+ */
100
+ export function isCompletionRun(commandPath) {
101
+ return commandPath.split(':')[0] === 'completion' || commandPath === '__complete';
102
+ }
103
+ /**
104
+ * True when the first-run completions tip must be deferred rather than shown.
105
+ *
106
+ * Deferring keeps the tip unconsumed, so it still reaches the user on a later
107
+ * run that can actually carry it. All three cases are runs nobody would read a
108
+ * hint from: JSON output, the completion machinery itself, and a stderr that is
109
+ * not a terminal — pipes and the agent-driven runs that dominate this CLI's
110
+ * usage would otherwise burn the user's one-shot tip into a log nobody opens.
111
+ */
112
+ export function shouldDeferCompletionTip(command, stderrIsTty) {
113
+ return isJsonRun(command) || isCompletionRun(getCommandPath(command)) || !stderrIsTty;
114
+ }
75
115
  program
76
116
  .name('openspec')
77
117
  .description('AI-native system for spec-driven development')
@@ -87,15 +127,32 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
87
127
  if (opts.color === false) {
88
128
  process.env.NO_COLOR = '1';
89
129
  }
90
- // Show first-run telemetry notice (if not seen)
91
- await maybeShowTelemetryNotice();
130
+ // Show first-run telemetry notice (if not seen). It's written to stderr, so it
131
+ // never pollutes stdout — but --json runs still defer it (see isJsonRun) so the
132
+ // very first invocation stays free of any incidental output on either stream.
133
+ await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
92
134
  // Track command execution (use actionCommand to get the actual subcommand)
93
135
  const commandPath = getCommandPath(actionCommand);
94
136
  await trackCommand(commandPath, version);
95
137
  });
96
138
  // Shutdown telemetry after command completes
97
- program.hook('postAction', async () => {
98
- await shutdown();
139
+ program.hook('postAction', async (_thisCommand, actionCommand) => {
140
+ // Show the first-run shell-completions tip (on stderr, so piped stdout stays
141
+ // clean). postAction, not preAction: the tip trails the command's own output
142
+ // instead of pushing an error message or `init`'s setup summary down the
143
+ // screen. Deferred — not consumed — whenever nobody would read it: JSON runs,
144
+ // `openspec completion ...`, and a stderr that is not a terminal (agents and
145
+ // pipes would otherwise silently burn the user's one-shot tip).
146
+ try {
147
+ await maybeShowCompletionTip({
148
+ silent: shouldDeferCompletionTip(actionCommand, Boolean(process.stderr.isTTY)),
149
+ });
150
+ }
151
+ finally {
152
+ // The flush runs even if the hint throws: parse() is synchronous, so a
153
+ // rejection here has no catch anywhere above it.
154
+ await shutdown();
155
+ }
99
156
  });
100
157
  const availableToolIds = AI_TOOLS
101
158
  .filter((tool) => tool.skillsDir || tool.globalSkillsDir)
@@ -108,6 +165,7 @@ program
108
165
  .command('init [path]')
109
166
  .description('Initialize OpenSpec in your project')
110
167
  .option('--tools <tools>', toolsOptionDescription)
168
+ .option('--language <language>', 'Write new OpenSpec artifacts in this language')
111
169
  .option('--force', 'Auto-cleanup legacy files without prompting')
112
170
  .option('--profile <profile>', 'Override global config profile (core or custom)')
113
171
  .option('--no-animation', 'Show a static welcome screen instead of the animated one')
@@ -138,6 +196,7 @@ program
138
196
  const { InitCommand } = await import('../core/init.js');
139
197
  const initCommand = new InitCommand({
140
198
  tools: options?.tools,
199
+ language: options?.language,
141
200
  force: options?.force,
142
201
  profile: options?.profile,
143
202
  animation: options?.animation,
@@ -246,6 +305,9 @@ program
246
305
  const root = await resolveRootForCommand(options ?? {}, {
247
306
  json: options?.json,
248
307
  failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
308
+ // Preserve the cwd fallback for pre-config.yaml projects. The resolver
309
+ // still lets a registered/default store take precedence over it.
310
+ allowImplicitRoot: existsSync(path.join(process.cwd(), 'openspec', 'project.md')),
249
311
  });
250
312
  if (!root) {
251
313
  return;
@@ -340,10 +402,12 @@ changeCmd
340
402
  .action(async (changeName, options) => {
341
403
  try {
342
404
  const changeCommand = new ChangeCommand();
405
+ // validate() already sets process.exitCode, and Node honours it at
406
+ // natural exit. Calling process.exit() here would skip commander's
407
+ // postAction hook — the same trap called out for `update` below — which
408
+ // kills the telemetry flush and the first-run completions tip on what is
409
+ // a routine outcome, not an error: a change that fails validation.
343
410
  await changeCommand.validate(changeName, options);
344
- if (typeof process.exitCode === 'number' && process.exitCode !== 0) {
345
- process.exit(process.exitCode);
346
- }
347
411
  }
348
412
  catch (error) {
349
413
  console.error(`Error: ${error.message}`);
@@ -383,6 +447,7 @@ program
383
447
  .option('--all', 'Validate all changes and specs')
384
448
  .option('--changes', 'Validate all changes')
385
449
  .option('--specs', 'Validate all specs')
450
+ .option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
386
451
  .option('--type <type>', 'Specify item type when ambiguous: change|spec')
387
452
  .option('--strict', 'Enable strict validation mode')
388
453
  .option('--json', 'Output validation results as JSON')
@@ -572,12 +637,18 @@ program
572
637
  .command('schemas')
573
638
  .description('List available workflow schemas with descriptions')
574
639
  .option('--json', 'Output as JSON (for agent use)')
640
+ .option('--store <id>', STORE_OPTION_DESCRIPTION)
641
+ .addOption(hiddenStorePathOption())
575
642
  .action(async (options) => {
576
643
  try {
577
644
  await schemasCommand(options);
578
645
  }
579
646
  catch (error) {
580
- failWithError(error);
647
+ failWithError(error, {
648
+ enabled: options.json,
649
+ payload: { schemas: [], root: null },
650
+ fallbackCode: 'schemas_error',
651
+ });
581
652
  process.exit(1);
582
653
  }
583
654
  });
@@ -246,6 +246,7 @@ export class ChangeCommand {
246
246
  // Derived from changesPath so the main specs come from the same root the
247
247
  // change itself was resolved against.
248
248
  mainSpecsDir: path.join(path.dirname(changesPath), 'specs'),
249
+ projectRoot: path.dirname(path.dirname(changesPath)),
249
250
  });
250
251
  if (options?.json) {
251
252
  console.log(JSON.stringify(report, null, 2));
@@ -10,6 +10,11 @@ interface ProfileStateDiff {
10
10
  hasChanges: boolean;
11
11
  lines: string[];
12
12
  }
13
+ interface WorkflowPromptMeta {
14
+ name: string;
15
+ description: string;
16
+ }
17
+ export declare const WORKFLOW_PROMPT_META: Record<string, WorkflowPromptMeta>;
13
18
  /**
14
19
  * Resolve the effective current profile state from global config defaults.
15
20
  */
@@ -8,7 +8,7 @@ import { OPENSPEC_DIR_NAME } from '../core/config.js';
8
8
  import { hasProjectConfigDrift } from '../core/profile-sync-drift.js';
9
9
  import { UpdateCommand } from '../core/update.js';
10
10
  import { asErrorMessage, isPromptCancellationError } from './shared-output.js';
11
- const WORKFLOW_PROMPT_META = {
11
+ export const WORKFLOW_PROMPT_META = {
12
12
  propose: {
13
13
  name: 'Propose change',
14
14
  description: 'Create proposal, design, and tasks from a request',
@@ -29,6 +29,10 @@ const WORKFLOW_PROMPT_META = {
29
29
  name: 'Apply tasks',
30
30
  description: 'Implement tasks from the current change',
31
31
  },
32
+ update: {
33
+ name: 'Update change',
34
+ description: 'Revise the planning artifacts of an existing change',
35
+ },
32
36
  ff: {
33
37
  name: 'Fast-forward',
34
38
  description: 'Run a faster implementation workflow',
@@ -449,6 +453,7 @@ export function registerConfigCommand(program) {
449
453
  delivery: currentState.delivery,
450
454
  workflows: [...currentState.workflows],
451
455
  };
456
+ let workflowSelectionChanged = false;
452
457
  if (action === 'both' || action === 'delivery') {
453
458
  const deliveryChoices = [
454
459
  {
@@ -493,8 +498,11 @@ export function registerConfigCommand(program) {
493
498
  };
494
499
  };
495
500
  const selectedWorkflows = await checkbox({
501
+ // The `instructions` option was removed in @inquirer/checkbox v5.
502
+ // Its replacement, the built-in keys help tip, renders
503
+ // "↑↓ navigate • space select • ⏎ submit" by default — a superset of
504
+ // the hint this used to pass — so no theme override is needed here.
496
505
  message: 'Select workflows to make available:',
497
- instructions: 'Space to toggle, Enter to confirm',
498
506
  pageSize: ALL_WORKFLOWS.length,
499
507
  theme: {
500
508
  icon: {
@@ -505,7 +513,12 @@ export function registerConfigCommand(program) {
505
513
  choices: ALL_WORKFLOWS.map(formatWorkflowChoice),
506
514
  });
507
515
  nextState.workflows = selectedWorkflows;
508
- nextState.profile = deriveProfileFromWorkflowSelection(selectedWorkflows);
516
+ workflowSelectionChanged =
517
+ selectedWorkflows.length !== currentState.workflows.length ||
518
+ selectedWorkflows.some((workflow) => !currentState.workflows.includes(workflow));
519
+ nextState.profile = workflowSelectionChanged
520
+ ? deriveProfileFromWorkflowSelection(selectedWorkflows)
521
+ : currentState.profile;
509
522
  }
510
523
  const diff = diffProfileState(currentState, nextState);
511
524
  if (!diff.hasChanges) {
@@ -520,7 +533,9 @@ export function registerConfigCommand(program) {
520
533
  console.log();
521
534
  config.profile = nextState.profile;
522
535
  config.delivery = nextState.delivery;
523
- config.workflows = nextState.workflows;
536
+ if (currentState.profile !== 'custom' || workflowSelectionChanged) {
537
+ config.workflows = nextState.workflows;
538
+ }
524
539
  saveGlobalConfig(config);
525
540
  // Check if inside an OpenSpec project
526
541
  const projectDir = process.cwd();
@@ -2,6 +2,8 @@ import { execSync, execFileSync } from 'child_process';
2
2
  import { createRequire } from 'module';
3
3
  import os from 'os';
4
4
  const require = createRequire(import.meta.url);
5
+ const MAX_TITLE_LENGTH = 72;
6
+ const TITLE_PREFIX = 'Feedback: ';
5
7
  /**
6
8
  * Check if gh CLI is installed and available in PATH
7
9
  * Uses platform-appropriate command: 'where' on Windows, 'which' on Unix/macOS
@@ -69,18 +71,37 @@ Submitted via OpenSpec CLI
69
71
  * Format the feedback title
70
72
  */
71
73
  function formatTitle(message) {
72
- return `Feedback: ${message}`;
74
+ const normalizedMessage = message.replace(/\s+/g, ' ').trim();
75
+ const title = `${TITLE_PREFIX}${normalizedMessage}`;
76
+ if (Array.from(title).length <= MAX_TITLE_LENGTH) {
77
+ return title;
78
+ }
79
+ const availableLength = MAX_TITLE_LENGTH - TITLE_PREFIX.length - 1;
80
+ let candidate = '';
81
+ let candidateLength = 0;
82
+ const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(normalizedMessage);
83
+ for (const { segment } of segments) {
84
+ const segmentLength = Array.from(segment).length;
85
+ if (candidateLength + segmentLength > availableLength) {
86
+ break;
87
+ }
88
+ candidate += segment;
89
+ candidateLength += segmentLength;
90
+ }
91
+ candidate = candidate.trimEnd();
92
+ const lastSpace = candidate.lastIndexOf(' ');
93
+ const summary = lastSpace > 0 ? candidate.slice(0, lastSpace) : candidate;
94
+ return `${TITLE_PREFIX}${summary}…`;
73
95
  }
74
96
  /**
75
97
  * Format the full feedback body
76
98
  */
77
- function formatBody(bodyText) {
78
- const parts = [];
99
+ function formatBody(message, bodyText) {
100
+ const parts = ['## Summary', '', message];
79
101
  if (bodyText) {
80
- parts.push(bodyText);
81
- parts.push(''); // Empty line before metadata
102
+ parts.push('', '## Details', '', bodyText);
82
103
  }
83
- parts.push(generateMetadata());
104
+ parts.push('', generateMetadata());
84
105
  return parts.join('\n');
85
106
  }
86
107
  /**
@@ -216,7 +237,7 @@ export class FeedbackCommand {
216
237
  async execute(message, options) {
217
238
  // Format title and body once for all code paths
218
239
  const title = formatTitle(message);
219
- const body = formatBody(options?.body);
240
+ const body = formatBody(message, options?.body);
220
241
  // Check if gh CLI is installed
221
242
  if (!isGhInstalled()) {
222
243
  handleFallback(title, body, 'missing');
@@ -1,7 +1,8 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
+ import { createHash } from 'node:crypto';
3
4
  import ora from 'ora';
4
- import { stringify as stringifyYaml } from 'yaml';
5
+ import { stringify as stringifyYaml, parseDocument } from 'yaml';
5
6
  import { getSchemaDir, getProjectSchemasDir, getUserSchemasDir, getPackageSchemasDir, isSchemaDir, listSchemas, } from '../core/artifact-graph/resolver.js';
6
7
  import { parseSchema, SchemaValidationError } from '../core/artifact-graph/schema.js';
7
8
  import { FileSystemUtils } from '../utils/file-system.js';
@@ -240,6 +241,54 @@ function assertSchemaTreeCanBeCopied(src, allowedRoot = src, ancestors = new Set
240
241
  ancestors.delete(canonicalSrc);
241
242
  }
242
243
  }
244
+ /**
245
+ * Produces a stable content fingerprint of a directory: a SHA-256 over every
246
+ * file's relative path AND its bytes (plus directory paths), walked in sorted
247
+ * order. Two directories with byte-identical trees produce the same digest, and
248
+ * ANY change to a file's contents, size, or the set of paths changes it. Used to
249
+ * detect a concurrent modification of a fork destination between the moment the
250
+ * overwrite is authorized and the moment it is actually moved/deleted, so those
251
+ * changes are never silently destroyed.
252
+ */
253
+ function fingerprintDir(dir) {
254
+ const hash = createHash('sha256');
255
+ const walk = (current, rel) => {
256
+ const entries = fs
257
+ .readdirSync(current, { withFileTypes: true })
258
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
259
+ for (const entry of entries) {
260
+ const abs = path.join(current, entry.name);
261
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
262
+ // Use the entry type from readdir (no separate lstat), then read the file
263
+ // directly — avoiding a stat-then-read check/use gap. Size is derived from
264
+ // the bytes actually read, so the digest still covers content and length.
265
+ if (entry.isDirectory()) {
266
+ hash.update(`D:${relPath}\n`);
267
+ walk(abs, relPath);
268
+ }
269
+ else if (entry.isFile()) {
270
+ const contents = fs.readFileSync(abs);
271
+ hash.update(`F:${relPath}:${contents.length}:`);
272
+ hash.update(contents);
273
+ hash.update('\n');
274
+ }
275
+ else {
276
+ // Symlinks / other entry types: record the type + path (and the link
277
+ // target when readable) so a swap of one for another is still detected.
278
+ let target = '';
279
+ try {
280
+ target = fs.readlinkSync(abs);
281
+ }
282
+ catch {
283
+ // Non-symlink or unreadable target; the type marker below suffices.
284
+ }
285
+ hash.update(`O:${relPath}:${target}\n`);
286
+ }
287
+ }
288
+ };
289
+ walk(dir, '');
290
+ return hash.digest('hex');
291
+ }
243
292
  /**
244
293
  * Default artifacts with descriptions for schema init.
245
294
  */
@@ -556,39 +605,155 @@ export function registerSchemaCommand(program) {
556
605
  // Validate the complete source before a forced fork removes anything.
557
606
  const trustedSourceDir = fs.realpathSync(sourceDir);
558
607
  assertSchemaTreeCanBeCopied(trustedSourceDir);
608
+ // Validate the source's schema content up front too, so a structurally
609
+ // invalid source is rejected before the --force path can remove an
610
+ // existing destination. This keeps `fork --force` atomic — an unusable
611
+ // source never destroys a valid destination — matching `schema init`,
612
+ // which likewise validates before it overwrites.
613
+ parseSchema(fs.readFileSync(path.join(trustedSourceDir, 'schema.yaml'), 'utf-8'));
559
614
  // Check destination
560
- const destinationDir = path.join(getProjectSchemasDir(projectRoot), destinationName);
561
- if (fs.existsSync(destinationDir)) {
562
- if (!options?.force) {
563
- if (options?.json) {
564
- console.log(JSON.stringify({
565
- forked: false,
566
- error: `Schema '${destinationName}' already exists`,
567
- suggestion: 'Use --force to overwrite',
568
- }, null, 2));
615
+ const schemasDir = getProjectSchemasDir(projectRoot);
616
+ const destinationDir = path.join(schemasDir, destinationName);
617
+ // Reject a self-fork. Forking a schema onto itself with --force would
618
+ // otherwise remove the source at the replacement step below and then
619
+ // fail the copy, destroying the only copy of the schema. Resolve both
620
+ // sides to their real paths (realpathSync follows symlinks; path.resolve
621
+ // is a fallback only for a destination that does not exist yet) so a
622
+ // symlink or a `.`/`..` spelling of the same directory is still caught.
623
+ const resolvedDestination = fs.existsSync(destinationDir)
624
+ ? fs.realpathSync(destinationDir)
625
+ : path.resolve(destinationDir);
626
+ if (resolvedDestination === trustedSourceDir) {
627
+ throw new Error(`Cannot fork schema '${source}' onto itself; choose a different destination name`);
628
+ }
629
+ const destinationExists = fs.existsSync(destinationDir);
630
+ if (destinationExists && !options?.force) {
631
+ if (options?.json) {
632
+ console.log(JSON.stringify({
633
+ forked: false,
634
+ error: `Schema '${destinationName}' already exists`,
635
+ suggestion: 'Use --force to overwrite',
636
+ }, null, 2));
637
+ }
638
+ else {
639
+ console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
640
+ console.error('Use --force to overwrite');
641
+ }
642
+ process.exitCode = 1;
643
+ return;
644
+ }
645
+ // Fingerprint the destination the user authorized us to overwrite, BEFORE
646
+ // we spend time staging. Staging can take a while, and a concurrent
647
+ // process may edit the destination in that window; the fingerprint lets
648
+ // us detect such a change and abort rather than clobber it.
649
+ const authorizedDestinationFingerprint = destinationExists
650
+ ? fingerprintDir(destinationDir)
651
+ : null;
652
+ // Stage the complete fork in a temporary sibling directory first, then
653
+ // swap it into place. This keeps `fork --force` atomic: an existing
654
+ // destination is only removed once the new fork has been fully copied,
655
+ // name-updated, and (via the up-front parseSchema above) validated. Any
656
+ // failure while staging leaves both the source and the existing
657
+ // destination exactly as they were.
658
+ if (spinner)
659
+ spinner.start(`Forking '${source}' to '${destinationName}'...`);
660
+ fs.mkdirSync(schemasDir, { recursive: true });
661
+ const stagingDir = fs.mkdtempSync(path.join(schemasDir, '.fork-staging-'));
662
+ try {
663
+ copyDirRecursive(trustedSourceDir, stagingDir);
664
+ // Update name in the staged schema.yaml via yaml's Document API
665
+ // instead of re-serializing the parsed object, so block scalars,
666
+ // comments, and key order in the source schema.yaml survive the fork.
667
+ const stagedSchemaPath = path.join(stagingDir, 'schema.yaml');
668
+ const schemaContent = fs.readFileSync(stagedSchemaPath, 'utf-8');
669
+ const doc = parseDocument(schemaContent);
670
+ doc.set('name', destinationName);
671
+ fs.writeFileSync(stagedSchemaPath, doc.toString());
672
+ // Authoritative gate: validate the COMPLETED staged schema — the exact
673
+ // bytes we are about to install — not just the source at the pre-check.
674
+ // The source files copyDirRecursive reads can change mid-copy, so a
675
+ // source that was valid up front can still produce an invalid staged
676
+ // fork. Validating here, before ANY destructive step, guarantees we
677
+ // never install an invalid fork or delete a valid destination for one.
678
+ try {
679
+ parseSchema(fs.readFileSync(stagedSchemaPath, 'utf-8'));
680
+ }
681
+ catch (validationError) {
682
+ throw new Error(`The staged fork of '${source}' is not a valid schema (the source may have changed during copy); ` +
683
+ `aborted, '${destinationName}' was not modified.`, { cause: validationError });
684
+ }
685
+ // Swap the staged fork into place. When a destination already exists,
686
+ // move it aside to a sibling backup FIRST, then install the staged
687
+ // fork; only once the install succeeds is the backup discarded. If the
688
+ // install rename itself fails (e.g. a Windows lock), the backup is
689
+ // moved back so the user's original destination is never lost.
690
+ if (destinationExists) {
691
+ if (spinner)
692
+ spinner.text = `Replacing existing schema '${destinationName}'...`;
693
+ // Revalidate immediately before the destructive move: if the
694
+ // destination changed on disk while we were staging (or was removed),
695
+ // its fingerprint no longer matches what the user authorized. Abort
696
+ // WITHOUT touching it, so the concurrent changes are preserved. The
697
+ // outer catch cleans up staging.
698
+ const currentFingerprint = fs.existsSync(destinationDir)
699
+ ? fingerprintDir(destinationDir)
700
+ : null;
701
+ if (currentFingerprint !== authorizedDestinationFingerprint) {
702
+ throw new Error(`Schema '${destinationName}' at ${destinationDir} changed on disk while the fork was being prepared. ` +
703
+ `Aborted to preserve those concurrent changes; nothing was overwritten. Re-run the fork to overwrite the current contents.`);
704
+ }
705
+ const backupDir = `${destinationDir}.fork-backup-${process.pid}-${Date.now()}`;
706
+ fs.renameSync(destinationDir, backupDir);
707
+ try {
708
+ fs.renameSync(stagingDir, destinationDir);
709
+ }
710
+ catch (installError) {
711
+ // Install failed after the original was moved aside. Try to move
712
+ // it back. If that restore ALSO fails, the original is stranded in
713
+ // the backup dir — surface an error naming both the backup and the
714
+ // destination so the user can recover manually, and attach the
715
+ // original install error as the cause. Never swallow this.
716
+ try {
717
+ fs.renameSync(backupDir, destinationDir);
718
+ }
719
+ catch (restoreError) {
720
+ throw new Error(`Failed to install the forked schema and could not restore the previous '${destinationName}'. ` +
721
+ `Your previous schema is preserved at ${backupDir}; move it back to ${destinationDir} to restore. ` +
722
+ `Restore error: ${restoreError.message}`, { cause: installError });
723
+ }
724
+ throw installError;
725
+ }
726
+ // Revalidate before discarding the backup: only delete it if it is
727
+ // still byte-for-byte the original destination we moved aside. If it
728
+ // changed during the install window (a concurrent write to the
729
+ // moved-aside directory), do NOT delete it — leave it in place and
730
+ // surface where it is so nothing is lost.
731
+ if (fingerprintDir(backupDir) === authorizedDestinationFingerprint) {
732
+ fs.rmSync(backupDir, { recursive: true, force: true });
569
733
  }
570
734
  else {
571
- console.error(`Error: Schema '${destinationName}' already exists at ${destinationDir}`);
572
- console.error('Use --force to overwrite');
735
+ console.error(`Warning: the previous '${destinationName}' changed during the fork and was NOT deleted; ` +
736
+ `its pre-fork copy is preserved at ${backupDir}.`);
573
737
  }
574
- process.exitCode = 1;
575
- return;
576
738
  }
577
- // Remove existing
578
- if (spinner)
579
- spinner.start(`Removing existing schema '${destinationName}'...`);
580
- fs.rmSync(destinationDir, { recursive: true });
739
+ else {
740
+ fs.renameSync(stagingDir, destinationDir);
741
+ }
742
+ }
743
+ catch (error) {
744
+ // Remove only the staging directory we created this run; the source
745
+ // and any existing destination are left exactly as we found them.
746
+ // Guard the cleanup in its own try/catch so a failed removal (e.g. a
747
+ // locked file on Windows) can never mask the original error, then
748
+ // rethrow so the real failure still drives the JSON/exit-code report.
749
+ try {
750
+ fs.rmSync(stagingDir, { recursive: true, force: true });
751
+ }
752
+ catch {
753
+ // Best-effort cleanup; the original error below is what matters.
754
+ }
755
+ throw error;
581
756
  }
582
- // Copy schema
583
- if (spinner)
584
- spinner.start(`Forking '${source}' to '${destinationName}'...`);
585
- copyDirRecursive(trustedSourceDir, destinationDir);
586
- // Update name in schema.yaml
587
- const destSchemaPath = path.join(destinationDir, 'schema.yaml');
588
- const schemaContent = fs.readFileSync(destSchemaPath, 'utf-8');
589
- const schema = parseSchema(schemaContent);
590
- schema.name = destinationName;
591
- fs.writeFileSync(destSchemaPath, stringifyYaml(schema));
592
757
  if (spinner)
593
758
  spinner.succeed(`Forked '${source}' to '${destinationName}'`);
594
759
  if (options?.json) {
@@ -2,6 +2,7 @@ interface ExecuteOptions {
2
2
  all?: boolean;
3
3
  changes?: boolean;
4
4
  specs?: boolean;
5
+ archived?: boolean;
5
6
  type?: string;
6
7
  strict?: boolean;
7
8
  json?: boolean;
@@ -29,6 +30,28 @@ export declare class ValidateCommand {
29
30
  private printReport;
30
31
  private printNextSteps;
31
32
  private runBulkValidation;
33
+ /**
34
+ * Lists archived change ids from the resolved root's archive directory,
35
+ * mirroring `getArchivedChangeIds` but store-aware (uses `root.archiveDir`
36
+ * rather than a cwd-relative path). Directories only, hidden entries skipped.
37
+ *
38
+ * Only a missing archive directory (ENOENT) is an empty list; a permission
39
+ * error, an I/O error, or an `archive` path that is a file (ENOTDIR) is a real
40
+ * failure and must not read as "no archived changes" — that would let a
41
+ * pre-commit lint pass without inspecting anything (#205).
42
+ */
43
+ private listArchivedChangeIds;
44
+ /**
45
+ * Validates that every archived change has all of its tasks completed.
46
+ *
47
+ * An archived change is expected to be finished; an archived change with
48
+ * unchecked tasks is a real integrity problem the normal validate flow never
49
+ * surfaces, because active-change discovery excludes the archive directory
50
+ * (#205). Reuses the same task-progress counting `status`, `list`, and
51
+ * `archive` rely on, so what counts as a task never forks. Changes with no
52
+ * tasks pass (nothing to complete).
53
+ */
54
+ private runArchivedTaskValidation;
32
55
  }
33
56
  export {};
34
57
  //# sourceMappingURL=validate.d.ts.map