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
@@ -0,0 +1,94 @@
1
+ // The capabilities a proposal promises a delta spec for, read from its
2
+ // `## Capabilities` section, and the ones whose `specs/<capability>/spec.md`
3
+ // is not on disk yet. An interrupted propose leaves exactly that gap, which
4
+ // the specs glob alone cannot see once a single delta exists.
5
+ import * as fs from 'node:fs';
6
+ import * as path from 'node:path';
7
+ const COUNTED_LABELS = ['new capabilities:', 'modified capabilities:'];
8
+ function stripHtmlComments(markdown) {
9
+ let out = '';
10
+ let from = 0;
11
+ for (;;) {
12
+ const open = markdown.indexOf('<!--', from);
13
+ if (open === -1)
14
+ return out + markdown.slice(from);
15
+ out += markdown.slice(from, open);
16
+ const close = markdown.indexOf('-->', open + 4);
17
+ if (close === -1)
18
+ return out;
19
+ from = close + 3;
20
+ }
21
+ }
22
+ function firstBacktickedName(bullet) {
23
+ const open = bullet.indexOf('`');
24
+ if (open === -1)
25
+ return '';
26
+ const close = bullet.indexOf('`', open + 1);
27
+ return close === -1 ? '' : bullet.slice(open + 1, close).trim();
28
+ }
29
+ function isPlaceholder(name) {
30
+ return name === '' || name.toLowerCase() === 'none' || (name.startsWith('<') && name.endsWith('>'));
31
+ }
32
+ /**
33
+ * Names in document order, deduplicated. Only top-level `- ` bullets count, so
34
+ * a sub-bullet or a wrapped description line naming code never reads as a
35
+ * capability; any other unindented label or subheading closes the counted list.
36
+ */
37
+ export function parseProposalCapabilities(markdown) {
38
+ const names = [];
39
+ const seen = new Set();
40
+ let inSection = false;
41
+ let counting = false;
42
+ for (const rawLine of stripHtmlComments(markdown).split('\n')) {
43
+ const line = rawLine.trimEnd();
44
+ if (line.startsWith('# ') || line.startsWith('## ')) {
45
+ inSection = line.slice(line.indexOf(' ') + 1).trim().toLowerCase() === 'capabilities';
46
+ counting = false;
47
+ continue;
48
+ }
49
+ if (!inSection)
50
+ continue;
51
+ if (line.startsWith('- ')) {
52
+ const name = counting ? firstBacktickedName(line) : '';
53
+ if (!isPlaceholder(name) && !seen.has(name)) {
54
+ seen.add(name);
55
+ names.push(name);
56
+ }
57
+ continue;
58
+ }
59
+ if (line.length === 0 || /^\s/.test(line))
60
+ continue;
61
+ const label = line.toLowerCase();
62
+ if (COUNTED_LABELS.some((counted) => label.startsWith(counted)))
63
+ counting = true;
64
+ else if (label.startsWith('#') || label.endsWith(':'))
65
+ counting = false;
66
+ }
67
+ return names;
68
+ }
69
+ /**
70
+ * Listed capabilities without a `spec.md` under the change's `specs/`. A name
71
+ * that resolves outside `specs/` (an untrusted `../` path) can hold no delta
72
+ * there, so it counts as missing instead of probing the path.
73
+ */
74
+ export function missingCapabilitySpecs(changeDir) {
75
+ let proposal;
76
+ try {
77
+ proposal = fs.readFileSync(path.join(changeDir, 'proposal.md'), 'utf-8');
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ const specsDir = path.resolve(changeDir, 'specs');
83
+ return parseProposalCapabilities(proposal).filter((capability) => {
84
+ const specFile = path.resolve(specsDir, capability, 'spec.md');
85
+ if (!specFile.startsWith(specsDir + path.sep))
86
+ return true;
87
+ try {
88
+ return !fs.statSync(specFile).isFile();
89
+ }
90
+ catch {
91
+ return true;
92
+ }
93
+ });
94
+ }
@@ -1,5 +1,10 @@
1
1
  /** Emits a value as pretty JSON on stdout (the single-document `--json` contract). */
2
2
  export declare function printJson(payload: unknown): void;
3
+ /** Flattens repo-controlled text to one line, so a newline cannot forge a sibling instruction line. */
4
+ export declare function sanitizeInline(value: string, maxLength?: number): string;
5
+ export declare function escapeEnvelopeTags(value: string): string;
6
+ /** Attribute values are ids and directory names, never prose: escape every metacharacter. */
7
+ export declare function escapeEnvelopeAttribute(value: string): string;
3
8
  /**
4
9
  * A task's numbering label for a message (the leading `N.N` of its
5
10
  * description), falling back to its line number when the description carries
@@ -54,3 +59,15 @@ export declare function resolveChangeId(projectRoot: string, id: string): 'in-fl
54
59
  export declare function validateChangeExists(changeName: string | undefined, projectRoot: string): string;
55
60
  /** Validates that a schema resolves; throws with the available list otherwise. */
56
61
  export declare function validateSchemaExists(schemaName: string, projectRoot?: string): string;
62
+ /**
63
+ * Warns when every file a change's schema tracks as tasks contains list
64
+ * items but not one checkbox. Undefined when the schema tracks no task file
65
+ * at all (a bare top-level `tasks.md` no artifact declares is not read by
66
+ * this check; warning about it would be a guess about a file the tool never
67
+ * opens), when the tracked set has at least one checkbox anywhere, when a
68
+ * tracked file could not be read (it may hold the checkboxes the unreadable
69
+ * file lost), or when no tracked file carries a bare list item either. The
70
+ * message opens with `<file relative to changeDir>:<line of the first bare
71
+ * list item>`.
72
+ */
73
+ export declare function findMissingTaskCheckboxWarning(changeDir: string, projectRoot: string): string | undefined;
@@ -6,11 +6,44 @@
6
6
  // src/root-resolver.ts).
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
- import { getSchemaDir, listSchemas } from '../../vendor/openspec/engine/index.js';
9
+ import { getSchemaDir, listSchemas, parseTaskLines } from '../../vendor/openspec/engine/index.js';
10
+ import { resolveTaskFilesForChange } from '../../vendor/openspec/engine/task-progress.js';
11
+ import { describeNestedChange, findNestedChangesIn } from './nested-change.js';
10
12
  /** Emits a value as pretty JSON on stdout (the single-document `--json` contract). */
11
13
  export function printJson(payload) {
12
14
  console.log(JSON.stringify(payload, null, 2));
13
15
  }
16
+ /** Flattens repo-controlled text to one line, so a newline cannot forge a sibling instruction line. */
17
+ export function sanitizeInline(value, maxLength = 300) {
18
+ const flattened = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim();
19
+ return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}…` : flattened;
20
+ }
21
+ /** The tags `printInstructionsText` uses to frame its blocks; a block ends only at its own closing tag. */
22
+ const ENVELOPE_TAGS = [
23
+ 'artifact',
24
+ 'dependencies',
25
+ 'dependency',
26
+ 'description',
27
+ 'instruction',
28
+ 'output',
29
+ 'path',
30
+ 'template',
31
+ 'task',
32
+ 'unlocks',
33
+ 'warning',
34
+ ];
35
+ // Requires real `name="value"` syntax, not bare text: schema guidance writes
36
+ // `<task ids>`-style placeholders that must not read as a forged opener.
37
+ const ENVELOPE_TAG = new RegExp(`<(/?)(${ENVELOPE_TAGS.join('|')})((?:\\s+[A-Za-z_:][\\w:.-]*="[^"]*")*)\\s*>`, 'gi');
38
+ // Narrow on purpose: escaping every angle bracket also works, but mangles
39
+ // ordinary content like `### Requirement: <name>`.
40
+ export function escapeEnvelopeTags(value) {
41
+ return value.replace(ENVELOPE_TAG, (_match, slash, tag, attrs) => `&lt;${slash}${tag}${attrs ?? ''}&gt;`);
42
+ }
43
+ /** Attribute values are ids and directory names, never prose: escape every metacharacter. */
44
+ export function escapeEnvelopeAttribute(value) {
45
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
46
+ }
14
47
  const TASK_ID = /^(\d+(?:\.\d+)+(?:[A-Za-z]+)?)(?=\s|$)/;
15
48
  /**
16
49
  * A task's numbering label for a message (the leading `N.N` of its
@@ -127,6 +160,13 @@ export function validateChangeExists(changeName, projectRoot) {
127
160
  }
128
161
  throw new Error(`Change '${changeName}' not found. Available changes:\n ${available.join('\n ')}`);
129
162
  }
163
+ // The directory exists but is a namespace folder wrapping nested change
164
+ // directories. Every artifact lookup below it would report "not started"
165
+ // for work that is in fact there, so say what is actually wrong instead.
166
+ const nested = findNestedChangesIn(changesDirFor(projectRoot), changeName);
167
+ if (nested) {
168
+ throw new Error(describeNestedChange(nested));
169
+ }
130
170
  return changeName;
131
171
  }
132
172
  /** Validates that a schema resolves; throws with the available list otherwise. */
@@ -138,3 +178,144 @@ export function validateSchemaExists(schemaName, projectRoot) {
138
178
  }
139
179
  return schemaName;
140
180
  }
181
+ // ---------------------------------------------------------------------------
182
+ // Tracked tasks with no checkbox.
183
+ //
184
+ // Progress counts checkboxes and nothing else: a tasks file written as plain
185
+ // bullets or a numbered list reads as empty to `status`/`instructions apply`
186
+ // and leaves `archive` with no incomplete task to gate on, though the file
187
+ // carries unfinished work to a reader. `findMissingTaskCheckboxWarning`
188
+ // reports the first bare list item once every file the change tracks as
189
+ // tasks has zero checkboxes.
190
+ // ---------------------------------------------------------------------------
191
+ /** A list item carrying text but no checkbox: `- item`, `* item`, `+ item`, `1. item`, `1) item`. */
192
+ const LIST_ITEM = /^\s*(?:[-*+]|\d{1,9}[.)])\s+\S/;
193
+ /**
194
+ * The indent, in visual columns, at which a top-level line stops being
195
+ * content and becomes an indented code block per CommonMark. A tab counts as
196
+ * four.
197
+ */
198
+ const CODE_BLOCK_COLUMN = 4;
199
+ /** Leading whitespace of a line in visual columns, a tab counting as four. */
200
+ function indentColumns(line) {
201
+ let column = 0;
202
+ for (const char of line) {
203
+ if (char === ' ')
204
+ column += 1;
205
+ else if (char === '\t')
206
+ column += CODE_BLOCK_COLUMN - (column % CODE_BLOCK_COLUMN);
207
+ else
208
+ break;
209
+ }
210
+ return column;
211
+ }
212
+ /**
213
+ * A fenced code block delimiter: a run of three or more backticks or tildes
214
+ * indented at most three spaces, plus whatever follows it on the line (an
215
+ * info string on an opener, nothing on a valid closer).
216
+ */
217
+ const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)/;
218
+ /** The YAML front-matter delimiter: exactly three dashes; a longer run is a thematic break. */
219
+ const FRONT_MATTER = /^-{3}\s*$/;
220
+ const COMMENT_OPEN = '<!--';
221
+ const COMMENT_CLOSE = '-->';
222
+ /**
223
+ * Index of the first line after a leading YAML front-matter block, or 0 when
224
+ * the document opens with none. A list under `tags:` is metadata about the
225
+ * file, not the work it tracks.
226
+ */
227
+ function skipFrontMatter(lines) {
228
+ if (lines.length === 0 || !FRONT_MATTER.test(lines[0].replace(/^/, '').trimEnd())) {
229
+ return 0;
230
+ }
231
+ for (let index = 1; index < lines.length; index++) {
232
+ if (FRONT_MATTER.test(lines[index].trimEnd()))
233
+ return index + 1;
234
+ }
235
+ // An unterminated opener is a thematic break, not front matter: every list
236
+ // in the document stays visible to the scan.
237
+ return 0;
238
+ }
239
+ /**
240
+ * 1-based line of the first list item in rendered content, skipping fenced
241
+ * code, HTML comments, YAML front matter and top-level indented code. Every
242
+ * exclusion can only silence the warning, never manufacture one.
243
+ */
244
+ function findFirstListItemLine(content) {
245
+ const lines = content.split('\n');
246
+ let openFence;
247
+ let inComment = false;
248
+ let index = skipFrontMatter(lines);
249
+ for (; index < lines.length; index++) {
250
+ const line = lines[index];
251
+ if (inComment) {
252
+ if (line.includes(COMMENT_CLOSE))
253
+ inComment = false;
254
+ continue;
255
+ }
256
+ const fence = line.match(FENCE);
257
+ if (fence) {
258
+ const marker = fence[1][0];
259
+ const length = fence[1].length;
260
+ if (openFence === undefined) {
261
+ openFence = { marker, length };
262
+ }
263
+ else if (marker === openFence.marker && length >= openFence.length && fence[2].trim() === '') {
264
+ // CommonMark: a closer matches its opener's character, runs at least
265
+ // as long, and carries no info string.
266
+ openFence = undefined;
267
+ }
268
+ continue;
269
+ }
270
+ if (openFence !== undefined)
271
+ continue;
272
+ // Only a comment that opens the line hides it: `- [ ] 1.1 do it <!-- note`
273
+ // is a task line first.
274
+ if (line.trimStart().startsWith(COMMENT_OPEN)) {
275
+ if (!line.includes(COMMENT_CLOSE, line.indexOf(COMMENT_OPEN) + COMMENT_OPEN.length)) {
276
+ inComment = true;
277
+ }
278
+ continue;
279
+ }
280
+ if (LIST_ITEM.test(line) && indentColumns(line) < CODE_BLOCK_COLUMN)
281
+ return index + 1;
282
+ }
283
+ return undefined;
284
+ }
285
+ /**
286
+ * Warns when every file a change's schema tracks as tasks contains list
287
+ * items but not one checkbox. Undefined when the schema tracks no task file
288
+ * at all (a bare top-level `tasks.md` no artifact declares is not read by
289
+ * this check; warning about it would be a guess about a file the tool never
290
+ * opens), when the tracked set has at least one checkbox anywhere, when a
291
+ * tracked file could not be read (it may hold the checkboxes the unreadable
292
+ * file lost), or when no tracked file carries a bare list item either. The
293
+ * message opens with `<file relative to changeDir>:<line of the first bare
294
+ * list item>`.
295
+ */
296
+ export function findMissingTaskCheckboxWarning(changeDir, projectRoot) {
297
+ const files = resolveTaskFilesForChange(changeDir, projectRoot);
298
+ if (files.length === 0)
299
+ return undefined;
300
+ const documents = [];
301
+ for (const file of files) {
302
+ let content;
303
+ try {
304
+ content = fs.readFileSync(file, 'utf-8');
305
+ }
306
+ catch {
307
+ return undefined;
308
+ }
309
+ documents.push({ path: file, content });
310
+ }
311
+ if (documents.some((document) => parseTaskLines(document.content).length > 0))
312
+ return undefined;
313
+ for (const document of documents) {
314
+ const line = findFirstListItemLine(document.content);
315
+ if (line !== undefined) {
316
+ return (`${path.relative(changeDir, document.path)}:${line} lists tasks but carries no checkbox, so progress has ` +
317
+ 'nothing to flag as incomplete. Write each task as "- [ ] 1.1 Description".');
318
+ }
319
+ }
320
+ return undefined;
321
+ }
@@ -1,8 +1,8 @@
1
- import { type ChangeStatus } from '../../vendor/openspec/engine/index.js';
1
+ import { type ChangeStatus, type TaskProgress } from '../../vendor/openspec/engine/index.js';
2
2
  export interface StatusOptions {
3
3
  change?: string;
4
4
  schema?: string;
5
5
  json?: boolean;
6
6
  }
