@isaacriehm/cairn-core 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/align-undo/index.d.ts +6 -0
  3. package/dist/align-undo/index.js +6 -0
  4. package/dist/align-undo/index.js.map +1 -0
  5. package/dist/align-undo/log.d.ts +53 -0
  6. package/dist/align-undo/log.js +99 -0
  7. package/dist/align-undo/log.js.map +1 -0
  8. package/dist/align-undo/undo.d.ts +66 -0
  9. package/dist/align-undo/undo.js +187 -0
  10. package/dist/align-undo/undo.js.map +1 -0
  11. package/dist/attention/bulk-accept.js +1 -1
  12. package/dist/attention/bulk-accept.js.map +1 -1
  13. package/dist/attention/dedup.d.ts +2 -2
  14. package/dist/attention/dedup.js +16 -51
  15. package/dist/attention/dedup.js.map +1 -1
  16. package/dist/attention/index.d.ts +1 -0
  17. package/dist/attention/index.js +1 -0
  18. package/dist/attention/index.js.map +1 -1
  19. package/dist/attention/restore.js +1 -1
  20. package/dist/attention/restore.js.map +1 -1
  21. package/dist/attention/serve/api.d.ts +23 -0
  22. package/dist/attention/serve/api.js +344 -0
  23. package/dist/attention/serve/api.js.map +1 -0
  24. package/dist/attention/serve/index.d.ts +62 -0
  25. package/dist/attention/serve/index.js +205 -0
  26. package/dist/attention/serve/index.js.map +1 -0
  27. package/dist/decision-capture/id.d.ts +62 -25
  28. package/dist/decision-capture/id.js +78 -57
  29. package/dist/decision-capture/id.js.map +1 -1
  30. package/dist/decision-capture/index.d.ts +3 -3
  31. package/dist/decision-capture/index.js +3 -3
  32. package/dist/decision-capture/index.js.map +1 -1
  33. package/dist/drain/drain.d.ts +77 -0
  34. package/dist/drain/drain.js +464 -0
  35. package/dist/drain/drain.js.map +1 -0
  36. package/dist/drain/index.d.ts +5 -0
  37. package/dist/drain/index.js +5 -0
  38. package/dist/drain/index.js.map +1 -0
  39. package/dist/fix-align/index.d.ts +5 -0
  40. package/dist/fix-align/index.js +5 -0
  41. package/dist/fix-align/index.js.map +1 -0
  42. package/dist/fix-align/run.d.ts +99 -0
  43. package/dist/fix-align/run.js +258 -0
  44. package/dist/fix-align/run.js.map +1 -0
  45. package/dist/ground/alignment-pending.d.ts +28 -0
  46. package/dist/ground/alignment-pending.js +83 -0
  47. package/dist/ground/alignment-pending.js.map +1 -0
  48. package/dist/ground/anchor-map.d.ts +14 -0
  49. package/dist/ground/anchor-map.js +57 -0
  50. package/dist/ground/anchor-map.js.map +1 -0
  51. package/dist/ground/index.d.ts +9 -2
  52. package/dist/ground/index.js +8 -2
  53. package/dist/ground/index.js.map +1 -1
  54. package/dist/ground/paths.d.ts +21 -0
  55. package/dist/ground/paths.js +43 -0
  56. package/dist/ground/paths.js.map +1 -1
  57. package/dist/ground/schemas.d.ts +201 -0
  58. package/dist/ground/schemas.js +128 -3
  59. package/dist/ground/schemas.js.map +1 -1
  60. package/dist/ground/scope-index.js +2 -2
  61. package/dist/ground/scope-index.js.map +1 -1
  62. package/dist/ground/slug.d.ts +60 -0
  63. package/dist/ground/slug.js +103 -0
  64. package/dist/ground/slug.js.map +1 -0
  65. package/dist/ground/sot-bindings.d.ts +14 -0
  66. package/dist/ground/sot-bindings.js +80 -0
  67. package/dist/ground/sot-bindings.js.map +1 -0
  68. package/dist/ground/sot-cache.d.ts +18 -0
  69. package/dist/ground/sot-cache.js +63 -0
  70. package/dist/ground/sot-cache.js.map +1 -0
  71. package/dist/ground/topic-index.d.ts +20 -0
  72. package/dist/ground/topic-index.js +60 -0
  73. package/dist/ground/topic-index.js.map +1 -0
  74. package/dist/hooks/post-tool-use/citation-scanner.d.ts +1 -1
  75. package/dist/hooks/post-tool-use/citation-scanner.js +3 -3
  76. package/dist/hooks/post-tool-use/citation-scanner.js.map +1 -1
  77. package/dist/hooks/post-tool-use/copy-scanner.js +1 -1
  78. package/dist/hooks/post-tool-use/copy-scanner.js.map +1 -1
  79. package/dist/hooks/post-tool-use/index.d.ts +2 -0
  80. package/dist/hooks/post-tool-use/index.js +1 -0
  81. package/dist/hooks/post-tool-use/index.js.map +1 -1
  82. package/dist/hooks/post-tool-use/legend-builder.d.ts +1 -1
  83. package/dist/hooks/post-tool-use/legend-builder.js +2 -2
  84. package/dist/hooks/post-tool-use/legend-builder.js.map +1 -1
  85. package/dist/hooks/post-tool-use/sot-align.d.ts +166 -0
  86. package/dist/hooks/post-tool-use/sot-align.js +1311 -0
  87. package/dist/hooks/post-tool-use/sot-align.js.map +1 -0
  88. package/dist/hooks/pre-commit/index.d.ts +8 -0
  89. package/dist/hooks/pre-commit/index.js +8 -0
  90. package/dist/hooks/pre-commit/index.js.map +1 -0
  91. package/dist/hooks/pre-commit/sot-align-precommit.d.ts +60 -0
  92. package/dist/hooks/pre-commit/sot-align-precommit.js +221 -0
  93. package/dist/hooks/pre-commit/sot-align-precommit.js.map +1 -0
  94. package/dist/hooks/runners/session-start.js +41 -0
  95. package/dist/hooks/runners/session-start.js.map +1 -1
  96. package/dist/hooks/sot-align-common.d.ts +39 -0
  97. package/dist/hooks/sot-align-common.js +152 -0
  98. package/dist/hooks/sot-align-common.js.map +1 -0
  99. package/dist/index.d.ts +5 -0
  100. package/dist/index.js +5 -0
  101. package/dist/index.js.map +1 -1
  102. package/dist/init/index.d.ts +4 -2
  103. package/dist/init/index.js +2 -1
  104. package/dist/init/index.js.map +1 -1
  105. package/dist/init/ingest-docs.d.ts +30 -47
  106. package/dist/init/ingest-docs.js +113 -406
  107. package/dist/init/ingest-docs.js.map +1 -1
  108. package/dist/init/init.d.ts +8 -0
  109. package/dist/init/init.js +58 -29
  110. package/dist/init/init.js.map +1 -1
  111. package/dist/init/mapper-parallel.js +1 -1
  112. package/dist/init/mapper-parallel.js.map +1 -1
  113. package/dist/init/phases/5-brand.js +1 -1
  114. package/dist/init/phases/5-brand.js.map +1 -1
  115. package/dist/init/phases/5b-topic-index.d.ts +30 -0
  116. package/dist/init/phases/5b-topic-index.js +62 -0
  117. package/dist/init/phases/5b-topic-index.js.map +1 -0
  118. package/dist/init/phases/6-docs-ingest.d.ts +4 -5
  119. package/dist/init/phases/6-docs-ingest.js +5 -6
  120. package/dist/init/phases/6-docs-ingest.js.map +1 -1
  121. package/dist/init/phases/index.d.ts +2 -0
  122. package/dist/init/phases/index.js +1 -0
  123. package/dist/init/phases/index.js.map +1 -1
  124. package/dist/init/phases/parallel-678.d.ts +14 -17
  125. package/dist/init/phases/parallel-678.js +77 -98
  126. package/dist/init/phases/parallel-678.js.map +1 -1
  127. package/dist/init/phases/source-comments-output-io.d.ts +16 -10
  128. package/dist/init/phases/source-comments-output-io.js +7 -10
  129. package/dist/init/phases/source-comments-output-io.js.map +1 -1
  130. package/dist/init/phases/types.d.ts +1 -1
  131. package/dist/init/phases/types.js +1 -0
  132. package/dist/init/phases/types.js.map +1 -1
  133. package/dist/init/rules-merge/discover.d.ts +8 -3
  134. package/dist/init/rules-merge/discover.js +7 -3
  135. package/dist/init/rules-merge/discover.js.map +1 -1
  136. package/dist/init/rules-merge/ingest.d.ts +81 -28
  137. package/dist/init/rules-merge/ingest.js +456 -155
  138. package/dist/init/rules-merge/ingest.js.map +1 -1
  139. package/dist/init/sot-emit.d.ts +84 -0
  140. package/dist/init/sot-emit.js +218 -0
  141. package/dist/init/sot-emit.js.map +1 -0
  142. package/dist/init/source-comments/classify.d.ts +12 -10
  143. package/dist/init/source-comments/classify.js +13 -25
  144. package/dist/init/source-comments/classify.js.map +1 -1
  145. package/dist/init/source-comments/index.d.ts +1 -1
  146. package/dist/init/source-comments/index.js +1 -1
  147. package/dist/init/source-comments/index.js.map +1 -1
  148. package/dist/init/source-comments/ingest.d.ts +91 -67
  149. package/dist/init/source-comments/ingest.js +392 -349
  150. package/dist/init/source-comments/ingest.js.map +1 -1
  151. package/dist/init/topic-index/index.d.ts +36 -0
  152. package/dist/init/topic-index/index.js +46 -0
  153. package/dist/init/topic-index/index.js.map +1 -0
  154. package/dist/init/topic-index/judge.d.ts +20 -0
  155. package/dist/init/topic-index/judge.js +65 -0
  156. package/dist/init/topic-index/judge.js.map +1 -0
  157. package/dist/init/topic-index/resolve.d.ts +50 -0
  158. package/dist/init/topic-index/resolve.js +196 -0
  159. package/dist/init/topic-index/resolve.js.map +1 -0
  160. package/dist/init/topic-index/walk.d.ts +43 -0
  161. package/dist/init/topic-index/walk.js +293 -0
  162. package/dist/init/topic-index/walk.js.map +1 -0
  163. package/dist/mcp/history/summarizer.js +1 -1
  164. package/dist/mcp/history/summarizer.js.map +1 -1
  165. package/dist/mcp/schemas.d.ts +46 -9
  166. package/dist/mcp/schemas.js +48 -12
  167. package/dist/mcp/schemas.js.map +1 -1
  168. package/dist/mcp/tools/align-drain.d.ts +7 -0
  169. package/dist/mcp/tools/align-drain.js +26 -0
  170. package/dist/mcp/tools/align-drain.js.map +1 -0
  171. package/dist/mcp/tools/archive.js +1 -1
  172. package/dist/mcp/tools/archive.js.map +1 -1
  173. package/dist/mcp/tools/attention-restore.js +1 -1
  174. package/dist/mcp/tools/attention-restore.js.map +1 -1
  175. package/dist/mcp/tools/attention-serve.d.ts +23 -0
  176. package/dist/mcp/tools/attention-serve.js +78 -0
  177. package/dist/mcp/tools/attention-serve.js.map +1 -0
  178. package/dist/mcp/tools/attention-wait.d.ts +18 -0
  179. package/dist/mcp/tools/attention-wait.js +74 -0
  180. package/dist/mcp/tools/attention-wait.js.map +1 -0
  181. package/dist/mcp/tools/index.js +7 -0
  182. package/dist/mcp/tools/index.js.map +1 -1
  183. package/dist/mcp/tools/init-phases.js +4 -1
  184. package/dist/mcp/tools/init-phases.js.map +1 -1
  185. package/dist/mcp/tools/record-decision.js +14 -2
  186. package/dist/mcp/tools/record-decision.js.map +1 -1
  187. package/dist/mcp/tools/resolve-attention.d.ts +2 -2
  188. package/dist/mcp/tools/resolve-attention.js +830 -7
  189. package/dist/mcp/tools/resolve-attention.js.map +1 -1
  190. package/dist/status-line/event-queue.d.ts +40 -0
  191. package/dist/status-line/event-queue.js +195 -0
  192. package/dist/status-line/event-queue.js.map +1 -0
  193. package/dist/status-line/format.d.ts +1 -1
  194. package/dist/status-line/format.js +49 -6
  195. package/dist/status-line/format.js.map +1 -1
  196. package/dist/status-line/index.d.ts +41 -0
  197. package/dist/status-line/index.js +14 -0
  198. package/dist/status-line/index.js.map +1 -1
  199. package/dist/status-line/reader.js +23 -18
  200. package/dist/status-line/reader.js.map +1 -1
  201. package/dist/status-line/writer.d.ts +1 -1
  202. package/dist/status-line/writer.js +5 -0
  203. package/dist/status-line/writer.js.map +1 -1
  204. package/dist/text/jaccard.d.ts +19 -0
  205. package/dist/text/jaccard.js +68 -0
  206. package/dist/text/jaccard.js.map +1 -0
  207. package/package.json +1 -1
  208. package/templates/.cairn/git-hooks/pre-commit +16 -3
  209. package/templates/attention-ui/app.css +406 -0
  210. package/templates/attention-ui/app.js +384 -0
  211. package/templates/attention-ui/index.html +56 -0
