@fission-ai/openspec 1.8.0 → 1.9.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 (48) hide show
  1. package/dist/cli/index.d.ts +15 -0
  2. package/dist/cli/index.js +33 -4
  3. package/dist/commands/change.js +1 -0
  4. package/dist/commands/config.d.ts +5 -0
  5. package/dist/commands/config.js +5 -1
  6. package/dist/commands/schema.js +193 -28
  7. package/dist/commands/validate.d.ts +23 -0
  8. package/dist/commands/validate.js +148 -4
  9. package/dist/commands/workflow/schemas.d.ts +2 -0
  10. package/dist/commands/workflow/schemas.js +9 -2
  11. package/dist/core/archive.js +10 -3
  12. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  13. package/dist/core/artifact-graph/resolver.js +14 -0
  14. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  15. package/dist/core/command-generation/adapters/command-code.js +34 -0
  16. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  17. package/dist/core/command-generation/adapters/index.js +1 -0
  18. package/dist/core/command-generation/registry.js +2 -0
  19. package/dist/core/completions/command-registry.js +5 -0
  20. package/dist/core/config.d.ts +1 -0
  21. package/dist/core/config.js +16 -15
  22. package/dist/core/init.js +19 -7
  23. package/dist/core/legacy-cleanup.d.ts +10 -0
  24. package/dist/core/legacy-cleanup.js +48 -26
  25. package/dist/core/parsers/requirement-blocks.js +41 -9
  26. package/dist/core/parsers/requirement-text.d.ts +8 -0
  27. package/dist/core/parsers/requirement-text.js +4 -3
  28. package/dist/core/root-selection.d.ts +1 -1
  29. package/dist/core/shared-skill-target.d.ts +15 -0
  30. package/dist/core/shared-skill-target.js +30 -0
  31. package/dist/core/specs-apply.js +11 -4
  32. package/dist/core/templates/workflows/apply-change.js +3 -0
  33. package/dist/core/templates/workflows/propose.js +2 -2
  34. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  35. package/dist/core/templates/workflows/store-selection.js +1 -1
  36. package/dist/core/templates/workflows/update-change.js +4 -4
  37. package/dist/core/update.js +34 -7
  38. package/dist/core/validation/task-numbering.d.ts +16 -0
  39. package/dist/core/validation/task-numbering.js +57 -0
  40. package/dist/core/validation/validator.d.ts +5 -2
  41. package/dist/core/validation/validator.js +57 -3
  42. package/dist/telemetry/index.d.ts +3 -1
  43. package/dist/telemetry/index.js +7 -1
  44. package/dist/utils/interactive.d.ts +30 -1
  45. package/dist/utils/interactive.js +105 -2
  46. package/dist/utils/task-progress.d.ts +34 -1
  47. package/dist/utils/task-progress.js +52 -28
  48. package/package.json +1 -1
@@ -5,6 +5,21 @@ 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;
8
23
  export { program };
9
24
  export declare function runCli(argv?: string[]): void;
10
25
  //# 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';
@@ -72,6 +72,24 @@ export function getCommandPath(command) {
72
72
  }
73
73
  return names.join(':') || 'openspec';
74
74
  }
75
+ /**
76
+ * True when the executing command asked for JSON output — used to suppress the
77
+ * first-run telemetry notice so stdout stays a single valid JSON document.
78
+ *
79
+ * `--json` reaches commands three ways, so a single parsed option is not enough:
80
+ * - declared on the leaf (`openspec status --json`) → `opts().json`
81
+ * - declared on a parent group and read via globals (`openspec workset --json list`)
82
+ * → `optsWithGlobals().json`
83
+ * - a residual arg on a permissive group that never declares the option
84
+ * (`openspec store --json`, which detects it from `command.args`) → `args`
85
+ *
86
+ * Suppressing is always safe: the disclosure is only deferred to the next
87
+ * non-JSON run, never lost, whereas printing it on a JSON run corrupts stdout.
88
+ */
89
+ export function isJsonRun(command) {
90
+ return (command.optsWithGlobals().json === true ||
91
+ command.args.includes('--json'));
92
+ }
75
93
  program
76
94
  .name('openspec')
77
95
  .description('AI-native system for spec-driven development')
@@ -87,8 +105,9 @@ program.hook('preAction', async (thisCommand, actionCommand) => {
87
105
  if (opts.color === false) {
88
106
  process.env.NO_COLOR = '1';
89
107
  }
90
- // Show first-run telemetry notice (if not seen)
91
- await maybeShowTelemetryNotice();
108
+ // Show first-run telemetry notice (if not seen). Suppress it whenever the run
109
+ // asked for JSON so stdout stays a single valid JSON document (see isJsonRun).
110
+ await maybeShowTelemetryNotice({ silent: isJsonRun(actionCommand) });
92
111
  // Track command execution (use actionCommand to get the actual subcommand)
93
112
  const commandPath = getCommandPath(actionCommand);
94
113
  await trackCommand(commandPath, version);
@@ -246,6 +265,9 @@ program
246
265
  const root = await resolveRootForCommand(options ?? {}, {
247
266
  json: options?.json,
248
267
  failurePayload: options?.specs ? { specs: [], root: null } : { changes: [], root: null },
268
+ // Preserve the cwd fallback for pre-config.yaml projects. The resolver
269
+ // still lets a registered/default store take precedence over it.
270
+ allowImplicitRoot: existsSync(path.join(process.cwd(), 'openspec', 'project.md')),
249
271
  });
250
272
  if (!root) {
251
273
  return;
@@ -383,6 +405,7 @@ program
383
405
  .option('--all', 'Validate all changes and specs')
384
406
  .option('--changes', 'Validate all changes')
385
407
  .option('--specs', 'Validate all specs')
408
+ .option('--archived', 'Validate that archived changes have all tasks completed (for pre-commit linting)')
386
409
  .option('--type <type>', 'Specify item type when ambiguous: change|spec')
387
410
  .option('--strict', 'Enable strict validation mode')
388
411
  .option('--json', 'Output validation results as JSON')
@@ -572,12 +595,18 @@ program
572
595
  .command('schemas')
573
596
  .description('List available workflow schemas with descriptions')
574
597
  .option('--json', 'Output as JSON (for agent use)')
598
+ .option('--store <id>', STORE_OPTION_DESCRIPTION)
599
+ .addOption(hiddenStorePathOption())
575
600
  .action(async (options) => {
576
601
  try {
577
602
  await schemasCommand(options);
578
603
  }
579
604
  catch (error) {
580
- failWithError(error);
605
+ failWithError(error, {
606
+ enabled: options.json,
607
+ payload: { schemas: [], root: null },
608
+ fallbackCode: 'schemas_error',
609
+ });
581
610
  process.exit(1);
582
611
  }
583
612
  });
@@ -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',
@@ -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
@@ -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;