@isaacriehm/cairn-core 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/align-undo/index.d.ts +6 -0
  3. package/dist/align-undo/index.js +6 -0
  4. package/dist/align-undo/index.js.map +1 -0
  5. package/dist/align-undo/log.d.ts +53 -0
  6. package/dist/align-undo/log.js +99 -0
  7. package/dist/align-undo/log.js.map +1 -0
  8. package/dist/align-undo/undo.d.ts +66 -0
  9. package/dist/align-undo/undo.js +187 -0
  10. package/dist/align-undo/undo.js.map +1 -0
  11. package/dist/attention/bulk-accept.js +1 -1
  12. package/dist/attention/bulk-accept.js.map +1 -1
  13. package/dist/attention/dedup.d.ts +2 -2
  14. package/dist/attention/dedup.js +16 -51
  15. package/dist/attention/dedup.js.map +1 -1
  16. package/dist/attention/index.d.ts +1 -0
  17. package/dist/attention/index.js +1 -0
  18. package/dist/attention/index.js.map +1 -1
  19. package/dist/attention/restore.js +1 -1
  20. package/dist/attention/restore.js.map +1 -1
  21. package/dist/attention/serve/api.d.ts +23 -0
  22. package/dist/attention/serve/api.js +344 -0
  23. package/dist/attention/serve/api.js.map +1 -0
  24. package/dist/attention/serve/index.d.ts +62 -0
  25. package/dist/attention/serve/index.js +205 -0
  26. package/dist/attention/serve/index.js.map +1 -0
  27. package/dist/decision-capture/id.d.ts +62 -25
  28. package/dist/decision-capture/id.js +78 -57
  29. package/dist/decision-capture/id.js.map +1 -1
  30. package/dist/decision-capture/index.d.ts +3 -3
  31. package/dist/decision-capture/index.js +3 -3
  32. package/dist/decision-capture/index.js.map +1 -1
  33. package/dist/drain/drain.d.ts +77 -0
  34. package/dist/drain/drain.js +464 -0
  35. package/dist/drain/drain.js.map +1 -0
  36. package/dist/drain/index.d.ts +5 -0
  37. package/dist/drain/index.js +5 -0
  38. package/dist/drain/index.js.map +1 -0
  39. package/dist/fix-align/index.d.ts +5 -0
  40. package/dist/fix-align/index.js +5 -0
  41. package/dist/fix-align/index.js.map +1 -0
  42. package/dist/fix-align/run.d.ts +99 -0
  43. package/dist/fix-align/run.js +258 -0
  44. package/dist/fix-align/run.js.map +1 -0
  45. package/dist/ground/alignment-pending.d.ts +28 -0
  46. package/dist/ground/alignment-pending.js +83 -0
  47. package/dist/ground/alignment-pending.js.map +1 -0
  48. package/dist/ground/anchor-map.d.ts +14 -0
  49. package/dist/ground/anchor-map.js +57 -0
  50. package/dist/ground/anchor-map.js.map +1 -0
  51. package/dist/ground/index.d.ts +9 -2
  52. package/dist/ground/index.js +8 -2
  53. package/dist/ground/index.js.map +1 -1
  54. package/dist/ground/paths.d.ts +21 -0
  55. package/dist/ground/paths.js +43 -0
  56. package/dist/ground/paths.js.map +1 -1
  57. package/dist/ground/schemas.d.ts +201 -0
  58. package/dist/ground/schemas.js +128 -3
  59. package/dist/ground/schemas.js.map +1 -1
  60. package/dist/ground/scope-index.js +2 -2
  61. package/dist/ground/scope-index.js.map +1 -1
  62. package/dist/ground/slug.d.ts +60 -0
  63. package/dist/ground/slug.js +103 -0
  64. package/dist/ground/slug.js.map +1 -0
  65. package/dist/ground/sot-bindings.d.ts +14 -0
  66. package/dist/ground/sot-bindings.js +80 -0
  67. package/dist/ground/sot-bindings.js.map +1 -0
  68. package/dist/ground/sot-cache.d.ts +18 -0
  69. package/dist/ground/sot-cache.js +63 -0
  70. package/dist/ground/sot-cache.js.map +1 -0
  71. package/dist/ground/topic-index.d.ts +20 -0
  72. package/dist/ground/topic-index.js +60 -0
  73. package/dist/ground/topic-index.js.map +1 -0
  74. package/dist/hooks/post-tool-use/citation-scanner.d.ts +1 -1
  75. package/dist/hooks/post-tool-use/citation-scanner.js +3 -3
  76. package/dist/hooks/post-tool-use/citation-scanner.js.map +1 -1
  77. package/dist/hooks/post-tool-use/copy-scanner.js +1 -1
  78. package/dist/hooks/post-tool-use/copy-scanner.js.map +1 -1
  79. package/dist/hooks/post-tool-use/index.d.ts +2 -0
  80. package/dist/hooks/post-tool-use/index.js +1 -0
  81. package/dist/hooks/post-tool-use/index.js.map +1 -1
  82. package/dist/hooks/post-tool-use/legend-builder.d.ts +1 -1
  83. package/dist/hooks/post-tool-use/legend-builder.js +2 -2
  84. package/dist/hooks/post-tool-use/legend-builder.js.map +1 -1
  85. package/dist/hooks/post-tool-use/sot-align.d.ts +166 -0
  86. package/dist/hooks/post-tool-use/sot-align.js +1311 -0
  87. package/dist/hooks/post-tool-use/sot-align.js.map +1 -0
  88. package/dist/hooks/pre-commit/index.d.ts +8 -0
  89. package/dist/hooks/pre-commit/index.js +8 -0
  90. package/dist/hooks/pre-commit/index.js.map +1 -0
  91. package/dist/hooks/pre-commit/sot-align-precommit.d.ts +60 -0
  92. package/dist/hooks/pre-commit/sot-align-precommit.js +221 -0
  93. package/dist/hooks/pre-commit/sot-align-precommit.js.map +1 -0
  94. package/dist/hooks/runners/session-start.js +41 -0
  95. package/dist/hooks/runners/session-start.js.map +1 -1
  96. package/dist/hooks/sot-align-common.d.ts +39 -0
  97. package/dist/hooks/sot-align-common.js +152 -0
  98. package/dist/hooks/sot-align-common.js.map +1 -0
  99. package/dist/index.d.ts +5 -0
  100. package/dist/index.js +5 -0
  101. package/dist/index.js.map +1 -1
  102. package/dist/init/index.d.ts +4 -2
  103. package/dist/init/index.js +2 -1
  104. package/dist/init/index.js.map +1 -1
  105. package/dist/init/ingest-docs.d.ts +30 -47
  106. package/dist/init/ingest-docs.js +113 -406
  107. package/dist/init/ingest-docs.js.map +1 -1
  108. package/dist/init/init.d.ts +8 -0
  109. package/dist/init/init.js +58 -29
  110. package/dist/init/init.js.map +1 -1
  111. package/dist/init/mapper-parallel.js +1 -1
  112. package/dist/init/mapper-parallel.js.map +1 -1
  113. package/dist/init/phases/5-brand.js +1 -1
  114. package/dist/init/phases/5-brand.js.map +1 -1
  115. package/dist/init/phases/5b-topic-index.d.ts +30 -0
  116. package/dist/init/phases/5b-topic-index.js +62 -0
  117. package/dist/init/phases/5b-topic-index.js.map +1 -0
  118. package/dist/init/phases/6-docs-ingest.d.ts +4 -5
  119. package/dist/init/phases/6-docs-ingest.js +5 -6
  120. package/dist/init/phases/6-docs-ingest.js.map +1 -1
  121. package/dist/init/phases/index.d.ts +2 -0
  122. package/dist/init/phases/index.js +1 -0
  123. package/dist/init/phases/index.js.map +1 -1
  124. package/dist/init/phases/parallel-678.d.ts +14 -17
  125. package/dist/init/phases/parallel-678.js +77 -98
  126. package/dist/init/phases/parallel-678.js.map +1 -1
  127. package/dist/init/phases/source-comments-output-io.d.ts +16 -10
  128. package/dist/init/phases/source-comments-output-io.js +7 -10
  129. package/dist/init/phases/source-comments-output-io.js.map +1 -1
  130. package/dist/init/phases/types.d.ts +1 -1
  131. package/dist/init/phases/types.js +1 -0
  132. package/dist/init/phases/types.js.map +1 -1
  133. package/dist/init/rules-merge/discover.d.ts +8 -3
  134. package/dist/init/rules-merge/discover.js +7 -3
  135. package/dist/init/rules-merge/discover.js.map +1 -1
  136. package/dist/init/rules-merge/ingest.d.ts +81 -28
  137. package/dist/init/rules-merge/ingest.js +456 -155
  138. package/dist/init/rules-merge/ingest.js.map +1 -1
  139. package/dist/init/sot-emit.d.ts +84 -0
  140. package/dist/init/sot-emit.js +218 -0
  141. package/dist/init/sot-emit.js.map +1 -0
  142. package/dist/init/source-comments/classify.d.ts +12 -10
  143. package/dist/init/source-comments/classify.js +13 -25
  144. package/dist/init/source-comments/classify.js.map +1 -1
  145. package/dist/init/source-comments/index.d.ts +1 -1
  146. package/dist/init/source-comments/index.js +1 -1
  147. package/dist/init/source-comments/index.js.map +1 -1
  148. package/dist/init/source-comments/ingest.d.ts +91 -67
  149. package/dist/init/source-comments/ingest.js +392 -349
  150. package/dist/init/source-comments/ingest.js.map +1 -1
  151. package/dist/init/topic-index/index.d.ts +36 -0
  152. package/dist/init/topic-index/index.js +46 -0
  153. package/dist/init/topic-index/index.js.map +1 -0
  154. package/dist/init/topic-index/judge.d.ts +20 -0
  155. package/dist/init/topic-index/judge.js +65 -0
  156. package/dist/init/topic-index/judge.js.map +1 -0
  157. package/dist/init/topic-index/resolve.d.ts +50 -0
  158. package/dist/init/topic-index/resolve.js +196 -0
  159. package/dist/init/topic-index/resolve.js.map +1 -0
  160. package/dist/init/topic-index/walk.d.ts +43 -0
  161. package/dist/init/topic-index/walk.js +293 -0
  162. package/dist/init/topic-index/walk.js.map +1 -0
  163. package/dist/mcp/history/summarizer.js +1 -1
  164. package/dist/mcp/history/summarizer.js.map +1 -1
  165. package/dist/mcp/schemas.d.ts +46 -9
  166. package/dist/mcp/schemas.js +48 -12
  167. package/dist/mcp/schemas.js.map +1 -1
  168. package/dist/mcp/tools/align-drain.d.ts +7 -0
  169. package/dist/mcp/tools/align-drain.js +26 -0
  170. package/dist/mcp/tools/align-drain.js.map +1 -0
  171. package/dist/mcp/tools/archive.js +1 -1
  172. package/dist/mcp/tools/archive.js.map +1 -1
  173. package/dist/mcp/tools/attention-restore.js +1 -1
  174. package/dist/mcp/tools/attention-restore.js.map +1 -1
  175. package/dist/mcp/tools/attention-serve.d.ts +23 -0
  176. package/dist/mcp/tools/attention-serve.js +78 -0
  177. package/dist/mcp/tools/attention-serve.js.map +1 -0
  178. package/dist/mcp/tools/attention-wait.d.ts +18 -0
  179. package/dist/mcp/tools/attention-wait.js +74 -0
  180. package/dist/mcp/tools/attention-wait.js.map +1 -0
  181. package/dist/mcp/tools/index.js +7 -0
  182. package/dist/mcp/tools/index.js.map +1 -1
  183. package/dist/mcp/tools/init-phases.js +4 -1
  184. package/dist/mcp/tools/init-phases.js.map +1 -1
  185. package/dist/mcp/tools/record-decision.js +14 -2
  186. package/dist/mcp/tools/record-decision.js.map +1 -1
  187. package/dist/mcp/tools/resolve-attention.d.ts +2 -2
  188. package/dist/mcp/tools/resolve-attention.js +830 -7
  189. package/dist/mcp/tools/resolve-attention.js.map +1 -1
  190. package/dist/status-line/event-queue.d.ts +40 -0
  191. package/dist/status-line/event-queue.js +195 -0
  192. package/dist/status-line/event-queue.js.map +1 -0
  193. package/dist/status-line/format.d.ts +1 -1
  194. package/dist/status-line/format.js +49 -6
  195. package/dist/status-line/format.js.map +1 -1
  196. package/dist/status-line/index.d.ts +41 -0
  197. package/dist/status-line/index.js +14 -0
  198. package/dist/status-line/index.js.map +1 -1
  199. package/dist/status-line/reader.js +23 -18
  200. package/dist/status-line/reader.js.map +1 -1
  201. package/dist/status-line/writer.d.ts +1 -1
  202. package/dist/status-line/writer.js +5 -0
  203. package/dist/status-line/writer.js.map +1 -1
  204. package/dist/text/jaccard.d.ts +19 -0
  205. package/dist/text/jaccard.js +68 -0
  206. package/dist/text/jaccard.js.map +1 -0
  207. package/package.json +1 -1
  208. package/templates/.cairn/git-hooks/pre-commit +16 -3
  209. package/templates/attention-ui/app.css +406 -0
  210. package/templates/attention-ui/app.js +384 -0
  211. package/templates/attention-ui/index.html +56 -0