@@ -1,36 +1,62 @@
1
1
  /**
2
- * Phase 7c orchestrator discover + parse + Haiku classify + persist.
2
+ * Phase 7c orchestrator (v0.5.0 SoT model).
3
3
  *
4
- * Ingests CLAUDE.md, AGENTS.md, .claude/CLAUDE.md, and `.claude/rules/**.md`
5
- * during init. Each H2/H3 section is classified by Haiku as:
4
+ * Plan §5.4 algorithm:
5
+ * 1. Discover sections in `CLAUDE.md`, `AGENTS.md`, `.claude/rules/*.md`.
6
+ * 2. Topic-index lookup (built by phase 5b) before classification:
7
+ * - **Match** — slug already owns a docs/CLAUDE.md/AGENTS.md/rule
8
+ * SoT and was emitted by an earlier phase. Phase 7c records the
9
+ * cite (no source rewrite — operator's narrative stays intact)
10
+ * and skips emit.
11
+ * - **Net-new** — slug is in topic-index but not yet emitted.
12
+ * Phase 7c classifies the section via Haiku (kind only:
13
+ * decision / domain-rule / constraint / informational), emits
14
+ * a verbatim DEC/INV via `sot-emit` with `sot_kind: "path"` +
15
+ * `sot_path: <file>#<anchor>`, auto-promotes (`status: accepted`).
16
+ * 3. Conflict detection — for each freshly emitted entity, scan
17
+ * accepted DECs/INVs in `sot-cache.yaml` for high Jaccard overlap
18
+ * against the new body, then run a Haiku contradiction judge per
19
+ * candidate (`contradict | agree | unrelated`). On `contradict`,
20
+ * write `.cairn/ground/conflicts/<new>__<other>.md` with both
21
+ * prose sides + Haiku reasoning. The cairn-attention skill renders
22
+ * these per §5.4.1; **no source rewrite ever fires from conflicts**.
23
+ * 4. Auto-promote — every novel entity ships `status: accepted`. The
24
+ * `_inbox/` draft queue is gone (the v0.4.x review surface was the
25
+ * v0.5.0 pivot's primary motivation).
6
26
  *
7
- * - "rule-net-new" — section states a rule cairn doesn't have yet (DEC draft to inbox)
8
- * - "rule-conflict" section conflicts with existing cairn state (soft-conflict to attention)
9
- * - "informational" TOC, history, walkthrough — no action
10
- * - "operator-keep" already inside keep-marker block (skipped pre-classification)
11
- *
12
- * Net-new rules become DEC drafts in `.cairn/ground/decisions/_inbox/`.
13
- * Soft conflicts append to `.cairn/baseline/rule-conflicts-<ISO>.yaml`.
14
- *
15
- * Resilient: a single Haiku failure marks the section "informational" and
16
- * continues. All output paths captured in the result so the skill can surface
17
- * them.
27
+ * Output side-effects (all relative to repoRoot):
28
+ * - `.cairn/ground/decisions/<DEC-id>.md` (one per novel decision/domain-rule)
29
+ * - `.cairn/ground/invariants/<INV-id>.md` (one per novel constraint)
30
+ * - `.cairn/ground/topic-index.yaml` (extended w/ dec_id stamps)
31
+ * - `.cairn/ground/sot-bindings.yaml` (forward+reverse for new ids)
32
+ * - `.cairn/ground/sot-cache.yaml` (token cache for Layer A)
33
+ * - `.cairn/ground/conflicts/<a>__<b>.md` (one per contradiction)
34
+ * - `.cairn/baseline/rules-merge-<ISO>.yaml` (full audit)
18
35
  */
