@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
@@ -20,12 +20,17 @@
20
20
  * | invalidation_event | c | abort — caller archives task (no-op here) |
21
21
  * | drift | a/b/c | acknowledged (drift surface not yet active) |
22
22
  */
23
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
23
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
24
24
  import { dirname, join } from "node:path";
25
+ import { writeFileSafe } from "../../fs.js";
26
+ import { createHash } from "node:crypto";
27
+ import { stringify as stringifyYaml } from "yaml";
25
28
  import { parseDraftMeta, restoreDec, runDecSourceStrip, } from "../../attention/index.js";
26
29
  import { writeInvalidationEvent } from "../../events/index.js";
27
- import { decisionsDir } from "../../ground/index.js";
28
- import { writeDecisionsLedger } from "../../ground/ledgers.js";
30
+ import { alignmentPendingDir, archivedConflictsDir, bindDec, bodyContentHash, conflictsDir, decisionsDir, deleteSotCacheEntry, deriveLedgerDecId, deriveLedgerInvId, invariantsDir, parseFrontmatterRecord, readSotBindings, readSotCache, recordDriftEvent, setSotCacheEntry, unbindDec, writeSotBindings, writeSotCache, } from "../../ground/index.js";
31
+ import { tokenize } from "../../text/jaccard.js";
32
+ import { applyStripReplace, formatBareCitation, } from "../../init/source-comments/index.js";
33
+ import { writeDecisionsLedger, writeInvariantsLedger } from "../../ground/ledgers.js";
29
34
  import { clearDeferState, writeDeferState, } from "../../hooks/defer.js";
30
35
  import { withWriteLock } from "../../lock.js";
31
36
  import { logger } from "../../logger.js";
@@ -37,6 +42,12 @@ async function handler(ctx, input) {
37
42
  const block = requireBootstrap(ctx.repoRoot);
38
43
  if (block !== null)
39
44
  return block;
45
+ // The fourth choice slot is only meaningful for conflict resolution.
46
+ // Reject `d` on every other kind so the schema's permissive enum
47
+ // doesn't quietly fall through.
48
+ if (input.choice === "d" && input.kind !== "conflict") {
49
+ return mcpError("VALIDATION_FAILED", `choice "d" is only valid for kind=conflict, got kind=${input.kind}`);
50
+ }
40
51
  switch (input.kind) {
41
52
  case "decision_draft":
42
53
  return resolveDecisionDraft(ctx, input);
@@ -54,6 +65,10 @@ async function handler(ctx, input) {
54
65
  return resolveBypass(ctx, input);
55
66
  case "review":
56
67
  return resolveReview(ctx, input);
68
+ case "conflict":
69
+ return resolveConflict(ctx, input);
70
+ case "alignment_pending":
71
+ return resolveAlignmentPending(ctx, input);
57
72
  }
58
73
  }
59
74
  function resolveBypass(ctx, input) {
@@ -284,15 +299,776 @@ function resolveBaselineFinding(ctx, input) {
284
299
  };
285
300
  });
286
301
  }
