freeharness 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +8 -7
  2. package/canonical/HARVEST.yaml +25 -14
  3. package/canonical/PROVENANCE.yaml +1 -1
  4. package/canonical/agents/README.md +77 -36
  5. package/canonical/agents/agentic-architect.md +2 -1
  6. package/canonical/agents/database-reviewer.md +6 -8
  7. package/canonical/agents/explorer-ai-engineer-analyst.md +2 -6
  8. package/canonical/agents/implementer.md +30 -0
  9. package/canonical/agents/pr-reviewer.md +9 -11
  10. package/canonical/agents/python-reviewer.md +7 -9
  11. package/canonical/agents/report-formatter.md +7 -3
  12. package/canonical/agents/security-reviewer.md +7 -8
  13. package/canonical/agents/test-writer.md +5 -2
  14. package/canonical/hooks/cost-prices.json +11 -9
  15. package/canonical/hooks/freeharness-config-guard.mjs +8072 -87
  16. package/canonical/hooks/freeharness-context-monitor.mjs +88 -54
  17. package/canonical/hooks/freeharness-converge.mjs +78 -23
  18. package/canonical/hooks/freeharness-cost-tracker.mjs +1129 -218
  19. package/canonical/hooks/freeharness-memory-guard.mjs +20 -23
  20. package/canonical/hooks/freeharness-precompact.mjs +217 -87
  21. package/canonical/hooks/freeharness-quota-tap.mjs +173 -0
  22. package/canonical/hooks/freeharness-reload-context.mjs +197 -0
  23. package/canonical/hooks/freeharness-repeat-tool.mjs +1 -1
  24. package/canonical/hooks/freeharness-sdd-context.mjs +970 -0
  25. package/canonical/hooks/freeharness-session-start +297 -59
  26. package/canonical/hooks/hooks.claude-code.json +1 -1
  27. package/canonical/hooks/hooks.config-guard.claude-code.json +3 -2
  28. package/canonical/hooks/hooks.context-monitor.claude-code.json +1 -1
  29. package/canonical/hooks/hooks.converge.claude-code.json +1 -1
  30. package/canonical/hooks/hooks.memory-guard.claude-code.json +3 -2
  31. package/canonical/hooks/hooks.reload-context.claude-code.json +15 -0
  32. package/canonical/openspec/schema.spec-driven.yaml +29 -8
  33. package/canonical/openspec/templates/follow-ups.md +3 -2
  34. package/canonical/openspec/templates/proposal.md +1 -1
  35. package/canonical/openspec/templates/tasks.md +8 -0
  36. package/canonical/rules/CLAUDE.md +5 -4
  37. package/canonical/rules/routing.md +10 -9
  38. package/canonical/skills/agent-evaluation/SKILL.md +17 -6
  39. package/canonical/skills/agent-evaluation/evals/cases.yaml +38 -23
  40. package/canonical/skills/agentic-architecture/SKILL.md +4 -0
  41. package/canonical/skills/agentic-architecture/evals/cases.yaml +27 -0
  42. package/canonical/skills/code-review/SKILL.md +9 -2
  43. package/canonical/skills/code-review/evals/cases.yaml +1118 -27
  44. package/canonical/skills/git-workflow/SKILL.md +2 -0
  45. package/canonical/skills/local-code/SKILL.md +2 -2
  46. package/canonical/skills/local-code/evals/cases.yaml +1 -1
  47. package/canonical/skills/report-formatting/SKILL.md +10 -2
  48. package/canonical/skills/report-formatting/reference/ARCHITECTURE.md +3 -3
  49. package/canonical/skills/report-formatting/reference/HELPERS.md +10 -5
  50. package/canonical/skills/report-formatting/reference/RELEASE.md +1 -2
  51. package/canonical/skills/report-formatting/reference/TEMPLATES.md +3 -2
  52. package/canonical/skills/report-formatting/reference/authoring.md +25 -37
  53. package/canonical/skills/report-formatting/scripts/build-architecture.js +1 -1
  54. package/canonical/skills/report-formatting/scripts/build-md-claro.js +5 -8
  55. package/canonical/skills/report-formatting/scripts/check-env.js +0 -4
  56. package/canonical/skills/report-formatting/scripts/docmodel.js +3 -11
  57. package/canonical/skills/report-formatting/scripts/example-build.js +8 -10
  58. package/canonical/skills/report-formatting/scripts/example_build_ArchitectureReview_docx.js +8 -10
  59. package/canonical/skills/report-formatting/scripts/example_build_ExecutiveReport_docx.js +8 -10
  60. package/canonical/skills/report-formatting/scripts/theme-claro.js +39 -10
  61. package/canonical/skills/subagent-driven-development/SKILL.md +35 -28
  62. package/canonical/skills/subagent-driven-development/evals/cases.yaml +3496 -19
  63. package/canonical/skills/subagent-driven-development/reference/brief.md +185 -35
  64. package/canonical/skills/subagent-driven-development/reference/handoff.md +82 -2
  65. package/canonical/skills/subagent-driven-development/reference/loop.md +57 -0
  66. package/canonical/skills/subagent-driven-development/reference/trees.md +40 -0
  67. package/canonical/skills/subagent-driven-development/reference/workspace.md +31 -0
  68. package/canonical/skills/subagent-driven-development/scripts/validate-handoff.mjs +4771 -326
  69. package/dist/adapters/harness/claude-code.d.ts +19 -3
  70. package/dist/adapters/harness/claude-code.js +204 -39
  71. package/dist/adapters/harness/opencode.js +16 -2
  72. package/dist/adapters/harness/plugin.js +28 -17
  73. package/dist/adapters/memory/doctor.d.ts +174 -29
  74. package/dist/adapters/memory/doctor.js +758 -134
  75. package/dist/adapters/memory/migrate.d.ts +95 -0
  76. package/dist/adapters/memory/migrate.js +497 -0
  77. package/dist/adapters/memory/paths.d.ts +22 -0
  78. package/dist/adapters/memory/paths.js +85 -0
  79. package/dist/adapters/memory/vendored.d.ts +56 -29
  80. package/dist/adapters/memory/vendored.js +191 -154
  81. package/dist/adapters/sync/diff.js +4 -1
  82. package/dist/cli/cleanup-prompt.d.ts +35 -0
  83. package/dist/cli/cleanup-prompt.js +68 -0
  84. package/dist/cli/cleanup.d.ts +22 -24
  85. package/dist/cli/cleanup.js +182 -68
  86. package/dist/cli/cost-quota.d.ts +158 -0
  87. package/dist/cli/cost-quota.js +645 -0
  88. package/dist/cli/cost.d.ts +408 -46
  89. package/dist/cli/cost.js +1731 -57
  90. package/dist/cli/doctor.d.ts +52 -13
  91. package/dist/cli/doctor.js +193 -44
  92. package/dist/cli/hard-cleanup.d.ts +93 -0
  93. package/dist/cli/hard-cleanup.js +552 -0
  94. package/dist/cli/install-facts.d.ts +28 -0
  95. package/dist/cli/install-facts.js +66 -0
  96. package/dist/cli/json-key-edit.d.ts +18 -0
  97. package/dist/cli/json-key-edit.js +118 -0
  98. package/dist/cli/memory-import.d.ts +74 -0
  99. package/dist/cli/memory-import.js +469 -0
  100. package/dist/cli/plugin-validate.d.ts +12 -0
  101. package/dist/cli/plugin-validate.js +43 -0
  102. package/dist/cli/uninstall.d.ts +1 -1
  103. package/dist/cli/uninstall.js +2 -2
  104. package/dist/cli/upstream-launchers.d.ts +49 -0
  105. package/dist/cli/upstream-launchers.js +287 -0
  106. package/dist/index.js +247 -70
  107. package/dist/infra/fsutil.d.ts +31 -3
  108. package/dist/infra/fsutil.js +69 -6
  109. package/dist/infra/manifest.d.ts +2 -1
  110. package/dist/infra/manifest.js +3 -2
  111. package/dist/infra/prices.d.ts +18 -0
  112. package/dist/infra/prices.js +64 -0
  113. package/node_modules/@freeharness/core/dist/tiers.d.ts +10 -1
  114. package/node_modules/@freeharness/core/dist/tiers.js +24 -2
  115. package/node_modules/@freeharness/core/package.json +1 -1
  116. package/node_modules/@freeharness/sdd/PROVENANCE.yaml +279 -2
  117. package/node_modules/@freeharness/sdd/dist/src/bin/openspec.js +34 -8
  118. package/node_modules/@freeharness/sdd/dist/src/shim/archive.d.ts +7 -6
  119. package/node_modules/@freeharness/sdd/dist/src/shim/archive.js +72 -64
  120. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.d.ts +48 -16
  121. package/node_modules/@freeharness/sdd/dist/src/shim/follow-up.js +168 -70
  122. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.d.ts +36 -6
  123. package/node_modules/@freeharness/sdd/dist/src/shim/instructions.js +110 -49
  124. package/node_modules/@freeharness/sdd/dist/src/shim/list.d.ts +1 -1
  125. package/node_modules/@freeharness/sdd/dist/src/shim/list.js +57 -14
  126. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.d.ts +7 -0
  127. package/node_modules/@freeharness/sdd/dist/src/shim/nested-change.js +118 -0
  128. package/node_modules/@freeharness/sdd/dist/src/shim/new-change.js +3 -1
  129. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.d.ts +12 -0
  130. package/node_modules/@freeharness/sdd/dist/src/shim/proposal-capabilities.js +94 -0
  131. package/node_modules/@freeharness/sdd/dist/src/shim/shared.d.ts +17 -0
  132. package/node_modules/@freeharness/sdd/dist/src/shim/shared.js +182 -1
  133. package/node_modules/@freeharness/sdd/dist/src/shim/status.d.ts +2 -2
  134. package/node_modules/@freeharness/sdd/dist/src/shim/status.js +34 -5
  135. package/node_modules/@freeharness/sdd/dist/src/shim/validate.d.ts +9 -0
  136. package/node_modules/@freeharness/sdd/dist/src/shim/validate.js +49 -41
  137. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.d.ts +21 -0
  138. package/node_modules/@freeharness/sdd/dist/src/shim/warnings.js +66 -0
  139. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.d.ts +4 -0
  140. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/line-endings.js +17 -0
  141. package/node_modules/@freeharness/sdd/dist/vendor/openspec/apply/specs-apply.js +95 -22
  142. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.d.ts +6 -0
  143. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/change-status-policy.js +14 -6
  144. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.d.ts +1 -1
  145. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/index.js +1 -1
  146. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.d.ts +3 -3
  147. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/outputs.js +24 -4
  148. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/task-progress.js +4 -7
  149. package/node_modules/@freeharness/sdd/dist/vendor/openspec/engine/types.js +13 -1
  150. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/apply-change.js +21 -6
  151. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/archive-change.js +80 -19
  152. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/explore.js +42 -22
  153. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.d.ts +32 -0
  154. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/project-root.js +39 -0
  155. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/propose.js +16 -9
  156. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/sync-specs.js +36 -1
  157. package/node_modules/@freeharness/sdd/dist/vendor/openspec/generation/templates/workflows/update-change.js +65 -30
  158. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.d.ts +1 -1
  159. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/constants.js +1 -1
  160. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.d.ts +8 -13
  161. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/change-parser.js +79 -102
  162. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/markdown-parser.js +4 -9
  163. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.d.ts +50 -10
  164. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-blocks.js +165 -37
  165. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.d.ts +24 -3
  166. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/requirement-text.js +55 -8
  167. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/parsers/spec-structure.js +4 -1
  168. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/purpose-placeholder.js +19 -9
  169. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.d.ts +10 -0
  170. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/spec-discovery.js +115 -0
  171. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.d.ts +3 -3
  172. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/task-lines.js +20 -20
  173. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.d.ts +6 -0
  174. package/node_modules/@freeharness/sdd/dist/vendor/openspec/validation/validator.js +75 -14
  175. package/node_modules/@freeharness/sdd/package.json +1 -1
  176. package/node_modules/@freeharness/sdd/vendor/openspec/assets/schema.upstream.yaml +47 -7
  177. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-apply-change/SKILL.md +25 -7
  178. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-archive-change/SKILL.md +50 -11
  179. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-bulk-archive-change/SKILL.md +48 -10
  180. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-continue-change/SKILL.md +27 -7
  181. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-explore/SKILL.md +25 -12
  182. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-ff-change/SKILL.md +17 -5
  183. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-new-change/SKILL.md +12 -1
  184. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-onboard/SKILL.md +47 -33
  185. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-propose/SKILL.md +17 -5
  186. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-sync-specs/SKILL.md +29 -1
  187. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-update-change/SKILL.md +31 -13
  188. package/node_modules/@freeharness/sdd/vendor/openspec/assets/skills/openspec-verify-change/SKILL.md +75 -27
  189. package/package.json +6 -4
  190. package/provenance/canonical.PROVENANCE.yaml +1 -1
  191. package/provenance/memory.PROVENANCE.yaml +458 -6
  192. package/provenance/sdd.PROVENANCE.yaml +279 -2
  193. package/canonical/hooks/freeharness-auto-memory.mjs +0 -158
  194. package/canonical/hooks/hooks.auto-memory.claude-code.json +0 -16
