@fission-ai/openspec 1.7.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 (113) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/index.d.ts +15 -0
  3. package/dist/cli/index.js +39 -5
  4. package/dist/commands/change.js +16 -1
  5. package/dist/commands/config.d.ts +5 -0
  6. package/dist/commands/config.js +5 -1
  7. package/dist/commands/schema.js +281 -47
  8. package/dist/commands/spec.js +30 -6
  9. package/dist/commands/validate.d.ts +23 -0
  10. package/dist/commands/validate.js +148 -4
  11. package/dist/commands/workflow/instructions.js +32 -27
  12. package/dist/commands/workflow/schemas.d.ts +2 -0
  13. package/dist/commands/workflow/schemas.js +9 -2
  14. package/dist/commands/workflow/status.js +2 -2
  15. package/dist/commands/workflow/templates.js +15 -5
  16. package/dist/core/archive.d.ts +14 -0
  17. package/dist/core/archive.js +1272 -151
  18. package/dist/core/artifact-graph/index.d.ts +1 -1
  19. package/dist/core/artifact-graph/index.js +1 -1
  20. package/dist/core/artifact-graph/instruction-loader.d.ts +3 -1
  21. package/dist/core/artifact-graph/instruction-loader.js +15 -5
  22. package/dist/core/artifact-graph/outputs.d.ts +1 -0
  23. package/dist/core/artifact-graph/outputs.js +67 -5
  24. package/dist/core/artifact-graph/resolver.d.ts +0 -12
  25. package/dist/core/artifact-graph/resolver.js +49 -9
  26. package/dist/core/artifact-graph/types.js +21 -3
  27. package/dist/core/available-tools.d.ts +2 -2
  28. package/dist/core/available-tools.js +14 -3
  29. package/dist/core/change-metadata/schema.d.ts +1 -0
  30. package/dist/core/change-metadata/schema.js +7 -0
  31. package/dist/core/change-status-policy.js +2 -2
  32. package/dist/core/command-generation/adapters/command-code.d.ts +19 -0
  33. package/dist/core/command-generation/adapters/command-code.js +34 -0
  34. package/dist/core/command-generation/adapters/index.d.ts +1 -0
  35. package/dist/core/command-generation/adapters/index.js +1 -0
  36. package/dist/core/command-generation/registry.js +2 -0
  37. package/dist/core/completions/command-registry.js +13 -0
  38. package/dist/core/config-schema.d.ts +3 -0
  39. package/dist/core/config-schema.js +28 -1
  40. package/dist/core/config.d.ts +4 -0
  41. package/dist/core/config.js +40 -17
  42. package/dist/core/file-state.js +56 -24
  43. package/dist/core/github-copilot/cloud-agent.d.ts +95 -0
  44. package/dist/core/github-copilot/cloud-agent.js +497 -0
  45. package/dist/core/global-config.d.ts +11 -0
  46. package/dist/core/init.d.ts +23 -0
  47. package/dist/core/init.js +253 -46
  48. package/dist/core/legacy-cleanup.d.ts +10 -0
  49. package/dist/core/legacy-cleanup.js +52 -28
  50. package/dist/core/migration.d.ts +6 -4
  51. package/dist/core/migration.js +78 -22
  52. package/dist/core/parsers/requirement-blocks.d.ts +10 -0
  53. package/dist/core/parsers/requirement-blocks.js +93 -1
  54. package/dist/core/parsers/requirement-text.d.ts +8 -0
  55. package/dist/core/parsers/requirement-text.js +4 -3
  56. package/dist/core/parsers/spec-structure.d.ts +1 -1
  57. package/dist/core/parsers/spec-structure.js +17 -1
  58. package/dist/core/profile-sync-drift.js +40 -5
  59. package/dist/core/project-config.d.ts +3 -0
  60. package/dist/core/project-config.js +31 -1
  61. package/dist/core/root-selection.d.ts +1 -1
  62. package/dist/core/shared/index.d.ts +1 -0
  63. package/dist/core/shared/index.js +1 -0
  64. package/dist/core/shared/skill-content-equivalence.d.ts +7 -0
  65. package/dist/core/shared/skill-content-equivalence.js +41 -0
  66. package/dist/core/shared/skill-paths.d.ts +13 -0
  67. package/dist/core/shared/skill-paths.js +23 -0
  68. package/dist/core/shared/tool-detection.js +66 -35
  69. package/dist/core/shared-skill-target.d.ts +31 -0
  70. package/dist/core/shared-skill-target.js +186 -0
  71. package/dist/core/specs-apply.d.ts +72 -0
  72. package/dist/core/specs-apply.js +459 -59
  73. package/dist/core/templates/skill-templates.d.ts +1 -1
  74. package/dist/core/templates/skill-templates.js +1 -1
  75. package/dist/core/templates/workflows/apply-change.d.ts +10 -0
  76. package/dist/core/templates/workflows/apply-change.js +25 -182
  77. package/dist/core/templates/workflows/archive-change.js +8 -4
  78. package/dist/core/templates/workflows/bulk-archive-change.js +30 -26
  79. package/dist/core/templates/workflows/continue-change.js +6 -6
  80. package/dist/core/templates/workflows/explore.js +42 -18
  81. package/dist/core/templates/workflows/onboard.js +9 -3
  82. package/dist/core/templates/workflows/propose.js +72 -20
  83. package/dist/core/templates/workflows/store-selection.d.ts +1 -1
  84. package/dist/core/templates/workflows/store-selection.js +1 -1
  85. package/dist/core/templates/workflows/sync-specs.js +96 -18
  86. package/dist/core/templates/workflows/update-change.js +10 -8
  87. package/dist/core/update.d.ts +1 -0
  88. package/dist/core/update.js +136 -30
  89. package/dist/core/validation/task-numbering.d.ts +16 -0
  90. package/dist/core/validation/task-numbering.js +57 -0
  91. package/dist/core/validation/validator.d.ts +26 -3
  92. package/dist/core/validation/validator.js +189 -19
  93. package/dist/core/version-check.js +6 -11
  94. package/dist/telemetry/config.d.ts +3 -4
  95. package/dist/telemetry/index.d.ts +12 -5
  96. package/dist/telemetry/index.js +27 -9
  97. package/dist/utils/change-metadata.d.ts +17 -4
  98. package/dist/utils/change-metadata.js +26 -4
  99. package/dist/utils/ci.d.ts +12 -0
  100. package/dist/utils/ci.js +16 -0
  101. package/dist/utils/command-references.d.ts +14 -2
  102. package/dist/utils/command-references.js +45 -3
  103. package/dist/utils/file-system.d.ts +10 -0
  104. package/dist/utils/file-system.js +70 -0
  105. package/dist/utils/interactive.d.ts +52 -0
  106. package/dist/utils/interactive.js +135 -0
  107. package/dist/utils/spec-discovery.d.ts +4 -4
  108. package/dist/utils/spec-discovery.js +19 -6
  109. package/dist/utils/task-progress.d.ts +51 -1
  110. package/dist/utils/task-progress.js +93 -41
  111. package/package.json +1 -1
  112. package/schemas/spec-driven/schema.yaml +8 -6
  113. package/schemas/spec-driven/templates/proposal.md +6 -4
