@isaacriehm/cairn-core 0.4.3 → 0.6.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 (186) 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 +162 -0
  7. package/dist/align-undo/log.js.map +1 -0
  8. package/dist/align-undo/undo.d.ts +65 -0
  9. package/dist/align-undo/undo.js +397 -0
  10. package/dist/align-undo/undo.js.map +1 -0
  11. package/dist/attention/bulk-accept.js +9 -22
  12. package/dist/attention/bulk-accept.js.map +1 -1
  13. package/dist/attention/dedup.js +1 -47
  14. package/dist/attention/dedup.js.map +1 -1
  15. package/dist/attention/serve/api.js +3 -17
  16. package/dist/attention/serve/api.js.map +1 -1
  17. package/dist/attention/serve/index.js +3 -3
  18. package/dist/attention/serve/index.js.map +1 -1
  19. package/dist/drain/drain.d.ts +77 -0
  20. package/dist/drain/drain.js +464 -0
  21. package/dist/drain/drain.js.map +1 -0
  22. package/dist/drain/index.d.ts +5 -0
  23. package/dist/drain/index.js +5 -0
  24. package/dist/drain/index.js.map +1 -0
  25. package/dist/fix-align/index.d.ts +7 -0
  26. package/dist/fix-align/index.js +6 -0
  27. package/dist/fix-align/index.js.map +1 -0
  28. package/dist/fix-align/run.d.ts +99 -0
  29. package/dist/fix-align/run.js +258 -0
  30. package/dist/fix-align/run.js.map +1 -0
  31. package/dist/fix-align/sentinel.d.ts +59 -0
  32. package/dist/fix-align/sentinel.js +149 -0
  33. package/dist/fix-align/sentinel.js.map +1 -0
  34. package/dist/fs.d.ts +5 -0
  35. package/dist/fs.js +11 -0
  36. package/dist/fs.js.map +1 -0
  37. package/dist/gc/apply.js +4 -4
  38. package/dist/gc/apply.js.map +1 -1
  39. package/dist/ground/alignment-pending.d.ts +28 -0
  40. package/dist/ground/alignment-pending.js +83 -0
  41. package/dist/ground/alignment-pending.js.map +1 -0
  42. package/dist/ground/anchor-map.d.ts +14 -0
  43. package/dist/ground/anchor-map.js +56 -0
  44. package/dist/ground/anchor-map.js.map +1 -0
  45. package/dist/ground/frontmatter.d.ts +12 -0
  46. package/dist/ground/frontmatter.js +28 -0
  47. package/dist/ground/frontmatter.js.map +1 -1
  48. package/dist/ground/index.d.ts +10 -3
  49. package/dist/ground/index.js +9 -3
  50. package/dist/ground/index.js.map +1 -1
  51. package/dist/ground/paths.d.ts +21 -0
  52. package/dist/ground/paths.js +43 -0
  53. package/dist/ground/paths.js.map +1 -1
  54. package/dist/ground/schemas.d.ts +201 -0
  55. package/dist/ground/schemas.js +135 -10
  56. package/dist/ground/schemas.js.map +1 -1
  57. package/dist/ground/scope-index.js +4 -4
  58. package/dist/ground/scope-index.js.map +1 -1
  59. package/dist/ground/slug.d.ts +60 -0
  60. package/dist/ground/slug.js +103 -0
  61. package/dist/ground/slug.js.map +1 -0
  62. package/dist/ground/sot-bindings.d.ts +14 -0
  63. package/dist/ground/sot-bindings.js +79 -0
  64. package/dist/ground/sot-bindings.js.map +1 -0
  65. package/dist/ground/sot-cache.d.ts +18 -0
  66. package/dist/ground/sot-cache.js +62 -0
  67. package/dist/ground/sot-cache.js.map +1 -0
  68. package/dist/ground/topic-index.d.ts +27 -0
  69. package/dist/ground/topic-index.js +82 -0
  70. package/dist/ground/topic-index.js.map +1 -0
  71. package/dist/hooks/post-tool-use/index.d.ts +2 -0
  72. package/dist/hooks/post-tool-use/index.js +1 -0
  73. package/dist/hooks/post-tool-use/index.js.map +1 -1
  74. package/dist/hooks/post-tool-use/sot-align.d.ts +166 -0
  75. package/dist/hooks/post-tool-use/sot-align.js +1306 -0
  76. package/dist/hooks/post-tool-use/sot-align.js.map +1 -0
  77. package/dist/hooks/pre-commit/index.d.ts +8 -0
  78. package/dist/hooks/pre-commit/index.js +8 -0
  79. package/dist/hooks/pre-commit/index.js.map +1 -0
  80. package/dist/hooks/pre-commit/sot-align-precommit.d.ts +60 -0
  81. package/dist/hooks/pre-commit/sot-align-precommit.js +221 -0
  82. package/dist/hooks/pre-commit/sot-align-precommit.js.map +1 -0
  83. package/dist/hooks/runners/session-start.js +41 -0
  84. package/dist/hooks/runners/session-start.js.map +1 -1
  85. package/dist/hooks/sot-align-common.d.ts +39 -0
  86. package/dist/hooks/sot-align-common.js +152 -0
  87. package/dist/hooks/sot-align-common.js.map +1 -0
  88. package/dist/index.d.ts +5 -0
  89. package/dist/index.js +5 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/init/index.d.ts +4 -2
  92. package/dist/init/index.js +2 -1
  93. package/dist/init/index.js.map +1 -1
  94. package/dist/init/ingest-docs.d.ts +30 -47
  95. package/dist/init/ingest-docs.js +113 -410
  96. package/dist/init/ingest-docs.js.map +1 -1
  97. package/dist/init/init.d.ts +8 -0
  98. package/dist/init/init.js +58 -29
  99. package/dist/init/init.js.map +1 -1
  100. package/dist/init/mapper.js +6 -6
  101. package/dist/init/mapper.js.map +1 -1
  102. package/dist/init/phases/5-brand.js +1 -1
  103. package/dist/init/phases/5-brand.js.map +1 -1
  104. package/dist/init/phases/5b-topic-index.d.ts +30 -0
  105. package/dist/init/phases/5b-topic-index.js +62 -0
  106. package/dist/init/phases/5b-topic-index.js.map +1 -0
  107. package/dist/init/phases/6-docs-ingest.d.ts +4 -5
  108. package/dist/init/phases/6-docs-ingest.js +5 -6
  109. package/dist/init/phases/6-docs-ingest.js.map +1 -1
  110. package/dist/init/phases/index.d.ts +2 -0
  111. package/dist/init/phases/index.js +1 -0
  112. package/dist/init/phases/index.js.map +1 -1
  113. package/dist/init/phases/parallel-678.d.ts +14 -17
  114. package/dist/init/phases/parallel-678.js +77 -98
  115. package/dist/init/phases/parallel-678.js.map +1 -1
  116. package/dist/init/phases/source-comments-output-io.d.ts +16 -10
  117. package/dist/init/phases/source-comments-output-io.js +7 -10
  118. package/dist/init/phases/source-comments-output-io.js.map +1 -1
  119. package/dist/init/phases/types.d.ts +1 -1
  120. package/dist/init/phases/types.js +1 -0
  121. package/dist/init/phases/types.js.map +1 -1
  122. package/dist/init/rules-merge/discover.d.ts +8 -3
  123. package/dist/init/rules-merge/discover.js +7 -3
  124. package/dist/init/rules-merge/discover.js.map +1 -1
  125. package/dist/init/rules-merge/ingest.d.ts +81 -28
  126. package/dist/init/rules-merge/ingest.js +456 -162
  127. package/dist/init/rules-merge/ingest.js.map +1 -1
  128. package/dist/init/sot-emit.d.ts +84 -0
  129. package/dist/init/sot-emit.js +214 -0
  130. package/dist/init/sot-emit.js.map +1 -0
  131. package/dist/init/source-comments/classify.d.ts +12 -10
  132. package/dist/init/source-comments/classify.js +13 -25
  133. package/dist/init/source-comments/classify.js.map +1 -1
  134. package/dist/init/source-comments/index.d.ts +1 -1
  135. package/dist/init/source-comments/index.js +1 -1
  136. package/dist/init/source-comments/index.js.map +1 -1
  137. package/dist/init/source-comments/ingest.d.ts +91 -67
  138. package/dist/init/source-comments/ingest.js +392 -361
  139. package/dist/init/source-comments/ingest.js.map +1 -1
  140. package/dist/init/topic-index/index.d.ts +36 -0
  141. package/dist/init/topic-index/index.js +46 -0
  142. package/dist/init/topic-index/index.js.map +1 -0
  143. package/dist/init/topic-index/judge.d.ts +20 -0
  144. package/dist/init/topic-index/judge.js +65 -0
  145. package/dist/init/topic-index/judge.js.map +1 -0
  146. package/dist/init/topic-index/resolve.d.ts +50 -0
  147. package/dist/init/topic-index/resolve.js +196 -0
  148. package/dist/init/topic-index/resolve.js.map +1 -0
  149. package/dist/init/topic-index/walk.d.ts +43 -0
  150. package/dist/init/topic-index/walk.js +293 -0
  151. package/dist/init/topic-index/walk.js.map +1 -0
  152. package/dist/mcp/schemas.d.ts +45 -8
  153. package/dist/mcp/schemas.js +43 -7
  154. package/dist/mcp/schemas.js.map +1 -1
  155. package/dist/mcp/tools/align-drain.d.ts +7 -0
  156. package/dist/mcp/tools/align-drain.js +26 -0
  157. package/dist/mcp/tools/align-drain.js.map +1 -0
  158. package/dist/mcp/tools/index.d.ts +1 -1
  159. package/dist/mcp/tools/index.js +3 -0
  160. package/dist/mcp/tools/index.js.map +1 -1
  161. package/dist/mcp/tools/init-phases.js +4 -1
  162. package/dist/mcp/tools/init-phases.js.map +1 -1
  163. package/dist/mcp/tools/record-decision.js +5 -2
  164. package/dist/mcp/tools/record-decision.js.map +1 -1
  165. package/dist/mcp/tools/resolve-attention.d.ts +2 -2
  166. package/dist/mcp/tools/resolve-attention.js +781 -5
  167. package/dist/mcp/tools/resolve-attention.js.map +1 -1
  168. package/dist/status-line/event-queue.d.ts +40 -0
  169. package/dist/status-line/event-queue.js +195 -0
  170. package/dist/status-line/event-queue.js.map +1 -0
  171. package/dist/status-line/format.d.ts +1 -1
  172. package/dist/status-line/format.js +49 -6
  173. package/dist/status-line/format.js.map +1 -1
  174. package/dist/status-line/index.d.ts +41 -0
  175. package/dist/status-line/index.js +14 -0
  176. package/dist/status-line/index.js.map +1 -1
  177. package/dist/status-line/reader.js +23 -18
  178. package/dist/status-line/reader.js.map +1 -1
  179. package/dist/status-line/writer.d.ts +1 -1
  180. package/dist/status-line/writer.js +5 -0
  181. package/dist/status-line/writer.js.map +1 -1
  182. package/dist/text/jaccard.d.ts +19 -0
  183. package/dist/text/jaccard.js +68 -0
  184. package/dist/text/jaccard.js.map +1 -0
  185. package/package.json +1 -1
  186. package/templates/.cairn/git-hooks/pre-commit +16 -3