@@ -13,8 +13,11 @@
13
13
  import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import { parse as parseYaml } from 'yaml';
16
- import { loadChangeContext, generateInstructions, resolveSchema, resolveSchemaForChange, getSchemaDir, resolveArtifactOutputPath, resolveArtifactOutputs, parseTaskLines, METADATA_FILENAME, } from '../../vendor/openspec/engine/index.js';
17
- import { planningHomeStub, printJson, validateChangeExists, validateSchemaExists } from './shared.js';
16
+ import { loadChangeContext, generateInstructions, resolveSchema, resolveSchemaForChange, getSchemaDir, resolveArtifactOutputs, parseTaskLines, METADATA_FILENAME, } from '../../vendor/openspec/engine/index.js';
17
+ import { findUnreadDeltaFilesSync } from '../../vendor/openspec/validation/spec-discovery.js';
18
+ import { escapeEnvelopeAttribute, escapeEnvelopeTags, findMissingTaskCheckboxWarning, planningHomeStub, printJson, sanitizeInline, validateChangeExists, validateSchemaExists, } from './shared.js';
19
+ import { missingCapabilitySpecs } from './proposal-capabilities.js';
20
+ import { missingDeltaSpecsWarning, specsIncompleteWarning, tasksWithoutCheckboxWarning, unknownArtifactIdWarning, unreadDeltaFileWarning, } from './warnings.js';
18
21
  const SPECIAL_TARGETS = new Set(['apply', 'archive']);
