@isaacriehm/cairn-core 0.4.3 → 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 (162) 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/dedup.js +1 -47
  12. package/dist/attention/dedup.js.map +1 -1
  13. package/dist/drain/drain.d.ts +77 -0
  14. package/dist/drain/drain.js +464 -0
  15. package/dist/drain/drain.js.map +1 -0
  16. package/dist/drain/index.d.ts +5 -0
  17. package/dist/drain/index.js +5 -0
  18. package/dist/drain/index.js.map +1 -0
  19. package/dist/fix-align/index.d.ts +5 -0
  20. package/dist/fix-align/index.js +5 -0
  21. package/dist/fix-align/index.js.map +1 -0
  22. package/dist/fix-align/run.d.ts +99 -0
  23. package/dist/fix-align/run.js +258 -0
  24. package/dist/fix-align/run.js.map +1 -0
  25. package/dist/ground/alignment-pending.d.ts +28 -0
  26. package/dist/ground/alignment-pending.js +83 -0
  27. package/dist/ground/alignment-pending.js.map +1 -0
  28. package/dist/ground/anchor-map.d.ts +14 -0
  29. package/dist/ground/anchor-map.js +57 -0
  30. package/dist/ground/anchor-map.js.map +1 -0
  31. package/dist/ground/index.d.ts +9 -2
  32. package/dist/ground/index.js +8 -2
  33. package/dist/ground/index.js.map +1 -1
  34. package/dist/ground/paths.d.ts +21 -0
  35. package/dist/ground/paths.js +43 -0
  36. package/dist/ground/paths.js.map +1 -1
  37. package/dist/ground/schemas.d.ts +201 -0
  38. package/dist/ground/schemas.js +126 -1
  39. package/dist/ground/schemas.js.map +1 -1
  40. package/dist/ground/slug.d.ts +60 -0
  41. package/dist/ground/slug.js +103 -0
  42. package/dist/ground/slug.js.map +1 -0
  43. package/dist/ground/sot-bindings.d.ts +14 -0
  44. package/dist/ground/sot-bindings.js +80 -0
  45. package/dist/ground/sot-bindings.js.map +1 -0
  46. package/dist/ground/sot-cache.d.ts +18 -0
  47. package/dist/ground/sot-cache.js +63 -0
  48. package/dist/ground/sot-cache.js.map +1 -0
  49. package/dist/ground/topic-index.d.ts +20 -0
  50. package/dist/ground/topic-index.js +60 -0
  51. package/dist/ground/topic-index.js.map +1 -0
  52. package/dist/hooks/post-tool-use/index.d.ts +2 -0
  53. package/dist/hooks/post-tool-use/index.js +1 -0
  54. package/dist/hooks/post-tool-use/index.js.map +1 -1
  55. package/dist/hooks/post-tool-use/sot-align.d.ts +166 -0
  56. package/dist/hooks/post-tool-use/sot-align.js +1311 -0
  57. package/dist/hooks/post-tool-use/sot-align.js.map +1 -0
  58. package/dist/hooks/pre-commit/index.d.ts +8 -0
  59. package/dist/hooks/pre-commit/index.js +8 -0
  60. package/dist/hooks/pre-commit/index.js.map +1 -0
  61. package/dist/hooks/pre-commit/sot-align-precommit.d.ts +60 -0
  62. package/dist/hooks/pre-commit/sot-align-precommit.js +221 -0
  63. package/dist/hooks/pre-commit/sot-align-precommit.js.map +1 -0
  64. package/dist/hooks/runners/session-start.js +41 -0
  65. package/dist/hooks/runners/session-start.js.map +1 -1
  66. package/dist/hooks/sot-align-common.d.ts +39 -0
  67. package/dist/hooks/sot-align-common.js +152 -0
  68. package/dist/hooks/sot-align-common.js.map +1 -0
  69. package/dist/index.d.ts +5 -0
  70. package/dist/index.js +5 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/init/index.d.ts +4 -2
  73. package/dist/init/index.js +2 -1
  74. package/dist/init/index.js.map +1 -1
  75. package/dist/init/ingest-docs.d.ts +30 -47
  76. package/dist/init/ingest-docs.js +113 -410
  77. package/dist/init/ingest-docs.js.map +1 -1
  78. package/dist/init/init.d.ts +8 -0
  79. package/dist/init/init.js +58 -29
  80. package/dist/init/init.js.map +1 -1
  81. package/dist/init/phases/5-brand.js +1 -1
  82. package/dist/init/phases/5-brand.js.map +1 -1
  83. package/dist/init/phases/5b-topic-index.d.ts +30 -0
  84. package/dist/init/phases/5b-topic-index.js +62 -0
  85. package/dist/init/phases/5b-topic-index.js.map +1 -0
  86. package/dist/init/phases/6-docs-ingest.d.ts +4 -5
  87. package/dist/init/phases/6-docs-ingest.js +5 -6
  88. package/dist/init/phases/6-docs-ingest.js.map +1 -1
  89. package/dist/init/phases/index.d.ts +2 -0
  90. package/dist/init/phases/index.js +1 -0
  91. package/dist/init/phases/index.js.map +1 -1
  92. package/dist/init/phases/parallel-678.d.ts +14 -17
  93. package/dist/init/phases/parallel-678.js +77 -98
  94. package/dist/init/phases/parallel-678.js.map +1 -1
  95. package/dist/init/phases/source-comments-output-io.d.ts +16 -10
  96. package/dist/init/phases/source-comments-output-io.js +7 -10
  97. package/dist/init/phases/source-comments-output-io.js.map +1 -1
  98. package/dist/init/phases/types.d.ts +1 -1
  99. package/dist/init/phases/types.js +1 -0
  100. package/dist/init/phases/types.js.map +1 -1
  101. package/dist/init/rules-merge/discover.d.ts +8 -3
  102. package/dist/init/rules-merge/discover.js +7 -3
  103. package/dist/init/rules-merge/discover.js.map +1 -1
  104. package/dist/init/rules-merge/ingest.d.ts +81 -28
  105. package/dist/init/rules-merge/ingest.js +456 -162
  106. package/dist/init/rules-merge/ingest.js.map +1 -1
  107. package/dist/init/sot-emit.d.ts +84 -0
  108. package/dist/init/sot-emit.js +218 -0
  109. package/dist/init/sot-emit.js.map +1 -0
  110. package/dist/init/source-comments/classify.d.ts +12 -10
  111. package/dist/init/source-comments/classify.js +13 -25
  112. package/dist/init/source-comments/classify.js.map +1 -1
  113. package/dist/init/source-comments/index.d.ts +1 -1
  114. package/dist/init/source-comments/index.js +1 -1
  115. package/dist/init/source-comments/index.js.map +1 -1
  116. package/dist/init/source-comments/ingest.d.ts +91 -67
  117. package/dist/init/source-comments/ingest.js +392 -361
  118. package/dist/init/source-comments/ingest.js.map +1 -1
  119. package/dist/init/topic-index/index.d.ts +36 -0
  120. package/dist/init/topic-index/index.js +46 -0
  121. package/dist/init/topic-index/index.js.map +1 -0
  122. package/dist/init/topic-index/judge.d.ts +20 -0
  123. package/dist/init/topic-index/judge.js +65 -0
  124. package/dist/init/topic-index/judge.js.map +1 -0
  125. package/dist/init/topic-index/resolve.d.ts +50 -0
  126. package/dist/init/topic-index/resolve.js +196 -0
  127. package/dist/init/topic-index/resolve.js.map +1 -0
  128. package/dist/init/topic-index/walk.d.ts +43 -0
  129. package/dist/init/topic-index/walk.js +293 -0
  130. package/dist/init/topic-index/walk.js.map +1 -0
  131. package/dist/mcp/schemas.d.ts +45 -8
  132. package/dist/mcp/schemas.js +43 -7
  133. package/dist/mcp/schemas.js.map +1 -1
  134. package/dist/mcp/tools/align-drain.d.ts +7 -0
  135. package/dist/mcp/tools/align-drain.js +26 -0
  136. package/dist/mcp/tools/align-drain.js.map +1 -0
  137. package/dist/mcp/tools/index.js +3 -0
  138. package/dist/mcp/tools/index.js.map +1 -1
  139. package/dist/mcp/tools/init-phases.js +4 -1
  140. package/dist/mcp/tools/init-phases.js.map +1 -1
  141. package/dist/mcp/tools/resolve-attention.d.ts +2 -2
  142. package/dist/mcp/tools/resolve-attention.js +828 -5
  143. package/dist/mcp/tools/resolve-attention.js.map +1 -1
  144. package/dist/status-line/event-queue.d.ts +40 -0
  145. package/dist/status-line/event-queue.js +195 -0
  146. package/dist/status-line/event-queue.js.map +1 -0
  147. package/dist/status-line/format.d.ts +1 -1
  148. package/dist/status-line/format.js +49 -6
  149. package/dist/status-line/format.js.map +1 -1
  150. package/dist/status-line/index.d.ts +41 -0
  151. package/dist/status-line/index.js +14 -0
  152. package/dist/status-line/index.js.map +1 -1
  153. package/dist/status-line/reader.js +23 -18
  154. package/dist/status-line/reader.js.map +1 -1
  155. package/dist/status-line/writer.d.ts +1 -1
  156. package/dist/status-line/writer.js +5 -0
  157. package/dist/status-line/writer.js.map +1 -1
  158. package/dist/text/jaccard.d.ts +19 -0
  159. package/dist/text/jaccard.js +68 -0
  160. package/dist/text/jaccard.js.map +1 -0
  161. package/package.json +1 -1
  162. package/templates/.cairn/git-hooks/pre-commit +16 -3