@@ -1,34 +1,54 @@
1
1
  /**
2
- * Phase 7b orchestrator — walker → classifier → persist.
2
+ * Phase 7b orchestrator (v0.5.0 SoT model) — walker → classifier →
3
+ * topic-index lookup → emit-or-cite → strip-replace.
3
4
  *
4
- * Output:
5
- * - DEC drafts (one per "rationale" classification with non-empty title)
6
- * written to `.cairn/ground/decisions/_inbox/<id>.draft.md`
7
- * - Invariant files (one per "constraint" classification with non-empty
8
- * suggestedInvariant) written directly to
9
- * `.cairn/ground/invariants/INV-<NNNN>.md` with `status: active`. Auto-
10
- * promote the operator can edit / supersede via cairn-attention or
11
- * direct edit. Invariants don't go through an `_inbox/` review queue
12
- * because the classifier emits hard rules, not policy decisions.
13
- * - Canonical-map citations appended to
14
- * `.cairn/baseline/canonical-citations-<ISO>.yaml`
15
- * - Full audit (every block + classification) at
16
- * `.cairn/baseline/source-comments-<ISO>.yaml` consumed by the
17
- * strip-replace stage so it doesn't have to re-walk.
5
+ * Plan §5.3 algorithm:
6
+ * 1. Walk source files for prose-bearing comments (existing logic).
7
+ * 2. Classify via Haiku, kind only — `rationale` / `constraint` /
8
+ * `citation` / `license` / `other`. No paraphrased title, no rewritten
9
+ * invariant body, no canonical-topic suggestion.
10
+ * 3. Build a content-fingerprint slug for every rationale + constraint
11
+ * block. Look it up in the existing topic-index:
12
+ * a. **Cite-existing** slug already owned by a docs/CLAUDE.md/
13
+ * AGENTS.md/rule entry that has been emitted. Strip-replace
14
+ * inserts `// §DEC-<existing>` (or `§INV-<existing>`) at the
15
+ * comment's offset. No new ground-state file is written.
16
+ * b. **Novel** slug not in the topic-index, or owned by an
17
+ * un-emitted entry. Add the block to the topic-index as a
18
+ * new source-comment SoT entry; emit a verbatim DEC/INV via
19
+ * `emitFromTopicIndex` with `sot_kind: "ledger"`. After emit,
20
+ * strip-replace inserts `§DEC-<new>` / `§INV-<new>`.
21
+ * 4. Auto-promote — every newly-emitted entity ships with
22
+ * `status: accepted` (no `_inbox/` draft queue). Plan §1's pivot:
23
+ * the inbox-as-blocker was the bug; verbatim bodies + auto-promote
24
+ * remove the manual review step.
25
+ * 5. License + citation + other classifications → no-op (no DEC, no
26
+ * strip-replace). License blocks stay verbatim in source.
27
+ *
28
+ * Output side-effects (all relative to repoRoot):
29
+ * - `.cairn/ground/decisions/<DEC-id>.md` (one per novel rationale)
30
+ * - `.cairn/ground/invariants/<INV-id>.md` (one per novel constraint)
31
+ * - `.cairn/ground/topic-index.yaml` (extended with source-comment SoT entries)
32
+ * - `.cairn/ground/anchor-map.yaml` (one anchor per novel slug)
33
+ * - `.cairn/ground/sot-bindings.yaml` (forward+reverse for new ids)
34
+ * - `.cairn/ground/sot-cache.yaml` (token cache for Layer A)
35
+ * - `.cairn/ground/scope-index.yaml` (file → ids that landed in source)
36
+ * - `.cairn/baseline/source-comments-<ISO>.yaml` (full audit — every block + verdict)
37
+ * - source files (stripped & cited per replacement)
18
38
  */