19
22
  export function instructionsHandler(projectRoot, artifactId, options) {
20
23
  const changeName = validateChangeExists(options.change, projectRoot);
@@ -48,7 +51,10 @@ export function instructionsHandler(projectRoot, artifactId, options) {
48
51
  }
49
52
  export function printInstructionsText(instructions, isBlocked) {
50
53
  const { artifactId, changeName, schemaName, changeDir, resolvedOutputPath, description, instruction, template, dependencies, unlocks, } = instructions;
51
- console.log(`<artifact id="${artifactId}" change="${changeName}" schema="${schemaName}">`);
54
+ // a quote in changeName closes the attribute otherwise
55
+ console.log(`<artifact id="${escapeEnvelopeAttribute(artifactId)}"` +
56
+ ` change="${escapeEnvelopeAttribute(changeName)}"` +
57
+ ` schema="${escapeEnvelopeAttribute(schemaName)}">`);
52
58
  console.log();
53
59
  if (instructions.skipped) {
54
60
  console.log('<warning>');
@@ -62,13 +68,13 @@ export function printInstructionsText(instructions, isBlocked) {
62
68
  const missing = dependencies.filter((d) => !d.done).map((d) => d.id);
63
69
  console.log('<warning>');
64
70
  console.log('This artifact has unmet dependencies. Complete them first or proceed with caution.');
65
- console.log(`Missing: ${missing.join(', ')}`);
71
+ console.log(`Missing: ${escapeEnvelopeTags(missing.join(', '))}`);
66
72
  console.log('</warning>');
67
73
  console.log();
68
74
  }
69
75
  console.log('<task>');
70
- console.log(`Create the ${artifactId} artifact for change "${changeName}".`);
71
- console.log(description);
76
+ console.log(`Create the ${escapeEnvelopeTags(artifactId)} artifact for change "${escapeEnvelopeTags(changeName)}".`);
77
+ console.log(escapeEnvelopeTags(description));
72
78
  console.log('</task>');
73
79
  console.log();
74
80
  if (dependencies.length > 0) {
@@ -77,50 +83,70 @@ export function printInstructionsText(instructions, isBlocked) {
77
83
  console.log();
78
84
  for (const dep of dependencies) {
79
85
  if (dep.skipped) {
80
- console.log(`<dependency id="${dep.id}" status="skipped">`);
86
+ console.log(`<dependency id="${escapeEnvelopeAttribute(dep.id)}" status="skipped">`);
81
87
  console.log(' <description>Skipped: the change declares skip_specs, so this artifact has no files to read.</description>');
82
88
  console.log('</dependency>');
83
89
  continue;
84
90
  }
85
91
  const status = dep.done ? 'done' : 'missing';
86
92
  const fullPath = path.join(changeDir, dep.path);
87
- console.log(`<dependency id="${dep.id}" status="${status}">`);
88
- console.log(` <path>${fullPath}</path>`);
89
- console.log(` <description>${dep.description}</description>`);
93
+ console.log(`<dependency id="${escapeEnvelopeAttribute(dep.id)}" status="${status}">`);
94
+ console.log(` <path>${escapeEnvelopeTags(fullPath)}</path>`);
95
+ console.log(` <description>${escapeEnvelopeTags(dep.description)}</description>`);
90
96
  console.log('</dependency>');
91
97
  }
92
98
  console.log('</dependencies>');
93
99
  console.log();
94
100
  }
95
101
  console.log('<output>');
96
- console.log(`Write to: ${resolvedOutputPath}`);
102
+ console.log(`Write to: ${escapeEnvelopeTags(resolvedOutputPath)}`);
97
103
  console.log('</output>');
98
104
  console.log();
99
105
  if (instruction) {
100
106
  console.log('<instruction>');
101
- console.log(instruction.trim());
107
+ console.log(escapeEnvelopeTags(instruction.trim()));
102
108
  console.log('</instruction>');
103
109
  console.log();
104
110
  }
105
111
  console.log('<template>');
106
112
  console.log('<!-- Use this as the structure for your output file. Fill in the sections. -->');
107
- console.log(template.trim());
113
+ // Copied verbatim into the output file: only the envelope's own tags are neutralized.
114
+ console.log(escapeEnvelopeTags(template.trim()));
108
115
  console.log('</template>');
109
116
  console.log();
110
117
  if (unlocks.length > 0) {
111
118
  console.log('<unlocks>');
112
- console.log(`Completing this artifact enables: ${unlocks.join(', ')}`);
119
+ console.log(`Completing this artifact enables: ${escapeEnvelopeTags(unlocks.join(', '))}`);
113
120
  console.log('</unlocks>');
114
121
  console.log();
115
122
  }
116
123
  console.log('</artifact>');
117
124
  }
125
+ /**
126
+ * Parses one line at a time so each task keeps its own index in the file; the
127
+ * engine reader stays the single definition of what a task line is.
128
+ */
129
+ export function parseLocatedTasks(content, sourcePath) {
130
+ const tasks = [];
131
+ for (const [index, line] of content.split('\n').entries()) {
132
+ const [task] = parseTaskLines(line);
133
+ if (task)
134
+ tasks.push({ ...task, sourcePath, line: index + 1 });
135
+ }
136
+ return tasks;
137
+ }
118
138
  function toTaskItems(parsed) {
119
139
  const tasks = [];
120
140
  for (const task of parsed) {
121
141
  if (task.description.length === 0)
122
142
  continue;
123
- tasks.push({ id: `${tasks.length + 1}`, description: task.description, done: task.done });
143
+ tasks.push({
144
+ id: `${tasks.length + 1}`,
145
+ description: task.description,
146
+ done: task.done,
147
+ sourcePath: task.sourcePath,
148
+ line: task.line,
149
+ });
124
150
  }
125
151
  return tasks;
126
152
  }
@@ -129,22 +155,29 @@ const SPECS_PATH_PREFIX = 'specs/';
129
155
  function isSpecsArtifactPath(generates) {
130
156
  return generates.replace(/^(?:\.\/)+/, '').startsWith(SPECS_PATH_PREFIX);
131
157
  }
158
+ /** Empty when the schema writes no specs or the change skips any of them, so neither case warns. */
159
+ function activeSpecArtifacts(artifacts, skippedArtifacts) {
160
+ const specArtifacts = artifacts.filter((artifact) => isSpecsArtifactPath(artifact.generates));
161
+ return specArtifacts.some((artifact) => skippedArtifacts?.has(artifact.id)) ? [] : specArtifacts;
162
+ }
163
+ function missingCapabilityWarnings(changeDir) {
164
+ const missing = missingCapabilitySpecs(changeDir).map((capability) => sanitizeInline(capability));
165
+ return missing.length > 0 ? [specsIncompleteWarning(missing)] : [];
166
+ }
132
167
  /**
133
- * Every artifact still to build before apply's own gate is satisfied, in the
134
- * schema's declared build order - the transitive closure of `apply.requires`.
135
- *
136
- * Apply blocks on the first artifact in `requiredArtifactIds` that is not yet
137
- * complete, so naming only that one hides the rest of the chain: a change
138
- * holding only a proposal, whose schema chains specs -> design -> tasks,
139
- * would be told only "Missing artifacts: tasks" - an instruction that, taken
140
- * literally, skips every artifact tasks itself depends on.
168
+ * The status side of the proposal-coverage check. Unlike apply it also fires
169
+ * with no delta at all, since status has no zero-delta warning to defer to.
141
170
  */
171
+ export function collectSpecsIncompleteWarnings(artifacts, changeDir, skippedArtifacts) {
172
+ return activeSpecArtifacts(artifacts, skippedArtifacts).length > 0 ? missingCapabilityWarnings(changeDir) : [];
173
+ }
142
174
  /**
143
- * Every artifact still to build (`missing`), plus a warning for any id in the
144
- * `requires` chain that names no artifact in the schema. An unknown id used to
145
- * be dropped by the `if (!artifact) continue` below with no trace anywhere in
146
- * the instructions payload - a typo in a schema's `requires` list read as an
147
- * empty, satisfied dependency instead of the configuration error it is.
175
+ * Every artifact in the transitive closure of `apply.requires` still to build
176
+ * (`missing`), in the schema's build order, since naming only the first hop
177
+ * apply blocks on would hide the rest of the chain. Plus a warning for any
178
+ * id in the `requires` chain that names no artifact in the schema: without it,
179
+ * a typo in a schema's `requires` list reads as an empty, satisfied dependency
180
+ * instead of the configuration error it is.
148
181
  */
149
182
  function collectMissingPrerequisites(requiredArtifactIds, schema, buildOrder, completed) {
150
183
  const byId = new Map(schema.artifacts.map((artifact) => [artifact.id, artifact]));
@@ -175,9 +208,7 @@ function collectMissingPrerequisites(requiredArtifactIds, schema, buildOrder, co
175
208
  // real artifact. Pushing it to the end keeps real artifacts in their proper
176
209
  // order and leaves the broken one visibly last.
177
210
  const missingSorted = [...missing].sort((a, b) => (order.get(a) ?? Number.MAX_SAFE_INTEGER) - (order.get(b) ?? Number.MAX_SAFE_INTEGER));
178
- const warnings = unknownIds.length > 0
179
- ? [`Schema declares a dependency on an artifact id not defined in this schema: ${unknownIds.join(', ')}.`]
180
- : [];
211
+ const warnings = unknownIds.length > 0 ? [unknownArtifactIdWarning(unknownIds)] : [];
181
212
  return { missing: missingSorted, warnings };
182
213
  }
183
214
  /**
@@ -193,18 +224,22 @@ function collectMissingPrerequisites(requiredArtifactIds, schema, buildOrder, co
193
224
  * required artifact, the absent specs are the next step, not a warning. A
194
225
  * schema with no spec-producing artifact, or a change carrying `skip_specs`,
195
226
  * never triggers it.
227
+ *
228
+ * A delta file the merge path never reads still satisfies the specs glob,
229
+ * so it reads as written here while validate rejects it. Each one is named.
230
+ * One delta also satisfies the glob, so a capability the proposal lists
231
+ * without its spec.md is named too.
196
232
  */
197
233
  function collectApplyWarnings(state, schema, changeDir, changeName, skippedArtifacts) {
198
234
  if (state === 'blocked')
199
235
  return [];
200
- const specArtifacts = schema.artifacts.filter((artifact) => isSpecsArtifactPath(artifact.generates));
236
+ const specArtifacts = activeSpecArtifacts(schema.artifacts, skippedArtifacts);
201
237
  if (specArtifacts.length === 0)
202
238
  return [];
203
- if (specArtifacts.some((artifact) => skippedArtifacts?.has(artifact.id)))
204
- return [];
239
+ const unreadWarnings = findUnreadDeltaFilesSync(path.join(changeDir, 'specs')).map((file) => unreadDeltaFileWarning(changeName, file));
205
240
  const hasDeltas = specArtifacts.some((artifact) => resolveArtifactOutputs(changeDir, artifact.generates).length > 0);
206
241
  if (hasDeltas)
207
- return [];
242
+ return [...unreadWarnings, ...missingCapabilityWarnings(changeDir)];
208
243
  const metadataPath = path.join(changeDir, METADATA_FILENAME);
209
244
  // Names the artifact this schema actually declares rather than the literal
210
245
  // "specs": a schema whose spec-producing artifact is, say, "contracts" would
@@ -212,11 +247,7 @@ function collectApplyWarnings(state, schema, changeDir, changeName, skippedArtif
212
247
  // With more than one such artifact there is no single right answer, so the
213
248
  // id becomes a placeholder instead of a guess.
214
249
  const specTarget = specArtifacts.length === 1 ? specArtifacts[0].id : '<artifact-id>';
215
- return [
216
- `This change has no delta specs and does not declare \`skip_specs: true\`, so \`openspec validate ${changeName}\` fails on it. ` +
217
- `Write the delta specs before implementing (\`openspec instructions ${specTarget} --change ${changeName}\`), ` +
218
- `or add \`skip_specs: true\` to ${metadataPath} if this change really changes no specified behavior.`,
219
- ];
250
+ return [...unreadWarnings, missingDeltaSpecsWarning(changeName, specTarget, metadataPath)];
220
251
  }
221
252
  function buildApplyInstructions(projectRoot, changeName, schemaName) {
222
253
  const context = loadChangeContext(projectRoot, changeName, schemaName);
@@ -241,13 +272,25 @@ function buildApplyInstructions(projectRoot, changeName, schemaName) {
241
272
  if (outputs.length > 0)
242
273
  contextFiles[artifact.id] = outputs;
243
274
  }
275
+ // tracksFile may be a glob; resolve every concrete file it matches.
244
276
  let parsedTasks = [];
277
+ const unavailableTrackingFiles = [];
245
278
  let tracksFileExists = false;
246
279
  if (tracksFile) {
247
- const tracksPath = resolveArtifactOutputPath(changeDir, tracksFile);
248
- tracksFileExists = fs.existsSync(tracksPath);
249
- if (tracksFileExists) {
250
- parsedTasks = parseTaskLines(fs.readFileSync(tracksPath, 'utf-8'));
280
+ const tracksPaths = resolveArtifactOutputs(changeDir, tracksFile);
281
+ tracksFileExists = tracksPaths.length > 0;
282
+ for (const tracksPath of tracksPaths) {
283
+ try {
284
+ parsedTasks.push(...parseLocatedTasks(fs.readFileSync(tracksPath, 'utf-8'), tracksPath));
285
+ }
286
+ catch (error) {
287
+ const code = error?.code;
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ unavailableTrackingFiles.push({
290
+ path: tracksPath,
291
+ reason: code && !message.includes(code) ? `${code}: ${message}` : message,
292
+ });
293
+ }
251
294
  }
252
295
  }
253
296
  const tasks = toTaskItems(parsedTasks);
@@ -264,13 +307,17 @@ function buildApplyInstructions(projectRoot, changeName, schemaName) {
264
307
  state = 'blocked';
265
308
  instruction = `The ${path.basename(tracksFile)} file is missing and must be created.\nUse openspec-continue-change to generate the tracking file.`;
266
309
  }
310
+ else if (tracksFile && unavailableTrackingFiles.length > 0 && tasks.length === 0) {
311
+ state = 'blocked';
312
+ instruction = 'No readable task descriptions are available.';
313
+ }
267
314
  else if (tracksFile && tracksFileExists && tasks.length === 0) {
268
315
  state = 'blocked';
269
316
  instruction = `The ${path.basename(tracksFile)} file exists but contains no tasks to work on.\nAdd tasks or regenerate it with openspec-continue-change.`;
270
317
  }
271
- else if (tracksFile && remaining === 0 && total > 0) {
318
+ else if (tracksFile && unavailableTrackingFiles.length === 0 && remaining === 0 && total > 0) {
272
319
  state = 'all_done';
273
- instruction = 'All tasks are complete! This change is ready to be archived.';
320
+ instruction = 'All tracked tasks are complete.\nReview or verify the change as appropriate before archiving.';
274
321
  }
275
322
  else if (!tracksFile) {
276
323
  state = 'ready';
@@ -282,14 +329,23 @@ function buildApplyInstructions(projectRoot, changeName, schemaName) {
282
329
  schemaInstruction?.trim() ??
283
330
  'Read context files, work through pending tasks, mark complete as you go.\nPause if you hit blockers or need clarification.';
284
331
  }
332
+ if (unavailableTrackingFiles.length > 0) {
333
+ const details = unavailableTrackingFiles.map((file) => `- ${file.path}: ${file.reason}`).join('\n');
334
+ instruction += `\nTask completion is not verified because tracking evidence was unavailable:\n${details}`;
335
+ }
285
336
  // Computed regardless of state: a "ready" change (apply.requires satisfied)
286
337
  // can still be missing the artifacts its own required artifact transitively
287
338
  // depends on - the schema's own gate does not require them, so this is the
288
339
  // only place that names the rest of the chain.
289
340
  const { missing: missingPrerequisites, warnings: unknownIdWarnings } = collectMissingPrerequisites(requiredArtifactIds, schema, context.graph.getBuildOrder(), context.completed);
341
+ // Orthogonal to `state`, and checked even when blocked: a tracks file with
342
+ // no checkbox reads as zero tasks and blocks apply with "contains no tasks
343
+ // to work on", which names the symptom but not the cause (#1774).
344
+ const checkboxWarning = findMissingTaskCheckboxWarning(changeDir, projectRoot);
290
345
  const warnings = [
291
346
  ...unknownIdWarnings,
292
347
  ...collectApplyWarnings(state, schema, changeDir, changeName, context.skippedArtifacts),
348
+ ...(checkboxWarning ? [tasksWithoutCheckboxWarning(checkboxWarning)] : []),
293
349
  ];
294
350
  return {
295
351
  changeName,
@@ -298,6 +354,8 @@ function buildApplyInstructions(projectRoot, changeName, schemaName) {
298
354
  contextFiles,
299
355
  progress: { total, complete, remaining },
300
356
  tasks,
357
+ taskTrackingConfigured: tracksFile !== null,
358
+ ...(unavailableTrackingFiles.length > 0 ? { unavailableTrackingFiles } : {}),
301
359
  state,
302
360
  ...(missingArtifacts.length > 0 ? { missingArtifacts } : {}),
303
361
  ...(missingPrerequisites.length > 0 ? { missingPrerequisites } : {}),
@@ -346,7 +404,7 @@ function applyInstructions(projectRoot, changeName, options) {
346
404
  if (instructions.warnings && instructions.warnings.length > 0) {
347
405
  console.log('### Warnings');
348
406
  for (const warning of instructions.warnings) {
349
- console.log(`- ${warning}`);
407
+ console.log(`- ${warning.message}`);
350
408
  }
351
409
  console.log();
352
410
  }
@@ -367,7 +425,8 @@ function applyInstructions(projectRoot, changeName, options) {
367
425
  if (instructions.tasks.length > 0) {
368
426
  console.log('### Tasks');
369
427
  for (const task of instructions.tasks) {
370
- console.log(`- ${task.done ? '[x]' : '[ ]'} ${task.description}`);
428
+ // Upstream prints no location; a text reader needs it as much as the JSON skill does.
429
+ console.log(`- ${task.done ? '[x]' : '[ ]'} ${task.description} (${task.sourcePath}:${task.line})`);
371
430
  }
372
431
  console.log();
373
432
  }
@@ -442,14 +501,16 @@ function archiveInstructions(projectRoot, changeName, options) {
442
501
  console.log(`## Archive Inputs: ${changeName}`);
443
502
  console.log();
444
503
  if (payload.context) {
504
+ // Verbatim on purpose: escaping '#' would also fire inside fenced code.
445
505
  console.log('### Project Context (required instruction input)');
446
506
  console.log(payload.context);
447
507
  console.log();
448
508
  }
449
509
  if (payload.operationGuidance) {
450
510
  console.log('### Operation Guidance (advisory)');
511
+ // Flattened, never truncated: guidance an agent has to follow in full.
451
512
  for (const g of payload.operationGuidance)
452
- console.log(`- ${g}`);
513
+ console.log(`- ${sanitizeInline(g, Infinity)}`);
453
514
  console.log();
454
515
  }
455
516
  if (!payload.context && !payload.operationGuidance) {
@@ -2,4 +2,4 @@ export interface ListOptions {
2
2
  json?: boolean;
3
3
  sort?: 'recent' | 'name';
4
4
  }
5
- export declare function listHandler(projectRoot: string, options: ListOptions): Promise<number>;
5
+ export declare function listHandler(projectRoot: string | null, options: ListOptions): Promise<number>;
@@ -2,24 +2,43 @@
2
2
  //
3
3
  // Reconstructed from src/core/list.ts (changes mode), minus store/root-selection.
4
4
  // JSON shape matches upstream: { changes: [{ name, completedTasks, totalTasks,
5
- // lastModified, status }] }, sorted most-recently-modified first by default.
5
+ // lastModified, status }], root }, sorted most-recently-modified first by
6
+ // default. Root resolution is the caller's job (bin.ts): a null projectRoot
7
+ // still yields a valid JSON document, `{ changes: [], root: null }`, on exit
8
+ // code 1, and creates nothing on disk, so a caller such as the emitted /opsx
9
+ // workflows can probe for a project without a write.
6
10
  import * as fs from 'node:fs';
7
11
  import * as path from 'node:path';
8
12
  import { getTaskProgressForChange, formatTaskStatus } from '../../vendor/openspec/engine/index.js';
9
13
  import { changesDirFor, printableId, printJson } from './shared.js';
14
+ import { describeNestedChange, findNestedChanges } from './nested-change.js';
15
+ import { nestedChangeWarning } from './warnings.js';
16
+ /** A removed-mid-listing or dangling/looping symlink entry: skip it rather than fail the listing. */
17
+ function isUnresolvableEntryError(error) {
18
+ if (typeof error !== 'object' || error === null || !('code' in error))
19
+ return false;
20
+ const code = error.code;
21
+ return code === 'ENOENT' || code === 'ELOOP';
22
+ }
10
23
  /** Most recent mtime of any file under a directory (recursive). */
11
24
  function getLastModified(dirPath) {
12
25
  let latest = null;
13
26
  const walk = (dir) => {
14
27
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
15
28
  const full = path.join(dir, entry.name);
16
- if (entry.isDirectory()) {
17
- walk(full);
29
+ try {
30
+ if (entry.isDirectory()) {
31
+ walk(full);
32
+ }
33
+ else {
34
+ const stat = fs.statSync(full);
35
+ if (latest === null || stat.mtime > latest)
36
+ latest = stat.mtime;
37
+ }
18
38
  }
19
- else {
20
- const stat = fs.statSync(full);
21
- if (latest === null || stat.mtime > latest)
22
- latest = stat.mtime;
39
+ catch (error) {
40
+ if (!isUnresolvableEntryError(error))
41
+ throw error;
23
42
  }
24
43
  }
25
44
  };
@@ -28,6 +47,14 @@ function getLastModified(dirPath) {
28
47
  }
29
48
  export async function listHandler(projectRoot, options) {
30
49
  const { json = false, sort = 'recent' } = options;
50
+ if (!projectRoot) {
51
+ if (json)
52
+ printJson({ changes: [], root: null });
53
+ else
54
+ console.error('No OpenSpec root found. Run this inside a project with an openspec/ directory.');
55
+ return 1;
56
+ }
57
+ const root = { path: projectRoot };
31
58
  const changesDir = changesDirFor(projectRoot);
32
59
  let entries;
33
60
  try {
@@ -44,11 +71,13 @@ export async function listHandler(projectRoot, options) {
44
71
  .map((e) => e.name);
45
72
  if (changeDirs.length === 0) {
46
73
  if (json)
47
- printJson({ changes: [] });
74
+ printJson({ changes: [], root });
48
75
  else
49
76
  console.log('No active changes found.');
50
77
  return 0;
51
78
  }
79
+ const nestedFindings = findNestedChanges(changesDir, changeDirs);
80
+ const nestedByName = new Map(nestedFindings.map((f) => [f.name, f]));
52
81
  const changes = [];
53
82
  for (const name of changeDirs) {
54
83
  const progress = await getTaskProgressForChange(changesDir, name, projectRoot);
@@ -59,6 +88,7 @@ export async function listHandler(projectRoot, options) {
59
88
  openTasks: progress.open,
60
89
  dispositionedTasks: progress.dispositioned,
61
90
  lastModified: getLastModified(path.join(changesDir, name)),
91
+ ...(nestedByName.has(name) ? { nested: nestedByName.get(name).nested } : {}),
62
92
  });
63
93
  }
64
94
  if (sort === 'recent') {
@@ -75,7 +105,14 @@ export async function listHandler(projectRoot, options) {
75
105
  totalTasks: c.totalTasks,
76
106
  lastModified: c.lastModified.toISOString(),
77
107
  status: c.totalTasks === 0 ? 'no-tasks' : c.openTasks === 0 ? 'complete' : 'in-progress',
108
+ ...(c.nested ? { nested: c.nested } : {}),
78
109
  })),
110
+ ...(nestedFindings.length > 0
111
+ ? {
112
+ warnings: nestedFindings.map(nestedChangeWarning),
113
+ }
114
+ : {}),
115
+ root,
79
116
  });
80
117
  return 0;
81
118
  }
@@ -83,13 +120,19 @@ export async function listHandler(projectRoot, options) {
83
120
  const names = new Map(changes.map((c) => [c.name, printableId(c.name)]));
84
121
  const nameWidth = Math.max(...[...names.values()].map((n) => n.length));
85
122
  for (const change of changes) {
86
- const status = formatTaskStatus({
87
- total: change.totalTasks,
88
- completed: change.completedTasks,
89
- open: change.openTasks,
90
- dispositioned: change.dispositionedTasks,
91
- });
123
+ const status = change.nested
124
+ ? 'not a change'
125
+ : formatTaskStatus({
126
+ total: change.totalTasks,
127
+ completed: change.completedTasks,
128
+ open: change.openTasks,
129
+ dispositioned: change.dispositionedTasks,
130
+ });
92
131
  console.log(` ${names.get(change.name).padEnd(nameWidth)} ${status}`);
93
132
  }
133
+ for (const finding of nestedFindings) {
134
+ console.log();
135
+ console.log(`Warning: ${describeNestedChange(finding)}`);
136
+ }
94
137
  return 0;
95
138
  }
@@ -0,0 +1,7 @@
1
+ export interface NestedChangeFinding {
2
+ name: string;
3
+ nested: string[];
4
+ }
5
+ export declare function findNestedChangesIn(changesDir: string, name: string): NestedChangeFinding | undefined;
6
+ export declare function findNestedChanges(changesDir: string, names: string[]): NestedChangeFinding[];
7
+ export declare function describeNestedChange(finding: NestedChangeFinding): string;
@@ -0,0 +1,118 @@
1
+ // Detects `changes/<area>/<name>/` namespace folders wrapping nested change
2
+ // directories, which OpenSpec does not support: a change is a directory
3
+ // directly under `changes/` and nothing else.
4
+ import * as fs from 'node:fs';
5
+ import * as path from 'node:path';
6
+ import { artifactOutputExists, resolveSchema, resolveSchemaForChange, METADATA_FILENAME, } from '../../vendor/openspec/engine/index.js';
7
+ const CHANGE_ROOT_MARKERS = [METADATA_FILENAME, 'proposal.md', 'tasks.md', 'design.md'];
8
+ const DELTA_SPECS_DIR = 'specs';
9
+ const MAX_NESTING_DEPTH = 3;
10
+ function hasChangeRootMarker(dir) {
11
+ return CHANGE_ROOT_MARKERS.some((marker) => {
12
+ try {
13
+ return fs.statSync(path.join(dir, marker)).isFile();
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ });
19
+ }
20
+ function hasAnyFileUnder(dir) {
21
+ let entries;
22
+ try {
23
+ entries = fs.readdirSync(dir, { withFileTypes: true });
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ for (const entry of entries) {
29
+ if (entry.name.startsWith('.'))
30
+ continue;
31
+ if (entry.isFile() || entry.isSymbolicLink())
32
+ return true;
33
+ if (entry.isDirectory() && hasAnyFileUnder(path.join(dir, entry.name)))
34
+ return true;
35
+ }
36
+ return false;
37
+ }
38
+ function hasSchemaOutput(dir, projectRoot) {
39
+ try {
40
+ const schema = resolveSchema(resolveSchemaForChange(dir, undefined, projectRoot), projectRoot);
41
+ return schema.artifacts.some((artifact) => artifactOutputExists(dir, artifact.generates));
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ function looksLikeChange(dir, projectRoot) {
48
+ if (hasChangeRootMarker(dir))
49
+ return true;
50
+ if (hasAnyFileUnder(path.join(dir, DELTA_SPECS_DIR)))
51
+ return true;
52
+ return hasSchemaOutput(dir, projectRoot);
53
+ }
54
+ function hasOwnFile(dir) {
55
+ let entries;
56
+ try {
57
+ entries = fs.readdirSync(dir, { withFileTypes: true });
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ return entries.some((entry) => !entry.name.startsWith('.') && !entry.isDirectory());
63
+ }
64
+ function readSubdirectories(dir) {
65
+ let entries;
66
+ try {
67
+ entries = fs.readdirSync(dir, { withFileTypes: true });
68
+ }
69
+ catch {
70
+ return [];
71
+ }
72
+ return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')).map((entry) => entry.name);
73
+ }
74
+ function collectNested(dir, prefix, depth, found, projectRoot) {
75
+ if (depth > MAX_NESTING_DEPTH)
76
+ return;
77
+ for (const child of readSubdirectories(dir)) {
78
+ const childPath = path.join(dir, child);
79
+ const id = `${prefix}/${child}`;
80
+ if (looksLikeChange(childPath, projectRoot)) {
81
+ found.push(id);
82
+ continue;
83
+ }
84
+ collectNested(childPath, id, depth + 1, found, projectRoot);
85
+ }
86
+ }
87
+ export function findNestedChangesIn(changesDir, name) {
88
+ if (name === 'archive' || name.startsWith('.'))
89
+ return undefined;
90
+ const dir = path.join(changesDir, name);
91
+ const projectRoot = path.resolve(changesDir, '..', '..');
92
+ if (looksLikeChange(dir, projectRoot))
93
+ return undefined;
94
+ if (hasOwnFile(dir))
95
+ return undefined;
96
+ const nested = [];
97
+ collectNested(dir, name, 1, nested, projectRoot);
98
+ if (nested.length === 0)
99
+ return undefined;
100
+ return { name, nested: nested.sort() };
101
+ }
102
+ export function findNestedChanges(changesDir, names) {
103
+ return names
104
+ .map((name) => findNestedChangesIn(changesDir, name))
105
+ .filter((finding) => finding !== undefined);
106
+ }
107
+ function flatten(nestedId) {
108
+ return nestedId.split('/').join('-');
109
+ }
110
+ export function describeNestedChange(finding) {
111
+ const list = finding.nested.map((id) => `openspec/changes/${id}/`).join(', ');
112
+ const example = flatten(finding.nested[0]);
113
+ return (`"${finding.name}" is not a change: it is a folder wrapping ${list}. ` +
114
+ 'A change must be a directory directly under openspec/changes/, so those ' +
115
+ 'nested directories are invisible to OpenSpec while the folder around them ' +
116
+ 'is reported as a change. Nested paths are supported under openspec/specs/ ' +
117
+ `only. Rename each nested change to a flat name (for example "${example}").`);
118
+ }
@@ -92,7 +92,9 @@ export function newChangeHandler(projectRoot, name, options) {
92
92
  if (!name)
93
93
  throw new Error('Missing required argument <name>');
94
94
  validateChangeName(name);
95
- const schemaName = options.schema ?? DEFAULT_SCHEMA;
95
+ // An empty or blank flag value (including a trailing `--schema`) is no schema choice,
96
+ // so it must not reach the lookup as a schema name.
97
+ const schemaName = options.schema?.trim() ? options.schema : DEFAULT_SCHEMA;
96
98
  validateSchemaExists(schemaName, projectRoot);
97
99
  const openspecDir = path.join(projectRoot, 'openspec');
98
100
  const changeDir = path.join(openspecDir, 'changes', name);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Names in document order, deduplicated. Only top-level `- ` bullets count, so
3
+ * a sub-bullet or a wrapped description line naming code never reads as a
4
+ * capability; any other unindented label or subheading closes the counted list.
5
+ */
6
+ export declare function parseProposalCapabilities(markdown: string): string[];
7
+ /**
8
+ * Listed capabilities without a `spec.md` under the change's `specs/`. A name
9
+ * that resolves outside `specs/` (an untrusted `../` path) can hold no delta
10
+ * there, so it counts as missing instead of probing the path.
11
+ */
12
+ export declare function missingCapabilitySpecs(changeDir: string): string[];