@@ -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 { computeDecisionId, 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,53 +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 = computeDecisionId({
137
- title: cls.proposedDecTitle,
138
- rationale: cls.proposedRationale,
139
- capture_source: "init-rules-merge",
140
- source_file: cls.source,
141
- source_offset: cls.startOffset,
142
- raw: cls.title,
143
- }, existingIds);
144
- existingIds.add(id);
145
- if (args.dryRun !== true) {
146
- const written = writeDecDraft({
147
- repoRoot,
148
- id,
149
- classification: cls,
150
- generatedAt: nowIso,
151
- });
152
- 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 };
153
226
  }
154
- else {
155
- decDraftsWritten.push({
156
- id,
157
- path: `.cairn/ground/decisions/_inbox/${id}.draft.md`,
158
- 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,
159
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
+ }
160
307
  }
161
308
  }
162
- if (cls.kind === "rule-conflict") {
163
- conflictRows.push({
164
- source_file: cls.source,
165
- section_title: cls.title,
166
- section_offset: cls.startOffset,
167
- conflicts_with: cls.conflictsWith,
168
- });
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
+ }
169
327
  }
170
328
  }
329
+ // ── 8. Audit yaml ───────────────────────────────────────────────
171
330
  const auditRelPath = `.cairn/baseline/rules-merge-${tsSlug}.yaml`;