19
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
36
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
37
  import { dirname, join } from "node:path";
21
38
  import { stringify as stringifyYaml } from "yaml";
22
39
  import { runClaude } from "../../claude/index.js";
23
- import { allocateDecisionId, scanExistingDecisionIds, } from "../../decision-capture/id.js";
24
- import { decisionsDir } from "../../ground/paths.js";
40
+ import { conflictsDir, emptyAnchorMap, emptySotBindings, emptySotCache, emptyTopicIndex, readAnchorMap, readSotBindings, readSotCache, readTopicIndex, setSotCacheEntry, topicSlug, writeAnchorMap, writeSotBindings, writeSotCache, writeTopicIndex, } from "../../ground/index.js";
41
+ import { writeDecisionsLedger, writeInvariantsLedger } from "../../ground/ledgers.js";
25
42
  import { logger } from "../../logger.js";
43
+ import { jaccard, tokenize } from "../../text/jaccard.js";
44
+ import { emitFromTopicIndex } from "../sot-emit.js";
26
45
  import { discoverRuleSources } from "./discover.js";
27
46
  import { parseRuleSections } from "./parse-sections.js";
28
47
  const log = logger("init.rules-merge.ingest");
29
48
  const PER_SECTION_TIMEOUT_MS = 60_000;
30
49
  const SECTION_BODY_CAP = 4_000;
31
50
  const CONCURRENCY = 4;
51
+ const CAPTURE_SOURCE = "init-rules-merge";
52
+ /** Conflict-scan tuning. */
53
+ const CONFLICT_JACCARD_THRESHOLD = 0.4;
54
+ const CONFLICT_MAX_CANDIDATES_PER_EMIT = 3;
55
+ const CONFLICT_MAX_JUDGE_CALLS = 25;
56
+ const CONFLICT_BODY_CAP = 1_500;
57
+ const PER_CONTRADICTION_TIMEOUT_MS = 30_000;
32
58
  /* -------------------------------------------------------------------------- */