@@ -0,0 +1,1306 @@
1
+ /**
2
+ * Layer A — live SoT alignment hook (plan §4.1).
3
+ *
4
+ * `cairn hook sot-align` runs as a PostToolUse hook on Claude Code's
5
+ * Write/Edit. For every prose block in the just-written file the
6
+ * pipeline picks one of:
7
+ *
8
+ * - **Tier 1 (deterministic, no Haiku)** — block is a verbatim/
9
+ * near-verbatim duplicate of an existing accepted DEC/INV body
10
+ * (Jaccard ≥ 0.85, 3-shingle ≥ 60%, length ratio 0.5-2.0).
11
+ * Auto-replace with `// §DEC-<hash>` (or `# §DEC-<hash>` per
12
+ * language). Statusline blip `⬡ aligned`.
13
+ *
14
+ * - **Tier 2 — Haiku dedup judge, two-pass.**
15
+ * Pass 1 (cheap, snippet + candidate body) → `same | different |
16
+ * ambiguous`. `same` → cite. `different` → next candidate.
17
+ * `ambiguous` → escalate to Pass 2.
18
+ * Pass 2 (full bodies + ±200-char source context + step-by-step
19
+ * prompt) → `same | different | augments | ambiguous`. `same`
20
+ * cite; `different` next; `augments` triggers two-stage delta:
21
+ * - Stage 1 — Haiku extracts the delta prose ("NO_DELTA" → same).
22
+ * - Stage 2 — Haiku classifies the delta `constraint | rationale`.
23
+ * constraint → fresh INV linked via `derived_from`; rationale
24
+ * → fresh DEC linked via `related`. The augmented source
25
+ * gains a `// §INV-<new>` / `// §DEC-<new>` cite *alongside*
26
+ * the existing one (existing token preserved).
27
+ * `ambiguous` (still!) → write to `.cairn/ground/alignment-
28
+ * pending/<id>.md` and surface via cairn-attention.
29
+ *
30
+ * - **Tier 3 — Haiku creation judge, two-pass.**
31
+ * Pass 1 → `decision | constraint | descriptive | ambiguous`.
32
+ * `descriptive` no-op (false-positive DEC creation pollutes
33
+ * ground state worse than missed capture). `ambiguous` →
34
+ * escalate to Pass 2 (full prose + ±200-char context + step-by-
35
+ * step prompt). Pass-2-still-ambiguous → alignment-pending.
36
+ *
37
+ * Hard rules:
38
+ * - The hook never blocks the Write. Failures degrade to no-op +
39
+ * log; the operator's edit always succeeds.
40
+ * - Per-Write call caps: max HAIKU_PASS1_CAP Pass-1 calls + max
41
+ * HAIKU_PASS2_CAP Pass-2 calls per Write. Excess defers to
42
+ * `.cairn/staleness/layer-a-deferred.jsonl` for Layer C drain.
43
+ * - Verdict cache at `.cairn/cache/haiku/<scope>/<blockHash>-<key>.json`
44
+ * so re-running the same prose hits cache instead of Haiku.
45
+ * - Source files outside Claude's repo (cwd) are skipped. Markdown
46
+ * (`.md`/`.mdx`) skipped entirely — operator-curated narrative is
47
+ * handled by phase 5b's topic-index + the doc-drift sensor.
48
+ */
49
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
50
+ import { createHash } from "node:crypto";
51
+ import { dirname, join, relative } from "node:path";
52
+ import { writeFileSafe } from "../../fs.js";
53
+ import { stringify as stringifyYaml } from "yaml";
54
+ import { readHookStdin } from "../runners/payload.js";
55
+ import { resolveRepoRoot } from "../../session-start/index.js";
56
+ import { runClaude } from "../../claude/index.js";
57
+ import { bindDec, bodyContentHash, decisionsDir, deriveLedgerDecId, deriveLedgerInvId, emptyAnchorMap, invariantsDir, readAnchorMap, readSotBindings, readSotCache, readTopicIndex, recordDriftEvent, setAnchor, setSotCacheEntry, setTopic, topicSlug, writeAlignmentPending, writeAnchorMap, writeSotBindings, writeSotCache, writeTopicIndex, } from "../../ground/index.js";
58
+ import { writeDecisionsLedger, writeInvariantsLedger } from "../../ground/ledgers.js";
59
+ import { applyStripReplace, formatBareCitation, } from "../../init/source-comments/strip-replace.js";
60
+ import { appendAlignUndoEntry, } from "../../align-undo/index.js";
61
+ import { logger } from "../../logger.js";
62
+ import { tokenize } from "../../text/jaccard.js";
63
+ import { withWriteLock } from "../../lock.js";
64
+ import { pushEvent } from "../../status-line/event-queue.js";
65
+ import { TIER2_JACCARD_FLOOR, TOP_K_CANDIDATES, extractBlocks, isMarkdownPath, readEntityBody, tier1PickWithBody, topKCandidates, } from "../sot-align-common.js";
66
+ const log = logger("hooks.post-tool-use.sot-align");
67
+ const CAPTURE_SOURCE = "layer-a-sot-align";
68
+ /* -------------------------------------------------------------------------- */
69
+ /* Tunables — Layer A only (shared Tier 1/2 floors live in sot-align-common) */
70
+ /* -------------------------------------------------------------------------- */
71
+ const HAIKU_PASS1_CAP = 5;
72
+ const HAIKU_PASS2_CAP = 2;
73
+ const PER_HAIKU_TIMEOUT_MS = 30_000;
74
+ const BLOCK_BODY_CAP = 1_500;
75
+ const SOURCE_CONTEXT_RADIUS = 200;
76
+ /**
77
+ * Run the Layer A pipeline against one repo-relative file.
78
+ */
79
+ export async function alignFile(args) {
80
+ const { repoRoot, filePath, sessionId } = args;
81
+ const result = {
82
+ blocksConsidered: 0,
83
+ tier1Aligned: 0,
84
+ tier2Aligned: 0,
85
+ decsCreated: 0,
86
+ invsCreated: 0,
87
+ augmentsDecs: 0,
88
+ augmentsInvs: 0,
89
+ pending: 0,
90
+ deferredToStaleness: 0,
91
+ descriptive: 0,
92
+ skipped: 0,
93
+ haikuPass1Calls: 0,
94
+ haikuPass2Calls: 0,
95
+ haikuCalls: 0,
96
+ };
97
+ // Plan §3.1 — markdown narrative (docs/, CLAUDE.md, AGENTS.md, rules)
98
+ // is operator-curated. Phase 5b's topic-index + the doc-drift sensor
99
+ // handle cross-source dedup there. Layer A only acts on code paths
100
+ // where strip-replace + bare `// §DEC-<hash>` cites are the right
101
+ // surface. Skipping markdown also avoids polluting docs with a
102
+ // `// §DEC-<hash>` line that isn't valid markdown syntax.
103
+ if (isMarkdownPath(filePath)) {
104
+ return result;
105
+ }
106
+ const blocks = extractBlocks(repoRoot, filePath);
107
+ result.blocksConsidered = blocks.length;
108
+ if (blocks.length === 0)
109
+ return result;
110
+ const cache = readSotCache(repoRoot);
111
+ const cacheEntries = Object.values(cache.entries).filter((e) => e.tokens.length > 0);
112
+ const stripItems = [];
113
+ // `undoLogEntries` shadows `stripItems` 1:1 — each push to one
114
+ // pushes to the other. After applyStripReplace succeeds we write
115
+ // these to `.cairn/state/align-undo-log.jsonl` for `cairn attention
116
+ // undo` (plan §11.7).
117
+ const undoLogEntries = [];
118
+ let pass1Calls = 0;
119
+ let pass2Calls = 0;
120
+ let auxiliaryCalls = 0; // delta extraction + classification
121
+ const pass1Cap = args.pass1Cap ?? HAIKU_PASS1_CAP;
122
+ const pass2Cap = args.pass2Cap ?? HAIKU_PASS2_CAP;
123
+ const skipCreation = args.skipCreation === true;
124
+ const fileSource = readFileMaybe(repoRoot, filePath);
125
+ // After every Tier-3 / augments emit we append the fresh entry to
126
+ // `cacheEntries` so a later block in the same Write that mirrors the
127
+ // just-emitted prose flows through Tier 1 / Tier 2 instead of
128
+ // emitting a second duplicate DEC. Without this, two similar JSDoc
129
+ // blocks in the same file would each become their own ledger DEC.
130
+ const recordFreshEntry = (id, body) => {
131
+ cacheEntries.push({
132
+ dec_id: id,
133
+ sot_path: "ledger",
134
+ body_hash: bodyContentHash(body),
135
+ tokens: Array.from(tokenize(body, { codeAware: true })),
136
+ shingles: [],
137
+ mtime_ms: Date.now(),
138
+ });
139
+ };
140
+ for (const block of blocks) {
141
+ if (block.prose.length < 80) {
142
+ result.skipped += 1;
143
+ continue;
144
+ }
145
+ const blockTokens = tokenize(block.prose, { codeAware: true });
146
+ if (blockTokens.size < 10) {
147
+ result.skipped += 1;
148
+ continue;
149
+ }
150
+ const candidates = topKCandidates(blockTokens, cacheEntries, TIER2_JACCARD_FLOOR, TOP_K_CANDIDATES);
151
+ // Tier 1 — deterministic shortcut.
152
+ const tier1Match = tier1PickWithBody(repoRoot, block, candidates);
153
+ if (tier1Match !== null) {
154
+ const item = buildCiteItem(block, tier1Match.id);
155
+ stripItems.push(item);
156
+ undoLogEntries.push(makeUndoEntry({
157
+ sessionId,
158
+ kind: "tier1-cite",
159
+ block,
160
+ item,
161
+ primaryId: tier1Match.id,
162
+ }));
163
+ pushAlignBlip(repoRoot, sessionId, tier1Match.id, "aligned");
164
+ result.tier1Aligned += 1;
165
+ continue;
166
+ }
167
+ let tier2Outcome = { kind: "no-hit" };
168
+ if (candidates.length > 0) {
169
+ candidateLoop: for (const cand of candidates) {
170
+ const candBody = readEntityBody(repoRoot, cand.id);
171
+ if (candBody === null)
172
+ continue;
173
+ // Verdict cache scoped on (block prose, candidate id, fresh body
174
+ // hash). The hash is computed from `candBody` we just read off
175
+ // disk rather than the sot-cache snapshot in `cand.body_hash` —
176
+ // the operator can edit DEC bodies between sot-cache refreshes,
177
+ // and a stale "same" verdict against an old body would let us
178
+ // cite a now-different DEC.
179
+ const candScope = `${cand.id}-${bodyContentHash(candBody).slice(0, 12)}`;
180
+ // Pass 1.
181
+ const cachedP1 = readVerdictCache(repoRoot, "dedup-p1", block.prose, candScope);
182
+ let p1;
183
+ if (cachedP1 === "same" ||
184
+ cachedP1 === "different" ||
185
+ cachedP1 === "ambiguous") {
186
+ p1 = cachedP1;
187
+ }
188
+ else {
189
+ // Cap check fires only when we'd actually make a fresh call —
190
+ // cache hits at cap still return their cached verdict (free).
191
+ if (pass1Calls >= pass1Cap) {
192
+ tier2Outcome = { kind: "deferred-cap" };
193
+ break;
194
+ }
195
+ pass1Calls += 1;
196
+ p1 = await runDedupJudgePass1({
197
+ blockBody: block.prose,
198
+ candidate: { id: cand.id, body: candBody },
199
+ mock: args.mockDedupJudgePass1,
200
+ });
201
+ writeVerdictCache(repoRoot, "dedup-p1", block.prose, candScope, p1);
202
+ }
203
+ if (p1 === "same") {
204
+ tier2Outcome = { kind: "cite", id: cand.id };
205
+ break;
206
+ }
207
+ if (p1 === "different")
208
+ continue;
209
+ // Pass 1 ambiguous → escalate to Pass 2.
210
+ const cachedP2 = readVerdictCache(repoRoot, "dedup-p2", block.prose, candScope);
211
+ let p2;
212
+ if (cachedP2 === "same" ||
213
+ cachedP2 === "different" ||
214
+ cachedP2 === "augments" ||
215
+ cachedP2 === "ambiguous") {
216
+ p2 = cachedP2;
217
+ }
218
+ else {
219
+ if (pass2Calls >= pass2Cap) {
220
+ tier2Outcome = { kind: "deferred-cap" };
221
+ break;
222
+ }
223
+ pass2Calls += 1;
224
+ p2 = await runDedupJudgePass2({
225
+ blockBody: block.prose,
226
+ blockContext: surroundingContext(fileSource, block.startOffset, block.endOffset),
227
+ candidate: { id: cand.id, body: candBody },
228
+ mock: args.mockDedupJudgePass2,
229
+ });
230
+ writeVerdictCache(repoRoot, "dedup-p2", block.prose, candScope, p2);
231
+ }
232
+ if (p2 === "same") {
233
+ tier2Outcome = { kind: "cite", id: cand.id };
234
+ break;
235
+ }
236
+ if (p2 === "different")
237
+ continue;
238
+ if (p2 === "augments") {
239
+ tier2Outcome = {
240
+ kind: "augments",
241
+ existingId: cand.id,
242
+ existingBody: candBody,
243
+ existingKind: cand.id.startsWith("INV-") ? "INV" : "DEC",
244
+ candScope,
245
+ };
246
+ break candidateLoop;
247
+ }
248
+ // p2 === "ambiguous" — alignment-pending surface.
249
+ tier2Outcome = { kind: "deferred-pending", existingId: cand.id };
250
+ break;
251
+ }
252
+ }
253
+ if (tier2Outcome.kind === "deferred-cap") {
254
+ deferToStaleness(repoRoot, block, "tier2-cap-exceeded");
255
+ result.deferredToStaleness += 1;
256
+ continue;
257
+ }
258
+ if (tier2Outcome.kind === "cite") {
259
+ const item = buildCiteItem(block, tier2Outcome.id);
260
+ stripItems.push(item);
261
+ undoLogEntries.push(makeUndoEntry({
262
+ sessionId,
263
+ kind: "tier2-cite",
264
+ block,
265
+ item,
266
+ primaryId: tier2Outcome.id,
267
+ }));
268
+ pushAlignBlip(repoRoot, sessionId, tier2Outcome.id, "aligned");
269
+ result.tier2Aligned += 1;
270
+ continue;
271
+ }
272
+ if (tier2Outcome.kind === "deferred-pending") {
273
+ writeAlignmentPending({
274
+ repoRoot,
275
+ block,
276
+ kind: "tier2-ambiguous",
277
+ existingId: tier2Outcome.existingId,
278
+ existingBody: readEntityBody(repoRoot, tier2Outcome.existingId) ?? "",
279
+ });
280
+ result.pending += 1;
281
+ continue;
282
+ }
283
+ if (tier2Outcome.kind === "augments") {
284
+ // Stage 1 — extract delta. Stage 2 — classify constraint vs rationale.
285
+ // Cache scope = (block, candidate-id-with-body-hash) so a refreshed
286
+ // candidate body forces a fresh extraction instead of reusing a
287
+ // delta computed against the prior body.
288
+ const cachedDelta = readVerdictCache(repoRoot, "delta-extract", block.prose, tier2Outcome.candScope);
289
+ let delta;
290
+ if (cachedDelta !== null) {
291
+ delta = cachedDelta;
292
+ }
293
+ else {
294
+ auxiliaryCalls += 1;
295
+ delta = await runDeltaExtract({
296
+ blockBody: block.prose,
297
+ candidateBody: tier2Outcome.existingBody,
298
+ mock: args.mockDeltaExtract,
299
+ });
300
+ writeVerdictCache(repoRoot, "delta-extract", block.prose, tier2Outcome.candScope, delta);
301
+ }
302
+ if (delta.trim() === "NO_DELTA" || delta.trim().length === 0) {
303
+ // Pass-2 said augments but Stage 1 found nothing — treat as same.
304
+ const item = buildCiteItem(block, tier2Outcome.existingId);
305
+ stripItems.push(item);
306
+ undoLogEntries.push(makeUndoEntry({
307
+ sessionId,
308
+ kind: "tier2-cite",
309
+ block,
310
+ item,
311
+ primaryId: tier2Outcome.existingId,
312
+ }));
313
+ pushAlignBlip(repoRoot, sessionId, tier2Outcome.existingId, "aligned");
314
+ result.tier2Aligned += 1;
315
+ continue;
316
+ }
317
+ const cachedKind = readVerdictCache(repoRoot, "delta-classify", delta, tier2Outcome.candScope);
318
+ let deltaKind;
319
+ if (cachedKind === "constraint" || cachedKind === "rationale") {
320
+ deltaKind = cachedKind;
321
+ }
322
+ else {
323
+ auxiliaryCalls += 1;
324
+ deltaKind = await runDeltaClassify({
325
+ delta,
326
+ mock: args.mockDeltaClassify,
327
+ });
328
+ writeVerdictCache(repoRoot, "delta-classify", delta, tier2Outcome.candScope, deltaKind);
329
+ }
330
+ const augEmit = await emitAugmentSibling({
331
+ repoRoot,
332
+ block,
333
+ delta,
334
+ deltaKind,
335
+ existingId: tier2Outcome.existingId,
336
+ });
337
+ if (augEmit === null) {
338
+ result.skipped += 1;
339
+ continue;
340
+ }
341
+ // Existing § token preserved; add the new sibling cite alongside.
342
+ const augItem = buildAugmentCiteItem(block, tier2Outcome.existingId, augEmit.id);
343
+ stripItems.push(augItem);
344
+ undoLogEntries.push(makeUndoEntry({
345
+ sessionId,
346
+ kind: "augments",
347
+ block,
348
+ item: augItem,
349
+ primaryId: augEmit.id,
350
+ primaryKind: augEmit.kind,
351
+ augmentsExistingId: tier2Outcome.existingId,
352
+ }));
353
+ recordFreshEntry(augEmit.id, delta);
354
+ if (augEmit.kind === "INV") {
355
+ result.augmentsInvs += 1;
356
+ pushAlignBlip(repoRoot, sessionId, tier2Outcome.existingId, "constrained");
357
+ }
358
+ else {
359
+ result.augmentsDecs += 1;
360
+ pushAlignBlip(repoRoot, sessionId, tier2Outcome.existingId, "supplemented");
361
+ }
362
+ continue;
363
+ }
364
+ // Tier 3 — creation judge, two-pass.
365
+ if (skipCreation) {
366
+ // `cairn fix align --no-creation` — the operator wants
367
+ // duplicate consolidation only, not fresh DEC creation. Treat
368
+ // the block as descriptive without invoking Haiku.
369
+ result.descriptive += 1;
370
+ continue;
371
+ }
372
+ const cachedT3P1 = readVerdictCache(repoRoot, "create-p1", block.prose, "creation");
373
+ let creationP1;
374
+ if (cachedT3P1 === "decision" ||
375
+ cachedT3P1 === "constraint" ||
376
+ cachedT3P1 === "descriptive" ||
377
+ cachedT3P1 === "ambiguous") {
378
+ creationP1 = cachedT3P1;
379
+ }
380
+ else {
381
+ if (pass1Calls >= pass1Cap) {
382
+ deferToStaleness(repoRoot, block, "tier3-cap-exceeded");
383
+ result.deferredToStaleness += 1;
384
+ continue;
385
+ }
386
+ pass1Calls += 1;
387
+ creationP1 = await runCreationJudgePass1({
388
+ blockBody: block.prose,
389
+ file: block.file,
390
+ line: block.startLine,
391
+ mock: args.mockCreationJudgePass1,
392
+ });
393
+ writeVerdictCache(repoRoot, "create-p1", block.prose, "creation", creationP1);
394
+ }
395
+ let creationVerdict = creationP1;
396
+ if (creationVerdict === "ambiguous") {
397
+ const cachedT3P2 = readVerdictCache(repoRoot, "create-p2", block.prose, "creation");
398
+ let creationP2;
399
+ if (cachedT3P2 === "decision" ||
400
+ cachedT3P2 === "constraint" ||
401
+ cachedT3P2 === "descriptive" ||
402
+ cachedT3P2 === "ambiguous") {
403
+ creationP2 = cachedT3P2;
404
+ }
405
+ else {
406
+ if (pass2Calls >= pass2Cap) {
407
+ deferToStaleness(repoRoot, block, "tier3-pass2-cap-exceeded");
408
+ result.deferredToStaleness += 1;
409
+ continue;
410
+ }
411
+ pass2Calls += 1;
412
+ creationP2 = await runCreationJudgePass2({
413
+ blockBody: block.prose,
414
+ blockContext: surroundingContext(fileSource, block.startOffset, block.endOffset),
415
+ file: block.file,
416
+ line: block.startLine,
417
+ mock: args.mockCreationJudgePass2,
418
+ });
419
+ writeVerdictCache(repoRoot, "create-p2", block.prose, "creation", creationP2);
420
+ }
421
+ creationVerdict = creationP2;
422
+ }
423
+ if (creationVerdict === "descriptive") {
424
+ result.descriptive += 1;
425
+ continue;
426
+ }
427
+ if (creationVerdict === "ambiguous") {
428
+ writeAlignmentPending({
429
+ repoRoot,
430
+ block,
431
+ kind: "tier3-ambiguous",
432
+ });
433
+ result.pending += 1;
434
+ continue;
435
+ }
436
+ // creationVerdict === "decision" | "constraint" → emit ledger entity.
437
+ const emit = await emitLedgerEntity({
438
+ repoRoot,
439
+ block,
440
+ kind: creationVerdict,
441
+ });
442
+ if (emit === null) {
443
+ result.skipped += 1;
444
+ continue;
445
+ }
446
+ const createItem = buildCiteItem(block, emit.id);
447
+ stripItems.push(createItem);
448
+ undoLogEntries.push(makeUndoEntry({
449
+ sessionId,
450
+ kind: "tier3-creation",
451
+ block,
452
+ item: createItem,
453
+ primaryId: emit.id,
454
+ primaryKind: emit.kind,
455
+ }));
456
+ recordFreshEntry(emit.id, block.prose);
457
+ if (emit.kind === "DEC") {
458
+ result.decsCreated += 1;
459
+ pushAlignBlip(repoRoot, sessionId, emit.id, "created-dec");
460
+ }
461
+ else {
462
+ result.invsCreated += 1;
463
+ pushAlignBlip(repoRoot, sessionId, emit.id, "created-inv");
464
+ }
465
+ }
466
+ result.haikuPass1Calls = pass1Calls;
467
+ result.haikuPass2Calls = pass2Calls;
468
+ result.haikuCalls = pass1Calls + pass2Calls + auxiliaryCalls;
469
+ if (stripItems.length > 0) {
470
+ try {
471
+ const dirtyDecisions = {};
472
+ for (const it of stripItems)
473
+ dirtyDecisions[it.file] = "overwrite";
474
+ await withWriteLock(repoRoot, () => {
475
+ applyStripReplace({ repoRoot, items: stripItems, dirtyDecisions });
476
+ });
477
+ // strip-replace landed — append undo records so `cairn attention
478
+ // undo` can roll back the cite, fresh DEC creation, or augments
479
+ // sibling. We log AFTER the write so an aborted apply doesn't
480
+ // leave a misleading audit trail.
481
+ for (const u of undoLogEntries)
482
+ await appendAlignUndoEntry(repoRoot, u);
483
+ }
484
+ catch (err) {
485
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "Layer A strip-replace failed");
486
+ }
487
+ }
488
+ return result;
489
+ }
490
+ /* -------------------------------------------------------------------------- */
491
+ /* Align-undo entry builder */
492
+ /* -------------------------------------------------------------------------- */
493
+ function makeUndoEntry(args) {
494
+ const entry = {
495
+ ts: new Date().toISOString(),
496
+ session_id: args.sessionId,
497
+ kind: args.kind,
498
+ file: args.item.file,
499
+ start_offset: args.item.startOffset,
500
+ end_offset: args.item.endOffset,
501
+ original_raw: args.item.expectedRaw ?? args.block.raw,
502
+ replacement: args.item.replacement,
503
+ primary_id: args.primaryId,
504
+ };
505
+ if (args.primaryKind !== undefined)
506
+ entry.primary_kind = args.primaryKind;
507
+ if (args.augmentsExistingId !== undefined)
508
+ entry.augments_existing_id = args.augmentsExistingId;
509
+ return entry;
510
+ }
511
+ /* -------------------------------------------------------------------------- */
512
+ /* Block extraction */
513
+ /* -------------------------------------------------------------------------- */
514
+ /* -------------------------------------------------------------------------- */
515
+ /* Strip-replace item builder */
516
+ /* -------------------------------------------------------------------------- */
517
+ function buildCiteItem(block, decId) {
518
+ return {
519
+ blockId: block.id,
520
+ file: block.file,
521
+ startOffset: block.startOffset,
522
+ endOffset: block.endOffset,
523
+ replacement: formatBareCitation(block.lang, decId),
524
+ expectedRaw: block.raw,
525
+ };
526
+ }
527
+ /* -------------------------------------------------------------------------- */
528
+ /* Haiku dedup judge — Pass 1 */
529
+ /* -------------------------------------------------------------------------- */
530
+ const DEDUP_P1_SCHEMA = {
531
+ type: "object",
532
+ additionalProperties: false,
533
+ required: ["verdict"],
534
+ properties: {
535
+ verdict: { type: "string", enum: ["same", "different", "ambiguous"] },
536
+ },
537
+ };
538
+ const DEDUP_P1_SYSTEM = `You compare two prose blocks and return a single verdict.
539
+
540
+ Reply ONLY the JSON: { "verdict": "same" | "different" | "ambiguous" }.
541
+
542
+ - "same" both blocks describe the same decision/rule (overlap is total)
543
+ - "different" they describe distinct topics
544
+ - "ambiguous" related but not clearly the same — escalate
545
+
546
+ Be conservative on "same" — only flag when the two blocks make the same
547
+ binding statement with compatible wording.`;
548
+ async function runDedupJudgePass1(args) {
549
+ if (args.mock !== undefined) {
550
+ return args.mock({ blockBody: args.blockBody, candidate: args.candidate });
551
+ }
552
+ const a = capBody(args.blockBody);
553
+ const b = capBody(args.candidate.body);
554
+ const prompt = [
555
+ "Block A (just written):",
556
+ a,
557
+ "",
558
+ `Block B (existing ${args.candidate.id}):`,
559
+ b,
560
+ "",
561
+ "Are these the same decision/rule?",
562
+ ].join("\n");
563
+ try {
564
+ const result = await runClaude({
565
+ tier: "haiku",
566
+ system: DEDUP_P1_SYSTEM,
567
+ prompt,
568
+ jsonSchema: DEDUP_P1_SCHEMA,
569
+ timeoutMs: PER_HAIKU_TIMEOUT_MS,
570
+ isolateAmbientContext: true,
571
+ });
572
+ const parsed = result.parsed;
573
+ if (typeof parsed !== "object" || parsed === null)
574
+ return "ambiguous";
575
+ const v = parsed["verdict"];
576
+ if (v === "same" || v === "different")
577
+ return v;
578
+ return "ambiguous";
579
+ }
580
+ catch (err) {
581
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "dedup judge pass-1 failed; treating as ambiguous");
582
+ return "ambiguous";
583
+ }
584
+ }
585
+ /* -------------------------------------------------------------------------- */
586
+ /* Haiku dedup judge — Pass 2 (CoT) */
587
+ /* -------------------------------------------------------------------------- */
588
+ const DEDUP_P2_SCHEMA = {
589
+ type: "object",
590
+ additionalProperties: false,
591
+ required: ["verdict"],
592
+ properties: {
593
+ verdict: {
594
+ type: "string",
595
+ enum: ["same", "different", "augments", "ambiguous"],
596
+ },
597
+ reasoning: { type: "string" },
598
+ },
599
+ };
600
+ const DEDUP_P2_SYSTEM = `You compare two prose blocks for whether they describe the same decision/rule.
601
+
602
+ Use step-by-step reasoning:
603
+ Step 1: list the specific facts in Block A.
604
+ Step 2: list the specific facts in Block B.
605
+ Step 3: do these capture the SAME decision, or do they differ?
606
+
607
+ Final verdict (return JSON):
608
+ - "same" both blocks make the same binding statement
609
+ - "different" they describe distinct topics
610
+ - "augments" same topic, but A adds rationale/context that B doesn't have
611
+ - "ambiguous" cannot tell
612
+
613
+ Be conservative on "same"/"augments" — when in doubt, prefer "ambiguous".`;
614
+ async function runDedupJudgePass2(args) {
615
+ if (args.mock !== undefined) {
616
+ return args.mock({
617
+ blockBody: args.blockBody,
618
+ blockContext: args.blockContext,
619
+ candidate: args.candidate,
620
+ });
621
+ }
622
+ const a = capBody(args.blockBody);
623
+ const b = capBody(args.candidate.body);
624
+ const ctx = args.blockContext.trim();
625
+ const prompt = [
626
+ "Block A (just written):",
627
+ a,
628
+ "",
629
+ "---surrounding source context---",
630
+ ctx.length > 0 ? ctx : "(no surrounding context available)",
631
+ "",
632
+ `Block B (existing ${args.candidate.id}, full body):`,
633
+ b,
634
+ "",
635
+ "Step 1: list specific facts in A.",
636
+ "Step 2: list specific facts in B.",
637
+ "Step 3: do these capture the SAME decision, or do they differ?",
638
+ "",
639
+ "Final verdict: same | different | augments | ambiguous.",
640
+ ].join("\n");
641
+ try {
642
+ const result = await runClaude({
643
+ tier: "haiku",
644
+ system: DEDUP_P2_SYSTEM,
645
+ prompt,
646
+ jsonSchema: DEDUP_P2_SCHEMA,
647
+ timeoutMs: PER_HAIKU_TIMEOUT_MS,
648
+ isolateAmbientContext: true,
649
+ });
650
+ const parsed = result.parsed;
651
+ if (typeof parsed !== "object" || parsed === null)
652
+ return "ambiguous";
653
+ const v = parsed["verdict"];
654
+ if (v === "same" || v === "different" || v === "augments")
655
+ return v;
656
+ return "ambiguous";
657
+ }
658
+ catch (err) {
659
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "dedup judge pass-2 failed; treating as ambiguous");
660
+ return "ambiguous";
661
+ }
662
+ }
663
+ /* -------------------------------------------------------------------------- */
664
+ /* Augments delta extraction (Stage 1) + classification (Stage 2) */
665
+ /* -------------------------------------------------------------------------- */
666
+ const DELTA_EXTRACT_SCHEMA = {
667
+ type: "object",
668
+ additionalProperties: false,
669
+ required: ["delta"],
670
+ properties: { delta: { type: "string" } },
671
+ };
672
+ const DELTA_EXTRACT_SYSTEM = `You extract the delta — the new content in Block A that is not present in Block B.
673
+
674
+ Output JSON: { "delta": "<text>" }.
675
+ - If A says everything B says PLUS something new, return ONLY the new content verbatim.
676
+ - If A and B fully overlap (no real delta), return the literal string "NO_DELTA".
677
+ - Do not summarize. Do not paraphrase. Verbatim or NO_DELTA.`;
678
+ async function runDeltaExtract(args) {
679
+ if (args.mock !== undefined) {
680
+ return args.mock({
681
+ blockBody: args.blockBody,
682
+ candidateBody: args.candidateBody,
683
+ });
684
+ }
685
+ const a = capBody(args.blockBody);
686
+ const b = capBody(args.candidateBody);
687
+ const prompt = [
688
+ "Block A (just written):",
689
+ a,
690
+ "",
691
+ "Block B (existing DEC body):",
692
+ b,
693
+ "",
694
+ "Extract ONLY the new content from A that is not present in B.",
695
+ "Output exactly the delta text, no summary. If overlap is total, output \"NO_DELTA\".",
696
+ ].join("\n");
697
+ try {
698
+ const result = await runClaude({
699
+ tier: "haiku",
700
+ system: DELTA_EXTRACT_SYSTEM,
701
+ prompt,
702
+ jsonSchema: DELTA_EXTRACT_SCHEMA,
703
+ timeoutMs: PER_HAIKU_TIMEOUT_MS,
704
+ isolateAmbientContext: true,
705
+ });
706
+ const parsed = result.parsed;
707
+ if (typeof parsed !== "object" || parsed === null)
708
+ return "NO_DELTA";
709
+ const d = parsed["delta"];
710
+ return typeof d === "string" ? d : "NO_DELTA";
711
+ }
712
+ catch (err) {
713
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "delta extract failed; treating as NO_DELTA");
714
+ return "NO_DELTA";
715
+ }
716
+ }
717
+ const DELTA_CLASSIFY_SCHEMA = {
718
+ type: "object",
719
+ additionalProperties: false,
720
+ required: ["kind"],
721
+ properties: {
722
+ kind: { type: "string", enum: ["constraint", "rationale"] },
723
+ },
724
+ };
725
+ const DELTA_CLASSIFY_SYSTEM = `You classify a delta as either a CONSTRAINT or SUPPLEMENTAL RATIONALE.
726
+
727
+ - "constraint" the delta states a hard rule (must / must not / never / always / required / forbidden).
728
+ - "rationale" the delta is additional context / motivation for an existing decision.
729
+
730
+ Reply ONLY: { "kind": "constraint" | "rationale" }.`;
731
+ async function runDeltaClassify(args) {
732
+ if (args.mock !== undefined) {
733
+ return args.mock({ delta: args.delta });
734
+ }
735
+ const d = capBody(args.delta);
736
+ const prompt = [
737
+ "Delta:",
738
+ d,
739
+ "",
740
+ "Is this a CONSTRAINT (must / must not / never) or SUPPLEMENTAL RATIONALE?",
741
+ ].join("\n");
742
+ try {
743
+ const result = await runClaude({
744
+ tier: "haiku",
745
+ system: DELTA_CLASSIFY_SYSTEM,
746
+ prompt,
747
+ jsonSchema: DELTA_CLASSIFY_SCHEMA,
748
+ timeoutMs: PER_HAIKU_TIMEOUT_MS,
749
+ isolateAmbientContext: true,
750
+ });
751
+ const parsed = result.parsed;
752
+ if (typeof parsed !== "object" || parsed === null)
753
+ return "rationale";
754
+ const k = parsed["kind"];
755
+ if (k === "constraint" || k === "rationale")
756
+ return k;
757
+ return "rationale";
758
+ }
759
+ catch (err) {
760
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "delta classify failed; treating as rationale");
761
+ return "rationale";
762
+ }
763
+ }
764
+ /* -------------------------------------------------------------------------- */
765
+ /* Haiku creation judge — Pass 1 */
766
+ /* -------------------------------------------------------------------------- */
767
+ const CREATION_P1_SCHEMA = {
768
+ type: "object",
769
+ additionalProperties: false,
770
+ required: ["verdict"],
771
+ properties: {
772
+ verdict: {
773
+ type: "string",
774
+ enum: ["decision", "constraint", "descriptive", "ambiguous"],
775
+ },
776
+ },
777
+ };
778
+ const CREATION_P1_SYSTEM = `You classify a single prose block as one of:
779
+
780
+ - "decision" contains an explicit decision verb (chose, selected, picked,
781
+ decided) AND a comparative/rationale clause (over X, because Y).
782
+ - "constraint" contains an explicit constraint verb (must, must not, never,
783
+ always, required, forbidden).
784
+ - "descriptive" explains what the code does, intent, behavior notes.
785
+ No decision verb, no constraint verb.
786
+ - "ambiguous" cannot tell.
787
+
788
+ Default to "descriptive" when uncertain — false-positive DEC creation
789
+ pollutes the ground state worse than missed capture.
790
+
791
+ Reply ONLY: { "verdict": "decision" | "constraint" | "descriptive" | "ambiguous" }`;
792
+ async function runCreationJudgePass1(args) {
793
+ if (args.mock !== undefined) {
794
+ return args.mock({ blockBody: args.blockBody, file: args.file, line: args.line });
795
+ }
796
+ const a = capBody(args.blockBody);
797
+ const prompt = [`Block at ${args.file}:${args.line}:`, a].join("\n");
798
+ try {
799
+ const result = await runClaude({
800
+ tier: "haiku",
801
+ system: CREATION_P1_SYSTEM,
802
+ prompt,
803
+ jsonSchema: CREATION_P1_SCHEMA,
804
+ timeoutMs: PER_HAIKU_TIMEOUT_MS,
805
+ isolateAmbientContext: true,
806
+ });
807
+ const parsed = result.parsed;
808
+ if (typeof parsed !== "object" || parsed === null)
809
+ return "descriptive";
810
+ const v = parsed["verdict"];
811
+ if (v === "decision" ||
812
+ v === "constraint" ||
813
+ v === "descriptive" ||
814
+ v === "ambiguous") {
815
+ return v;
816
+ }
817
+ return "descriptive";
818
+ }
819
+ catch (err) {
820
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "creation judge pass-1 failed; treating as descriptive");
821
+ return "descriptive";
822
+ }
823
+ }
824
+ /* -------------------------------------------------------------------------- */
825
+ /* Haiku creation judge — Pass 2 (CoT) */
826
+ /* -------------------------------------------------------------------------- */
827
+ const CREATION_P2_SCHEMA = CREATION_P1_SCHEMA;
828
+ const CREATION_P2_SYSTEM = `You classify a prose block, using step-by-step reasoning before the verdict.
829
+
830
+ Step 1: list explicit decision/constraint verbs in the block.
831
+ Step 2: does the block describe a CHOICE the codebase made (decision),
832
+ a RULE the code must obey (constraint), or just describe what
833
+ the code does (descriptive)?
834
+ Step 3: default to descriptive when in doubt — false-positive DEC creation
835
+ pollutes ground state worse than missed capture.
836
+
837
+ Final verdict JSON: { "verdict": "decision" | "constraint" | "descriptive" | "ambiguous" }.`;
838
+ async function runCreationJudgePass2(args) {
839
+ if (args.mock !== undefined) {
840
+ return args.mock({
841
+ blockBody: args.blockBody,
842
+ blockContext: args.blockContext,
843
+ file: args.file,
844
+ line: args.line,
845
+ });
846
+ }
847
+ const a = capBody(args.blockBody);
848
+ const ctx = args.blockContext.trim();
849
+ const prompt = [
850
+ `Block at ${args.file}:${args.line}:`,
851
+ a,
852
+ "",
853
+ "---surrounding source context---",
854
+ ctx.length > 0 ? ctx : "(no surrounding context available)",
855
+ "",
856
+ "Step 1: list explicit decision/constraint verbs in the block.",
857
+ "Step 2: choice / rule / descriptive?",
858
+ "Step 3: default to descriptive when in doubt.",
859
+ "",
860
+ "Final verdict: decision | constraint | descriptive | ambiguous.",
861
+ ].join("\n");
862
+ try {
863
+ const result = await runClaude({
864
+ tier: "haiku",
865
+ system: CREATION_P2_SYSTEM,
866
+ prompt,
867
+ jsonSchema: CREATION_P2_SCHEMA,
868
+ timeoutMs: PER_HAIKU_TIMEOUT_MS,
869
+ isolateAmbientContext: true,
870
+ });
871
+ const parsed = result.parsed;
872
+ if (typeof parsed !== "object" || parsed === null)
873
+ return "descriptive";
874
+ const v = parsed["verdict"];
875
+ if (v === "decision" ||
876
+ v === "constraint" ||
877
+ v === "descriptive" ||
878
+ v === "ambiguous") {
879
+ return v;
880
+ }
881
+ return "descriptive";
882
+ }
883
+ catch (err) {
884
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "creation judge pass-2 failed; treating as descriptive");
885
+ return "descriptive";
886
+ }
887
+ }
888
+ function capBody(body) {
889
+ return body.length > BLOCK_BODY_CAP
890
+ ? `${body.slice(0, BLOCK_BODY_CAP)}\n…[truncated]`
891
+ : body;
892
+ }
893
+ function readFileMaybe(repoRoot, filePath) {
894
+ const abs = join(repoRoot, filePath);
895
+ if (!existsSync(abs))
896
+ return "";
897
+ try {
898
+ return readFileSync(abs, "utf8");
899
+ }
900
+ catch {
901
+ return "";
902
+ }
903
+ }
904
+ function surroundingContext(fileSource, startOffset, endOffset) {
905
+ if (fileSource.length === 0)
906
+ return "";
907
+ const ctxStart = Math.max(0, startOffset - SOURCE_CONTEXT_RADIUS);
908
+ const ctxEnd = Math.min(fileSource.length, endOffset + SOURCE_CONTEXT_RADIUS);
909
+ return fileSource.slice(ctxStart, ctxEnd);
910
+ }
911
+ async function emitLedgerEntity(args) {
912
+ const { repoRoot, block, kind } = args;
913
+ const isDec = kind === "decision";
914
+ const inputs = {
915
+ source_file: block.file,
916
+ source_offset: block.startLine,
917
+ capture_source: CAPTURE_SOURCE,
918
+ };
919
+ const id = isDec ? deriveLedgerDecId(inputs) : deriveLedgerInvId(inputs);
920
+ const now = new Date().toISOString();
921
+ const title = firstLine(block.prose);
922
+ const fm = {
923
+ id,
924
+ title,
925
+ type: isDec ? "adr" : "invariant",
926
+ status: isDec ? "accepted" : "active",
927
+ audience: "dual",
928
+ generated: now,
929
+ "verified-at": now,
930
+ sot_kind: "ledger",
931
+ sot_path: "ledger",
932
+ sot_content_hash: bodyContentHash(block.prose),
933
+ capture_source: CAPTURE_SOURCE,
934
+ source_file: block.file,
935
+ };
936
+ if (isDec) {
937
+ fm["decided_at"] = now;
938
+ fm["decided_by"] = "cairn-layer-a";
939
+ }
940
+ const dir = isDec ? decisionsDir(repoRoot) : invariantsDir(repoRoot);
941
+ const abs = join(dir, `${id}.md`);
942
+ if (existsSync(abs)) {
943
+ // Idempotent — same source location keeps producing the same id.
944
+ return { id, kind: isDec ? "DEC" : "INV" };
945
+ }
946
+ try {
947
+ await withWriteLock(repoRoot, () => {
948
+ mkdirSync(dir, { recursive: true });
949
+ const out = `---\n${stringifyYaml(fm).trimEnd()}\n---\n\n${block.prose.trim()}\n`;
950
+ writeFileSync(abs, out, "utf8");
951
+ // Bind sot-path → id + cache tokens for future Layer A passes.
952
+ const bindings = readSotBindings(repoRoot);
953
+ const updatedBindings = bindDec(bindings, id, "ledger");
954
+ updatedBindings.generated = new Date().toISOString();
955
+ writeSotBindings(repoRoot, updatedBindings);
956
+ const cache = readSotCache(repoRoot);
957
+ const updatedCache = setSotCacheEntry(cache, id, {
958
+ dec_id: id,
959
+ sot_path: "ledger",
960
+ body_hash: bodyContentHash(block.prose),
961
+ tokens: Array.from(tokenize(block.prose, { codeAware: true })),
962
+ shingles: [],
963
+ mtime_ms: Date.now(),
964
+ });
965
+ updatedCache.generated = new Date().toISOString();
966
+ writeSotCache(repoRoot, updatedCache);
967
+ // Topic-index entry so phase 5b sees this slug as already emitted.
968
+ const slug = topicSlug(block.prose);
969
+ const ti = readTopicIndex(repoRoot);
970
+ const updatedTi = setTopic(ti, slug, {
971
+ slug,
972
+ dec_id: id,
973
+ sot_source: block.file,
974
+ candidates: [
975
+ {
976
+ file: block.file,
977
+ kind: "source-comment",
978
+ line_range: [block.startLine, block.endLine],
979
+ },
980
+ ],
981
+ created_at: now,
982
+ });
983
+ updatedTi.generated = now;
984
+ writeTopicIndex(repoRoot, updatedTi);
985
+ const am = readAnchorMap(repoRoot);
986
+ const baseAm = Object.keys(am.anchors).length > 0 ? am : emptyAnchorMap();
987
+ const updatedAm = setAnchor(baseAm, slug, {
988
+ file: block.file,
989
+ content_hash: bodyContentHash(block.prose),
990
+ line_range: [block.startLine, block.endLine],
991
+ kind: "source-comment",
992
+ });
993
+ updatedAm.generated = now;
994
+ writeAnchorMap(repoRoot, updatedAm);
995
+ try {
996
+ if (isDec)
997
+ writeDecisionsLedger({ repoRoot });
998
+ else
999
+ writeInvariantsLedger({ repoRoot });
1000
+ }
1001
+ catch (err) {
1002
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "ledger rebuild failed after Layer A emit");
1003
+ }
1004
+ });
1005
+ }
1006
+ catch (err) {
1007
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "Layer A ledger emit failed");
1008
+ return null;
1009
+ }
1010
+ return { id, kind: isDec ? "DEC" : "INV" };
1011
+ }
1012
+ function firstLine(text) {
1013
+ const first = text.split("\n").find((l) => l.trim().length > 0) ?? "";
1014
+ return first.replace(/^[#*\-\s>]+/, "").trim().slice(0, 120) || "(untitled)";
1015
+ }
1016
+ function readVerdictCache(repoRoot, scope, blockBody, scopeKey) {
1017
+ const path = verdictCachePath(repoRoot, scope, blockBody, scopeKey);
1018
+ if (!existsSync(path))
1019
+ return null;
1020
+ try {
1021
+ const raw = readFileSync(path, "utf8");
1022
+ const parsed = JSON.parse(raw);
1023
+ return typeof parsed.verdict === "string" ? parsed.verdict : null;
1024
+ }
1025
+ catch {
1026
+ return null;
1027
+ }
1028
+ }
1029
+ function writeVerdictCache(repoRoot, scope, blockBody, scopeKey, verdict) {
1030
+ const path = verdictCachePath(repoRoot, scope, blockBody, scopeKey);
1031
+ try {
1032
+ writeFileSafe(path, JSON.stringify({ verdict, ts: new Date().toISOString() }, null, 2));
1033
+ }
1034
+ catch {
1035
+ /* best-effort */
1036
+ }
1037
+ }
1038
+ function verdictCachePath(repoRoot, scope, blockBody, scopeKey) {
1039
+ const blockHash = createHash("sha256").update(blockBody, "utf8").digest("hex").slice(0, 12);
1040
+ return join(repoRoot, ".cairn", "cache", "haiku", scope, `${blockHash}-${scopeKey}.json`);
1041
+ }
1042
+ /* -------------------------------------------------------------------------- */
1043
+ /* Staleness defer + statusline */
1044
+ /* -------------------------------------------------------------------------- */
1045
+ function deferToStaleness(repoRoot, block, reason) {
1046
+ try {
1047
+ recordDriftEvent(repoRoot, {
1048
+ ts: new Date().toISOString(),
1049
+ kind: "doc-drift",
1050
+ path: block.file,
1051
+ detail: `Layer A deferred block at ${block.file}:${block.startLine}-${block.endLine}; reason=${reason}`,
1052
+ severity: "soft",
1053
+ });
1054
+ // Append the verbatim block + reason to a Layer-A-specific JSONL so
1055
+ // Layer C can pick it up without re-walking the file.
1056
+ const path = join(repoRoot, ".cairn", "staleness", "layer-a-deferred.jsonl");
1057
+ mkdirSync(dirname(path), { recursive: true });
1058
+ appendFileSync(path, `${JSON.stringify({
1059
+ ts: new Date().toISOString(),
1060
+ file: block.file,
1061
+ startLine: block.startLine,
1062
+ endLine: block.endLine,
1063
+ startOffset: block.startOffset,
1064
+ endOffset: block.endOffset,
1065
+ prose: block.prose,
1066
+ reason,
1067
+ })}\n`, "utf8");
1068
+ }
1069
+ catch (err) {
1070
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "staleness defer write failed");
1071
+ }
1072
+ }
1073
+ function pushAlignBlip(repoRoot, sessionId, decId, kind) {
1074
+ if (sessionId === null)
1075
+ return;
1076
+ try {
1077
+ pushEvent(repoRoot, sessionId, { kind, primary_id: decId });
1078
+ }
1079
+ catch {
1080
+ /* best-effort */
1081
+ }
1082
+ }
1083
+ function emitShapeB(additionalContext) {
1084
+ const out = {
1085
+ continue: true,
1086
+ hookSpecificOutput: {
1087
+ hookEventName: "PostToolUse",
1088
+ additionalContext,
1089
+ },
1090
+ };
1091
+ process.stdout.write(JSON.stringify(out));
1092
+ process.stdout.write("\n");
1093
+ }
1094
+ function parsePayload(text) {
1095
+ if (text.trim().length === 0)
1096
+ return {};
1097
+ try {
1098
+ const parsed = JSON.parse(text);
1099
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
1100
+ }
1101
+ catch {
1102
+ return {};
1103
+ }
1104
+ }
1105
+ function computeRelPath(repoRoot, filePath) {
1106
+ const rel = relative(repoRoot, filePath);
1107
+ if (rel.startsWith("..") || rel.length === 0)
1108
+ return filePath;
1109
+ return rel.replace(/\\/g, "/");
1110
+ }
1111
+ function summarize(result) {
1112
+ const parts = [];
1113
+ if (result.tier1Aligned > 0)
1114
+ parts.push(`tier1=${result.tier1Aligned}`);
1115
+ if (result.tier2Aligned > 0)
1116
+ parts.push(`tier2=${result.tier2Aligned}`);
1117
+ if (result.decsCreated > 0)
1118
+ parts.push(`decs=${result.decsCreated}`);
1119
+ if (result.invsCreated > 0)
1120
+ parts.push(`invs=${result.invsCreated}`);
1121
+ if (result.deferredToStaleness > 0)
1122
+ parts.push(`deferred=${result.deferredToStaleness}`);
1123
+ if (parts.length === 0)
1124
+ return "";
1125
+ return `cairn:sot-align — ${parts.join(" · ")}`;
1126
+ }
1127
+ export async function runSotAlign() {
1128
+ try {
1129
+ const raw = await readHookStdin();
1130
+ const payload = parsePayload(raw);
1131
+ const tool = payload.tool_name;
1132
+ if (tool !== "Write" && tool !== "Edit") {
1133
+ emitShapeB("");
1134
+ return;
1135
+ }
1136
+ const filePath = payload.tool_input?.file_path;
1137
+ if (typeof filePath !== "string" || filePath.length === 0) {
1138
+ emitShapeB("");
1139
+ return;
1140
+ }
1141
+ const cwd = typeof payload.cwd === "string" && payload.cwd.length > 0 ? payload.cwd : process.cwd();
1142
+ const repoRoot = resolveRepoRoot(cwd);
1143
+ if (repoRoot === null) {
1144
+ emitShapeB("");
1145
+ return;
1146
+ }
1147
+ const relPath = computeRelPath(repoRoot, filePath);
1148
+ // Skip cairn's own state surface — strip-replace + ledger writes
1149
+ // would re-trigger the hook in a loop.
1150
+ if (relPath === ".cairn" || relPath.startsWith(".cairn/")) {
1151
+ emitShapeB("");
1152
+ return;
1153
+ }
1154
+ // Skip files outside the repo root.
1155
+ if (relPath.startsWith("../")) {
1156
+ emitShapeB("");
1157
+ return;
1158
+ }
1159
+ const sessionId = typeof payload.session_id === "string" && payload.session_id.length > 0
1160
+ ? payload.session_id
1161
+ : null;
1162
+ const result = await alignFile({ repoRoot, filePath: relPath, sessionId });
1163
+ emitShapeB(summarize(result));
1164
+ }
1165
+ catch (err) {
1166
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "Layer A hook failed; degrading to no-op");
1167
+ emitShapeB("");
1168
+ }
1169
+ }
1170
+ async function emitAugmentSibling(args) {
1171
+ const { repoRoot, block, delta, deltaKind, existingId } = args;
1172
+ const isInv = deltaKind === "constraint";
1173
+ // Augments sibling id is keyed on (existingId, source location) so the
1174
+ // same delta firing twice (re-run) hits the same id.
1175
+ const inputs = {
1176
+ source_file: block.file,
1177
+ source_offset: block.startLine,
1178
+ capture_source: `${CAPTURE_SOURCE}-augments-${existingId}`,
1179
+ };
1180
+ const id = isInv ? deriveLedgerInvId(inputs) : deriveLedgerDecId(inputs);
1181
+ const now = new Date().toISOString();
1182
+ const trimmedDelta = delta.trim();
1183
+ const title = firstLine(trimmedDelta);
1184
+ const fm = {
1185
+ id,
1186
+ title,
1187
+ type: isInv ? "invariant" : "adr",
1188
+ status: isInv ? "active" : "accepted",
1189
+ audience: "dual",
1190
+ generated: now,
1191
+ "verified-at": now,
1192
+ sot_kind: "ledger",
1193
+ sot_path: "ledger",
1194
+ sot_content_hash: bodyContentHash(trimmedDelta),
1195
+ capture_source: CAPTURE_SOURCE,
1196
+ source_file: block.file,
1197
+ };
1198
+ if (isInv) {
1199
+ // INV augments derives_from the existing target — frontmatter plan §3.2.
1200
+ fm["derived_from"] = existingId;
1201
+ }
1202
+ else {
1203
+ // DEC augments relates to the existing target.
1204
+ fm["related"] = existingId;
1205
+ }
1206
+ if (!isInv) {
1207
+ fm["decided_at"] = now;
1208
+ fm["decided_by"] = "cairn-layer-a-augments";
1209
+ }
1210
+ const dir = isInv ? invariantsDir(repoRoot) : decisionsDir(repoRoot);
1211
+ const abs = join(dir, `${id}.md`);
1212
+ if (existsSync(abs)) {
1213
+ return { id, kind: isInv ? "INV" : "DEC" };
1214
+ }
1215
+ try {
1216
+ await withWriteLock(repoRoot, () => {
1217
+ mkdirSync(dir, { recursive: true });
1218
+ const out = `---\n${stringifyYaml(fm).trimEnd()}\n---\n\n${trimmedDelta}\n`;
1219
+ writeFileSync(abs, out, "utf8");
1220
+ // Bind new id → ledger.
1221
+ const bindings = readSotBindings(repoRoot);
1222
+ const updatedBindings = bindDec(bindings, id, "ledger");
1223
+ updatedBindings.generated = new Date().toISOString();
1224
+ writeSotBindings(repoRoot, updatedBindings);
1225
+ // Append delta tokens to sot-cache so the augments DEC/INV is
1226
+ // visible to subsequent Tier 1/2 passes within the same run.
1227
+ const cache = readSotCache(repoRoot);
1228
+ const updatedCache = setSotCacheEntry(cache, id, {
1229
+ dec_id: id,
1230
+ sot_path: "ledger",
1231
+ body_hash: bodyContentHash(trimmedDelta),
1232
+ tokens: Array.from(tokenize(trimmedDelta, { codeAware: true })),
1233
+ shingles: [],
1234
+ mtime_ms: Date.now(),
1235
+ });
1236
+ updatedCache.generated = new Date().toISOString();
1237
+ writeSotCache(repoRoot, updatedCache);
1238
+ // Topic-index entry — distinct slug from existing target's body.
1239
+ const slug = topicSlug(trimmedDelta);
1240
+ const ti = readTopicIndex(repoRoot);
1241
+ const updatedTi = setTopic(ti, slug, {
1242
+ slug,
1243
+ dec_id: id,
1244
+ sot_source: block.file,
1245
+ candidates: [
1246
+ {
1247
+ file: block.file,
1248
+ kind: "source-comment",
1249
+ line_range: [block.startLine, block.endLine],
1250
+ },
1251
+ ],
1252
+ created_at: now,
1253
+ });
1254
+ updatedTi.generated = now;
1255
+ writeTopicIndex(repoRoot, updatedTi);
1256
+ const am = readAnchorMap(repoRoot);
1257
+ const baseAm = Object.keys(am.anchors).length > 0 ? am : emptyAnchorMap();
1258
+ const updatedAm = setAnchor(baseAm, slug, {
1259
+ file: block.file,
1260
+ content_hash: bodyContentHash(trimmedDelta),
1261
+ line_range: [block.startLine, block.endLine],
1262
+ kind: "source-comment",
1263
+ });
1264
+ updatedAm.generated = now;
1265
+ writeAnchorMap(repoRoot, updatedAm);
1266
+ try {
1267
+ if (isInv)
1268
+ writeInvariantsLedger({ repoRoot });
1269
+ else
1270
+ writeDecisionsLedger({ repoRoot });
1271
+ }
1272
+ catch (err) {
1273
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "ledger rebuild failed after augments emit");
1274
+ }
1275
+ });
1276
+ }
1277
+ catch (err) {
1278
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "augments sibling emit failed");
1279
+ return null;
1280
+ }
1281
+ return { id, kind: isInv ? "INV" : "DEC" };
1282
+ }
1283
+ /**
1284
+ * Augments cite preserves the existing § token and adds the sibling
1285
+ * cite alongside it. Replacement collapses the original block to two
1286
+ * stacked cite lines so both DEC bodies render at this site.
1287
+ */
1288
+ function buildAugmentCiteItem(block, existingId, newId) {
1289
+ const a = formatBareCitation(block.lang, existingId);
1290
+ const b = formatBareCitation(block.lang, newId);
1291
+ return {
1292
+ blockId: block.id,
1293
+ file: block.file,
1294
+ startOffset: block.startOffset,
1295
+ endOffset: block.endOffset,
1296
+ replacement: `${a}\n${b}`,
1297
+ expectedRaw: block.raw,
1298
+ };
1299
+ }
1300
+ /* -------------------------------------------------------------------------- */
1301
+ /* Alignment-pending queue — shared with Layer C drain via ground module */
1302
+ /* -------------------------------------------------------------------------- */
1303
+ // `writeAlignmentPending` lives in ground/alignment-pending.ts so both
1304
+ // the Layer A hook (here) and the Layer C SessionStart drain can write
1305
+ // to the same attention surface with the same on-disk shape.
1306
+ //# sourceMappingURL=sot-align.js.map