172
331
  const auditPath = join(repoRoot, auditRelPath);
173
- let conflictsPath = null;
174
332
  const kindCounts = {
175
- "rule-net-new": 0,
176
- "rule-conflict": 0,
333
+ decision: 0,
334
+ "domain-rule": 0,
335
+ constraint: 0,
177
336
  informational: 0,
178
337
  "operator-keep": 0,
179
338
  };
@@ -186,58 +345,52 @@ export async function runRulesMerge(args) {
186
345
  sources: sources.map((s) => ({ path: s.path, kind: s.kind, size: s.size })),
187
346
  sections_total: allClassifications.length,
188
347
  kind_counts: kindCounts,
348
+ decs_written: decsWritten.length,
349
+ invs_written: invsWritten.length,
350
+ cites_emitted: citesEmitted.length,
351
+ conflicts: conflicts.length,
189
352
  classifications: allClassifications.map((c) => ({
190
353
  source: c.source,
191
354
  title: c.title,
192
355
  level: c.level,
193
356
  kind: c.kind,
357
+ slug: c.slug,
194
358
  start_offset: c.startOffset,
195
- proposed_dec_title: c.proposedDecTitle,
196
- proposed_rationale: c.proposedRationale,
197
- conflicts_with: c.conflictsWith,
198
359
  failed: c.failed,
199
360
  ...(c.errorMessage !== undefined ? { error: c.errorMessage } : {}),
200
361
  })),
201
362
  });
202
- if (conflictRows.length > 0) {
203
- const rel = `.cairn/baseline/rule-conflicts-${tsSlug}.yaml`;
204
- conflictsPath = join(repoRoot, rel);
205
- writeYaml(conflictsPath, {
206
- run_at: nowIso,
207
- conflicts: conflictRows,
208
- });
209
- }
210
363
  }
211
364
  log.info({
212
365
  sources: sources.length,
213
366
  sections: allClassifications.length,
214
367
  kindCounts,
215
- decDrafts: decDraftsWritten.length,
216
- conflicts: conflictRows.length,
368
+ decs: decsWritten.length,
369
+ invs: invsWritten.length,
370
+ cites: citesEmitted.length,
371
+ conflicts: conflicts.length,
217
372
  }, "rules merge complete");
218
373
  return {
219
374
  sources,
220
375
  sectionsTotal: allClassifications.length,
221
376
  classifications: allClassifications,
222
- decDraftsWritten,
223
- conflictsRecorded: conflictRows.length,
224
- conflictsPath,
377
+ decsWritten,
378
+ invsWritten,
379
+ citesEmitted,
380
+ conflicts,
225
381
  auditPath,
226
382
  auditRelPath,
227
383
  kindCounts,
228
384
  };
229
385
  }