19
39
  import { mkdirSync, writeFileSync } from "node:fs";
20
40
  import { dirname, join } from "node:path";
21
41
  import { stringify as stringifyYaml } from "yaml";
22
- import { allocateDecisionId, allocateInvariantId, scanExistingDecisionIds, scanExistingInvariantIds, } from "../../decision-capture/id.js";
23
- import { writeInvariantsLedger } from "../../ground/ledgers.js";
24
- import { decisionsDir, invariantsDir } from "../../ground/paths.js";
25
- import { coerceInvariantIds, readScopeIndex, writeScopeIndex, } from "../../ground/scope-index.js";
42
+ import { writeDecisionsLedger, writeInvariantsLedger, } from "../../ground/ledgers.js";
43
+ import { bodyContentHash, deriveLedgerDecId, deriveLedgerInvId, emptyAnchorMap, emptySotBindings, emptySotCache, emptyTopicIndex, readAnchorMap, readSotBindings, readSotCache, readTopicIndex, setAnchor, setSotCacheEntry, setTopic, topicSlug, writeAnchorMap, writeSotBindings, writeSotCache, writeTopicIndex, } from "../../ground/index.js";
44
+ import { coerceDecisionIds, coerceInvariantIds, readScopeIndex, writeScopeIndex, } from "../../ground/scope-index.js";
26
45
  import { logger } from "../../logger.js";
27
- import { scoreDecDraft, scoreInvariant, } from "../../attention/scoring.js";
46
+ import { emitFromTopicIndex } from "../sot-emit.js";
28
47
  import { applyStripReplace, formatBareCitation, } from "./strip-replace.js";
29
48
  import { classifyBlocks } from "./classify.js";
30
49
  import { walkSourceComments } from "./walker.js";
31
50
  const log = logger("init.source-comments.ingest");
51
+ const CAPTURE_SOURCE = "init-source-comments";
32
52
  /* -------------------------------------------------------------------------- */
33
53
  /* Public */
34
54
  /* -------------------------------------------------------------------------- */
