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