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
@@ -7,13 +7,14 @@
7
7
  import { promises as fs } from 'fs';
8
8
  import { randomUUID } from 'crypto';
9
9
  import path from 'path';
10
- import { extractRequirementsSection, findMissingCurrentScenarios, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from '../validation/parsers/requirement-blocks.js';
10
+ import { extractRequirementsSection, diffScenarioNames, describeScenarioBalance, describeUnappliedContent, foldRequirementName, parseDeltaSpec, normalizeRequirementName, } from '../validation/parsers/requirement-blocks.js';
11
11
  import { findMainSpecStructureIssues } from '../validation/parsers/spec-structure.js';
12
12
  import { buildCodeFenceMask } from '../validation/parsers/code-fence.js';
13
13
  import { MarkdownParser } from '../validation/parsers/markdown-parser.js';
14
14
  import { MIN_PURPOSE_LENGTH, PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX, } from '../validation/constants.js';
15
15
  import { discoverSpecFiles } from '../validation/spec-discovery.js';
16
16
  import { FileSystemUtils } from '../engine/file-system.js';
17
+ import { matchLineEnding } from './line-endings.js';
17
18
  function isLexicallyWithin(allowedDirectory, targetPath) {
18
19
  const relative = path.relative(path.resolve(allowedDirectory), path.resolve(targetPath));
19
20
  return (relative === '' ||
@@ -21,7 +22,7 @@ function isLexicallyWithin(allowedDirectory, targetPath) {
21
22
  !relative.startsWith(`..${path.sep}`) &&
22
23
  !path.isAbsolute(relative)));
23
24
  }
24
- function resolveTrustedSpecPath(specsRoot, specPath) {
25
+ function resolveTrustedSpecPath(specsRoot, specPath, projectRoot) {
25
26
  if (!isLexicallyWithin(specsRoot, specPath)) {
26
27
  throw new Error(`Path is outside the allowed directory: ${specPath}`);
27
28
  }
@@ -41,6 +42,12 @@ function resolveTrustedSpecPath(specsRoot, specPath) {
41
42
  // Freeze their canonical location as the trust root so later swaps are
42
43
  // rejected while a nested spec.md link still cannot escape.
43
44
  const root = FileSystemUtils.canonicalizeExistingPath(path.dirname(specPath));
45
+ // An external capability link is supported; name the real write target
46
+ // instead of silently writing outside the project while reporting the
47
+ // in-project path.
48
+ if (projectRoot && !isLexicallyWithin(FileSystemUtils.canonicalizeExistingPath(projectRoot), root)) {
49
+ process.emitWarning(`Capability '${path.basename(path.dirname(specPath))}' links outside the project; writing to ${root}`, 'OpenSpecExternalSpecWrite');
50
+ }
44
51
  const file = path.join(root, path.basename(specPath));
45
52
  FileSystemUtils.assertPathWithin(root, file);
46
53
  return { root, file };
@@ -68,7 +75,8 @@ export async function findSpecUpdates(changeDir, mainSpecsDir) {
68
75
  for (const { id, specFile } of discovered) {
69
76
  const targetFile = path.join(mainSpecsDir, ...id.split('/'), 'spec.md');
70
77
  const source = resolveTrustedSpecPath(changeSpecsDir, specFile);
71
- const target = resolveTrustedSpecPath(mainSpecsDir, targetFile);
78
+ // mainSpecsDir is always <project root>/openspec/specs.
79
+ const target = resolveTrustedSpecPath(mainSpecsDir, targetFile, path.dirname(path.dirname(mainSpecsDir)));
72
80
  // Check if target exists
73
81
  let exists = false;
74
82
  try {
@@ -110,6 +118,27 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
110
118
  // Parse deltas from the change spec file
111
119
  const plan = parseDeltaSpec(changeContent);
112
120
  const specName = update.id;
121
+ // Without this refusal a TO: with no pending FROM: pairs with whichever
122
+ // FROM: comes next, renaming a requirement the author never named. Refuse
123
+ // rather than guess. (A FROM: without its TO: stays the FreeHarness
124
+ // advisory note reported by validate; not blocking here.)
125
+ if (plan.unpairedRenames.length > 0) {
126
+ const first = plan.unpairedRenames[0];
127
+ throw new Error(`${specName} validation failed - RENAMED entry on line ${first.line} has no matching FROM: for header "### Requirement: ${first.name}". ` +
128
+ `Write each rename as a FROM: line followed immediately by its TO: line.`);
129
+ }
130
+ // A well-formed requirement outside every delta section is not applied.
131
+ // Say so here too: archive is the last point the author can still notice.
132
+ for (const orphan of plan.orphanedRequirements) {
133
+ const where = orphan.section ? `under "## ${orphan.section}"` : 'above the first "## " section';
134
+ warn(`${specName} - requirement "${orphan.name}" (line ${orphan.line}) is ${where}, ` +
135
+ `which is not a delta section, so it was not applied. ` +
136
+ `Move it under ADDED/MODIFIED/REMOVED/RENAMED Requirements.`);
137
+ }
138
+ // Archive carries requirement blocks only; name any other text it leaves behind.
139
+ for (const lost of plan.unappliedContent) {
140
+ warn(`${specName} - ${describeUnappliedContent(lost)} (line ${lost.line}) is not carried into the main spec.`);
141
+ }
113
142
  // Pre-validate duplicates within sections
114
143
  const addedNames = new Set();
115
144
  for (const add of plan.added) {
@@ -165,12 +194,11 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
165
194
  for (const { from, to } of plan.renamed) {
166
195
  const fromNorm = normalizeRequirementName(from);
167
196
  const toNorm = normalizeRequirementName(to);
168
- // A REMOVED naming the FROM side contradicts the rename. This used to
169
- // fail incidentally at apply time (the rename consumed the old header,
170
- // so REMOVED hit "not found"); now that a missing REMOVED target is a
171
- // no-op, the conflict must be rejected explicitly. Compared folded, so
172
- // a case/whitespace variant cannot slip past the guard and degrade
173
- // into a warned no-op.
197
+ // A REMOVED naming the FROM side contradicts the rename. A missing
198
+ // REMOVED target is a no-op, so without this explicit rejection the
199
+ // rename consumes the old header and the REMOVED silently does nothing.
200
+ // Compared folded, so a case/whitespace variant cannot slip past the
201
+ // guard and degrade into a warned no-op.
174
202
  const removedFoldMatch = [...removedNamesSet].find((r) => foldRequirementName(r) === foldRequirementName(fromNorm));
175
203
  if (removedFoldMatch !== undefined) {
176
204
  throw new Error(`${specName} validation failed - requirement present in multiple sections (RENAMED and REMOVED) for header "### Requirement: ${from}"` +
@@ -293,6 +321,13 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
293
321
  if (nameToBlock.has(to)) {
294
322
  throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - target already exists`);
295
323
  }
324
+ // A target differing from another requirement only in case/whitespace
325
+ // would leave two copies; the source itself is exempt so a case-only
326
+ // rename stays allowed.
327
+ const targetNearMiss = [...nameToBlock.keys()].find((k) => k !== from && foldRequirementName(k) === foldRequirementName(to));
328
+ if (targetNearMiss !== undefined) {
329
+ throw new Error(`${specName} RENAMED failed for header "### Requirement: ${r.to}" - "### Requirement: ${nameToBlock.get(targetNearMiss).name}" already exists and differs only in case or spacing; choose a distinct name`);
330
+ }
296
331
  const block = nameToBlock.get(from);
297
332
  const newHeader = `### Requirement: ${to}`;
298
333
  const rawLines = block.raw.split('\n');
@@ -352,9 +387,9 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
352
387
  if (!modHeaderMatch || normalizeRequirementName(modHeaderMatch[1]) !== key) {
353
388
  throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - header mismatch in content`);
354
389
  }
355
- const missingScenarios = findMissingCurrentScenarios(currentBlock, mod);
356
- if (missingScenarios.length > 0) {
357
- throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${missingScenarios.map(name => `"${name}"`).join(', ')}. Refresh the change spec before archiving to avoid dropping scenarios.`);
390
+ const scenarioDiff = diffScenarioNames(currentBlock, mod);
391
+ if (scenarioDiff.missing.length > 0) {
392
+ throw new Error(`${specName} MODIFIED failed for header "### Requirement: ${mod.name}" - current spec contains scenario(s) not present in the modified block: ${scenarioDiff.missing.map(name => `"${name}"`).join(', ')}. ${describeScenarioBalance(scenarioDiff)} Refresh the change spec before archiving to avoid dropping scenarios.`);
358
393
  }
359
394
  // Identical content means the modification was already synced to the
360
395
  // baseline (early-sync pattern) — count only real replacements, so a
@@ -379,6 +414,14 @@ export async function buildUpdatedSpec(update, changeName, options = {}) {
379
414
  }
380
415
  throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - already exists`);
381
416
  }
417
+ // A name differing from an existing requirement only in case/whitespace is
418
+ // that requirement written again. Compared against the spec as it stands
419
+ // after the earlier operations, so a variant this delta removed or
420
+ // renamed away is fine.
421
+ const nearMiss = [...nameToBlock.keys()].find((k) => foldRequirementName(k) === foldRequirementName(key));
422
+ if (nearMiss !== undefined) {
423
+ throw new Error(`${specName} ADDED failed for header "### Requirement: ${add.name}" - "### Requirement: ${nameToBlock.get(nearMiss).name}" already exists and differs only in case or spacing; use MODIFIED with that exact header to change it, or choose a distinct name`);
424
+ }
382
425
  nameToBlock.set(key, add);
383
426
  addedApplied++;
384
427
  }
@@ -865,11 +908,20 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
865
908
  // Create target directory if needed
866
909
  const targetDir = path.dirname(update.target);
867
910
  await fs.mkdir(targetDir, { recursive: true });
911
+ // `rebuilt` is always LF; match the target's own convention on write, or a
912
+ // CRLF checkout sees the whole spec change. Only ENOENT means "no file to
913
+ // match"; any other read error propagates rather than reading as absent.
914
+ const previous = await fs.readFile(update.target, 'utf-8').catch((error) => {
915
+ if (error.code === 'ENOENT')
916
+ return undefined;
917
+ throw error;
918
+ });
919
+ const toWrite = previous === undefined ? rebuilt : matchLineEnding(rebuilt, previous);
868
920
  await options.beforeMutate?.();
869
921
  // Preserve the established in-place write semantics: symlink referents,
870
922
  // hard-linked specs, ACLs, extended attributes, and filesystems without hard
871
923
  // links must continue to behave as they did before capability retirement.
872
- await fs.writeFile(update.target, rebuilt);
924
+ await fs.writeFile(update.target, toWrite);
873
925
  if (options.silent)
874
926
  return;
875
927
  const specName = update.id;
@@ -886,15 +938,36 @@ export async function writeUpdatedSpec(update, rebuilt, counts, options = {}) {
886
938
  /** Blank out `<!-- ... -->` spans, preserving line count so indices stay aligned. */
887
939
  function maskHtmlComments(content) {
888
940
  const blank = (text) => text.replace(/[^\n]/g, ' ');
889
- // `--!>` is a comment terminator as well as `-->`.
890
- const masked = content.replace(/<!--[\s\S]*?--!?>/g, blank);
891
- // A comment that is never closed runs to end of file, so everything after it
892
- // is commented out too. Without this an unterminated `<!--` above a
893
- // `## Purpose` left the commented-out header looking real (#1413).
894
- const unterminated = masked.indexOf('<!--');
895
- if (unterminated === -1)
896
- return masked;
897
- return masked.slice(0, unterminated) + blank(masked.slice(unterminated));
941
+ // Linear scan: every character is visited once. A `/<!--[\s\S]*?--!?>/g`
942
+ // replace re-scans to end of file from every `<!--`, which is quadratic on a
943
+ // spec dense in comment openers.
944
+ let out = '';
945
+ let index = 0;
946
+ for (;;) {
947
+ const open = content.indexOf('<!--', index);
948
+ if (open === -1)
949
+ return out + content.slice(index);
950
+ out += content.slice(index, open);
951
+ // `--!>` is a comment terminator as well as `-->`.
952
+ let close = -1;
953
+ for (let i = open + 4; i < content.length; i++) {
954
+ if (content.startsWith('-->', i)) {
955
+ close = i + 3;
956
+ break;
957
+ }
958
+ if (content.startsWith('--!>', i)) {
959
+ close = i + 4;
960
+ break;
961
+ }
962
+ }
963
+ // A comment that is never closed runs to end of file, so everything after
964
+ // it is commented out too. Without this an unterminated `<!--` above a
965
+ // `## Purpose` left the commented-out header looking real (#1413).
966
+ if (close === -1)
967
+ return out + blank(content.slice(open));
968
+ out += blank(content.slice(open, close));
969
+ index = close;
970
+ }
898
971
  }
899
972
  /**
900
973
  * Read the body of a `## Purpose` section, ignoring markdown that only appears
@@ -23,4 +23,10 @@ export interface ActionContextInput {
23
23
  artifactIds: string[];
24
24
  }
25
25
  export declare function buildActionContext(input: ActionContextInput): ActionContext;
26
+ /** One next action, in both forms, so text and JSON name the same step. */
27
+ export interface ChangeNextStep {
28
+ command: string;
29
+ sentence: string;
30
+ }
31
+ export declare function resolveNextStep(input: ChangeNextStepsInput): ChangeNextStep | undefined;
26
32
  export declare function buildNextSteps(input: ChangeNextStepsInput): string[];
@@ -19,14 +19,22 @@ export function buildActionContext(input) {
19
19
  ],
20
20
  };
21
21
  }
22
- export function buildNextSteps(input) {
22
+ export function resolveNextStep(input) {
23
23
  const readyArtifact = input.artifactStatuses.find((artifact) => artifact.status === 'ready');
24
- const steps = [];
25
24
  if (readyArtifact) {
26
- steps.push(`Run openspec instructions ${readyArtifact.id} --change "${input.changeName}" --json before writing that artifact.`);
25
+ const command = `openspec instructions ${readyArtifact.id} --change "${input.changeName}" --json`;
26
+ return { command, sentence: `Run ${command} before writing that artifact.` };
27
27
  }
28
- else if (input.allArtifactsComplete) {
29
- steps.push(`All planning artifacts are complete. Run openspec instructions apply --change "${input.changeName}" --json to inspect implementation progress.`);
28
+ if (input.allArtifactsComplete) {
29
+ const command = `openspec instructions apply --change "${input.changeName}" --json`;
30
+ return {
31
+ command,
32
+ sentence: `All planning artifacts are complete. Run ${command} to inspect implementation progress.`,
33
+ };
30
34
  }
31
- return steps;
35
+ return undefined;
36
+ }
37
+ export function buildNextSteps(input) {
38
+ const step = resolveNextStep(input);
39
+ return step ? [step.sentence] : [];
32
40
  }
@@ -8,4 +8,4 @@ export { artifactOutputExists, isGlobPattern, resolveArtifactOutputPath, resolve
8
8
  export { readChangeMetadata, resolveSchemaForChange, writeChangeMetadata, ChangeMetadataSchema, ChangeMetadataError, METADATA_FILENAME, type ChangeMetadata, } from './change-metadata.js';
9
9
  export { loadTemplate, loadChangeContext, generateInstructions, formatChangeStatus, TemplateLoadError, SKIP_SPECS_INSTRUCTIONS_WARNING, type ChangeContext, type LoadChangeContextOptions, type ArtifactInstructions, type DependencyInfo, type ArtifactStatus, type ChangeStatus, type ArtifactPathSummary, } from './instruction-loader.js';
10
10
  export { getTaskProgressForChange, parseTaskLines, countTasksFromContent, formatTaskStatus, type TaskProgress, type ParsedTask, } from './task-progress.js';
11
- export { buildActionContext, buildNextSteps, type ActionContext, } from './change-status-policy.js';
11
+ export { buildActionContext, buildNextSteps, resolveNextStep, type ActionContext, type ChangeNextStep, } from './change-status-policy.js';
@@ -18,4 +18,4 @@ export { loadTemplate, loadChangeContext, generateInstructions, formatChangeStat
18
18
  // Task progress (list)
19
19
  export { getTaskProgressForChange, parseTaskLines, countTasksFromContent, formatTaskStatus, } from './task-progress.js';
20
20
  // Change-status policy
21
- export { buildActionContext, buildNextSteps, } from './change-status-policy.js';
21
+ export { buildActionContext, buildNextSteps, resolveNextStep, } from './change-status-policy.js';
@@ -1,6 +1,6 @@
1
- /**
2
- * Checks if a path contains glob pattern characters.
3
- */
1
+ /** True when a `{...}` group has a comma (alternation) or `..` (range): `{api,ui}` is a glob, `{draft}` alone is literal. */
2
+ export declare function hasBraceExpansion(pattern: string): boolean;
3
+ /** Checks if a path is a glob: wildcards, a brace expansion, or an extglob (`@(...)`, `!(...)`, ...). */
4
4
  export declare function isGlobPattern(pattern: string): boolean;
5
5
  /**
6
6
  * Resolves an artifact's `generates` value to an absolute path under the change
@@ -13,11 +13,31 @@
13
13
  import * as fs from 'node:fs';
14
14
  import * as path from 'node:path';
15
15
  import { FileSystemUtils } from './file-system.js';
16
- /**
17
- * Checks if a path contains glob pattern characters.
18
- */
16
+ const EXTGLOB_RE = /[!*+?@]\([^(]*\)/u;
17
+ const BRACE_EXPANSION_SEPARATORS_RE = /,|\.\./u;
18
+ /** True when a `{...}` group has a comma (alternation) or `..` (range): `{api,ui}` is a glob, `{draft}` alone is literal. */
19
+ export function hasBraceExpansion(pattern) {
20
+ const openings = [];
21
+ for (let index = 0; index < pattern.length; index += 1) {
22
+ if (pattern[index] === '{')
23
+ openings.push(index);
24
+ if (pattern[index] !== '}')
25
+ continue;
26
+ const opening = openings.pop();
27
+ if (opening !== undefined && BRACE_EXPANSION_SEPARATORS_RE.test(pattern.slice(opening, index))) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ /** Checks if a path is a glob: wildcards, a brace expansion, or an extglob (`@(...)`, `!(...)`, ...). */
19
34
  export function isGlobPattern(pattern) {
20
- return pattern.includes('*') || pattern.includes('?') || pattern.includes('[');
35
+ const normalized = FileSystemUtils.toPosixPath(pattern);
36
+ return (normalized.includes('*') ||
37
+ normalized.includes('?') ||
38
+ normalized.includes('[') ||
39
+ EXTGLOB_RE.test(normalized) ||
40
+ hasBraceExpansion(normalized));
21
41
  }
22
42
  /**
23
43
  * Resolves an artifact's `generates` value to an absolute path under the change
@@ -11,17 +11,14 @@ import * as path from 'node:path';
11
11
  import { resolveSchema } from './resolver.js';
12
12
  import { resolveArtifactOutputs } from './outputs.js';
13
13
  import { resolveSchemaForChange } from './change-metadata.js';
14
- /**
15
- * A Markdown task line: a `-`/`*` bullet carrying a `[ ]` or `[x]` checkbox.
16
- * Leading whitespace is allowed so nested sub-tasks count like their parents.
17
- */
18
- const TASK_LINE_PATTERN = /^\s*[-*]\s*\[([\sxX])\]\s*(.*)/;
14
+ /** The task-line pattern of validation/task-lines.ts, which documents it; keep the two identical. */
15
+ const TASK_LINE_PATTERN = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s])\s*\](?![([])|\](?![([])|\s+\])\s*(.*)/;
19
16
  export function parseTaskLines(content) {
20
17
  const tasks = [];
21
18
  for (const line of content.split('\n')) {
22
19
  const match = line.match(TASK_LINE_PATTERN);
23
20
  if (match) {
24
- tasks.push({ done: match[1].toLowerCase() === 'x', description: match[2].trim() });
21
+ tasks.push({ done: (match[1] ?? '').toLowerCase() === 'x', description: match[2].trim() });
25
22
  }
26
23
  }
27
24
  return tasks;
@@ -49,7 +46,7 @@ function parseTasksWithDisposition(content) {
49
46
  for (const line of content.split('\n')) {
50
47
  const task = line.match(TASK_LINE_PATTERN);
51
48
  if (task) {
52
- current = { done: task[1].toLowerCase() === 'x', description: task[2].trim(), dispositioned: false };
49
+ current = { done: (task[1] ?? '').toLowerCase() === 'x', description: task[2].trim(), dispositioned: false };
53
50
  tasks.push(current);
54
51
  continue;
55
52
  }
@@ -3,6 +3,7 @@
3
3
  // No behavioural changes. Part of the schema-validation slice.
4
4
  import * as path from 'node:path';
5
5
  import { z } from 'zod';
6
+ import { hasBraceExpansion } from './outputs.js';
6
7
  function relativePathSchema(fieldName) {
7
8
  return z
8
9
  .string()
@@ -21,13 +22,24 @@ function relativePathSchema(fieldName) {
21
22
  });
22
23
  }
23
24
  // Artifact definition schema
24
- export const ArtifactSchema = z.object({
25
+ export const ArtifactSchema = z
26
+ .object({
25
27
  id: z.string().min(1, { error: 'Artifact ID is required' }),
26
28
  generates: relativePathSchema('generates field'),
27
29
  description: z.string(),
28
30
  template: relativePathSchema('template field'),
29
31
  instruction: z.string().optional(),
30
32
  requires: z.array(z.string()).default([]),
33
+ })
34
+ // Refined at the object level so the message can name the artifact id.
35
+ .superRefine((artifact, ctx) => {
36
+ if (hasBraceExpansion(artifact.generates)) {
37
+ ctx.addIssue({
38
+ code: 'custom',
39
+ path: ['generates'],
40
+ message: `artifact "${artifact.id}" generates pattern "${artifact.generates}": brace expansion is not supported (the matcher would never match it)`,
41
+ });
42
+ }
31
43
  });
32
44
  // Apply phase configuration for schema-aware apply instructions
33
45
  export const ApplyPhaseSchema = z.object({
@@ -1,4 +1,11 @@
1
1
  import { STORE_SELECTION_GUIDANCE } from './store-selection.js';
2
+ import { PROJECT_ROOT_GUARD } from './project-root.js';
3
+ /**
4
+ * `continue` is not among FreeHarness's six emitted workflows, so the
5
+ * blocked-state handoff below always renders the CLI fallback rather than
6
+ * naming a skill that was never generated.
7
+ */
8
+ const BLOCKED_STATE_HANDOFF = 'run `openspec status --change "<name>" --json`, select the next `ready` artifact (not `skipped` or `blocked`), and use `openspec instructions "<artifact-id>" --change "<name>" --json` for its rules and template. Keep the selected `--store <id>` on both commands.';
2
9
  /**
3
10
  * The apply workflow instructions, authored once and rendered by both the
4
11
  * skill and command surfaces. The surfaces are intentionally distinct, but
@@ -13,6 +20,8 @@ export function getApplyInstructions() {
13
20
 
14
21
  ${STORE_SELECTION_GUIDANCE}
15
22
 
23
+ ${PROJECT_ROOT_GUARD}
24
+
16
25
  **Input**: Optionally specify a change name (e.g., \`/opsx:apply add-auth\`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
17
26
 
18
27
  **Steps**
@@ -44,14 +53,17 @@ ${STORE_SELECTION_GUIDANCE}
44
53
  This returns:
45
54
  - \`contextFiles\`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
46
55
  - Progress (total, complete, remaining)
47
- - Task list with status
56
+ - Task list with status, source path, and source line
48
57
  - Dynamic instruction based on current state
49
58
  - Optional \`context\`: current required project instruction input from the selected root
50
59
  - Optional \`operationGuidance\`: current advisory guidance for apply
60
+ - \`missingArtifacts\` (when present): required artifact ids with no output
51
61
 
52
62
  **Handle states:**
53
- - If \`state: "blocked"\` (missing artifacts): show message, suggest using \`/opsx:continue\` (if it is not installed, run \`openspec status --change "<name>" --json\` to see the next artifact and \`openspec instructions <artifact-id> --change "<name>" --json\` for how to create it)
54
- - If \`state: "all_done"\`: congratulate, suggest archive
63
+ - If \`state: "blocked"\`: show the message and pause implementation.
64
+ - If \`missingArtifacts\` is non-empty: ${BLOCKED_STATE_HANDOFF}
65
+ - Otherwise, follow the CLI instruction to create or repair the schema-configured tracking file from existing planning artifacts. Do not assume another artifact is ready or start implementation while blocked.
66
+ - If \`state: "all_done"\`: report that all tracked tasks are complete and suggest review or verification as appropriate before archiving
55
67
  - Otherwise: proceed to implementation
56
68
 
57
69
  Treat \`context\` as a required prompt-level input. Read and consider it, and
@@ -93,7 +105,9 @@ ${STORE_SELECTION_GUIDANCE}
93
105
  - Show which task is being worked on
94
106
  - Make the code changes required
95
107
  - Keep changes minimal and focused
96
- - Mark task complete in the tasks file: \`- [ ]\` → \`- [x]\`
108
+ - Before editing, confirm the checkbox at the returned \`sourcePath\` and \`line\` still matches the task description; if it does not, rerun the apply instructions and use the refreshed location
109
+ - Mark the task complete at its returned \`sourcePath\` and \`line\`: \`- [ ]\` → \`- [x]\`
110
+ - Rerun the apply instructions and confirm that task is now done and progress changed
97
111
  - Continue to next task
98
112
 
99
113
  **Pause if:**
@@ -108,7 +122,7 @@ ${STORE_SELECTION_GUIDANCE}
108
122
  Display:
109
123
  - Tasks completed this session
110
124
  - Overall progress: "N/M tasks complete"
111
- - If all done: suggest archive
125
+ - If all done: report that tracked tasks are complete and suggest review or verification as appropriate before archiving
112
126
  - If paused: explain why and wait for guidance
113
127
 
114
128
  **Output During Implementation**
@@ -139,7 +153,7 @@ Working on task 4/7: <task description>
139
153
  - [x] Task 2
140
154
  ...
141
155
 
142
- All tasks complete! You can archive this change with \`/opsx:archive\`.
156
+ All tracked tasks are complete. Review or verify the change as appropriate before archiving. You can archive this change with \`/opsx:archive\`.
143
157
  \`\`\`
144
158
 
145
159
  **Output On Pause (Issue Encountered)**
@@ -173,6 +187,7 @@ What would you like to do?
173
187
  - When a task needs work beyond what the spec describes, surface the added scope and pause - never silently narrow, defer, or simplify away specified behavior
174
188
  - Only mark a task \`- [x]\` when its specified behavior is fully implemented, not when it is partially done or deferred
175
189
  - Use contextFiles from CLI output, don't assume specific file names
190
+ - Use each task's sourcePath and line to update its exact checkbox
176
191
  - Do not use context or operation guidance as proof that a task is complete
177
192
  - Apply relevant project context; report conflicts with controlling workflow inputs
178
193
  - Consider every guidance entry; explain any inapplicable or conflicting advice