7
7
  export declare function statusHandler(projectRoot: string, options: StatusOptions): Promise<number>;
8
- export declare function printStatusText(status: ChangeStatus): void;
8
+ export declare function printStatusText(status: ChangeStatus, taskProgress: TaskProgress): void;
@@ -5,8 +5,11 @@
5
5
  // (artifacts[].status, artifacts[].requires, applyRequires, isPlanningComplete /
6
6
  // isComplete). The store-only `root` field is omitted; `planningHome` is the
7
7
  // store-free stub from shared.ts (V4-P4, #1703).
8
- import { loadChangeContext, formatChangeStatus, getTaskProgressForChange, } from '../../vendor/openspec/engine/index.js';
9
- import { changesDirFor, getAvailableChanges, planningHomeStub, printJson, validateChangeExists, validateSchemaExists, } from './shared.js';
8
+ import { loadChangeContext, formatChangeStatus, getTaskProgressForChange, formatTaskStatus, resolveNextStep, } from '../../vendor/openspec/engine/index.js';
9
+ import { changesDirFor, findMissingTaskCheckboxWarning, getAvailableChanges, planningHomeStub, printJson, validateChangeExists, validateSchemaExists, } from './shared.js';
10
+ import { collectSpecsIncompleteWarnings } from './instructions.js';
11
+ import { readUnknownMetadataKeys } from './validate.js';
12
+ import { tasksWithoutCheckboxWarning, unknownMetadataKeysWarning } from './warnings.js';
10
13
  export async function statusHandler(projectRoot, options) {
11
14
  if (!options.change) {
12
15
  const available = getAvailableChanges(projectRoot);
@@ -31,17 +34,31 @@ export async function statusHandler(projectRoot, options) {
31
34
  // checked, so the tasks artifact is complete for a reader's purposes exactly
32
35
  // when `open` reaches zero.
33
36
  const taskProgress = await getTaskProgressForChange(changesDirFor(projectRoot), changeName, projectRoot);
37
+ // Reports a tracked tasks file that lists work with no checkbox at all (#1774).
38
+ const checkboxWarning = findMissingTaskCheckboxWarning(context.changeDir, projectRoot);
39
+ const unknownKeys = readUnknownMetadataKeys(context.changeDir, projectRoot);
40
+ const warnings = [
41
+ ...(checkboxWarning ? [tasksWithoutCheckboxWarning(checkboxWarning)] : []),
42
+ ...(unknownKeys.length > 0 ? [unknownMetadataKeysWarning(unknownKeys)] : []),
43
+ ...collectSpecsIncompleteWarnings(context.graph.getAllArtifacts(), context.changeDir, context.skippedArtifacts),
44
+ ];
34
45
  if (options.json) {
35
46
  printJson({
36
47
  ...status,
37
48
  done: taskProgress.done,
38
49
  open: taskProgress.open,
39
50
  dispositioned: taskProgress.dispositioned,
51
+ ...(warnings.length > 0 ? { warnings } : {}),
40
52
  planningHome: planningHomeStub(projectRoot),
41
53
  });
42
54
  return 0;
43
55
  }
44
- printStatusText(status);
56
+ printStatusText(status, taskProgress);
57
+ if (warnings.length > 0) {
58
+ console.log();
59
+ for (const warning of warnings)
60
+ console.log(`Warning: ${warning.message}`);
61
+ }
45
62
  return 0;
46
63
  }
47
64
  function indicator(status) {
@@ -56,7 +73,7 @@ function indicator(status) {
56
73
  return '[-]';
57
74
  }
58
75
  }
59
- export function printStatusText(status) {
76
+ export function printStatusText(status, taskProgress) {
60
77
  const doneCount = status.artifacts.filter((a) => a.status === 'done').length;
61
78
  const skippedCount = status.artifacts.filter((a) => a.status === 'skipped').length;
62
79
  const total = status.artifacts.length - skippedCount;
@@ -67,6 +84,7 @@ export function printStatusText(status) {
67
84
  }
68
85
  const skippedSuffix = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
69
86
  console.log(`Progress: ${doneCount}/${total} artifacts complete${skippedSuffix}`);
87
+ console.log(`Tasks: ${formatTaskStatus(taskProgress)}`);
70
88
  console.log();
71
89
  for (const artifact of status.artifacts) {
72
90
  let line = `${indicator(artifact.status)} ${artifact.id}`;
@@ -78,8 +96,19 @@ export function printStatusText(status) {
78
96
  }
79
97
  console.log(line);
80
98
  }
81
- if (status.isPlanningComplete) {
99
+ // Same inputs as the JSON `nextSteps` sentence, so both surfaces name the same command.
100
+ const nextStep = resolveNextStep({
101
+ changeName: status.changeName,
102
+ artifactStatuses: status.artifacts,
103
+ allArtifactsComplete: status.isPlanningComplete,
104
+ });
105
+ if (status.isPlanningComplete || nextStep) {
82
106
  console.log();
107
+ }
108
+ if (status.isPlanningComplete) {
83
109
  console.log('All planning artifacts complete!');
84
110
  }
111
+ if (nextStep) {
112
+ console.log(`Next: ${nextStep.command}`);
113
+ }
85
114
  }
@@ -1,3 +1,12 @@
1
+ import { type TaskDisposition } from '../../vendor/openspec/validation/task-lines.js';
2
+ /** Why a disposition leaves its unchecked task open; null when it resolves it. */
3
+ export declare function dispositionReason(projectRoot: string, disposition: TaskDisposition): string | null;
4
+ /** The keys the change-metadata schema declares; a test keeps this equal to its zod shape. */
5
+ export declare const KNOWN_METADATA_KEYS: readonly string[];
6
+ /** Keys outside the schema and the `x-` namespace, stripped of control characters for printing. */
7
+ export declare function unknownMetadataKeys(metadata: Record<string, unknown>): string[];
8
+ /** Unreadable metadata yields none: validate reports that file as its own ERROR. */
9
+ export declare function readUnknownMetadataKeys(changeDir: string, projectRoot: string): string[];
1
10
  export interface ValidateOptions {
2
11
  specs?: boolean;
3
12
  strict?: boolean;
@@ -23,15 +23,24 @@ import { discoverSpecFiles } from '../../vendor/openspec/validation/spec-discove
23
23
  import { parseAnnotatedTaskLines } from '../../vendor/openspec/validation/task-lines.js';
24
24
  import { readChangeMetadata } from '../../vendor/openspec/engine/change-metadata.js';
25
25
  import { changesDirFor, getAvailableChanges, printJson, resolveChangeId, taskLabel } from './shared.js';
26
+ import { unknownMetadataKeysWarning } from './warnings.js';
26
27
  const METADATA_FILENAME = '.openspec.yaml';
27
28
  const TASKS_FILENAME = 'tasks.md';
28
29
  const DISPOSITION_KINDS = new Set(['superseded', 'moved', 'external']);
29
- /**
30
- * Extra issues from a change's `tasks.md` disposition lines: an ERROR for a
31
- * disposition under a checked task, a kind outside the three documented ones,
32
- * a `superseded`/`moved` id that resolves to neither an in-flight nor an
33
- * archived change, and an `external` line without a reference.
34
- */
30
+ /** Why a disposition leaves its unchecked task open; null when it resolves it. */
31
+ export function dispositionReason(projectRoot, disposition) {
32
+ const { kind, target } = disposition;
33
+ if (!DISPOSITION_KINDS.has(kind)) {
34
+ return `disposition kind '${kind}' is none of superseded, moved, external`;
35
+ }
36
+ if (kind === 'external') {
37
+ return target.trim() === '' ? 'an external disposition needs a reference' : null;
38
+ }
39
+ return resolveChangeId(projectRoot, target) === null
40
+ ? `${kind} disposition names unresolved change '${target}'`
41
+ : null;
42
+ }
43
+ /** ERRORs for a disposition under a checked task or one `dispositionReason` rejects. */
35
44
  function dispositionIssues(projectRoot, changeDir) {
36
45
  const tasksPath = path.join(changeDir, TASKS_FILENAME);
37
46
  if (!fs.existsSync(tasksPath))
@@ -42,45 +51,43 @@ function dispositionIssues(projectRoot, changeDir) {
42
51
  if (!task.disposition)
43
52
  continue;
44
53
  const label = taskLabel(task);
45
- if (task.done) {
46
- issues.push({
47
- level: 'ERROR',
48
- path: TASKS_FILENAME,
49
- line: task.line,
50
- message: `task ${label}: a disposition belongs to an unchecked task`,
51
- });
52
- continue;
53
- }
54
- const { kind, target } = task.disposition;
55
- if (!DISPOSITION_KINDS.has(kind)) {
56
- issues.push({
57
- level: 'ERROR',
58
- path: TASKS_FILENAME,
59
- line: task.line,
60
- message: `task ${label}: disposition kind '${kind}' is none of superseded, moved, external`,
61
- });
62
- }
63
- else if (kind === 'external') {
64
- if (target.trim() === '') {
65
- issues.push({
66
- level: 'ERROR',
67
- path: TASKS_FILENAME,
68
- line: task.line,
69
- message: `task ${label}: an external disposition needs a reference`,
70
- });
71
- }
72
- }
73
- else if (resolveChangeId(projectRoot, target) === null) {
74
- issues.push({
75
- level: 'ERROR',
76
- path: TASKS_FILENAME,
77
- line: task.line,
78
- message: `task ${label}: ${kind} disposition names unresolved change '${target}'`,
79
- });
54
+ const reason = task.done
55
+ ? 'a disposition belongs to an unchecked task'
56
+ : dispositionReason(projectRoot, task.disposition);
57
+ if (reason) {
58
+ issues.push({ level: 'ERROR', path: TASKS_FILENAME, line: task.line, message: `task ${label}: ${reason}` });
80
59
  }
81
60
  }
82
61
  return issues;
83
62
  }
63
+ /** The keys the change-metadata schema declares; a test keeps this equal to its zod shape. */
64
+ export const KNOWN_METADATA_KEYS = ['schema', 'created', 'skip_specs', 'depends_on', 'supersedes'];
65
+ const EXTENSION_KEY_PREFIX = 'x-';
66
+ const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
67
+ /** Keys outside the schema and the `x-` namespace, stripped of control characters for printing. */
68
+ export function unknownMetadataKeys(metadata) {
69
+ return Object.keys(metadata)
70
+ .filter((key) => !KNOWN_METADATA_KEYS.includes(key) && !key.startsWith(EXTENSION_KEY_PREFIX))
71
+ .map((key) => key.replace(CONTROL_CHARS, ''));
72
+ }
73
+ /** Unreadable metadata yields none: validate reports that file as its own ERROR. */
74
+ export function readUnknownMetadataKeys(changeDir, projectRoot) {
75
+ try {
76
+ const metadata = readChangeMetadata(changeDir, projectRoot);
77
+ return metadata ? unknownMetadataKeys(metadata) : [];
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ }
83
+ /** Same level rule as the in-flight dependency notice, so a typo never trips --strict alone. */
84
+ function metadataKeyIssues(projectRoot, changeDir, strict) {
85
+ const keys = readUnknownMetadataKeys(changeDir, projectRoot);
86
+ if (keys.length === 0)
87
+ return [];
88
+ const level = strict ? 'INFO' : 'WARNING';
89
+ return [{ level, path: METADATA_FILENAME, message: unknownMetadataKeysWarning(keys).message }];
90
+ }
84
91
  /**
85
92
  * Extra issues from a change's `depends_on` / `supersedes` metadata: an ERROR
86
93
  * for metadata that cannot be read, an ERROR for every id that resolves to
@@ -144,6 +151,7 @@ async function validateChangeItem(validator, projectRoot, id, strict) {
144
151
  const issues = [
145
152
  ...report.issues,
146
153
  ...dependencyIssues(projectRoot, changeDir, strict),
154
+ ...metadataKeyIssues(projectRoot, changeDir, strict),
147
155
  ...dispositionIssues(projectRoot, changeDir),
148
156
  ];
149
157
  const errors = issues.filter((i) => i.level === 'ERROR').length;
@@ -0,0 +1,21 @@
1
+ import { type NestedChangeFinding } from './nested-change.js';
2
+ export type ShimWarning = {
3
+ code: string;
4
+ message: string;
5
+ } & Record<string, unknown>;
6
+ export declare function tasksWithoutCheckboxWarning(message: string): ShimWarning;
7
+ export declare function nestedChangeWarning(finding: NestedChangeFinding): ShimWarning;
8
+ /** One warning covers every unknown id, so the text path prints a single line. */
9
+ export declare function unknownArtifactIdWarning(ids: string[]): ShimWarning;
10
+ /** `keys` arrive already stripped of control characters, since they come from an untrusted file. */
11
+ export declare function unknownMetadataKeysWarning(keys: string[]): ShimWarning;
12
+ /** `path` and `expected` are relative to the change directory. */
13
+ export declare function unreadDeltaFileWarning(changeName: string, file: {
14
+ path: string;
15
+ expected: string;
16
+ }): ShimWarning;
17
+ /** `spec` is the capability path, so a reader with several deltas knows which file holds the text. */
18
+ export declare function unappliedDeltaContentWarning(spec: string, message: string): ShimWarning;
19
+ /** `capabilities` are the proposal's listed names, flattened to one line each, with no `specs/<name>/spec.md`. */
20
+ export declare function specsIncompleteWarning(capabilities: string[]): ShimWarning;
21
+ export declare function missingDeltaSpecsWarning(changeName: string, specTarget: string, metadataPath: string): ShimWarning;
@@ -0,0 +1,66 @@
1
+ // The one element shape of every shim `--json` `warnings` array. `code` is a
2
+ // stable identifier a reader switches on; `message` equals the text the text
3
+ // output prints after its line prefix (`Warning: `, `- `), so the two surfaces
4
+ // never drift apart.
5
+ import { describeNestedChange } from './nested-change.js';
6
+ export function tasksWithoutCheckboxWarning(message) {
7
+ return { code: 'tasks_without_checkbox', message };
8
+ }
9
+ export function nestedChangeWarning(finding) {
10
+ return {
11
+ code: 'nested_change_directory',
12
+ name: finding.name,
13
+ nested: finding.nested,
14
+ message: describeNestedChange(finding),
15
+ };
16
+ }
17
+ /** One warning covers every unknown id, so the text path prints a single line. */
18
+ export function unknownArtifactIdWarning(ids) {
19
+ return {
20
+ code: 'unknown_artifact_id',
21
+ ids,
22
+ message: `Schema declares a dependency on an artifact id not defined in this schema: ${ids.join(', ')}.`,
23
+ };
24
+ }
25
+ /** `keys` arrive already stripped of control characters, since they come from an untrusted file. */
26
+ export function unknownMetadataKeysWarning(keys) {
27
+ return {
28
+ code: 'unknown_metadata_keys',
29
+ keys,
30
+ message: `.openspec.yaml has unknown metadata key(s), ignored: ${keys.join(', ')}. ` +
31
+ 'A misspelled key drops its setting; extension keys start with x-.',
32
+ };
33
+ }
34
+ /** `path` and `expected` are relative to the change directory. */
35
+ export function unreadDeltaFileWarning(changeName, file) {
36
+ const filePath = `specs/${file.path}`;
37
+ const expected = `specs/${file.expected}`;
38
+ return {
39
+ code: 'unread_delta_file',
40
+ path: filePath,
41
+ expected,
42
+ message: `${filePath} is not a capability's spec.md, so \`openspec validate ${changeName}\` rejects it and archive never merges it. ` +
43
+ `Move its requirements into ${expected}.`,
44
+ };
45
+ }
46
+ /** `spec` is the capability path, so a reader with several deltas knows which file holds the text. */
47
+ export function unappliedDeltaContentWarning(spec, message) {
48
+ return { code: 'unapplied_delta_content', spec, message };
49
+ }
50
+ /** `capabilities` are the proposal's listed names, flattened to one line each, with no `specs/<name>/spec.md`. */
51
+ export function specsIncompleteWarning(capabilities) {
52
+ return {
53
+ code: 'specs_incomplete',
54
+ capabilities,
55
+ message: `The proposal lists capabilities with no delta spec yet: ${capabilities.join(', ')}. ` +
56
+ 'Write specs/<capability>/spec.md for each before implementing, or drop it from the proposal.',
57
+ };
58
+ }
59
+ export function missingDeltaSpecsWarning(changeName, specTarget, metadataPath) {
60
+ return {
61
+ code: 'missing_delta_specs',
62
+ message: `This change has no delta specs and does not declare \`skip_specs: true\`, so \`openspec validate ${changeName}\` fails on it. ` +
63
+ `Write the delta specs before implementing (\`openspec instructions ${specTarget} --change ${changeName}\`), ` +
64
+ `or add \`skip_specs: true\` to ${metadataPath} if this change really changes no specified behavior.`,
65
+ };
66
+ }
@@ -0,0 +1,4 @@
1
+ export type LineEnding = '\n' | '\r\n';
2
+ export declare function detectLineEnding(content: string): LineEnding | undefined;
3
+ export declare function applyLineEnding(content: string, ending: LineEnding): string;
4
+ export declare function matchLineEnding(content: string, original: string): string;
@@ -0,0 +1,17 @@
1
+ // Line-ending helpers: parsers normalize CRLF to LF on read; these restore a
2
+ // file's own convention on write so an in-place edit does not rewrite every line.
3
+ export function detectLineEnding(content) {
4
+ const crlf = content.match(/\r\n/g)?.length ?? 0;
5
+ const lf = content.match(/(?<!\r)\n/g)?.length ?? 0;
6
+ if (crlf === 0 && lf === 0)
7
+ return undefined;
8
+ return crlf >= lf ? '\r\n' : '\n';
9
+ }
10
+ export function applyLineEnding(content, ending) {
11
+ const normalized = content.replace(/\r\n/g, '\n');
12
+ return ending === '\n' ? normalized : normalized.replace(/\n/g, '\r\n');
13
+ }
14
+ export function matchLineEnding(content, original) {
15
+ const ending = detectLineEnding(original);
16
+ return ending === undefined ? content : applyLineEnding(content, ending);
17
+ }