302
+ const CONFLICT_ID_RE = /^(DEC|INV)-[0-9a-f]{7,}$/;
303
+ const CONFLICT_PAIR_RE = /^((DEC|INV)-[0-9a-f]{7,})__((DEC|INV)-[0-9a-f]{7,})$/;
304
+ function entityRefFor(repoRoot, id) {
305
+ if (id.startsWith("INV-")) {
306
+ const abs = join(invariantsDir(repoRoot), `${id}.md`);
307
+ return { id, kind: "INV", abs, rel: `.cairn/ground/invariants/${id}.md` };
308
+ }
309
+ const abs = join(decisionsDir(repoRoot), `${id}.md`);
310
+ return { id, kind: "DEC", abs, rel: `.cairn/ground/decisions/${id}.md` };
311
+ }
312
+ function parseConflictFile(repoRoot, itemId) {
313
+ if (!CONFLICT_PAIR_RE.test(itemId))
314
+ return null;
315
+ const dir = conflictsDir(repoRoot);
316
+ const filename = `${itemId}.md`;
317
+ const abs = join(dir, filename);
318
+ if (!existsSync(abs))
319
+ return null;
320
+ let raw;
321
+ try {
322
+ raw = readFileSync(abs, "utf8");
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ const { fm, body } = parseFrontmatterRecord(raw);
328
+ const aId = String(fm["a_id"] ?? itemId.split("__")[0] ?? "");
329
+ const bId = String(fm["b_id"] ?? itemId.split("__")[1] ?? "");
330
+ if (!CONFLICT_ID_RE.test(aId) || !CONFLICT_ID_RE.test(bId))
331
+ return null;
332
+ return {
333
+ abs,
334
+ rel: `.cairn/ground/conflicts/${filename}`,
335
+ filename,
336
+ aRef: entityRefFor(repoRoot, aId),
337
+ bRef: entityRefFor(repoRoot, bId),
338
+ fm,
339
+ body,
340
+ };
341
+ }
342
+ function readEntity(ref) {
343
+ if (!existsSync(ref.abs))
344
+ return null;
345
+ let raw;
346
+ try {
347
+ raw = readFileSync(ref.abs, "utf8");
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ const { fm, body } = parseFrontmatterRecord(raw);
353
+ return { fm, body, raw };
354
+ }
355
+ function writeEntity(ref, fm, body) {
356
+ const content = `---\n${stringifyYaml(fm).trimEnd()}\n---\n${body.startsWith("\n") ? body : `\n${body}`}`;
357
+ writeFileSync(ref.abs, content, "utf8");
358
+ }
359
+ function setSupersededBy(repoRoot, loser, winnerId, status) {
360
+ const parsed = readEntity(loser);
361
+ if (parsed === null)
362
+ return false;
363
+ parsed.fm["status"] = status;
364
+ if (status === "superseded")
365
+ parsed.fm["superseded_by"] = winnerId;
366
+ parsed.fm["verified-at"] = new Date().toISOString();
367
+ writeEntity(loser, parsed.fm, parsed.body);
368
+ return true;
369
+ }
370
+ function setSupersedes(loser, winner) {
371
+ const parsed = readEntity(winner);
372
+ if (parsed === null)
373
+ return false;
374
+ parsed.fm["supersedes"] = loser.id;
375
+ parsed.fm["verified-at"] = new Date().toISOString();
376
+ writeEntity(winner, parsed.fm, parsed.body);
377
+ return true;
378
+ }
379
+ function moveConflictToArchive(repoRoot, conflict) {
380
+ const archDir = archivedConflictsDir(repoRoot);
381
+ mkdirSync(archDir, { recursive: true });
382
+ const archAbs = join(archDir, conflict.filename);
383
+ renameSync(conflict.abs, archAbs);
384
+ return `.cairn/ground/conflicts/_archived/${conflict.filename}`;
385
+ }
386
+ function deleteConflictFile(conflict) {
387
+ try {
388
+ rmSync(conflict.abs, { force: true });
389
+ }
390
+ catch {
391
+ /* best-effort */
392
+ }
393
+ }
394
+ /**
395
+ * Plan §5.4.1 — losing-side prose stays in its source file
396
+ * post-resolution; the doc / CLAUDE.md / AGENTS.md narrative is
397
+ * preserved as-is. The original sot_path entry is now bound to a
398
+ * superseded / archived id, so phase 5b's next walk would re-emit a
399
+ * fresh DEC with the same content-addressed id (loop). Recording an
400
+ * `orphan_path` drift event surfaces the prose to the operator's
401
+ * attention queue so they can pick: re-cite the winner manually,
402
+ * promote it to a fresh DEC, or delete the orphan paragraph.
403
+ *
404
+ * The drift event includes `dec_id` pointing at the just-superseded
405
+ * entity so the attention surface can render context (which side won,
406
+ * what the orphan body looks like).
407
+ */
408
+ function recordOrphanDriftEvents(repoRoot, refs) {
409
+ const ts = new Date().toISOString();
410
+ for (const { ref, parsed } of refs) {
411
+ if (parsed === null)
412
+ continue;
413
+ const sotKind = parsed.fm["sot_kind"];
414
+ if (sotKind !== "path")
415
+ continue;
416
+ const sotPath = String(parsed.fm["sot_path"] ?? "");
417
+ if (sotPath.length === 0 || sotPath === "ledger")
418
+ continue;
419
+ try {
420
+ recordDriftEvent(repoRoot, {
421
+ ts,
422
+ kind: "orphan_path",
423
+ path: sotPath,
424
+ detail: `Conflict resolution superseded ${ref.id}; losing-side prose still lives at ${sotPath}.`,
425
+ severity: "soft",
426
+ dec_id: ref.id,
427
+ });
428
+ }
429
+ catch (err) {
430
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "orphan_path drift event write failed");
431
+ }
432
+ }
433
+ }
434
+ function rebuildLedgers(repoRoot) {
435
+ try {
436
+ writeDecisionsLedger({ repoRoot });
437
+ }
438
+ catch (err) {
439
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "decisions ledger rebuild failed after conflict resolution");
440
+ }
441
+ try {
442
+ writeInvariantsLedger({ repoRoot });
443
+ }
444
+ catch (err) {
445
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "invariants ledger rebuild failed after conflict resolution");
446
+ }
447
+ }
448
+ /**
449
+ * Drop superseded / archived losers from sot-bindings + sot-cache so
450
+ * Layer A's pre-filter doesn't pick them as candidates and phase 5b
451
+ * doesn't loop on a path bound to a now-superseded id. The orphan_path
452
+ * drift event (recorded separately for sot_kind="path" losers) remains
453
+ * the operator-facing surface to recover the orphaned prose.
454
+ */
455
+ function cleanLosersFromSotState(repoRoot, losers) {
456
+ let bindings = readSotBindings(repoRoot);
457
+ let cache = readSotCache(repoRoot);
458
+ let mutated = false;
459
+ for (const loser of losers) {
460
+ const nextBindings = unbindDec(bindings, loser.id);
461
+ if (nextBindings !== bindings) {
462
+ bindings = nextBindings;
463
+ mutated = true;
464
+ }
465
+ const nextCache = deleteSotCacheEntry(cache, loser.id);
466
+ if (nextCache !== cache) {
467
+ cache = nextCache;
468
+ mutated = true;
469
+ }
470
+ }
471
+ if (!mutated)
472
+ return;
473
+ try {
474
+ writeSotBindings(repoRoot, bindings);
475
+ }
476
+ catch (err) {
477
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "sot-bindings rewrite failed during conflict resolution cleanup");
478
+ }
479
+ try {
480
+ writeSotCache(repoRoot, cache);
481
+ }
482
+ catch (err) {
483
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "sot-cache rewrite failed during conflict resolution cleanup");
484
+ }
485
+ }
486
+ /**
487
+ * Bind + cache a merged entity that was just written to ground. Mirrors
488
+ * the bind/cache wiring in `emitFreshDec` so Layer A's pre-filter sees
489
+ * the merged DEC as a Tier-2 candidate immediately, instead of waiting
490
+ * for the next SessionStart drain to rebuild sot-cache.
491
+ */
492
+ function bindAndCacheMergedEntity(repoRoot, mergedId, mergedBody) {
493
+ let bindings = readSotBindings(repoRoot);
494
+ bindings = bindDec(bindings, mergedId, "ledger");
495
+ try {
496
+ writeSotBindings(repoRoot, bindings);
497
+ }
498
+ catch (err) {
499
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "sot-bindings rewrite failed for merged entity");
500
+ }
501
+ let cache = readSotCache(repoRoot);
502
+ cache = setSotCacheEntry(cache, mergedId, {
503
+ dec_id: mergedId,
504
+ sot_path: "ledger",
505
+ body_hash: bodyContentHash(mergedBody),
506
+ tokens: Array.from(tokenize(mergedBody, { codeAware: true })),
507
+ shingles: [],
508
+ mtime_ms: Date.now(),
509
+ });
510
+ try {
511
+ writeSotCache(repoRoot, cache);
512
+ }
513
+ catch (err) {
514
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "sot-cache rewrite failed for merged entity");
515
+ }
516
+ }
517
+ function loadAlignmentPending(repoRoot, itemId) {
518
+ const dir = alignmentPendingDir(repoRoot);
519
+ const filename = `${itemId}.md`;
520
+ const abs = join(dir, filename);
521
+ if (!existsSync(abs))
522
+ return null;
523
+ let raw;
524
+ try {
525
+ raw = readFileSync(abs, "utf8");
526
+ }
527
+ catch {
528
+ return null;
529
+ }
530
+ const { fm, body } = parseFrontmatterRecord(raw);
531
+ // Block prose lives between the first ```/``` fence pair under the
532
+ // "## Block ..." heading. Pick the first fenced block.
533
+ const blockMatch = body.match(/##\s+Block[^\n]*\n+```\n([\s\S]*?)\n```/);
534
+ const blockProse = blockMatch?.[1]?.trim() ?? "";
535
+ // Existing entity body (tier2-ambiguous only) lives in a second fenced block.
536
+ const existingId = typeof fm["existing_id"] === "string" ? fm["existing_id"] : null;
537
+ let existingBody = null;
538
+ if (existingId !== null) {
539
+ const existingMatch = body.match(/##\s+Existing\s+\S+[^\n]*\n+```\n([\s\S]*?)\n```/);
540
+ existingBody = existingMatch?.[1]?.trim() ?? null;
541
+ }
542
+ return {
543
+ abs,
544
+ rel: `.cairn/ground/alignment-pending/${filename}`,
545
+ filename,
546
+ fm,
547
+ blockProse,
548
+ existingId,
549
+ existingBody,
550
+ };
551
+ }
552
+ function buildPendingReplaceItem(fm, rawProse, replacement) {
553
+ const file = typeof fm["source_file"] === "string" ? fm["source_file"] : null;
554
+ const startOffset = typeof fm["start_offset"] === "number" ? fm["start_offset"] : null;
555
+ const endOffset = typeof fm["end_offset"] === "number" ? fm["end_offset"] : null;
556
+ if (file === null || startOffset === null || endOffset === null)
557
+ return null;
558
+ return {
559
+ blockId: typeof fm["slug"] === "string" ? `pending:${fm["slug"]}` : "pending:unknown",
560
+ file,
561
+ startOffset,
562
+ endOffset,
563
+ replacement,
564
+ expectedRaw: typeof fm["raw"] === "string" ? fm["raw"] : rawProse,
565
+ };
566
+ }
567
+ async function resolveAlignmentPending(ctx, input) {
568
+ const state = loadAlignmentPending(ctx.repoRoot, input.item_id);
569
+ if (state === null) {
570
+ return mcpError("FILE_NOT_FOUND", `no alignment-pending file for item_id=${input.item_id}`);
571
+ }
572
+ const kind = String(state.fm["kind"] ?? "");
573
+ const lang = typeof state.fm["lang"] === "string" ? state.fm["lang"] : "unknown";
574
+ const sourceFile = typeof state.fm["source_file"] === "string" ? state.fm["source_file"] : "";
575
+ const startLine = typeof state.fm["start_line"] === "number" ? state.fm["start_line"] : 0;
576
+ return withWriteLock(ctx.repoRoot, () => {
577
+ if (kind === "tier2-ambiguous") {
578
+ if (state.existingId === null) {
579
+ return mcpError("VALIDATION_FAILED", "tier2-ambiguous pending entry missing existing_id");
580
+ }
581
+ if (input.choice === "a") {
582
+ // Same — cite existing.
583
+ const replacement = formatBareCitation(lang, state.existingId);
584
+ const item = buildPendingReplaceItem(state.fm, state.blockProse, replacement);
585
+ if (item !== null)
586
+ applyStripReplace({
587
+ repoRoot: ctx.repoRoot,
588
+ items: [item],
589
+ dirtyDecisions: { [item.file]: "overwrite" },
590
+ });
591
+ rmSync(state.abs, { force: true });
592
+ return {
593
+ ok: true,
594
+ resolved_kind: "alignment_cite",
595
+ item_id: input.item_id,
596
+ existing_id: state.existingId,
597
+ };
598
+ }
599
+ if (input.choice === "b") {
600
+ // Augments — emit sibling DEC linked via `related`. Source gets
601
+ // both cites stacked. (Operator-driven augments emit a DEC
602
+ // sibling; constraint augments still flow through the Layer A
603
+ // delta classifier on a future Write.)
604
+ const id = emitOperatorAugmentSibling(ctx.repoRoot, {
605
+ source_file: sourceFile,
606
+ source_offset: startLine,
607
+ existingId: state.existingId,
608
+ delta: state.blockProse,
609
+ rationale: input.rationale ?? "",
610
+ });
611
+ const replacement = formatBareCitation(lang, state.existingId) +
612
+ "\n" +
613
+ formatBareCitation(lang, id);
614
+ const item = buildPendingReplaceItem(state.fm, state.blockProse, replacement);
615
+ if (item !== null)
616
+ applyStripReplace({
617
+ repoRoot: ctx.repoRoot,
618
+ items: [item],
619
+ dirtyDecisions: { [item.file]: "overwrite" },
620
+ });
621
+ rmSync(state.abs, { force: true });
622
+ try {
623
+ writeDecisionsLedger({ repoRoot: ctx.repoRoot });
624
+ }
625
+ catch {
626
+ /* best-effort */
627
+ }
628
+ return {
629
+ ok: true,
630
+ resolved_kind: "alignment_augments",
631
+ item_id: input.item_id,
632
+ existing_id: state.existingId,
633
+ new_id: id,
634
+ };
635
+ }
636
+ if (input.choice === "c") {
637
+ // New decision — emit fresh DEC, source carries new cite only.
638
+ const id = emitFreshDec(ctx.repoRoot, {
639
+ source_file: sourceFile,
640
+ source_offset: startLine,
641
+ body: state.blockProse,
642
+ captureSuffix: "operator-new",
643
+ related: null,
644
+ });
645
+ const replacement = formatBareCitation(lang, id);
646
+ const item = buildPendingReplaceItem(state.fm, state.blockProse, replacement);
647
+ if (item !== null)
648
+ applyStripReplace({
649
+ repoRoot: ctx.repoRoot,
650
+ items: [item],
651
+ dirtyDecisions: { [item.file]: "overwrite" },
652
+ });
653
+ rmSync(state.abs, { force: true });
654
+ try {
655
+ writeDecisionsLedger({ repoRoot: ctx.repoRoot });
656
+ }
657
+ catch {
658
+ /* best-effort */
659
+ }
660
+ return {
661
+ ok: true,
662
+ resolved_kind: "alignment_new",
663
+ item_id: input.item_id,
664
+ new_id: id,
665
+ };
666
+ }
667
+ if (input.choice === "d") {
668
+ // Replace — new becomes canonical, existing superseded.
669
+ const id = emitFreshDec(ctx.repoRoot, {
670
+ source_file: sourceFile,
671
+ source_offset: startLine,
672
+ body: state.blockProse,
673
+ captureSuffix: "operator-replace",
674
+ related: state.existingId,
675
+ });
676
+ // Mark existing as superseded.
677
+ const existingRef = entityRefFor(ctx.repoRoot, state.existingId);
678
+ const parsed = readEntity(existingRef);
679
+ if (parsed !== null) {
680
+ parsed.fm["status"] = "superseded";
681
+ parsed.fm["superseded_by"] = id;
682
+ parsed.fm["verified-at"] = new Date().toISOString();
683
+ writeEntity(existingRef, parsed.fm, parsed.body);
684
+ }
685
+ const replacement = formatBareCitation(lang, id);
686
+ const item = buildPendingReplaceItem(state.fm, state.blockProse, replacement);
687
+ if (item !== null)
688
+ applyStripReplace({
689
+ repoRoot: ctx.repoRoot,
690
+ items: [item],
691
+ dirtyDecisions: { [item.file]: "overwrite" },
692
+ });
693
+ rmSync(state.abs, { force: true });
694
+ try {
695
+ writeDecisionsLedger({ repoRoot: ctx.repoRoot });
696
+ writeInvariantsLedger({ repoRoot: ctx.repoRoot });
697
+ }
698
+ catch {
699
+ /* best-effort */
700
+ }
701
+ return {
702
+ ok: true,
703
+ resolved_kind: "alignment_replace",
704
+ item_id: input.item_id,
705
+ new_id: id,
706
+ superseded_id: state.existingId,
707
+ };
708
+ }
709
+ }
710
+ if (kind === "tier3-ambiguous") {
711
+ if (input.choice === "a" || input.choice === "b") {
712
+ const isInv = input.choice === "b";
713
+ const id = emitFreshDec(ctx.repoRoot, {
714
+ source_file: sourceFile,
715
+ source_offset: startLine,
716
+ body: state.blockProse,
717
+ captureSuffix: isInv ? "operator-constraint" : "operator-decision",
718
+ related: null,
719
+ asInv: isInv,
720
+ });
721
+ const replacement = formatBareCitation(lang, id);
722
+ const item = buildPendingReplaceItem(state.fm, state.blockProse, replacement);
723
+ if (item !== null)
724
+ applyStripReplace({
725
+ repoRoot: ctx.repoRoot,
726
+ items: [item],
727
+ dirtyDecisions: { [item.file]: "overwrite" },
728
+ });
729
+ rmSync(state.abs, { force: true });
730
+ try {
731
+ if (isInv)
732
+ writeInvariantsLedger({ repoRoot: ctx.repoRoot });
733
+ else
734
+ writeDecisionsLedger({ repoRoot: ctx.repoRoot });
735
+ }
736
+ catch {
737
+ /* best-effort */
738
+ }
739
+ return {
740
+ ok: true,
741
+ resolved_kind: isInv ? "alignment_constraint" : "alignment_decision",
742
+ item_id: input.item_id,
743
+ new_id: id,
744
+ };
745
+ }
746
+ if (input.choice === "c" || input.choice === "d") {
747
+ // Descriptive / none-of-these — drop the pending file. Source
748
+ // stays untouched (operator's narrative preserved).
749
+ rmSync(state.abs, { force: true });
750
+ return {
751
+ ok: true,
752
+ resolved_kind: input.choice === "c" ? "alignment_descriptive" : "alignment_skip",
753
+ item_id: input.item_id,
754
+ };
755
+ }
756
+ }
757
+ return mcpError("VALIDATION_FAILED", `unsupported alignment_pending kind=${kind} or choice=${input.choice}`);
758
+ });
759
+ }
760
+ function emitFreshDec(repoRoot, args) {
761
+ const isInv = args.asInv === true;
762
+ const inputs = {
763
+ source_file: args.source_file,
764
+ source_offset: args.source_offset,
765
+ capture_source: `layer-a-resolve-${args.captureSuffix}`,
766
+ };
767
+ const id = isInv ? deriveLedgerInvId(inputs) : deriveLedgerDecId(inputs);
768
+ const dir = isInv ? invariantsDir(repoRoot) : decisionsDir(repoRoot);
769
+ const abs = join(dir, `${id}.md`);
770
+ const trimmed = args.body.trim();
771
+ const now = new Date().toISOString();
772
+ const fm = {
773
+ id,
774
+ title: firstLineOf(trimmed),
775
+ type: isInv ? "invariant" : "adr",
776
+ status: isInv ? "active" : "accepted",
777
+ audience: "dual",
778
+ generated: now,
779
+ "verified-at": now,
780
+ sot_kind: "ledger",
781
+ sot_path: "ledger",
782
+ sot_content_hash: bodyContentHash(trimmed),
783
+ capture_source: `layer-a-resolve-${args.captureSuffix}`,
784
+ source_file: args.source_file,
785
+ };
786
+ if (!isInv) {
787
+ fm["decided_at"] = now;
788
+ fm["decided_by"] = "cairn-resolve-attention";
789
+ }
790
+ if (args.related !== null)
791
+ fm["related"] = args.related;
792
+ mkdirSync(dir, { recursive: true });
793
+ writeFileSync(abs, `---\n${stringifyYaml(fm).trimEnd()}\n---\n\n${trimmed}\n`, "utf8");
794
+ let bindings = readSotBindings(repoRoot);
795
+ bindings = bindDec(bindings, id, "ledger");
796
+ writeSotBindings(repoRoot, bindings);
797
+ let cache = readSotCache(repoRoot);
798
+ cache = setSotCacheEntry(cache, id, {
799
+ dec_id: id,
800
+ sot_path: "ledger",
801
+ body_hash: bodyContentHash(trimmed),
802
+ tokens: Array.from(tokenize(trimmed, { codeAware: true })),
803
+ shingles: [],
804
+ mtime_ms: Date.now(),
805
+ });
806
+ writeSotCache(repoRoot, cache);
807
+ return id;
808
+ }
809
+ function emitOperatorAugmentSibling(repoRoot, args) {
810
+ return emitFreshDec(repoRoot, {
811
+ source_file: args.source_file,
812
+ source_offset: args.source_offset,
813
+ body: args.delta,
814
+ captureSuffix: `operator-augments-${args.existingId}`,
815
+ related: args.existingId,
816
+ });
817
+ }
818
+ function firstLineOf(text) {
819
+ const first = text.split("\n").find((l) => l.trim().length > 0) ?? "";
820
+ return first.replace(/^[#*\-\s>]+/, "").trim().slice(0, 120) || "(untitled)";
821
+ }
822
+ async function resolveConflict(ctx, input) {
823
+ const conflict = parseConflictFile(ctx.repoRoot, input.item_id);
824
+ if (conflict === null) {
825
+ return mcpError("FILE_NOT_FOUND", `no conflict file for item_id=${input.item_id} (expected .cairn/ground/conflicts/${input.item_id}.md)`);
826
+ }
827
+ return withWriteLock(ctx.repoRoot, () => {
828
+ const winner = input.choice === "a" ? conflict.aRef : conflict.bRef;
829
+ const loser = input.choice === "a" ? conflict.bRef : conflict.aRef;
830
+ if (input.choice === "a" || input.choice === "b") {
831
+ const loserBefore = readEntity(loser);
832
+ const winnerOk = setSupersedes(loser, winner);
833
+ const loserOk = setSupersededBy(ctx.repoRoot, loser, winner.id, "superseded");
834
+ if (!winnerOk || !loserOk) {
835
+ return mcpError("VALIDATION_FAILED", `conflict resolution failed: missing entity (winner=${winnerOk ? "ok" : "missing"}, loser=${loserOk ? "ok" : "missing"})`);
836
+ }
837
+ recordOrphanDriftEvents(ctx.repoRoot, [{ ref: loser, parsed: loserBefore }]);
838
+ deleteConflictFile(conflict);
839
+ cleanLosersFromSotState(ctx.repoRoot, [loser]);
840
+ rebuildLedgers(ctx.repoRoot);
841
+ try {
842
+ writeInvalidationEvent(ctx.repoRoot, {
843
+ kind: "conflict_resolved_supersede",
844
+ refs: [
845
+ { kind: winner.kind === "DEC" ? "decision" : "invariant", id: winner.id },
846
+ { kind: loser.kind === "DEC" ? "decision" : "invariant", id: loser.id },
847
+ ],
848
+ path: winner.rel,
849
+ source: { session_id: ctx.sessionId ?? null, tool: "cairn_resolve_attention" },
850
+ });
851
+ }
852
+ catch {
853
+ /* best-effort */
854
+ }
855
+ return {
856
+ ok: true,
857
+ resolved_kind: "conflict_supersede",
858
+ item_id: input.item_id,
859
+ winner_id: winner.id,
860
+ loser_id: loser.id,
861
+ winner_path: winner.rel,
862
+ loser_path: loser.rel,
863
+ ...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
864
+ };
865
+ }
866
+ if (input.choice === "c") {
867
+ const aBefore = readEntity(conflict.aRef);
868
+ const bBefore = readEntity(conflict.bRef);
869
+ const merge = mergeConflict(ctx.repoRoot, conflict, input.rationale);
870
+ if ("error" in merge)
871
+ return merge.error;
872
+ recordOrphanDriftEvents(ctx.repoRoot, [
873
+ { ref: conflict.aRef, parsed: aBefore },
874
+ { ref: conflict.bRef, parsed: bBefore },
875
+ ]);
876
+ deleteConflictFile(conflict);
877
+ cleanLosersFromSotState(ctx.repoRoot, [conflict.aRef, conflict.bRef]);
878
+ rebuildLedgers(ctx.repoRoot);
879
+ try {
880
+ writeInvalidationEvent(ctx.repoRoot, {
881
+ kind: "conflict_resolved_merge",
882
+ refs: [
883
+ { kind: "decision", id: merge.mergedId },
884
+ {
885
+ kind: conflict.aRef.kind === "DEC" ? "decision" : "invariant",
886
+ id: conflict.aRef.id,
887
+ },
888
+ {
889
+ kind: conflict.bRef.kind === "DEC" ? "decision" : "invariant",
890
+ id: conflict.bRef.id,
891
+ },
892
+ ],
893
+ path: merge.mergedRel,
894
+ source: { session_id: ctx.sessionId ?? null, tool: "cairn_resolve_attention" },
895
+ });
896
+ }
897
+ catch {
898
+ /* best-effort */
899
+ }
900
+ return {
901
+ ok: true,
902
+ resolved_kind: "conflict_merge",
903
+ item_id: input.item_id,
904
+ merged_id: merge.mergedId,
905
+ merged_path: merge.mergedRel,
906
+ superseded_a: conflict.aRef.id,
907
+ superseded_b: conflict.bRef.id,
908
+ ...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
909
+ };
910
+ }
911
+ // choice === "d" — archive both. Conflict file moves to _archived/.
912
+ const aBefore = readEntity(conflict.aRef);
913
+ const bBefore = readEntity(conflict.bRef);
914
+ const aOk = setSupersededBy(ctx.repoRoot, conflict.aRef, conflict.bRef.id, "archived");
915
+ const bOk = setSupersededBy(ctx.repoRoot, conflict.bRef, conflict.aRef.id, "archived");
916
+ recordOrphanDriftEvents(ctx.repoRoot, [
917
+ { ref: conflict.aRef, parsed: aBefore },
918
+ { ref: conflict.bRef, parsed: bBefore },
919
+ ]);
920
+ const archivedRel = moveConflictToArchive(ctx.repoRoot, conflict);
921
+ cleanLosersFromSotState(ctx.repoRoot, [conflict.aRef, conflict.bRef]);
922
+ rebuildLedgers(ctx.repoRoot);
923
+ try {
924
+ writeInvalidationEvent(ctx.repoRoot, {
925
+ kind: "conflict_resolved_archive",
926
+ refs: [
927
+ {
928
+ kind: conflict.aRef.kind === "DEC" ? "decision" : "invariant",
929
+ id: conflict.aRef.id,
930
+ },
931
+ {
932
+ kind: conflict.bRef.kind === "DEC" ? "decision" : "invariant",
933
+ id: conflict.bRef.id,
934
+ },
935
+ ],
936
+ path: archivedRel,
937
+ source: { session_id: ctx.sessionId ?? null, tool: "cairn_resolve_attention" },
938
+ });
939
+ }
940
+ catch {
941
+ /* best-effort */
942
+ }
943
+ if (!aOk || !bOk) {
944
+ log.warn({ aOk, bOk, item_id: input.item_id }, "archive-both: one or both entities missing on disk");
945
+ }
946
+ return {
947
+ ok: true,
948
+ resolved_kind: "conflict_archive",
949
+ item_id: input.item_id,
950
+ a_id: conflict.aRef.id,
951
+ b_id: conflict.bRef.id,
952
+ archived_path: archivedRel,
953
+ ...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
954
+ };
955
+ });
956
+ }
957
+ function mergeConflict(repoRoot, conflict, rationale) {
958
+ const a = readEntity(conflict.aRef);
959
+ const b = readEntity(conflict.bRef);
960
+ if (a === null || b === null) {
961
+ return {
962
+ error: mcpError("VALIDATION_FAILED", `merge requires both entities present on disk (a=${a !== null}, b=${b !== null})`),
963
+ };
964
+ }
965
+ const now = new Date().toISOString();
966
+ // Merged entity inherits the kind of A (the freshly captured side).
967
+ // Mixed DEC/INV merges produce a DEC by convention — the merged
968
+ // entity carries combined narrative, not a single hard constraint.
969
+ const mergedKind = conflict.aRef.kind === "INV" && conflict.bRef.kind === "INV" ? "INV" : "DEC";
970
+ const mergedId = synthesizeMergedId(repoRoot, mergedKind);
971
+ const mergedRel = mergedKind === "DEC"
972
+ ? `.cairn/ground/decisions/${mergedId}.md`
973
+ : `.cairn/ground/invariants/${mergedId}.md`;
974
+ const mergedAbs = join(repoRoot, mergedRel);
975
+ const titleA = String(a.fm["title"] ?? conflict.aRef.id);
976
+ const titleB = String(b.fm["title"] ?? conflict.bRef.id);
977
+ const mergedTitle = `Merged: ${titleA} + ${titleB}`;
978
+ const mergedBody = [
979
+ "",
980
+ `# ${mergedId} — ${mergedTitle}`,
981
+ "",
982
+ `## ${conflict.aRef.id} (one side of the merge)`,
983
+ "",
984
+ a.body.trim(),
985
+ "",
986
+ `## ${conflict.bRef.id} (other side of the merge)`,
987
+ "",
988
+ b.body.trim(),
989
+ "",
990
+ "## Merge rationale",
991
+ "",
992
+ rationale !== undefined && rationale.trim().length > 0
993
+ ? rationale.trim()
994
+ : "(operator merged both sides via cairn-attention conflict resolution)",
995
+ "",
996
+ ].join("\n");
997
+ const mergedFm = {
998
+ id: mergedId,
999
+ title: mergedTitle,
1000
+ type: mergedKind === "DEC" ? "adr" : "invariant",
1001
+ status: mergedKind === "DEC" ? "accepted" : "active",
1002
+ audience: "dual",
1003
+ generated: now,
1004
+ "verified-at": now,
1005
+ sot_kind: "ledger",
1006
+ sot_path: "ledger",
1007
+ sot_content_hash: bodyContentHash(mergedBody),
1008
+ capture_source: "conflict-merge",
1009
+ related: `${conflict.aRef.id},${conflict.bRef.id}`,
1010
+ };
1011
+ if (mergedKind === "DEC") {
1012
+ mergedFm["decided_at"] = now;
1013
+ mergedFm["decided_by"] = "cairn-conflict-merge";
1014
+ }
1015
+ writeFileSafe(mergedAbs, `---\n${stringifyYaml(mergedFm).trimEnd()}\n---\n${mergedBody}`);
1016
+ // Both old entries get superseded_by → new merged id.
1017
+ setSupersededBy(repoRoot, conflict.aRef, mergedId, "superseded");
1018
+ setSupersededBy(repoRoot, conflict.bRef, mergedId, "superseded");
1019
+ // Bind + cache the merged entity so Layer A picks it up on the next
1020
+ // PostToolUse without waiting for SessionStart drain.
1021
+ bindAndCacheMergedEntity(repoRoot, mergedId, mergedBody);
1022
+ return { mergedId, mergedRel };
1023
+ }
1024
+ function synthesizeMergedId(repoRoot, kind) {
1025
+ // Content-addressed style — derive from the timestamp + a counter so
1026
+ // re-runs in the same millisecond don't collide. We don't have the
1027
+ // verbatim merged-body hash easily reachable here without circular
1028
+ // dependencies; the timestamp gives us a stable-enough seed since
1029
+ // merges are operator-driven and infrequent.
1030
+ const dir = kind === "DEC" ? decisionsDir(repoRoot) : invariantsDir(repoRoot);
1031
+ const existing = new Set();
1032
+ if (existsSync(dir)) {
1033
+ try {
1034
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
1035
+ if (e.isFile() && e.name.endsWith(".md")) {
1036
+ existing.add(e.name.replace(/\.md$/, ""));
1037
+ }
1038
+ }
1039
+ }
1040
+ catch {
1041
+ /* best-effort */
1042
+ }
1043
+ }
1044
+ const seed = `merge-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
1045
+ let candidate = `${kind}-${hashHex(seed).slice(0, 7)}`;
1046
+ let suffix = 0;
1047
+ while (existing.has(candidate)) {
1048
+ suffix += 1;
1049
+ candidate = `${kind}-${hashHex(`${seed}-${suffix}`).slice(0, 7)}`;
1050
+ }
1051
+ return candidate;
1052
+ }
1053
+ function hashHex(input) {
1054
+ return createHash("sha256").update(input, "utf8").digest("hex");
1055
+ }
287
1056
  function resolveInvalidationEvent(_ctx, input) {
288
1057
  // Per spec §7: A=refresh, B=continue-under-old, C=abort. The marker
289
1058
  // stamping + scope refresh happens in the calling skill, since it
290
1059
  // owns the session id. This tool just acknowledges the resolution
291
1060
  // so the skill can record it.
292
- const map = { a: "refresh", b: "continue_under_old", c: "abort" };
1061
+ const map = {
1062
+ a: "refresh",
1063
+ b: "continue_under_old",
1064
+ c: "abort",
1065
+ };
1066
+ // The "d" slot is filtered out for non-conflict kinds in the top
1067
+ // dispatcher, so this cast is safe.
1068
+ const choice = input.choice;
293
1069
  return Promise.resolve({
294
1070
  ok: true,
295
- resolved_kind: `invalidation_${map[input.choice]}`,
1071
+ resolved_kind: `invalidation_${map[choice]}`,
296
1072
  item_id: input.item_id,
297
1073
  ...(input.rationale !== undefined ? { rationale: input.rationale } : {}),
298
1074
  });