230
- /* -------------------------------------------------------------------------- */
231
- /* Classify single section (Haiku) */
232
- /* -------------------------------------------------------------------------- */
233
- async function classifySection(source, section) {
234
- const body = section.body.length > SECTION_BODY_CAP
235
- ? `${section.body.slice(0, SECTION_BODY_CAP)}\n…[truncated]`
236
- : 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;
237
390
  const prompt = [
238
- `Source: ${source.path}`,
239
- `Section title: ${section.title || "(preamble)"}`,
240
- `Heading level: ${section.level}`,
391
+ `Source: ${job.source.path}`,
392
+ `Section title: ${job.section.title || "(preamble)"}`,
393
+ `Heading level: ${job.section.level}`,
241
394
  "",
242
395
  "Body:",
243
396
  body,
@@ -253,94 +406,235 @@ async function classifySection(source, section) {
253
406
  });
254
407
  const parsed = result.parsed;
255
408
  if (typeof parsed !== "object" || parsed === null) {
256
- return informational({
257
- source: source.path,
258
- section,
259
- failed: true,
260
- errorMessage: "non-object response",
261
- });
409
+ return informational(job, true, "non-object response");
262
410
  }
263
411
  const r = parsed;
264
412
  const kindRaw = r["kind"];
265
- const kind = kindRaw === "rule-net-new" ||
266
- kindRaw === "rule-conflict" ||
267
- kindRaw === "operator-keep"
413
+ const kind = kindRaw === "decision" ||
414
+ kindRaw === "domain-rule" ||
415
+ kindRaw === "constraint"
268
416
  ? kindRaw
269
417
  : "informational";
270
418
  return {
271
- source: source.path,
272
- level: section.level,
273
- title: section.title,
274
- 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,
275
424
  kind,
276
- proposedDecTitle: typeof r["proposed_dec_title"] === "string" ? r["proposed_dec_title"] : "",
277
- proposedRationale: typeof r["proposed_rationale"] === "string" ? r["proposed_rationale"] : "",
278
- conflictsWith: typeof r["conflicts_with"] === "string" ? r["conflicts_with"] : "",
279
425
  failed: false,
280
426
  };
281
427
  }
282
428
  catch (err) {
283
- return informational({
284
- source: source.path,
285
- section,
286
- failed: true,
287
- errorMessage: err instanceof Error ? err.message : String(err),
288
- });
429
+ return informational(job, true, err instanceof Error ? err.message : String(err));
289
430
  }
290
431
  }
291
- function informational(args) {
432
+ function informational(job, failed, errorMessage) {
292
433
  return {
293
- source: args.source,
294
- level: args.section.level,
295
- title: args.section.title,
296
- 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,
297
439
  kind: "informational",
298
- proposedDecTitle: "",
299
- proposedRationale: "",
300
- conflictsWith: "",
301
- failed: args.failed,
302
- ...(args.errorMessage !== undefined ? { errorMessage: args.errorMessage } : {}),
440
+ failed,
441
+ ...(errorMessage !== undefined ? { errorMessage } : {}),
303
442
  };
304
443
  }
305
- function writeDecDraft(args) {
306
- const dir = decisionsDir(args.repoRoot);
307
- const inboxDir = join(dir, "_inbox");
308
- mkdirSync(inboxDir, { recursive: true });
309
- const filename = `${args.id}.draft.md`;
310
- const abs = join(inboxDir, filename);
311
- 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}`;
312
522
  const fm = {
313
- id: args.id,
314
- title: args.classification.proposedDecTitle || `(untitled — from ${args.classification.source})`,
315
- type: "adr",
316
- status: "draft-from-rules-merge",
317
- audience: "dual",
318
- generated: args.generatedAt,
319
- "verified-at": args.generatedAt,
320
- decided_at: args.generatedAt,
321
- decided_by: "cairn-init",
322
- capture_source: "init-rules-merge",
323
- capture_confidence: "medium",
324
- sourceFile: args.classification.source,
325
- 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,
326
532
  };
327
533
  const lines = [];
328
534
  lines.push("---");
329
535
  lines.push(stringifyYaml(fm).trimEnd());
330
536
  lines.push("---");
331
537
  lines.push("");
332
- lines.push(`# ${args.id} ${fm["title"]}`);
538
+ lines.push(`# Conflict — ${args.newId} vs ${args.otherId}`);
333
539
  lines.push("");
334
- lines.push("## Source section");
540
+ lines.push(`## ${args.newId} (just captured from \`${args.newSourceFile}\`)`);
335
541
  lines.push("");
336
- lines.push(`From \`${args.classification.source}\`, section "${args.classification.title}".`);
542
+ lines.push("```");
543
+ lines.push(args.newBody.trimEnd());
544
+ lines.push("```");
337
545
  lines.push("");
338
- lines.push("## Proposed rationale");
546
+ lines.push(`## ${args.otherId} (already accepted, sot_path: \`${args.otherSotPath}\`)`);
339
547
  lines.push("");
340
- 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)");
341
555
  lines.push("");
342
556
  writeFileSync(abs, lines.join("\n"), "utf8");
343
- 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, "-");
344
638
  }
345
639
  function writeYaml(path, payload) {
346
640
  mkdirSync(dirname(path), { recursive: true });