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
@@ -14,9 +14,10 @@ export declare function normalizeRequirementName(name: string): string;
14
14
  /**
15
15
  * Case- and whitespace-insensitive fold of a requirement name. Requirement
16
16
  * matching itself is case-sensitive (normalizeRequirementName); this fold
17
- * exists only for typo detection - near-miss REMOVED headers and the
18
- * RENAMED+REMOVED cross-section conflict - where two spellings that differ
19
- * only in case or interior whitespace mean a mistake, never two requirements.
17
+ * exists only for typo detection - near-miss REMOVED, ADDED and RENAMED
18
+ * headers and the RENAMED+REMOVED cross-section conflict - where two
19
+ * spellings that differ only in case or interior whitespace mean a mistake,
20
+ * never two requirements.
20
21
  */
21
22
  export declare function foldRequirementName(name: string): string;
22
23
  /**
@@ -33,14 +34,36 @@ export interface SkippedHeader {
33
34
  section: string;
34
35
  line: number;
35
36
  }
37
+ /** A `TO:` line in RENAMED with no pending `FROM:` - not left to silently pair with a later, unrelated `FROM:`. */
38
+ export interface UnpairedRename {
39
+ side: 'TO';
40
+ name: string;
41
+ line: number;
42
+ }
43
+ /** A canonical `### Requirement:` block outside every delta section (`## Notes`, above the first `## ` header, ...). */
44
+ export interface OrphanedRequirement {
45
+ name: string;
46
+ section: string | null;
47
+ line: number;
48
+ }
49
+ /** Delta text archive never carries into the main spec; `text` lets a warning quote it. */
50
+ export interface UnappliedContent {
51
+ section: string | null;
52
+ line: number;
53
+ text: string;
54
+ }
36
55
  export interface DeltaPlan {
37
56
  added: RequirementBlock[];
38
57
  modified: RequirementBlock[];
39
58
  removed: string[];
59
+ removedBlocks: RequirementBlock[];
40
60
  renamed: Array<{
41
61
  from: string;
42
62
  to: string;
43
63
  }>;
64
+ unpairedRenames: UnpairedRename[];
65
+ orphanedRequirements: OrphanedRequirement[];
66
+ unappliedContent: UnappliedContent[];
44
67
  skippedHeaders: SkippedHeader[];
45
68
  sectionPresence: {
46
69
  added: boolean;
@@ -53,13 +76,30 @@ export interface DeltaPlan {
53
76
  * Parse a delta-formatted spec change file content into a DeltaPlan with raw blocks.
54
77
  */
55
78
  export declare function parseDeltaSpec(content: string): DeltaPlan;
79
+ export declare function describeUnappliedContent(content: UnappliedContent, sentenceStart?: boolean): string;
80
+ /** Standalone entry for callers holding raw file text rather than a plan. */
81
+ export declare function findUnappliedContent(content: string): UnappliedContent[];
82
+ /** Both directions of the scenario-name comparison, plus the two totals. */
83
+ export interface ScenarioNameDiff {
84
+ /** Names the current block has that the incoming block does not cover. */
85
+ missing: string[];
86
+ /** Names the incoming block introduces that the current block does not have. */
87
+ added: string[];
88
+ /** Level-4 headers in the current block. */
89
+ currentCount: number;
90
+ /** Level-4 headers in the incoming block. */
91
+ incomingCount: number;
92
+ }
56
93
  /**
57
- * Scenario names the current requirement block has and the incoming
58
- * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
59
- * so every name reported here would be dropped from the main spec.
60
- *
61
- * Shared by archive (which refuses to apply the block) and validate (which
62
- * reports the same loss at authoring time, #1477), so the two cannot disagree
63
- * about what counts as a dropped scenario.
94
+ * Compare the scenario names of a current requirement block and an incoming
95
+ * (MODIFIED) one, in both directions. `missing` is the loss the guard exists
96
+ * to catch; `added` and the two counts are reported alongside it, since a
97
+ * block that omits two names and introduces two reads as a rename while one
98
+ * that introduces none reads as a truncation - neither is proof, so this
99
+ * decides nothing and only says what the two blocks contain.
64
100
  */
101
+ export declare function diffScenarioNames(current: RequirementBlock, incoming: RequirementBlock): ScenarioNameDiff;
102
+ /** Scenario names the current block has that the incoming block drops - the `missing` half of diffScenarioNames. */
65
103
  export declare function findMissingCurrentScenarios(current: RequirementBlock, incoming: RequirementBlock): string[];
104
+ /** The one sentence archive and validate both append when the scenario-loss guard fires. */
105
+ export declare function describeScenarioBalance(diff: ScenarioNameDiff): string;
@@ -1,13 +1,17 @@
1
1
  import { buildCodeFenceMask, SCENARIO_HEADER } from './requirement-text.js';
2
2
  export function normalizeRequirementName(name) {
3
- return name.trim();
3
+ // A trailing ATX closing run of `#`s is not part of the name (`### Foo ###`
4
+ // renders as `Foo`); only a run preceded by a space/tab closes the heading,
5
+ // so `C#` keeps its `#`.
6
+ return name.replace(/[ \t]+#+[ \t]*$/, '').trim();
4
7
  }
5
8
  /**
6
9
  * Case- and whitespace-insensitive fold of a requirement name. Requirement
7
10
  * matching itself is case-sensitive (normalizeRequirementName); this fold
8
- * exists only for typo detection - near-miss REMOVED headers and the
9
- * RENAMED+REMOVED cross-section conflict - where two spellings that differ
10
- * only in case or interior whitespace mean a mistake, never two requirements.
11
+ * exists only for typo detection - near-miss REMOVED, ADDED and RENAMED
12
+ * headers and the RENAMED+REMOVED cross-section conflict - where two
13
+ * spellings that differ only in case or interior whitespace mean a mistake,
14
+ * never two requirements.
11
15
  */
12
16
  export function foldRequirementName(name) {
13
17
  return normalizeRequirementName(name).toLowerCase().replace(/\s+/g, ' ');
@@ -115,15 +119,22 @@ export function parseDeltaSpec(content) {
115
119
  sink: skippedHeaders,
116
120
  }));
117
121
  const removedNames = removedLookup.bodies.flatMap((body) => parseRemovedNames(body));
122
+ const removedBlocks = removedLookup.bodies.flatMap((body) => parseRequirementBlocksFromSection(body));
118
123
  // Pairs are read per section, so a FROM in one copy of the header can never
119
124
  // pair with a TO written under a different copy.
120
- const renamedPairs = renamedLookup.bodies.flatMap((body) => parseRenamedPairs(body, { section: renamedLookup.title, sink: skippedHeaders }));
125
+ const unpairedRenames = [];
126
+ const renamedPairs = renamedLookup.bodies.flatMap((body) => parseRenamedPairs(body, { section: renamedLookup.title, sink: skippedHeaders }, unpairedRenames));
127
+ unpairedRenames.sort((a, b) => a.line - b.line);
121
128
  skippedHeaders.sort((a, b) => a.line - b.line);
122
129
  return {
123
130
  added,
124
131
  modified,
125
132
  removed: removedNames,
133
+ removedBlocks,
126
134
  renamed: renamedPairs,
135
+ unpairedRenames,
136
+ orphanedRequirements: findOrphanedRequirements(lines, fenceMask),
137
+ unappliedContent: collectUnappliedContent(lines, fenceMask),
127
138
  skippedHeaders,
128
139
  sectionPresence: {
129
140
  added: addedLookup.found,
@@ -133,6 +144,94 @@ export function parseDeltaSpec(content) {
133
144
  },
134
145
  };
135
146
  }
147
+ /** The four section titles the delta reader acts on, folded like `getSectionsCaseInsensitive` folds them. */
148
+ const DELTA_SECTION_TITLES = new Set(['ADDED Requirements', 'MODIFIED Requirements', 'REMOVED Requirements', 'RENAMED Requirements'].map((title) => title.toLowerCase()));
149
+ /** Every canonical `### Requirement:` header outside a delta section, in document order. */
150
+ function findOrphanedRequirements(lines, fenceMask) {
151
+ const orphans = [];
152
+ let section = null;
153
+ for (let i = 0; i < lines.length; i++) {
154
+ if (fenceMask[i])
155
+ continue;
156
+ // Same `## ` test splitTopLevelSections uses, so both agree on sections.
157
+ const sectionMatch = lines[i].match(/^(##)\s+(.+)$/);
158
+ if (sectionMatch) {
159
+ section = sectionMatch[2].trim();
160
+ continue;
161
+ }
162
+ if (section !== null && DELTA_SECTION_TITLES.has(section.toLowerCase()))
163
+ continue;
164
+ const header = lines[i].match(REQUIREMENT_HEADER_REGEX);
165
+ if (header) {
166
+ orphans.push({ name: normalizeRequirementName(header[1]), section, line: i + 1 });
167
+ }
168
+ }
169
+ return orphans;
170
+ }
171
+ /** Quoted text is cut here so one long paragraph cannot flood a warning line. */
172
+ const MAX_QUOTED_TEXT = 100;
173
+ export function describeUnappliedContent(content, sentenceStart = false) {
174
+ const where = content.section === null
175
+ ? 'text above the first "## " section'
176
+ : DELTA_SECTION_TITLES.has(content.section.toLowerCase())
177
+ ? `text before the first requirement in "## ${content.section}"`
178
+ : `section "## ${content.section}"`;
179
+ const quoted = content.text.length > MAX_QUOTED_TEXT ? `${content.text.slice(0, MAX_QUOTED_TEXT)}...` : content.text;
180
+ const text = `${where} ("${quoted}")`;
181
+ return sentenceStart ? text.charAt(0).toUpperCase() + text.slice(1) : text;
182
+ }
183
+ /** Standalone entry for callers holding raw file text rather than a plan. */
184
+ export function findUnappliedContent(content) {
185
+ const lines = normalizeLineEndings(content).split('\n');
186
+ return collectUnappliedContent(lines, buildCodeFenceMask(lines));
187
+ }
188
+ /**
189
+ * HTML comments, `# ` titles, Purpose (warned about separately), REMOVED and
190
+ * RENAMED bodies, and stray `###` headers before the first requirement (INFO
191
+ * since #498) are not reported.
192
+ */
193
+ function collectUnappliedContent(lines, fenceMask) {
194
+ // Comments only decide whether a line holds text; sections come from the
195
+ // merge's own split, and fenced lines keep their text so a `<!--` sample
196
+ // never hides what follows the fence.
197
+ const comments = lines
198
+ .map((line, i) => (fenceMask[i] ? '' : line))
199
+ .join('\n')
200
+ .replace(/<!--[\s\S]*?(?:--!?>|$)/g, (m) => m.replace(/[^\n]/g, ' '))
201
+ .split('\n');
202
+ const masked = lines.map((line, i) => (fenceMask[i] ? line : comments[i]));
203
+ const sections = splitTopLevelSections(lines, fenceMask);
204
+ const firstText = (from, to, skip) => {
205
+ for (let i = from; i < to; i++)
206
+ if (masked[i].trim() && !skip?.test(masked[i]))
207
+ return i;
208
+ return -1;
209
+ };
210
+ const found = [];
211
+ const preamble = firstText(0, sections.length ? sections[0].body.bodyStartLine - 2 : lines.length, /^#\s/);
212
+ if (preamble !== -1)
213
+ found.push({ section: null, line: preamble + 1, text: masked[preamble].trim() });
214
+ for (const { title, body } of sections) {
215
+ const folded = title.toLowerCase();
216
+ const start = body.bodyStartLine - 1;
217
+ if (folded === 'added requirements' || folded === 'modified requirements') {
218
+ const block = body.lines.findIndex((l, i) => !body.fenceMask[i] && REQUIREMENT_HEADER_REGEX.test(l));
219
+ const first = firstText(start, start + (block === -1 ? body.lines.length : block), /^###\s/);
220
+ if (first !== -1)
221
+ found.push({ section: title, line: first + 1, text: masked[first].trim() });
222
+ }
223
+ else if (!DELTA_SECTION_TITLES.has(folded)) {
224
+ // The Purpose carry-over reads past commented-out headers, so only a
225
+ // `## Purpose` outside a comment is the one archive handles.
226
+ if (folded === 'purpose' && masked[start - 1].trim())
227
+ continue;
228
+ const first = firstText(start, start + body.lines.length);
229
+ if (first !== -1)
230
+ found.push({ section: title, line: start, text: masked[first].trim() });
231
+ }
232
+ }
233
+ return found;
234
+ }
136
235
  /**
137
236
  * Every `## ` section of a delta file, as a list rather than a title-keyed
138
237
  * record.
@@ -276,7 +375,7 @@ function parseRemovedNames(sectionBody) {
276
375
  * or `+` into a silent no-op - archive reports success while the rename never
277
376
  * happens.
278
377
  */
279
- function parseRenamedPairs(sectionBody, skipped) {
378
+ function parseRenamedPairs(sectionBody, skipped, unpaired) {
280
379
  const { lines, fenceMask, bodyStartLine } = sectionBody;
281
380
  if (lines.length === 0)
282
381
  return [];
@@ -305,47 +404,76 @@ function parseRenamedPairs(sectionBody, skipped) {
305
404
  current.fromLine = i;
306
405
  }
307
406
  else if (toMatch) {
308
- current.to = normalizeRequirementName(toMatch[1]);
309
- if (current.from && current.to) {
310
- pairs.push({ from: current.from, to: current.to });
311
- current = {};
407
+ const to = normalizeRequirementName(toMatch[1]);
408
+ if (current.from === undefined) {
409
+ // No pending FROM: used to linger in `current.to` and silently pair
410
+ // with whichever FROM arrived next (#1806) - reported instead.
411
+ unpaired?.push({ side: 'TO', name: to, line: bodyStartLine + i });
412
+ continue;
312
413
  }
414
+ pairs.push({ from: current.from, to });
415
+ current = {};
313
416
  }
314
417
  }
315
418
  flushUnpairedFrom();
316
419
  return pairs;
317
420
  }
318
421
  /**
319
- * Scenario names the current requirement block has and the incoming
320
- * (MODIFIED) block does not. A MODIFIED requirement replaces the whole block,
321
- * so every name reported here would be dropped from the main spec.
322
- *
323
- * Shared by archive (which refuses to apply the block) and validate (which
324
- * reports the same loss at authoring time, #1477), so the two cannot disagree
325
- * about what counts as a dropped scenario.
422
+ * Compare the scenario names of a current requirement block and an incoming
423
+ * (MODIFIED) one, in both directions. `missing` is the loss the guard exists
424
+ * to catch; `added` and the two counts are reported alongside it, since a
425
+ * block that omits two names and introduces two reads as a rename while one
426
+ * that introduces none reads as a truncation - neither is proof, so this
427
+ * decides nothing and only says what the two blocks contain.
326
428
  */
327
- export function findMissingCurrentScenarios(current, incoming) {
328
- // Multiplicity-aware: a name present N times in current and M times in
329
- // incoming means max(0, N - M) instances are missing. Set membership would
330
- // treat N>M as fully covered and let archive silently drop duplicates
331
- // (residual #1246 / duplicate-scenario-name blind spot).
332
- const remainingIncoming = new Map();
333
- for (const scenario of parseScenarioBlocks(incoming.raw)) {
334
- const name = scenario.name;
335
- remainingIncoming.set(name, (remainingIncoming.get(name) ?? 0) + 1);
336
- }
337
- const missing = [];
338
- for (const scenario of parseScenarioBlocks(current.raw)) {
339
- const name = scenario.name;
340
- const remaining = remainingIncoming.get(name) ?? 0;
341
- if (remaining > 0) {
342
- remainingIncoming.set(name, remaining - 1);
343
- }
344
- else {
345
- missing.push(name);
429
+ export function diffScenarioNames(current, incoming) {
430
+ const currentNames = parseScenarioBlocks(current.raw).map((scenario) => scenario.name);
431
+ const incomingNames = parseScenarioBlocks(incoming.raw).map((scenario) => scenario.name);
432
+ // Multiplicity-aware: a name present N times on one side and M times on the
433
+ // other leaves max(0, N - M) instances unmatched. Set membership would treat
434
+ // N>M as fully covered and let archive silently drop duplicates (residual
435
+ // #1246 / duplicate-scenario-name blind spot).
436
+ const unmatched = (names, against) => {
437
+ const remaining = new Map();
438
+ for (const name of against)
439
+ remaining.set(name, (remaining.get(name) ?? 0) + 1);
440
+ const out = [];
441
+ for (const name of names) {
442
+ const left = remaining.get(name) ?? 0;
443
+ if (left > 0)
444
+ remaining.set(name, left - 1);
445
+ else
446
+ out.push(name);
346
447
  }
448
+ return out;
449
+ };
450
+ return {
451
+ missing: unmatched(currentNames, incomingNames),
452
+ added: unmatched(incomingNames, currentNames),
453
+ currentCount: currentNames.length,
454
+ incomingCount: incomingNames.length,
455
+ };
456
+ }
457
+ /** Scenario names the current block has that the incoming block drops - the `missing` half of diffScenarioNames. */
458
+ export function findMissingCurrentScenarios(current, incoming) {
459
+ return diffScenarioNames(current, incoming).missing;
460
+ }
461
+ /** At most this many added names are listed before the rest are counted. */
462
+ const MAX_LISTED_ADDED_SCENARIOS = 3;
463
+ /** The one sentence archive and validate both append when the scenario-loss guard fires. */
464
+ export function describeScenarioBalance(diff) {
465
+ const count = (value) => `${value} ${value === 1 ? 'scenario' : 'scenarios'}`;
466
+ const scale = `The modified block has ${count(diff.incomingCount)}; the current spec has ${count(diff.currentCount)}.`;
467
+ if (diff.added.length === 0) {
468
+ return `${scale} It adds none.`;
347
469
  }
348
- return missing;
470
+ const listed = diff.added
471
+ .slice(0, MAX_LISTED_ADDED_SCENARIOS)
472
+ .map((name) => `"${name}"`)
473
+ .join(', ');
474
+ const rest = diff.added.length - MAX_LISTED_ADDED_SCENARIOS;
475
+ const names = rest > 0 ? `${listed} and ${rest} more` : listed;
476
+ return `${scale} It adds ${count(diff.added.length)} not in the current spec: ${names}.`;
349
477
  }
350
478
  /**
351
479
  * Any non-fenced level-4 header on the given (masked) line. Reuses the spec
@@ -15,8 +15,26 @@ export { buildCodeFenceMask } from './code-fence.js';
15
15
  * as a scenario, so the delta counter must too (parity). The delta/loss path
16
16
  * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
17
17
  * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
18
+ * A header alone is not yet a scenario on either path: see hasScenarioBody.
18
19
  */
19
20
  export declare const SCENARIO_HEADER: RegExp;
21
+ /**
22
+ * EARS scenario title: `Scenario: WHEN <event> THE SYSTEM SHALL <behavior>`
23
+ * (also WHILE/WHERE/IF ... SHALL). FreeHarness addition (V4-P4): an EARS
24
+ * scenario carries its whole behavior in the header and may have no body -
25
+ * both the spec path (`MarkdownParser.parseScenarios`) and the delta counter
26
+ * below count it as a real scenario, matched case-insensitively against the
27
+ * text after `#### `.
28
+ */
29
+ export declare const EARS_SCENARIO_TITLE: RegExp;
30
+ /**
31
+ * Whether a scenario's body has content. The spec path
32
+ * (`MarkdownParser.parseScenarios`) drops a scenario whose body is empty, so
33
+ * the delta counter must too (parity): otherwise `validate` accepts a
34
+ * requirement whose only scenario is a bare header, and archive rejects it
35
+ * when it validates the rebuilt spec.
36
+ */
37
+ export declare function hasScenarioBody(body: string): boolean;
20
38
  /**
21
39
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
22
40
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -48,8 +66,11 @@ export declare function extractRequirementBody(bodyLines: string[]): string;
48
66
  */
49
67
  export declare function extractRequirementText(headerTitle: string, bodyLines: string[]): string;
50
68
  /**
51
- * Count the real scenarios in a requirement block: `#### ` headers on non-fenced
52
- * lines. A `#### Scenario:` that lives inside a fenced example is not a real
53
- * scenario and is not counted.
69
+ * Count the real scenarios in a requirement block: `#### ` headers on
70
+ * non-fenced lines whose body has content, or whose header is EARS-shaped. A
71
+ * `#### Scenario:` that lives inside a fenced example is not a real scenario
72
+ * and is not counted, and neither is a plain header with nothing under it.
54
73
  */
55
74
  export declare function countScenarios(bodyLines: string[]): number;
75
+ /** Count the `#### ` headers in a requirement block that do not count as a scenario. */
76
+ export declare function countEmptyScenarios(bodyLines: string[]): number;
@@ -22,8 +22,30 @@ const HEADER_LINE = /^#{1,6}\s/;
22
22
  * as a scenario, so the delta counter must too (parity). The delta/loss path
23
23
  * reuses this exact constant via `scenarioHeaderAt` in requirement-blocks.ts;
24
24
  * keep both paths on it rather than reintroducing a separate `Scenario:` regex.
25
+ * A header alone is not yet a scenario on either path: see hasScenarioBody.
25
26
  */
26
27
  export const SCENARIO_HEADER = /^####\s+/;
28
+ /** A header at scenario level or above (`#` to `####`): where a scenario body ends. */
29
+ const SCENARIO_BODY_END = /^#{1,4}\s/;
30
+ /**
31
+ * EARS scenario title: `Scenario: WHEN <event> THE SYSTEM SHALL <behavior>`
32
+ * (also WHILE/WHERE/IF ... SHALL). FreeHarness addition (V4-P4): an EARS
33
+ * scenario carries its whole behavior in the header and may have no body -
34
+ * both the spec path (`MarkdownParser.parseScenarios`) and the delta counter
35
+ * below count it as a real scenario, matched case-insensitively against the
36
+ * text after `#### `.
37
+ */
38
+ export const EARS_SCENARIO_TITLE = /^Scenario:\s*(?:WHEN|WHILE|WHERE|IF)\b[\s\S]*\b(?:SHALL|MUST)\b/i;
39
+ /**
40
+ * Whether a scenario's body has content. The spec path
41
+ * (`MarkdownParser.parseScenarios`) drops a scenario whose body is empty, so
42
+ * the delta counter must too (parity): otherwise `validate` accepts a
43
+ * requirement whose only scenario is a bare header, and archive rejects it
44
+ * when it validates the rebuilt spec.
45
+ */
46
+ export function hasScenarioBody(body) {
47
+ return body.trim().length > 0;
48
+ }
27
49
  /**
28
50
  * The one predicate for normative-keyword detection. Matches `SHALL` or `MUST`
29
51
  * as whole words so the change-delta reader and the schema-based reader accept
@@ -81,18 +103,43 @@ export function extractRequirementText(headerTitle, bodyLines) {
81
103
  return extractRequirementBody(bodyLines) || headerTitle.trim();
82
104
  }
83
105
  /**
84
- * Count the real scenarios in a requirement block: `#### ` headers on non-fenced
85
- * lines. A `#### Scenario:` that lives inside a fenced example is not a real
86
- * scenario and is not counted.
106
+ * Whether a scenario counts as real: a non-empty body, or an EARS-shaped
107
+ * header, which carries its whole behavior in the title and needs none.
108
+ */
109
+ function isCountedScenario(scenario) {
110
+ return hasScenarioBody(scenario.body) || EARS_SCENARIO_TITLE.test(scenario.header);
111
+ }
112
+ /**
113
+ * Count the real scenarios in a requirement block: `#### ` headers on
114
+ * non-fenced lines whose body has content, or whose header is EARS-shaped. A
115
+ * `#### Scenario:` that lives inside a fenced example is not a real scenario
116
+ * and is not counted, and neither is a plain header with nothing under it.
87
117
  */
88
118
  export function countScenarios(bodyLines) {
119
+ return readScenarios(bodyLines).filter(isCountedScenario).length;
120
+ }
121
+ /** Count the `#### ` headers in a requirement block that do not count as a scenario. */
122
+ export function countEmptyScenarios(bodyLines) {
123
+ return readScenarios(bodyLines).filter((scenario) => !isCountedScenario(scenario)).length;
124
+ }
125
+ /**
126
+ * Each scenario in a requirement block, as its header text and body. A body
127
+ * runs to the next non-fenced header of level 4 or above, the boundary the
128
+ * spec path uses, so a fenced block or a deeper `#####` header is part of it.
129
+ */
130
+ function readScenarios(bodyLines) {
89
131
  const mask = buildCodeFenceMask(bodyLines);
90
- let count = 0;
132
+ const scenarios = [];
91
133
  for (let i = 0; i < bodyLines.length; i++) {
92
- if (mask[i])
134
+ if (mask[i] || !SCENARIO_HEADER.test(bodyLines[i]))
93
135
  continue;
94
- if (SCENARIO_HEADER.test(bodyLines[i]))
95
- count++;
136
+ let end = i + 1;
137
+ while (end < bodyLines.length && (mask[end] || !SCENARIO_BODY_END.test(bodyLines[end])))
138
+ end++;
139
+ scenarios.push({
140
+ header: bodyLines[i].replace(SCENARIO_HEADER, ''),
141
+ body: bodyLines.slice(i + 1, end).join('\n'),
142
+ });
96
143
  }
97
- return count;
144
+ return scenarios;
98
145
  }
@@ -1,4 +1,5 @@
1
1
  import { buildCodeFenceMask } from './code-fence.js';
2
+ import { normalizeRequirementName } from './requirement-blocks.js';
2
3
  const REQUIREMENTS_SECTION_HEADER = /^##\s+Requirements\s*$/i;
3
4
  const TOP_LEVEL_SECTION_HEADER = /^##\s+/;
4
5
  const DELTA_HEADER = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\s*$/i;
@@ -53,7 +54,9 @@ export function findMainSpecStructureIssues(content) {
53
54
  });
54
55
  continue;
55
56
  }
56
- const requirementName = requirementMatch[1].trim();
57
+ // The same name every other reader uses, so a closed heading
58
+ // (`### Requirement: Foo ###`) duplicates `### Requirement: Foo`.
59
+ const requirementName = normalizeRequirementName(requirementMatch[1]);
57
60
  const previousLine = requirementLines.get(requirementName);
58
61
  if (previousLine !== undefined) {
59
62
  issues.push({
@@ -1,15 +1,25 @@
1
1
  import { buildCodeFenceMask } from './parsers/code-fence.js';
2
2
  import { PURPOSE_PLACEHOLDER_PREFIX, PURPOSE_PLACEHOLDER_SUFFIX } from './constants.js';
3
3
  /**
4
- * A `TBD` or `TODO` opening the Purpose. The lookahead keeps it off a longer
5
- * word that merely begins with those letters, like "TBDs" or "TODOs", while
6
- * still allowing the punctuation a marker is usually written with: `TODO:`,
7
- * `TBD -`. It rejects any letter, digit or combining mark rather than only the
8
- * ASCII ones `\b` knows about, because a Purpose is prose and prose is not
9
- * always written in Latin script - `TBD` followed by an Arabic-Indic digit is
10
- * as much a longer word as `TBDs` is.
4
+ * A `TBD` or `TODO` opening the Purpose. `WORD_END` keeps the marker off a
5
+ * longer word merely starting with those letters ("TBDs"), while allowing the
6
+ * punctuation a marker is usually written with (`TODO:`, `TBD -`); it rejects
7
+ * any letter/digit/combining mark, not only ASCII, since prose is not always
8
+ * Latin script.
9
+ *
10
+ * Case separates the marker from the word: `TODO` shouted in capitals is the
11
+ * marker whatever follows it, so `TODO write this later` is still unwritten.
12
+ * Any other case is a marker only when punctuation or the line end follows,
13
+ * because `todo` opens ordinary Spanish/Portuguese sentences ("Todo el...",
14
+ * "Todo o...") that are not placeholders - while `todo - write this later`
15
+ * and `tbd.` still report.
11
16
  */
12
- const LEADING_MARKER = /^(?:TBD|TODO)(?![\p{L}\p{N}\p{M}_])/iu;
17
+ const WORD_END = '(?![\\p{L}\\p{N}\\p{M}_])';
18
+ const MARKER_PUNCTUATION = '(?=[ \\t]*(?:$|\\n|[:\\-–—.,;()\\[\\]{}]))';
19
+ /** `TBD`/`TODO` in capitals: the marker, whatever follows it. */
20
+ const LEADING_MARKER_SHOUTED = new RegExp(`^(?:TBD|TODO)${WORD_END}`, 'u');
21
+ /** Any other case: a marker only when punctuation or the line end says so. */
22
+ const LEADING_MARKER_PUNCTUATED = new RegExp(`^(?:TBD|TODO)${WORD_END}${MARKER_PUNCTUATION}`, 'iu');
13
23
  const PURPOSE_HEADER = /^ {0,3}##(?!#)[ \t]+Purpose[ \t]*$/i;
14
24
  const TOP_LEVEL_HEADER = /^ {0,3}#{1,2}(?!#)[ \t]+/;
15
25
  /**
@@ -60,7 +70,7 @@ export function findPurposePlaceholderIssue(overview, content) {
60
70
  // that is nothing but a fenced block reduces to the same empty text here, and
61
71
  // is left to the brevity and empty-Purpose rules for the same reason.
62
72
  const prose = unfencedLines(overview).join('\n').trim();
63
- const leading = LEADING_MARKER.test(prose);
73
+ const leading = LEADING_MARKER_SHOUTED.test(prose) || LEADING_MARKER_PUNCTUATED.test(prose);
64
74
  if (!leading && generatedPlaceholderPrefixIndex(prose) === undefined)
65
75
  return null;
66
76
  // Which rule matched decides where the placeholder is, so the locator is told.
@@ -37,3 +37,13 @@ export declare function discoverSpecFiles(specsRoot: string): Promise<Discovered
37
37
  * decide.
38
38
  */
39
39
  export declare function hasAnyFileUnder(dirPath: string): Promise<boolean>;
40
+ export interface UnreadDeltaFile {
41
+ /** File path relative to the specs root, forward-slash separated. */
42
+ path: string;
43
+ /** The spec.md the merge path reads for it, relative to the specs root. */
44
+ expected: string;
45
+ }
46
+ /** Async driver of walkUnreadDeltaFiles. */
47
+ export declare function findUnreadDeltaFiles(specsRoot: string): Promise<UnreadDeltaFile[]>;
48
+ /** Sync driver of walkUnreadDeltaFiles, for the instructions shim. */
49
+ export declare function findUnreadDeltaFilesSync(specsRoot: string): UnreadDeltaFile[];