@@ -1,12 +1,31 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
- import { join } from 'path';
2
+ import path, { join } from 'path';
3
3
  import { MarkdownParser } from '../core/parsers/markdown-parser.js';
4
4
  import { Validator } from '../core/validation/validator.js';
5
5
  import { isInteractive } from '../utils/interactive.js';
6
6
  import { getSpecIds } from '../utils/item-discovery.js';
7
7
  import { discoverSpecFiles } from '../utils/spec-discovery.js';
8
+ import { FileSystemUtils } from '../utils/file-system.js';
8
9
  const SPECS_DIR = 'openspec/specs';
9
- function parseSpecFromFile(specPath, specId) {
10
+ function assertSpecPath(specsDir, specPath) {
11
+ const relativePath = path.relative(path.resolve(specsDir), path.resolve(specPath));
12
+ if (relativePath === '..' ||
13
+ relativePath.startsWith(`..${path.sep}`) ||
14
+ path.isAbsolute(relativePath)) {
15
+ throw new Error(`Path is outside the allowed directory: ${specPath}`);
16
+ }
17
+ try {
18
+ // Preserve confined spec.md links, including links to a sibling capability.
19
+ FileSystemUtils.assertPathWithin(specsDir, specPath);
20
+ }
21
+ catch {
22
+ // A capability directory may intentionally be a monorepo symlink. Treat it
23
+ // as the trust root while still rejecting a link outside that capability.
24
+ FileSystemUtils.assertPathWithin(path.dirname(specPath), specPath);
25
+ }
26
+ }
27
+ function parseSpecFromFile(specsDir, specPath, specId) {
28
+ assertSpecPath(specsDir, specPath);
10
29
  const content = readFileSync(specPath, 'utf-8');
11
30
  const parser = new MarkdownParser(content);
12
31
  return parser.parseSpec(specId);
@@ -41,7 +60,8 @@ function filterSpec(spec, options) {
41
60
  * Print the raw markdown content for a spec file without any formatting.
42
61
  * Raw-first behavior ensures text mode is a passthrough for deterministic output.
43
62
  */
44
- function printSpecTextRaw(specPath) {
63
+ function printSpecTextRaw(specsDir, specPath) {
64
+ assertSpecPath(specsDir, specPath);
45
65
  const content = readFileSync(specPath, 'utf-8');
46
66
  console.log(content);
47
67
  }
@@ -70,6 +90,7 @@ export class SpecCommand {
70
90
  }
71
91
  }
72
92
  const specPath = join(this.specsDir, specId, 'spec.md');
93
+ assertSpecPath(this.specsDir, specPath);
73
94
  if (!existsSync(specPath)) {
74
95
  // Root-aware callers get the absolute path; the cwd-based noun form
75
96
  // keeps its historical forward-slash relative message on all platforms.
@@ -80,7 +101,7 @@ export class SpecCommand {
80
101
  if (options.requirements && options.requirement) {
81
102
  throw new Error('Options --requirements and --requirement cannot be used together');
82
103
  }
83
- const parsed = parseSpecFromFile(specPath, specId);
104
+ const parsed = parseSpecFromFile(this.specsDir, specPath, specId);
84
105
  const filtered = filterSpec(parsed, options);
85
106
  const output = {
86
107
  id: specId,
@@ -94,7 +115,7 @@ export class SpecCommand {
94
115
  console.log(JSON.stringify(output, null, 2));
95
116
  return;
96
117
  }
97
- printSpecTextRaw(specPath);
118
+ printSpecTextRaw(this.specsDir, specPath);
98
119
  }
99
120
  }
100
121
  export function registerSpecCommand(rootProgram) {
@@ -138,7 +159,8 @@ export function registerSpecCommand(rootProgram) {
138
159
  const specs = discovered
139
160
  .map(({ id, specFile }) => {
140
161
  try {
141
- const spec = parseSpecFromFile(specFile, id);
162
+ assertSpecPath(SPECS_DIR, specFile);
163
+ const spec = parseSpecFromFile(SPECS_DIR, specFile, id);
142
164
  return {
143
165
  id,
144
166
  title: spec.name,
@@ -199,10 +221,12 @@ export function registerSpecCommand(rootProgram) {
199
221
  }
200
222
  }
201
223
  const specPath = join(SPECS_DIR, specId, 'spec.md');
224
+ assertSpecPath(SPECS_DIR, specPath);
202
225
  if (!existsSync(specPath)) {
203
226
  throw new Error(`Spec '${specId}' not found at openspec/specs/${specId}/spec.md`);
204
227
  }
205
228
  const validator = new Validator(options.strict);
229
+ assertSpecPath(SPECS_DIR, specPath);
206
230
  const report = await validator.validateSpec(specPath);
207
231
  if (options.json) {
208
232
  console.log(JSON.stringify(report, null, 2));
@@ -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);
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);
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);
@@ -7,13 +7,14 @@
7
7
  import ora from 'ora';
8
8
  import path from 'path';
9
9
  import * as fs from 'fs';
10
- import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
10
+ import { loadChangeContext, generateInstructions, resolveSchema, resolveArtifactOutputPath, resolveArtifactOutputs, } from '../../core/artifact-graph/index.js';
11
11
  import { getChangeDir, resolveCurrentPlanningHomeSync, } from '../../core/planning-home.js';
12
12
  import { resolveRootForCommand, withStoreFlag, toPlanningHome, toRootOutput, } from '../../core/root-selection.js';
13
13
  import { assembleReferenceIndex, renderReferencedStoresBlock, renderReferencedStoresSection, } from '../../core/references.js';
14
14
  import { readRegistrySnapshot } from '../../core/store/registry.js';
15
15
  import { loadOperationInputs, readProjectConfig, } from '../../core/project-config.js';
16
16
  import { validateChangeExists, validateSchemaExists, } from './shared.js';
17
+ import { parseTaskLines } from '../../utils/task-progress.js';
17
18
  // -----------------------------------------------------------------------------
18
19
  // Artifact Instructions Command
19
20
  // -----------------------------------------------------------------------------
@@ -206,25 +207,26 @@ export function printInstructionsText(instructions, isBlocked) {
206
207
  // Apply Instructions Command
207
208
  // -----------------------------------------------------------------------------
208
209
  /**
209
- * Parses tasks.md content and extracts task items with their completion status.
210
+ * Turns parsed task lines into the listed task items.
211
+ *
212
+ * A checkbox with no text after it is left out of the list: this is work for an
213
+ * agent to act on and tick off, and a bare `- [ ]` gives it nothing to match.
214
+ * It still counts toward progress, which is taken from every parsed line, so
215
+ * this list can be shorter than the totals beside it but never disagrees with
216
+ * `openspec list` or archive about how much work is left. An empty list is also
217
+ * what puts apply in its "nothing to work on" state, so a file of nothing but
218
+ * text-less checkboxes asks to be rewritten instead of being called done.
210
219
  */
211
- function parseTasksFile(content) {
220
+ function toTaskItems(parsed) {
212
221
  const tasks = [];
213
- const lines = content.split('\n');
214
- let taskIndex = 0;
215
- for (const line of lines) {
216
- // Match checkbox patterns: - [ ] or - [x] or - [X]
217
- const checkboxMatch = line.match(/^[-*]\s*\[([ xX])\]\s*(.+)\s*$/);
218
- if (checkboxMatch) {
219
- taskIndex++;
220
- const done = checkboxMatch[1].toLowerCase() === 'x';
221
- const description = checkboxMatch[2].trim();
222
- tasks.push({
223
- id: `${taskIndex}`,
224
- description,
225
- done,
226
- });
227
- }
222
+ for (const task of parsed) {
223
+ if (task.description.length === 0)
224
+ continue;
225
+ tasks.push({
226
+ id: `${tasks.length + 1}`,
227
+ description: task.description,
228
+ done: task.done,
229
+ });
228
230
  }
229
231
  return tasks;
230
232
  }
@@ -274,19 +276,21 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
274
276
  }
275
277
  }
276
278
  // Parse tasks if tracking file exists
277
- let tasks = [];
279
+ let parsedTasks = [];
278
280
  let tracksFileExists = false;
279
281
  if (tracksFile) {
280
- const tracksPath = path.join(changeDir, tracksFile);
282
+ const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
281
283
  tracksFileExists = fs.existsSync(tracksPath);
282
284
  if (tracksFileExists) {
283
285
  const tasksContent = await fs.promises.readFile(tracksPath, 'utf-8');
284
- tasks = parseTasksFile(tasksContent);
286
+ parsedTasks = parseTaskLines(tasksContent);
285
287
  }
286
288
  }
287
- // Calculate progress
288
- const total = tasks.length;
289
- const complete = tasks.filter((t) => t.done).length;
289
+ const tasks = toTaskItems(parsedTasks);
290
+ // Calculate progress over every checkbox in the file, listed or not, so these
291
+ // numbers match `openspec list` and archive's incomplete-task check.
292
+ const total = parsedTasks.length;
293
+ const complete = parsedTasks.filter((task) => task.done).length;
290
294
  const remaining = total - complete;
291
295
  // Determine state and instruction
292
296
  let state;
@@ -301,11 +305,12 @@ export async function generateApplyInstructions(projectRoot, changeName, schemaN
301
305
  state = 'blocked';
302
306
  instruction = `The ${tracksFilename} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
303
307
  }
304
- else if (tracksFile && tracksFileExists && total === 0) {
305
- // Tracking file exists but contains no tasks
308
+ else if (tracksFile && tracksFileExists && tasks.length === 0) {
309
+ // Tracking file exists but lists nothing an agent can work on: either no
310
+ // checkboxes at all, or only checkboxes with no text after them.
306
311
  const tracksFilename = path.basename(tracksFile);
307
312
  state = 'blocked';
308
- instruction = `The ${tracksFilename} file exists but contains no tasks.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
313
+ instruction = `The ${tracksFilename} file exists but contains no tasks to work on.\nAdd tasks to ${tracksFilename} or regenerate it with openspec-continue-change.`;
309
314
  }
310
315
  else if (tracksFile && remaining === 0 && total > 0) {
311
316
  state = 'all_done';
@@ -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;
@@ -89,9 +89,9 @@ export function printStatusText(status) {
89
89
  }
90
90
  console.log(line);
91
91
  }
92
- if (status.isComplete) {
92
+ if (status.isPlanningComplete) {
93
93
  console.log();
94
- console.log(chalk.green('All artifacts complete!'));
94
+ console.log(chalk.green('All planning artifacts complete!'));
95
95
  }
96
96
  }
97
97
  //# sourceMappingURL=status.js.map
@@ -39,11 +39,21 @@ export async function templatesCommand(options) {
39
39
  else {
40
40
  source = 'package';
41
41
  }
42
- const templates = graph.getAllArtifacts().map((artifact) => ({
43
- artifactId: artifact.id,
44
- templatePath: FileSystemUtils.canonicalizeExistingPath(path.join(schemaDir, 'templates', artifact.template)),
45
- source,
46
- }));
42
+ const templatesDir = path.join(schemaDir, 'templates');
43
+ const templates = graph.getAllArtifacts().map((artifact) => {
44
+ const templatePath = path.join(templatesDir, artifact.template);
45
+ try {
46
+ FileSystemUtils.assertPathWithin(templatesDir, templatePath);
47
+ return {
48
+ artifactId: artifact.id,
49
+ templatePath: FileSystemUtils.canonicalizeExistingPath(templatePath),
50
+ source,
51
+ };
52
+ }
53
+ catch {
54
+ throw new Error(`Template '${artifact.template}' for artifact '${artifact.id}' points outside the schema templates directory`);
55
+ }
56
+ });
47
57
  spinner?.stop();
48
58
  if (options.json) {
49
59
  const output = {};
@@ -1,3 +1,17 @@
1
+ /**
2
+ * True when the ONLY thing wrong with a rebuilt spec is that it has no
3
+ * requirements. That is the exact failure retiring a capability replaces
4
+ * (#1302); anything else means the spec is broken in a way the author still has
5
+ * to fix, so archive must abort exactly as it always did instead of retiring.
6
+ *
7
+ * Asking the validator - rather than counting requirement blocks a second time -
8
+ * is what makes "this spec could not have been written anyway" true by
9
+ * construction. The two counts genuinely disagree: `MarkdownParser` accepts any
10
+ * `###` heading under `## Requirements` as a requirement, while the delta block
11
+ * parser only indexes canonical `### Requirement:` headers and sweeps the rest
12
+ * into the preamble, which survives into the rebuilt spec.
13
+ */
14
+ export declare function isRetirableSpec(specName: string, rebuilt: string): Promise<boolean>;
1
15
  export interface ArchiveOptions {
2
16
  yes?: boolean;
3
17
  skipSpecs?: boolean;