@@ -36,6 +56,7 @@ export async function runSourceCommentsIngestion(args) {
36
56
  const repoRoot = args.repoRoot;
37
57
  const nowIso = args.nowIso ?? new Date().toISOString();
38
58
  const tsSlug = nowIso.replace(/[:.]/g, "-").slice(0, 19);
59
+ // ── 1. Walk ──────────────────────────────────────────────────────
39
60
  const walkOpts = { repoRoot };
40
61
  if (args.walkOptions?.fileCap !== undefined) {
41
62
  walkOpts.fileCap = args.walkOptions.fileCap;
@@ -44,6 +65,7 @@ export async function runSourceCommentsIngestion(args) {
44
65
  walkOpts.onlyFiles = args.walkOptions.onlyFiles;
45
66
  }
46
67
  const walk = walkSourceComments(walkOpts);
68
+ // ── 2. Classify (kind only) ─────────────────────────────────────
47
69
  const classifyResult = await classifyBlocks({
48
70
  blocks: walk.blocks,
49
71
  repoRoot,
@@ -64,129 +86,250 @@ export async function runSourceCommentsIngestion(args) {
64
86
  continue;
65
87
  kindCounts[c.kind] = (kindCounts[c.kind] ?? 0) + 1;
66
88
  }
67
- const decDraftsWritten = [];
68
- const invariantsWritten = [];
69
- const invariantProposals = [];
70
- const canonicalCitations = [];
71
- // Strip-replace items collected during the classification loop —
72
- // applied in one batch at the end so a single dirty-check round
73
- // covers all files. Each item points at the original constraint
74
- // comment block; replacement is `// §INV-NNNN`.
75
- const invariantStripItems = [];
76
- const existingIds = args.existingDecIds ?? scanExistingDecisionIds(repoRoot);
77
- const existingInvariantIds = args.existingInvIds ?? scanExistingInvariantIds(repoRoot);
78
- for (let i = 0; i < walk.blocks.length; i++) {
89
+ // ── 3. Topic-index lookup + extension ────────────────────────────
90
+ let topicIndex = readTopicIndex(repoRoot);
91
+ if (Object.keys(topicIndex.topics).length === 0)
92
+ topicIndex = emptyTopicIndex();
93
+ let anchorMap = readAnchorMap(repoRoot);
94
+ if (Object.keys(anchorMap.anchors).length === 0)
95
+ anchorMap = emptyAnchorMap();
96
+ const resolutionByBlockId = new Map();
97
+ const skipped = [];
98
+ const emitKindBySlug = new Map();
99
+ for (let i = 0; i < walk.blocks.length; i += 1) {
79
100
  const block = walk.blocks[i];
80
101
  const cls = classifyResult.classifications[i];
81
102
  if (block === undefined || cls === undefined)
82
103
  continue;
83
- if (cls.kind === "rationale" && cls.suggestedDecDraft.length > 0) {
84
- const id = allocateDecisionId(repoRoot, existingIds);
85
- existingIds.add(id);
86
- const confidence = args.globs !== undefined
87
- ? scoreDecDraft({
88
- sourceFile: block.file,
89
- prose: block.prose,
90
- title: cls.suggestedDecDraft,
91
- rawComment: block.raw,
92
- globs: args.globs,
93
- ...(args.pilotModule !== undefined ? { pilotModule: args.pilotModule } : {}),
94
- })
95
- : undefined;
96
- if (args.dryRun !== true) {
97
- const written = writeDecDraft({
98
- repoRoot,
99
- id,
100
- block,
101
- classification: cls,
102
- generatedAt: nowIso,
103
- ...(confidence !== undefined ? { confidence } : {}),
104
- });
105
- decDraftsWritten.push({
106
- id,
107
- path: written.relPath,
108
- sourceFile: block.file,
109
- });
110
- }
111
- else {
112
- decDraftsWritten.push({
113
- id,
114
- path: `.cairn/ground/decisions/_inbox/${id}.draft.md`,
115
- sourceFile: block.file,
116
- });
117
- }
104
+ if (cls.kind !== "rationale" && cls.kind !== "constraint") {
105
+ skipped.push({ blockId: block.id, reason: `kind=${cls.kind}` });
106
+ continue;
118
107
  }
119
- if (cls.kind === "constraint" && cls.suggestedInvariant.length > 0) {
120
- invariantProposals.push({
121
- block_id: block.id,
122
- source_file: block.file,
123
- start_line: block.startLine,
124
- end_line: block.endLine,
125
- proposed: cls.suggestedInvariant,
126
- canonical_topic: cls.suggestedCanonicalTopic,
108
+ if (cls.failed) {
109
+ skipped.push({
110
+ blockId: block.id,
111
+ reason: `classifier failed: ${cls.errorMessage ?? "unknown"}`,
127
112
  });
128
- const invId = allocateInvariantId(repoRoot, existingInvariantIds);
129
- existingInvariantIds.add(invId);
130
- const invConfidence = args.globs !== undefined
131
- ? scoreInvariant({
132
- sourceFile: block.file,
133
- prose: cls.suggestedInvariant,
134
- title: cls.suggestedInvariant.split("\n")[0] ?? "",
135
- rawComment: block.raw,
136
- globs: args.globs,
137
- ...(args.pilotModule !== undefined ? { pilotModule: args.pilotModule } : {}),
138
- })
139
- : undefined;
140
- if (args.dryRun !== true) {
141
- const written = writeInvariantFile({
142
- repoRoot,
143
- id: invId,
144
- block,
145
- classification: cls,
146
- generatedAt: nowIso,
147
- ...(invConfidence !== undefined ? { confidence: invConfidence } : {}),
148
- });
149
- invariantsWritten.push({
150
- id: invId,
151
- path: written.relPath,
152
- sourceFile: block.file,
153
- });
154
- }
155
- else {
156
- invariantsWritten.push({
157
- id: invId,
158
- path: `.cairn/ground/invariants/${invId}.md`,
159
- sourceFile: block.file,
160
- });
161
- }
162
- // Stage strip-replace for the source comment so the file ends
163
- // up carrying `// §INV-NNNN` (or `# §INV-NNNN` in hash-comment
164
- // languages) instead of the original essay block. Run after
165
- // the loop so all items go through one applyStripReplace pass.
166
- invariantStripItems.push({
113
+ continue;
114
+ }
115
+ const slug = topicSlug(block.prose);
116
+ const existing = topicIndex.topics[slug];
117
+ if (existing !== undefined && existing.dec_id !== undefined) {
118
+ // Cite-existing — another source already owns this topic + emitted.
119
+ resolutionByBlockId.set(block.id, {
120
+ kind: "cite",
121
+ existingId: existing.dec_id,
122
+ slug,
123
+ });
124
+ continue;
125
+ }
126
+ const emitKind = cls.kind === "constraint" ? "constraint" : "decision";
127
+ if (existing !== undefined) {
128
+ // Slug already in topic-index but not yet emitted (e.g. phase 5b
129
+ // walked it as some other kind). Keep the existing entry, just
130
+ // remember the emit kind so the classifier callback below maps
131
+ // the entry to the right phase-7b verdict.
132
+ emitKindBySlug.set(slug, emitKind);
133
+ resolutionByBlockId.set(block.id, { kind: "emit", slug, emitKind });
134
+ continue;
135
+ }
136
+ // Novel topic — register a fresh source-comment entry.
137
+ const lineRange = [block.startLine, block.endLine];
138
+ const newEntry = {
139
+ slug,
140
+ sot_source: block.file,
141
+ candidates: [
142
+ {
143
+ file: block.file,
144
+ kind: "source-comment",
145
+ line_range: lineRange,
146
+ },
147
+ ],
148
+ created_at: nowIso,
149
+ };
150
+ topicIndex = setTopic(topicIndex, slug, newEntry);
151
+ anchorMap = setAnchor(anchorMap, slug, {
152
+ file: block.file,
153
+ content_hash: bodyContentHash(block.prose),
154
+ line_range: lineRange,
155
+ kind: "source-comment",
156
+ });
157
+ emitKindBySlug.set(slug, emitKind);
158
+ resolutionByBlockId.set(block.id, { kind: "emit", slug, emitKind });
159
+ }
160
+ // ── 4. Emit (sot-emit, sot_kind=ledger) ──────────────────────────
161
+ const emit = await emitFromTopicIndex({
162
+ repoRoot,
163
+ topicIndex,
164
+ anchorMap,
165
+ filter: (entry) => entry.dec_id === undefined &&
166
+ isSourceCommentEntry(entry) &&
167
+ emitKindBySlug.has(entry.slug),
168
+ classifier: async ({ entry }) => {
169
+ const k = emitKindBySlug.get(entry.slug);
170
+ if (k === undefined)
171
+ return { kind: "skip", title: "" };
172
+ return { kind: k === "constraint" ? "constraint" : "decision", title: "" };
173
+ },
174
+ sot_kind: "ledger",
175
+ capture_source: CAPTURE_SOURCE,
176
+ idDeriver: ({ entry, kind }) => {
177
+ const sot = entry.candidates.find((c) => c.file === entry.sot_source);
178
+ const range = sot?.line_range;
179
+ const offset = range !== undefined ? range[0] : 0;
180
+ const inputs = {
181
+ source_file: entry.sot_source,
182
+ source_offset: offset,
183
+ capture_source: CAPTURE_SOURCE,
184
+ };
185
+ return kind === "constraint"
186
+ ? deriveLedgerInvId(inputs)
187
+ : deriveLedgerDecId(inputs);
188
+ },
189
+ });
190
+ topicIndex = emit.topicIndex;
191
+ if (args.dryRun !== true) {
192
+ persistGroundState({
193
+ repoRoot,
194
+ topicIndex,
195
+ anchorMap,
196
+ bindings: emit.bindings,
197
+ cache: emit.cache,
198
+ });
199
+ }
200
+ // ── 5. Build emit / cite records keyed by slug ──────────────────
201
+ const decsWritten = [];
202
+ const invsWritten = [];
203
+ const emittedIdBySlug = new Map();
204
+ for (const rec of emit.emitted) {
205
+ emittedIdBySlug.set(rec.slug, rec.id);
206
+ const target = {
207
+ id: rec.id,
208
+ path: rec.kind === "DEC"
209
+ ? `.cairn/ground/decisions/${rec.id}.md`
210
+ : `.cairn/ground/invariants/${rec.id}.md`,
211
+ sourceFile: rec.source_file,
212
+ slug: rec.slug,
213
+ status: "accepted",
214
+ };
215
+ if (rec.kind === "DEC")
216
+ decsWritten.push(target);
217
+ else
218
+ invsWritten.push(target);
219
+ }
220
+ for (const sk of emit.skipped) {
221
+ skipped.push({ blockId: `slug:${sk.slug}`, reason: sk.reason });
222
+ }
223
+ // ── 6. Strip-replace ─────────────────────────────────────────────
224
+ const citesEmitted = [];
225
+ const stripItems = [];
226
+ for (let i = 0; i < walk.blocks.length; i += 1) {
227
+ const block = walk.blocks[i];
228
+ if (block === undefined)
229
+ continue;
230
+ const resolution = resolutionByBlockId.get(block.id);
231
+ if (resolution === undefined)
232
+ continue;
233
+ const targetId = resolution.kind === "cite"
234
+ ? resolution.existingId
235
+ : emittedIdBySlug.get(resolution.slug);
236
+ if (targetId === undefined) {
237
+ skipped.push({
167
238
  blockId: block.id,
168
- file: block.file,
169
- startOffset: block.startOffset,
170
- endOffset: block.endOffset,
171
- replacement: formatBareCitation(block.lang, invId),
172
- expectedRaw: block.raw,
239
+ reason: `emit produced no id for slug ${resolution.slug}`,
240
+ });
241
+ continue;
242
+ }
243
+ if (resolution.kind === "cite") {
244
+ citesEmitted.push({
245
+ id: targetId,
246
+ sourceFile: block.file,
247
+ lineRange: [block.startLine, block.endLine],
248
+ slug: resolution.slug,
173
249
  });
174
250
  }
175
- if (cls.kind === "citation" && cls.suggestedCanonicalTopic.length > 0) {
176
- canonicalCitations.push({
177
- block_id: block.id,
178
- source_file: block.file,
179
- start_line: block.startLine,
180
- end_line: block.endLine,
181
- topic: cls.suggestedCanonicalTopic,
182
- excerpt: block.prose.slice(0, 240),
251
+ stripItems.push({
252
+ blockId: block.id,
253
+ file: block.file,
254
+ startOffset: block.startOffset,
255
+ endOffset: block.endOffset,
256
+ replacement: formatBareCitation(block.lang, targetId),
257
+ expectedRaw: block.raw,
258
+ });
259
+ }
260
+ let stripFilesModified = 0;
261
+ let stripItemsApplied = 0;
262
+ let stripItemsSkipped = 0;
263
+ let stripOutcomes = [];
264
+ let stripError = null;
265
+ if (args.dryRun !== true && stripItems.length > 0) {
266
+ log.info({
267
+ items: stripItems.length,
268
+ files: [...new Set(stripItems.map((it) => it.file))],
269
+ }, "strip-replace: starting");
270
+ try {
271
+ const dirtyDecisions = {};
272
+ for (const item of stripItems)
273
+ dirtyDecisions[item.file] = "overwrite";
274
+ const result = applyStripReplace({
275
+ repoRoot,
276
+ items: stripItems,
277
+ dirtyDecisions,
183
278
  });
279
+ stripFilesModified = result.filesModified;
280
+ stripItemsApplied = result.itemsApplied;
281
+ stripItemsSkipped = result.itemsSkipped;
282
+ stripOutcomes = result.files.map((o) => ({
283
+ file: o.file,
284
+ applied: o.itemsApplied,
285
+ skipped: o.itemsSkipped.map((s) => ({
286
+ blockId: s.blockId,
287
+ reason: s.reason,
288
+ })),
289
+ fileSkipReason: o.fileSkipReason ?? null,
290
+ }));
291
+ log.info({
292
+ filesModified: result.filesModified,
293
+ itemsApplied: result.itemsApplied,
294
+ itemsSkipped: result.itemsSkipped,
295
+ }, "strip-replace: complete");
296
+ try {
297
+ updateScopeIndexFromStripItems(repoRoot, stripItems);
298
+ }
299
+ catch (err) {
300
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "scope-index update from strip items failed");
301
+ }
302
+ }
303
+ catch (err) {
304
+ stripError = err instanceof Error ? err.message : String(err);
305
+ log.warn({ err: stripError }, "strip-replace failed");
306
+ }
307
+ }
308
+ else if (stripItems.length === 0) {
309
+ log.info("strip-replace: no items (no rationale/constraint blocks classified)");
310
+ }
311
+ // ── 7. Ledger rebuilds ───────────────────────────────────────────
312
+ if (args.dryRun !== true) {
313
+ if (invsWritten.length > 0) {
314
+ try {
315
+ writeInvariantsLedger({ repoRoot });
316
+ }
317
+ catch (err) {
318
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "invariants ledger rebuild failed");
319
+ }
320
+ }
321
+ if (decsWritten.length > 0) {
322
+ try {
323
+ writeDecisionsLedger({ repoRoot });
324
+ }
325
+ catch (err) {
326
+ log.warn({ err: err instanceof Error ? err.message : String(err) }, "decisions ledger rebuild failed");
327
+ }
184
328
  }
185
329
  }
330
+ // ── 8. Audit yaml ───────────────────────────────────────────────
186
331
  const auditRelPath = `.cairn/baseline/source-comments-${tsSlug}.yaml`;
187
332
  const auditPath = join(repoRoot, auditRelPath);
188
- let invariantProposalsPath = null;
189
- let canonicalCitationsPath = null;
190
333
  if (args.dryRun !== true) {
191
334
  writeYaml(auditPath, {
192
335
  run_at: nowIso,
@@ -201,6 +344,9 @@ export async function runSourceCommentsIngestion(args) {
201
344
  batches_failed: classifyResult.batchesFailed,
202
345
  input_tokens: classifyResult.inputTokens,
203
346
  output_tokens: classifyResult.outputTokens,
347
+ decs_written: decsWritten.length,
348
+ invs_written: invsWritten.length,
349
+ cites_emitted: citesEmitted.length,
204
350
  blocks: walk.blocks.map((b, idx) => ({
205
351
  block_id: b.id,
206
352
  file: b.file,
@@ -215,123 +361,36 @@ export async function runSourceCommentsIngestion(args) {
215
361
  end_offset: b.endOffset,
216
362
  raw: b.raw,
217
363
  classification: classifyResult.classifications[idx] ?? null,
364
+ resolution: serializeResolution(resolutionByBlockId.get(b.id)),
218
365
  })),
219
366
  });
220
- if (invariantProposals.length > 0) {
221
- const rel = `.cairn/baseline/invariant-proposals-${tsSlug}.yaml`;
222
- invariantProposalsPath = join(repoRoot, rel);
223
- writeYaml(invariantProposalsPath, {
224
- run_at: nowIso,
225
- proposals: invariantProposals,
226
- });
227
- }
228
- if (canonicalCitations.length > 0) {
229
- const rel = `.cairn/baseline/canonical-citations-${tsSlug}.yaml`;
230
- canonicalCitationsPath = join(repoRoot, rel);
231
- writeYaml(canonicalCitationsPath, {
232
- run_at: nowIso,
233
- citations: canonicalCitations,
234
- });
235
- }
236
367
  }
237
368
  log.info({
238
369
  files: walk.files.length,
239
370
  blocks: walk.blocks.length,
240
371
  kindCounts,
241
- decDrafts: decDraftsWritten.length,
242
- invariantProposals: invariantProposals.length,
243
- canonicalCitations: canonicalCitations.length,
372
+ decs: decsWritten.length,
373
+ invs: invsWritten.length,
374
+ cites: citesEmitted.length,
375
+ stripApplied: stripItemsApplied,
376
+ stripSkipped: stripItemsSkipped,
244
377
  inputTokens: classifyResult.inputTokens,
245
378
  outputTokens: classifyResult.outputTokens,
246
379
  }, "source-comments ingestion complete");
247
- // Rebuild the invariants ledger after writing new INV-<NNNN>.md files so
248
- // that §INV-NNNN tokens are resolvable before the strip-replace stage writes
249
- // bare citations into source. Lens + MCP read tools resolve through the
250
- // ledger, not by re-walking the dir.
251
- if (args.dryRun !== true && invariantsWritten.length > 0) {
252
- try {
253
- writeInvariantsLedger({ repoRoot });
254
- }
255
- catch (err) {
256
- log.warn({ err: err instanceof Error ? err.message : String(err) }, "invariants ledger rebuild failed");
257
- }
258
- }
259
- // Strip the original constraint comment from each source file and
260
- // replace with a bare `// §INV-NNNN` cite. Adoption assumes a clean
261
- // working tree (init phase 1's preflight); pass `overwrite` for
262
- // every file so dirty-skip doesn't bite us — the operator
263
- // consented to source mutation when they consented to adoption.
264
- let invariantStripFilesModified = 0;
265
- let invariantStripItemsApplied = 0;
266
- let invariantStripItemsSkipped = 0;
267
- let invariantStripOutcomes = [];
268
- let invariantStripError = null;
269
- if (args.dryRun !== true && invariantStripItems.length > 0) {
270
- log.info({
271
- items: invariantStripItems.length,
272
- files: [...new Set(invariantStripItems.map((it) => it.file))],
273
- }, "invariant strip-replace: starting");
274
- try {
275
- const dirtyDecisions = {};
276
- for (const item of invariantStripItems)
277
- dirtyDecisions[item.file] = "overwrite";
278
- const result = applyStripReplace({
279
- repoRoot,
280
- items: invariantStripItems,
281
- dirtyDecisions,
282
- });
283
- invariantStripFilesModified = result.filesModified;
284
- invariantStripItemsApplied = result.itemsApplied;
285
- invariantStripItemsSkipped = result.itemsSkipped;
286
- invariantStripOutcomes = result.files.map((o) => ({
287
- file: o.file,
288
- applied: o.itemsApplied,
289
- skipped: o.itemsSkipped.map((s) => ({ blockId: s.blockId, reason: s.reason })),
290
- fileSkipReason: o.fileSkipReason ?? null,
291
- }));
292
- log.info({
293
- filesModified: result.filesModified,
294
- itemsApplied: result.itemsApplied,
295
- itemsSkipped: result.itemsSkipped,
296
- outcomes: invariantStripOutcomes,
297
- }, "invariant strip-replace: complete");
298
- // Now that we know which §INV-NNNN landed in which file, populate
299
- // the scope-index for those files. Phase 3 mapper ran before any
300
- // invariants existed, so its scope_index entries for these files
301
- // had empty `invariants: []` arrays. Without this update the
302
- // read-enricher legend's "Invariants in scope" header stays blank
303
- // even though the source carries the cite tokens.
304
- try {
305
- updateScopeIndexFromStripItems(repoRoot, invariantStripItems);
306
- }
307
- catch (err) {
308
- log.warn({ err: err instanceof Error ? err.message : String(err) }, "scope-index update from strip items failed");
309
- }
310
- }
311
- catch (err) {
312
- invariantStripError = err instanceof Error ? err.message : String(err);
313
- log.warn({ err: invariantStripError }, "invariant strip-replace failed");
314
- }
315
- }
316
- else if (invariantStripItems.length === 0) {
317
- log.info("invariant strip-replace: no items (no constraint blocks classified)");
318
- }
319
380
  return {
320
381
  walk,
321
382
  classifications: classifyResult.classifications,
322
- decDraftsWritten,
323
- invariantsWritten,
324
- invariantStripFilesModified,
325
- invariantStripItemsApplied,
326
- invariantStripItemsSkipped,
327
- invariantStripOutcomes,
328
- invariantStripError,
329
- invariantProposalsAdded: invariantProposals.length,
330
- canonicalCitationsAdded: canonicalCitations.length,
383
+ decsWritten,
384
+ invsWritten,
385
+ citesEmitted,
386
+ skipped,
387
+ stripFilesModified,
388
+ stripItemsApplied,
389
+ stripItemsSkipped,
390
+ stripOutcomes,
391
+ stripError,
331
392
  auditPath,
332
393
  auditRelPath,
333
- invariantProposalsPath,
334
- canonicalCitationsPath,
335
394
  inputTokens: classifyResult.inputTokens,
336
395
  outputTokens: classifyResult.outputTokens,
337
396
  batchesRun: classifyResult.batchesRun,
@@ -340,56 +399,121 @@ export async function runSourceCommentsIngestion(args) {
340
399
  };
341
400
  }
342
401
  /* -------------------------------------------------------------------------- */
343
- /* Scope-index post-population */
402
+ /* Helpers */
344
403
  /* -------------------------------------------------------------------------- */
404
+ function isSourceCommentEntry(entry) {
405
+ const sot = entry.candidates.find((c) => c.file === entry.sot_source);
406
+ return sot?.kind === "source-comment";
407
+ }
408
+ function serializeResolution(resolution) {
409
+ if (resolution === undefined)
410
+ return null;
411
+ if (resolution.kind === "cite") {
412
+ return { kind: "cite", existing_id: resolution.existingId, slug: resolution.slug };
413
+ }
414
+ return { kind: "emit", slug: resolution.slug, emit_kind: resolution.emitKind };
415
+ }
416
+ function persistGroundState(args) {
417
+ const { repoRoot } = args;
418
+ // Re-read each file right before write so we merge with any other phase
419
+ // that committed concurrently. parallel-678 still uses Promise.allSettled
420
+ // across phases 6/7b/7c; sequential individual phase tools are race-free.
421
+ const freshTopic = readTopicIndex(repoRoot);
422
+ const baseTopic = Object.keys(freshTopic.topics).length > 0 ? freshTopic : emptyTopicIndex();
423
+ for (const [slug, entry] of Object.entries(args.topicIndex.topics)) {
424
+ baseTopic.topics[slug] = entry;
425
+ }
426
+ baseTopic.generated = new Date().toISOString();
427
+ writeTopicIndex(repoRoot, baseTopic);
428
+ const freshAnchor = readAnchorMap(repoRoot);
429
+ const baseAnchor = Object.keys(freshAnchor.anchors).length > 0 ? freshAnchor : emptyAnchorMap();
430
+ for (const [slug, anchor] of Object.entries(args.anchorMap.anchors)) {
431
+ baseAnchor.anchors[slug] = anchor;
432
+ }
433
+ baseAnchor.generated = new Date().toISOString();
434
+ writeAnchorMap(repoRoot, baseAnchor);
435
+ const freshBindings = readSotBindings(repoRoot);
436
+ const baseBindings = Object.keys(freshBindings.forward).length > 0 ? freshBindings : emptySotBindings();
437
+ for (const [decId, sotPath] of Object.entries(args.bindings.forward)) {
438
+ baseBindings.forward[decId] = sotPath;
439
+ }
440
+ for (const [sotPath, decIds] of Object.entries(args.bindings.reverse)) {
441
+ const seen = new Set(baseBindings.reverse[sotPath] ?? []);
442
+ for (const id of decIds)
443
+ seen.add(id);
444
+ baseBindings.reverse[sotPath] = Array.from(seen);
445
+ }
446
+ baseBindings.generated = new Date().toISOString();
447
+ writeSotBindings(repoRoot, baseBindings);
448
+ const freshCache = readSotCache(repoRoot);
449
+ let baseCache = Object.keys(freshCache.entries).length > 0 ? freshCache : emptySotCache();
450
+ for (const [decId, entry] of Object.entries(args.cache.entries)) {
451
+ baseCache = setSotCacheEntry(baseCache, decId, entry);
452
+ }
453
+ baseCache.generated = new Date().toISOString();
454
+ writeSotCache(repoRoot, baseCache);
455
+ }
345
456
  /**
346
- * After the strip-replace pass inserts `// §INV-NNNN` cites into source
347
- * files, fold those IDs into `.cairn/ground/scope-index.yaml`. The Phase
348
- * 3 mapper that originally seeded scope-index ran before any invariants
349
- * existed, so its `invariants: []` arrays for these files were correctly
350
- * empty. Now that the cite tokens are landed, the read-enricher's
351
- * "Invariants in scope" header should reflect them.
352
- *
353
- * Each file's existing invariants array gets unioned with the new IDs,
354
- * de-duplicated, and re-coerced (defense-in-depth). Files absent from
355
- * the scope-index get a fresh entry. Decisions arrays are left
356
- * untouched — DEC strips happen at accept-time via cairn-attention,
357
- * not in this Phase 7b bulk pass.
457
+ * After the strip-replace pass inserts cites into source files, fold those
458
+ * IDs into `.cairn/ground/scope-index.yaml`. Phase 3 mapper that originally
459
+ * seeded scope-index ran before any DECs/INVs existed, so its `decisions:
460
+ * []` / `invariants: []` arrays for these files were correctly empty. Now
461
+ * that the cite tokens are landed, the read-enricher's "in scope" headers
462
+ * should reflect them.
358
463
  */
359
464
  function updateScopeIndexFromStripItems(repoRoot, items) {
360
465
  if (items.length === 0)
361
466
  return;
362
- const idsByFile = new Map();
363
- const idMatch = (INV-\d{4,})/;
467
+ const decsByFile = new Map();
468
+ const invsByFile = new Map();
469
+ const decMatch = /§(DEC-[0-9a-f]{7,})/;
470
+ const invMatch = /§(INV-[0-9a-f]{7,})/;
364
471
  for (const item of items) {
365
- const m = item.replacement.match(idMatch);
366
- if (m === null)
367
- continue;
368
- const id = m[1];
369
- if (id === undefined)
370
- continue;
371
- let set = idsByFile.get(item.file);
372
- if (set === undefined) {
373
- set = new Set();
374
- idsByFile.set(item.file, set);
472
+ const decM = item.replacement.match(decMatch);
473
+ const invM = item.replacement.match(invMatch);
474
+ if (decM !== null) {
475
+ const id = decM[1];
476
+ if (id !== undefined) {
477
+ let set = decsByFile.get(item.file);
478
+ if (set === undefined) {
479
+ set = new Set();
480
+ decsByFile.set(item.file, set);
481
+ }
482
+ set.add(id);
483
+ }
484
+ }
485
+ if (invM !== null) {
486
+ const id = invM[1];
487
+ if (id !== undefined) {
488
+ let set = invsByFile.get(item.file);
489
+ if (set === undefined) {
490
+ set = new Set();
491
+ invsByFile.set(item.file, set);
492
+ }
493
+ set.add(id);
494
+ }
375
495
  }
376
- set.add(id);
377
496
  }
378
- if (idsByFile.size === 0)
497
+ if (decsByFile.size === 0 && invsByFile.size === 0)
379
498
  return;
380
499
  const existing = readScopeIndex(repoRoot) ?? {
381
500
  generated: new Date().toISOString(),
382
501
  files: {},
383
502
  };
384
- for (const [file, ids] of idsByFile) {
503
+ const allFiles = new Set([...decsByFile.keys(), ...invsByFile.keys()]);
504
+ for (const file of allFiles) {
385
505
  const prior = existing.files[file];
386
- const merged = coerceInvariantIds([
506
+ const mergedDecs = coerceDecisionIds([
507
+ ...(prior?.decisions ?? []),
508
+ ...(decsByFile.get(file) ?? []),
509
+ ]);
510
+ const mergedInvs = coerceInvariantIds([
387
511
  ...(prior?.invariants ?? []),
388
- ...ids,
512
+ ...(invsByFile.get(file) ?? []),
389
513
  ]);
390
514
  const next = {
391
- decisions: prior?.decisions ?? [],
392
- invariants: merged,
515
+ decisions: mergedDecs,
516
+ invariants: mergedInvs,
393
517
  };
394
518
  if (prior?.unscoped === true)
395
519
  next.unscoped = true;
@@ -400,92 +524,11 @@ function updateScopeIndexFromStripItems(repoRoot, items) {
400
524
  files: existing.files,
401
525
  };
402
526
  writeScopeIndex(repoRoot, updated);
403
- log.info({ files: idsByFile.size }, "scope-index updated with §INV cite tokens from strip-replace");
404
- }
405
- function writeDecDraft(args) {
406
- const dir = decisionsDir(args.repoRoot);
407
- const inboxDir = join(dir, "_inbox");
408
- mkdirSync(inboxDir, { recursive: true });
409
- const filename = `${args.id}.draft.md`;
410
- const abs = join(inboxDir, filename);
411
- const rel = `.cairn/ground/decisions/_inbox/${filename}`;
412
- const fm = {
413
- id: args.id,
414
- title: args.classification.suggestedDecDraft || `(untitled — from ${args.block.file})`,
415
- type: "adr",
416
- status: "draft-from-source-comment",
417
- audience: "dual",
418
- generated: args.generatedAt,
419
- "verified-at": args.generatedAt,
420
- decided_at: args.generatedAt,
421
- decided_by: "cairn-init",
422
- capture_source: "init-source-comments",
423
- capture_confidence: args.confidence ?? "medium",
424
- sourceFile: args.block.file,
425
- sourceRange: `${args.block.startLine}-${args.block.endLine}`,
426
- blockId: args.block.id,
427
- canonicalTopic: args.classification.suggestedCanonicalTopic,
428
- };
429
- const lines = [];
430
- lines.push("---");
431
- lines.push(stringifyYaml(fm).trimEnd());
432
- lines.push("---");
433
- lines.push("");
434
- lines.push(`# ${args.id} — ${fm["title"]}`);
435
- lines.push("");
436
- lines.push("## Source comment");
437
- lines.push("");
438
- lines.push("```");
439
- lines.push(args.block.raw);
440
- lines.push("```");
441
- lines.push("");
442
- lines.push("## Proposed rationale");
443
- lines.push("");
444
- lines.push(args.block.prose);
445
- lines.push("");
446
- writeFileSync(abs, lines.join("\n"), "utf8");
447
- return { absPath: abs, relPath: rel };
448
- }
449
- function writeInvariantFile(args) {
450
- const dir = invariantsDir(args.repoRoot);
451
- mkdirSync(dir, { recursive: true });
452
- const filename = `${args.id}.md`;
453
- const abs = join(dir, filename);
454
- const rel = `.cairn/ground/invariants/${filename}`;
455
- const fm = {
456
- id: args.id,
457
- title: args.classification.suggestedInvariant.split("\n")[0]?.slice(0, 120) ?? args.id,
458
- type: "invariant",
459
- status: "active",
460
- audience: "dual",
461
- generated: args.generatedAt,
462
- "verified-at": args.generatedAt,
463
- capture_confidence: args.confidence ?? "medium",
464
- source_decision: null,
465
- sourceFile: args.block.file,
466
- sourceRange: `${args.block.startLine}-${args.block.endLine}`,
467
- blockId: args.block.id,
468
- canonicalTopic: args.classification.suggestedCanonicalTopic,
469
- };
470
- const lines = [];
471
- lines.push("---");
472
- lines.push(stringifyYaml(fm).trimEnd());
473
- lines.push("---");
474
- lines.push("");
475
- lines.push(`# §${args.id} — ${fm["title"]}`);
476
- lines.push("");
477
- lines.push("## Constraint");
478
- lines.push("");
479
- lines.push(args.classification.suggestedInvariant.trim());
480
- lines.push("");
481
- lines.push("## Source comment");
482
- lines.push("");
483
- lines.push("```");
484
- lines.push(args.block.raw);
485
- lines.push("```");
486
- lines.push("");
487
- writeFileSync(abs, lines.join("\n"), "utf8");
488
- return { absPath: abs, relPath: rel };
527
+ log.info({
528
+ files: allFiles.size,
529
+ decs: Array.from(decsByFile.values()).reduce((acc, s) => acc + s.size, 0),
530
+ invs: Array.from(invsByFile.values()).reduce((acc, s) => acc + s.size, 0),
531
+ }, "scope-index updated with cite tokens from strip-replace");
489
532
  }
490
533
  function writeYaml(path, payload) {
491
534
  mkdirSync(dirname(path), { recursive: true });