33
- /* Schema + prompt */
59
+ /* Schemas + prompts */
34
60
  /* -------------------------------------------------------------------------- */
35
61
  const CLASSIFY_SCHEMA = {
36
62
  type: "object",
@@ -39,29 +65,40 @@ const CLASSIFY_SCHEMA = {
39
65
  properties: {
40
66
  kind: {
41
67
  type: "string",
42
- enum: ["rule-net-new", "rule-conflict", "informational", "operator-keep"],
68
+ enum: ["decision", "domain-rule", "constraint", "informational"],
43
69
  },
44
- proposed_dec_title: { type: "string" },
45
- proposed_rationale: { type: "string" },
46
- conflicts_with: { type: "string" },
47
70
  },
48
71
  };
49
- const CLASSIFY_SYSTEM = `You classify markdown sections of project-rule files for Cairn adoption.
72
+ const CLASSIFY_SYSTEM = `You classify markdown sections of project-rule files for Cairn's Single-Source-of-Truth ledger.
73
+
74
+ Each section comes from CLAUDE.md, AGENTS.md, or a .claude/rules/*.md file.
50
75
 
51
- Each section comes from one of: CLAUDE.md, AGENTS.md, .claude/CLAUDE.md, or a .claude/rules/*.md file.
76
+ Return JSON matching the schema. \`kind\` choices:
77
+ - "decision" paragraph describes a binding decision or architectural choice
78
+ - "domain-rule" paragraph states a domain rule developers must follow (treated as a decision in the ledger)
79
+ - "constraint" paragraph states a hard constraint / invariant (must / must not / never / always)
80
+ - "informational" TOC, walkthrough, history, formatting notes — nothing actionable
81
+
82
+ Be conservative — false-positive ledger entries pollute ground state worse than missed capture.
83
+ Default to "informational" when uncertain.`;
84
+ const CONTRADICTION_SCHEMA = {
85
+ type: "object",
86
+ additionalProperties: false,
87
+ required: ["verdict"],
88
+ properties: {
89
+ verdict: { type: "string", enum: ["contradict", "agree", "unrelated"] },
90
+ reasoning: { type: "string" },
91
+ },
92
+ };
93
+ const CONTRADICTION_SYSTEM = `You compare two project-rule statements for contradiction.
52
94
 
53
- Return JSON matching the schema. \`kind\` must be exactly one of:
54
- - "rule-net-new" the section states a binding rule cairn doesn't yet have
55
- - "rule-conflict" the section contradicts an existing cairn rule (provide conflicts_with id when known)
56
- - "informational" TOC, walkthrough, history, formatting notes — nothing to ingest
57
- - "operator-keep" the section is wrapped in keep-markers (rare — caller usually filters first)
95
+ Return JSON: { "verdict": "contradict" | "agree" | "unrelated", "reasoning": "<one sentence>" }.
58
96
 
59
- Optional fields:
60
- - proposed_dec_title 5-10 word imperative title (only when kind = "rule-net-new")
61
- - proposed_rationale 2-3 sentence summary (only when kind = "rule-net-new")
62
- - conflicts_with DEC-NNNN or §INV-NNNN id (only when kind = "rule-conflict")
97
+ - "contradict" the two statements cannot both be true / followed at once
98
+ - "agree" the two statements describe the same rule with compatible wording
99
+ - "unrelated" the statements address different topics
63
100
 
64
- Be conservative. When in doubt, "informational".`;
101
+ Be conservative on "contradict" — only flag a true logical contradiction (one says X, the other says NOT X). Surface-level differences in tone or scope are NOT contradictions.`;
65
102
  /* -------------------------------------------------------------------------- */
66
103
  /* Public */
67
104
  /* -------------------------------------------------------------------------- */
@@ -69,7 +106,10 @@ export async function runRulesMerge(args) {
69
106
  const repoRoot = args.repoRoot;
70
107
  const nowIso = args.nowIso ?? new Date().toISOString();
71
108
  const tsSlug = nowIso.replace(/[:.]/g, "-").slice(0, 19);
109
+ // ── 1. Discover + walk sections ──────────────────────────────────
72
110
  const sources = discoverRuleSources(repoRoot);
111
+ const ruleFilesSet = new Set(sources.map((s) => s.path));
112
+ const sectionsBySlug = new Map();
73
113
  const allClassifications = [];
74
114
  const jobs = [];
75
115
  for (const source of sources) {
@@ -84,27 +124,43 @@ export async function runRulesMerge(args) {
84
124
  const sections = parseRuleSections(body);
85
125
  for (const section of sections) {
86
126
  if (section.level === 0)
87
- continue; // skip preamble
127
+ continue;
88
128
  if (section.protectedByKeepMarker) {
89
129
  allClassifications.push({
90
130
  source: source.path,
91
131
  level: section.level,
92
132
  title: section.title,
93
133
  startOffset: section.startOffset,
134
+ slug: "",
94
135
  kind: "operator-keep",
95
- proposedDecTitle: "",
96
- proposedRationale: "",
97
- conflictsWith: "",
98
136
  failed: false,
99
137
  });
100
138
  continue;
101
139
  }
102
- jobs.push({ source, section });
140
+ const bodyMinusHeading = stripLeadingHeading(section.body);
141
+ if (bodyMinusHeading.length === 0) {
142
+ // Empty body after heading strip — nothing to classify or fingerprint.
143
+ continue;
144
+ }
145
+ const slug = topicSlug(bodyMinusHeading);
146
+ const anchor = headingToAnchor(section.title);
147
+ const job = { source, section, slug, bodyMinusHeading, anchor };
148
+ jobs.push(job);
149
+ sectionsBySlug.set(slug, {
150
+ sourcePath: source.path,
151
+ sectionTitle: section.title,
152
+ anchor,
153
+ bodyMinusHeading,
154
+ });
103
155
  }
104
156
  }
157
+ // ── 2. Classify each non-keep section ────────────────────────────
105
158
  if (args.mockClassify !== undefined) {
106
159
  for (const [idx, job] of jobs.entries()) {
107
- allClassifications.push(args.mockClassify(job.section, job.source));
160
+ const cls = args.mockClassify(job.section, job.source);
161
+ // Mock callers may not stamp `slug`; fill it in for them so the
162
+ // emit filter has something to look up.
163
+ allClassifications.push({ ...cls, slug: cls.slug.length > 0 ? cls.slug : job.slug });
108
164
  args.onSectionProgress?.({ index: idx + 1, total: jobs.length });
109
165
  }
110
166
  }
@@ -118,7 +174,7 @@ export async function runRulesMerge(args) {
118
174
  const job = jobs[idx];
119
175
  if (job === undefined)
120
176
  continue;
121
- const cls = await classifySection(job.source, job.section);
177
+ const cls = await classifySection(job);
122
178
  allClassifications.push(cls);
123
179
  completed += 1;
124
180
  args.onSectionProgress?.({ index: completed, total });
@@ -127,46 +183,156 @@ export async function runRulesMerge(args) {
127
183
  const workers = Array.from({ length: Math.min(CONCURRENCY, jobs.length) }, () => worker());
128
184
  await Promise.all(workers);
129
185
  }
130
- // Persist DEC drafts + conflicts.
131
- const decDraftsWritten = [];
132
- const conflictRows = [];
133
- const existingIds = args.existingDecIds ?? scanExistingDecisionIds(repoRoot);
186
+ const kindBySlug = new Map();
134
187
  for (const cls of allClassifications) {
135
- if (cls.kind === "rule-net-new" && cls.proposedDecTitle.length > 0) {
136
- const id = allocateDecisionId(repoRoot, existingIds);
137
- existingIds.add(id);
138
- if (args.dryRun !== true) {
139
- const written = writeDecDraft({
140
- repoRoot,
141
- id,
142
- classification: cls,
143
- generatedAt: nowIso,
144
- });
145
- decDraftsWritten.push({ id, path: written.relPath, sourceFile: cls.source });
188
+ if (cls.slug.length > 0)
189
+ kindBySlug.set(cls.slug, cls.kind);
190
+ }
191
+ // ── 3. Read ground state + identify cite-existing slugs ──────────
192
+ let topicIndex = readTopicIndex(repoRoot);
193
+ if (Object.keys(topicIndex.topics).length === 0)
194
+ topicIndex = emptyTopicIndex();
195
+ let anchorMap = readAnchorMap(repoRoot);
196
+ if (Object.keys(anchorMap.anchors).length === 0)
197
+ anchorMap = emptyAnchorMap();
198
+ const citesEmitted = [];
199
+ for (const [slug, ctx] of sectionsBySlug) {
200
+ const entry = topicIndex.topics[slug];
201
+ if (entry !== undefined && entry.dec_id !== undefined && !ruleFilesSet.has(entry.sot_source)) {
202
+ // Slug already SoT'd by phase 6 (docs); operator's CLAUDE.md / AGENTS.md
203
+ // section is a cite of the same fact. No source rewrite — operator's
204
+ // narrative stays intact. Plan §5.4.1.
205
+ citesEmitted.push({ id: entry.dec_id, sourceFile: ctx.sourcePath, slug });
206
+ }
207
+ }
208
+ // ── 4. Emit (sot-emit, sot_kind=path) ────────────────────────────
209
+ const emit = await emitFromTopicIndex({
210
+ repoRoot,
211
+ topicIndex,
212
+ anchorMap,
213
+ filter: (entry) => entry.dec_id === undefined &&
214
+ ruleFilesSet.has(entry.sot_source) &&
215
+ kindBySlug.has(entry.slug) &&
216
+ isEmittableKind(kindBySlug.get(entry.slug)),
217
+ classifier: async ({ entry }) => {
218
+ const ctx = sectionsBySlug.get(entry.slug);
219
+ const k = kindBySlug.get(entry.slug);
220
+ if (k === undefined || ctx === undefined)
221
+ return { kind: "skip", title: "" };
222
+ if (k === "constraint")
223
+ return { kind: "constraint", title: ctx.sectionTitle };
224
+ if (k === "decision" || k === "domain-rule") {
225
+ return { kind: "decision", title: ctx.sectionTitle };
146
226
  }
147
- else {
148
- decDraftsWritten.push({
149
- id,
150
- path: `.cairn/ground/decisions/_inbox/${id}.draft.md`,
151
- sourceFile: cls.source,
227
+ return { kind: "skip", title: "" };
228
+ },
229
+ sot_kind: "path",
230
+ capture_source: CAPTURE_SOURCE,
231
+ });
232
+ topicIndex = emit.topicIndex;
233
+ if (args.dryRun !== true) {
234
+ persistGroundState({
235
+ repoRoot,
236
+ topicIndex,
237
+ anchorMap,
238
+ bindings: emit.bindings,
239
+ cache: emit.cache,
240
+ });
241
+ }
242
+ // ── 5. Build emitted records ─────────────────────────────────────
243
+ const decsWritten = [];
244
+ const invsWritten = [];
245
+ for (const rec of emit.emitted) {
246
+ const ctx = sectionsBySlug.get(rec.slug);
247
+ const target = {
248
+ id: rec.id,
249
+ path: rec.kind === "DEC"
250
+ ? `.cairn/ground/decisions/${rec.id}.md`
251
+ : `.cairn/ground/invariants/${rec.id}.md`,
252
+ sourceFile: ctx?.sourcePath ?? rec.source_file,
253
+ slug: rec.slug,
254
+ status: "accepted",
255
+ };
256
+ if (rec.kind === "DEC")
257
+ decsWritten.push(target);
258
+ else
259
+ invsWritten.push(target);
260
+ }
261
+ // ── 6. Conflict scan ─────────────────────────────────────────────
262
+ const conflicts = [];
263
+ if (args.dryRun !== true && emit.emitted.length > 0) {
264
+ let judgeCalls = 0;
265
+ for (const rec of emit.emitted) {
266
+ if (judgeCalls >= CONFLICT_MAX_JUDGE_CALLS)
267
+ break;
268
+ const candidates = jaccardCandidates({
269
+ newId: rec.id,
270
+ newBody: rec.body,
271
+ cache: emit.cache,
272
+ threshold: CONFLICT_JACCARD_THRESHOLD,
273
+ topK: CONFLICT_MAX_CANDIDATES_PER_EMIT,
274
+ });
275
+ for (const cand of candidates) {
276
+ if (judgeCalls >= CONFLICT_MAX_JUDGE_CALLS)
277
+ break;
278
+ judgeCalls += 1;
279
+ const candBody = readEmittedBody(repoRoot, cand.id);
280
+ if (candBody === null)
281
+ continue;
282
+ const verdict = await runContradictionJudge({
283
+ newBody: rec.body,
284
+ candidateId: cand.id,
285
+ candidateBody: candBody,
286
+ mock: args.mockContradictionJudge,
152
287
  });
288
+ if (verdict.verdict === "contradict") {
289
+ const conflictPath = writeConflictFile({
290
+ repoRoot,
291
+ newId: rec.id,
292
+ newBody: rec.body,
293
+ newSourceFile: rec.source_file,
294
+ otherId: cand.id,
295
+ otherBody: candBody,
296
+ otherSotPath: cand.sot_path,
297
+ reasoning: verdict.reasoning,
298
+ generatedAt: nowIso,
299
+ });
300
+ conflicts.push({
301
+ newId: rec.id,
302
+ otherId: cand.id,
303
+ conflictPath,
304
+ reasoning: verdict.reasoning,
305
+ });
306
+ }
153
307
  }
154
308
  }
155
- if (cls.kind === "rule-conflict") {
156
- conflictRows.push({
157
- source_file: cls.source,
158
- section_title: cls.title,
159
- section_offset: cls.startOffset,
160
- conflicts_with: cls.conflictsWith,
161
- });
309
+ }
310
+ // ── 7. Ledger rebuilds ───────────────────────────────────────────
311
+ if (args.dryRun !== true) {
312
+ if (decsWritten.length > 0) {
313
+ try {
314
+ writeDecisionsLedger({ repoRoot });
315
+ }
316
+ catch (err) {
317
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "decisions ledger rebuild failed");
318
+ }
319
+ }
320
+ if (invsWritten.length > 0) {
321
+ try {
322
+ writeInvariantsLedger({ repoRoot });
323
+ }
324
+ catch (err) {
325
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "invariants ledger rebuild failed");
326
+ }
162
327
  }
163
328
  }
329
+ // ── 8. Audit yaml ───────────────────────────────────────────────
164
330
  const auditRelPath = `.cairn/baseline/rules-merge-${tsSlug}.yaml`;
165
331
  const auditPath = join(repoRoot, auditRelPath);
166
- let conflictsPath = null;
167
332
  const kindCounts = {
168
- "rule-net-new": 0,
169
- "rule-conflict": 0,
333
+ decision: 0,
334
+ "domain-rule": 0,
335
+ constraint: 0,
170
336
  informational: 0,
171
337
  "operator-keep": 0,
172
338
  };
@@ -179,58 +345,52 @@ export async function runRulesMerge(args) {
179
345
  sources: sources.map((s) => ({ path: s.path, kind: s.kind, size: s.size })),
180
346
  sections_total: allClassifications.length,
181
347
  kind_counts: kindCounts,
348
+ decs_written: decsWritten.length,
349
+ invs_written: invsWritten.length,
350
+ cites_emitted: citesEmitted.length,
351
+ conflicts: conflicts.length,
182
352
  classifications: allClassifications.map((c) => ({
183
353
  source: c.source,
184
354
  title: c.title,
185
355
  level: c.level,
186
356
  kind: c.kind,
357
+ slug: c.slug,
187
358
  start_offset: c.startOffset,
188
- proposed_dec_title: c.proposedDecTitle,
189
- proposed_rationale: c.proposedRationale,
190
- conflicts_with: c.conflictsWith,
191
359
  failed: c.failed,
192
360
  ...(c.errorMessage !== undefined ? { error: c.errorMessage } : {}),
193
361
  })),
194
362
  });
195
- if (conflictRows.length > 0) {
196
- const rel = `.cairn/baseline/rule-conflicts-${tsSlug}.yaml`;
197
- conflictsPath = join(repoRoot, rel);
198
- writeYaml(conflictsPath, {
199
- run_at: nowIso,
200
- conflicts: conflictRows,
201
- });
202
- }
203
363
  }
204
364
  log.info({
205
365
  sources: sources.length,
206
366
  sections: allClassifications.length,
207
367
  kindCounts,
208
- decDrafts: decDraftsWritten.length,
209
- conflicts: conflictRows.length,
368
+ decs: decsWritten.length,
369
+ invs: invsWritten.length,
370
+ cites: citesEmitted.length,
371
+ conflicts: conflicts.length,
210
372
  }, "rules merge complete");
211
373
  return {
212
374
  sources,
213
375
  sectionsTotal: allClassifications.length,
214
376
  classifications: allClassifications,
215
- decDraftsWritten,
216
- conflictsRecorded: conflictRows.length,
217
- conflictsPath,
377
+ decsWritten,
378
+ invsWritten,
379
+ citesEmitted,
380
+ conflicts,
218
381
  auditPath,
219
382
  auditRelPath,
220
383
  kindCounts,
221
384
  };
222
385
  }
223
- /* -------------------------------------------------------------------------- */
224
- /* Classify single section (Haiku) */
225
- /* -------------------------------------------------------------------------- */
226
- async function classifySection(source, section) {
227
- const body = section.body.length > SECTION_BODY_CAP
228
- ? `${section.body.slice(0, SECTION_BODY_CAP)}\n…[truncated]`
229
- : section.body;
386
+ async function classifySection(job) {
387
+ const body = job.bodyMinusHeading.length > SECTION_BODY_CAP
388
+ ? `${job.bodyMinusHeading.slice(0, SECTION_BODY_CAP)}\n…[truncated]`
389
+ : job.bodyMinusHeading;
230
390
  const prompt = [
231
- `Source: ${source.path}`,
232
- `Section title: ${section.title || "(preamble)"}`,
233
- `Heading level: ${section.level}`,
391
+ `Source: ${job.source.path}`,
392
+ `Section title: ${job.section.title || "(preamble)"}`,
393
+ `Heading level: ${job.section.level}`,
234
394
  "",
235
395
  "Body:",
236
396
  body,
@@ -246,94 +406,235 @@ async function classifySection(source, section) {
246
406
  });
247
407
  const parsed = result.parsed;
248
408
  if (typeof parsed !== "object" || parsed === null) {
249
- return informational({
250
- source: source.path,
251
- section,
252
- failed: true,
253
- errorMessage: "non-object response",
254
- });
409
+ return informational(job, true, "non-object response");
255
410
  }
256
411
  const r = parsed;
257
412
  const kindRaw = r["kind"];
258
- const kind = kindRaw === "rule-net-new" ||
259
- kindRaw === "rule-conflict" ||
260
- kindRaw === "operator-keep"
413
+ const kind = kindRaw === "decision" ||
414
+ kindRaw === "domain-rule" ||
415
+ kindRaw === "constraint"
261
416
  ? kindRaw
262
417
  : "informational";
263
418
  return {
264
- source: source.path,
265
- level: section.level,
266
- title: section.title,
267
- startOffset: section.startOffset,
419
+ source: job.source.path,
420
+ level: job.section.level,
421
+ title: job.section.title,
422
+ startOffset: job.section.startOffset,
423
+ slug: job.slug,
268
424
  kind,
269
- proposedDecTitle: typeof r["proposed_dec_title"] === "string" ? r["proposed_dec_title"] : "",
270
- proposedRationale: typeof r["proposed_rationale"] === "string" ? r["proposed_rationale"] : "",
271
- conflictsWith: typeof r["conflicts_with"] === "string" ? r["conflicts_with"] : "",
272
425
  failed: false,
273
426
  };
274
427
  }
275
428
  catch (err) {
276
- return informational({
277
- source: source.path,
278
- section,
279
- failed: true,
280
- errorMessage: err instanceof Error ? err.message : String(err),
281
- });
429
+ return informational(job, true, err instanceof Error ? err.message : String(err));
282
430
  }
283
431
  }
284
- function informational(args) {
432
+ function informational(job, failed, errorMessage) {
285
433
  return {
286
- source: args.source,
287
- level: args.section.level,
288
- title: args.section.title,
289
- startOffset: args.section.startOffset,
434
+ source: job.source.path,
435
+ level: job.section.level,
436
+ title: job.section.title,
437
+ startOffset: job.section.startOffset,
438
+ slug: job.slug,
290
439
  kind: "informational",
291
- proposedDecTitle: "",
292
- proposedRationale: "",
293
- conflictsWith: "",
294
- failed: args.failed,
295
- ...(args.errorMessage !== undefined ? { errorMessage: args.errorMessage } : {}),
440
+ failed,
441
+ ...(errorMessage !== undefined ? { errorMessage } : {}),
296
442
  };
297
443
  }
298
- function writeDecDraft(args) {
299
- const dir = decisionsDir(args.repoRoot);
300
- const inboxDir = join(dir, "_inbox");
301
- mkdirSync(inboxDir, { recursive: true });
302
- const filename = `${args.id}.draft.md`;
303
- const abs = join(inboxDir, filename);
304
- const rel = `.cairn/ground/decisions/_inbox/${filename}`;
444
+ function isEmittableKind(kind) {
445
+ return kind === "decision" || kind === "domain-rule" || kind === "constraint";
446
+ }
447
+ function jaccardCandidates(args) {
448
+ const newTokens = tokenize(args.newBody, { codeAware: true });
449
+ const scored = [];
450
+ for (const [id, entry] of Object.entries(args.cache.entries)) {
451
+ if (id === args.newId)
452
+ continue;
453
+ const candidateTokens = new Set(entry.tokens);
454
+ const score = jaccard(newTokens, candidateTokens);
455
+ if (score < args.threshold)
456
+ continue;
457
+ scored.push({ id, sot_path: entry.sot_path, similarity: score });
458
+ }
459
+ scored.sort((a, b) => b.similarity - a.similarity);
460
+ return scored.slice(0, args.topK);
461
+ }
462
+ async function runContradictionJudge(args) {
463
+ if (args.mock !== undefined) {
464
+ const verdict = await args.mock({
465
+ newBody: args.newBody,
466
+ candidateId: args.candidateId,
467
+ candidateBody: args.candidateBody,
468
+ });
469
+ return { verdict, reasoning: `(mock judge → ${verdict})` };
470
+ }
471
+ const a = capBody(args.newBody);
472
+ const b = capBody(args.candidateBody);
473
+ const prompt = [
474
+ "Statement A (newly captured by phase 7c):",
475
+ a,
476
+ "",
477
+ `Statement B (already accepted as ${args.candidateId}):`,
478
+ b,
479
+ "",
480
+ "Do these statements logically contradict each other?",
481
+ ].join("\n");
482
+ try {
483
+ const result = await runClaude({
484
+ tier: "haiku",
485
+ system: CONTRADICTION_SYSTEM,
486
+ prompt,
487
+ jsonSchema: CONTRADICTION_SCHEMA,
488
+ timeoutMs: PER_CONTRADICTION_TIMEOUT_MS,
489
+ isolateAmbientContext: true,
490
+ });
491
+ const parsed = result.parsed;
492
+ if (typeof parsed !== "object" || parsed === null) {
493
+ return { verdict: "unrelated", reasoning: "(non-object judge response)" };
494
+ }
495
+ const r = parsed;
496
+ const verdictRaw = r["verdict"];
497
+ const verdict = verdictRaw === "contradict" || verdictRaw === "agree"
498
+ ? verdictRaw
499
+ : "unrelated";
500
+ const reasoning = typeof r["reasoning"] === "string" ? r["reasoning"] : "";
501
+ return { verdict, reasoning };
502
+ }
503
+ catch (err) {
504
+ log.warn({
505
+ candidateId: args.candidateId,
506
+ err: err instanceof Error ? err.message : String(err),
507
+ }, "contradiction judge failed; treating as unrelated");
508
+ return { verdict: "unrelated", reasoning: "(judge failed)" };
509
+ }
510
+ }
511
+ function capBody(body) {
512
+ return body.length > CONFLICT_BODY_CAP
513
+ ? `${body.slice(0, CONFLICT_BODY_CAP)}\n…[truncated]`
514
+ : body;
515
+ }
516
+ function writeConflictFile(args) {
517
+ const dir = conflictsDir(args.repoRoot);
518
+ mkdirSync(dir, { recursive: true });
519
+ const filename = `${args.newId}__${args.otherId}.md`;
520
+ const abs = join(dir, filename);
521
+ const rel = `.cairn/ground/conflicts/${filename}`;
305
522
  const fm = {
306
- id: args.id,
307
- title: args.classification.proposedDecTitle || `(untitled — from ${args.classification.source})`,
308
- type: "adr",
309
- status: "draft-from-rules-merge",
310
- audience: "dual",
311
- generated: args.generatedAt,
312
- "verified-at": args.generatedAt,
313
- decided_at: args.generatedAt,
314
- decided_by: "cairn-init",
315
- capture_source: "init-rules-merge",
316
- capture_confidence: "medium",
317
- sourceFile: args.classification.source,
318
- sectionTitle: args.classification.title,
523
+ a_id: args.newId,
524
+ a_source: args.newSourceFile,
525
+ a_capture_source: CAPTURE_SOURCE,
526
+ b_id: args.otherId,
527
+ b_sot_path: args.otherSotPath,
528
+ detected_at: args.generatedAt,
529
+ detector: "phase-7c-contradiction-judge",
530
+ severity: "soft",
531
+ reasoning: args.reasoning,
319
532
  };
320
533
  const lines = [];
321
534
  lines.push("---");
322
535
  lines.push(stringifyYaml(fm).trimEnd());
323
536
  lines.push("---");
324
537
  lines.push("");
325
- lines.push(`# ${args.id} ${fm["title"]}`);
538
+ lines.push(`# Conflict — ${args.newId} vs ${args.otherId}`);
326
539
  lines.push("");
327
- lines.push("## Source section");
540
+ lines.push(`## ${args.newId} (just captured from \`${args.newSourceFile}\`)`);
328
541
  lines.push("");
329
- lines.push(`From \`${args.classification.source}\`, section "${args.classification.title}".`);
542
+ lines.push("```");
543
+ lines.push(args.newBody.trimEnd());
544
+ lines.push("```");
330
545
  lines.push("");
331
- lines.push("## Proposed rationale");
546
+ lines.push(`## ${args.otherId} (already accepted, sot_path: \`${args.otherSotPath}\`)`);
332
547
  lines.push("");
333
- lines.push(args.classification.proposedRationale);
548
+ lines.push("```");
549
+ lines.push(args.otherBody.trimEnd());
550
+ lines.push("```");
551
+ lines.push("");
552
+ lines.push("## Judge reasoning");
553
+ lines.push("");
554
+ lines.push(args.reasoning.trim().length > 0 ? args.reasoning.trim() : "(no reasoning provided)");
334
555
  lines.push("");
335
556
  writeFileSync(abs, lines.join("\n"), "utf8");
336
- return { absPath: abs, relPath: rel };
557
+ return rel;
558
+ }
559
+ function readEmittedBody(repoRoot, id) {
560
+ const isDec = id.startsWith("DEC-");
561
+ const dir = isDec
562
+ ? join(repoRoot, ".cairn", "ground", "decisions")
563
+ : join(repoRoot, ".cairn", "ground", "invariants");
564
+ const abs = join(dir, `${id}.md`);
565
+ if (!existsSync(abs))
566
+ return null;
567
+ let raw;
568
+ try {
569
+ raw = readFileSync(abs, "utf8");
570
+ }
571
+ catch {
572
+ return null;
573
+ }
574
+ // Strip frontmatter — body is everything past the second `---` line.
575
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?/);
576
+ if (fmMatch === null)
577
+ return raw.trim();
578
+ return raw.slice(fmMatch[0].length).trim();
579
+ }
580
+ function persistGroundState(args) {
581
+ const { repoRoot } = args;
582
+ // Re-read each ground-state file right before write so concurrent
583
+ // writers (phase 6 / 7b) don't get clobbered. parallel-678 runs the
584
+ // three phases sequentially under v0.5.0; this merge is defense in
585
+ // depth for the individual phase tools.
586
+ const freshTopic = readTopicIndex(repoRoot);
587
+ const baseTopic = Object.keys(freshTopic.topics).length > 0 ? freshTopic : emptyTopicIndex();
588
+ for (const [slug, entry] of Object.entries(args.topicIndex.topics)) {
589
+ baseTopic.topics[slug] = entry;
590
+ }
591
+ baseTopic.generated = new Date().toISOString();
592
+ writeTopicIndex(repoRoot, baseTopic);
593
+ const freshAnchor = readAnchorMap(repoRoot);
594
+ const baseAnchor = Object.keys(freshAnchor.anchors).length > 0 ? freshAnchor : emptyAnchorMap();
595
+ for (const [slug, anchor] of Object.entries(args.anchorMap.anchors)) {
596
+ baseAnchor.anchors[slug] = anchor;
597
+ }
598
+ baseAnchor.generated = new Date().toISOString();
599
+ writeAnchorMap(repoRoot, baseAnchor);
600
+ const freshBindings = readSotBindings(repoRoot);
601
+ const baseBindings = Object.keys(freshBindings.forward).length > 0 ? freshBindings : emptySotBindings();
602
+ for (const [decId, sotPath] of Object.entries(args.bindings.forward)) {
603
+ baseBindings.forward[decId] = sotPath;
604
+ }
605
+ for (const [sotPath, decIds] of Object.entries(args.bindings.reverse)) {
606
+ const seen = new Set(baseBindings.reverse[sotPath] ?? []);
607
+ for (const id of decIds)
608
+ seen.add(id);
609
+ baseBindings.reverse[sotPath] = Array.from(seen);
610
+ }
611
+ baseBindings.generated = new Date().toISOString();
612
+ writeSotBindings(repoRoot, baseBindings);
613
+ const freshCache = readSotCache(repoRoot);
614
+ let baseCache = Object.keys(freshCache.entries).length > 0 ? freshCache : emptySotCache();
615
+ for (const [decId, entry] of Object.entries(args.cache.entries)) {
616
+ baseCache = setSotCacheEntry(baseCache, decId, entry);
617
+ }
618
+ baseCache.generated = new Date().toISOString();
619
+ writeSotCache(repoRoot, baseCache);
620
+ }
621
+ function stripLeadingHeading(body) {
622
+ // parseRuleSections always pushes the heading line as the first entry
623
+ // of `body`; strip it so the slug + emitted DEC body match phase 5b's
624
+ // section fingerprint convention (heading excluded from fingerprint).
625
+ const newlineIdx = body.indexOf("\n");
626
+ const trimmedFirst = body.slice(0, newlineIdx === -1 ? body.length : newlineIdx).trim();
627
+ if (trimmedFirst.startsWith("#")) {
628
+ return body.slice(newlineIdx === -1 ? body.length : newlineIdx + 1).trim();
629
+ }
630
+ return body.trim();
631
+ }
632
+ function headingToAnchor(line) {
633
+ return line
634
+ .toLowerCase()
635
+ .replace(/[^a-z0-9\s-]/g, "")
636
+ .trim()
637
+ .replace(/\s+/g, "-");
337
638
  }
338
639
  function writeYaml(path, payload) {
339
640
  mkdirSync(dirname(path), { recursive: true });