@isaacriehm/cairn-core 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/align-undo/index.d.ts +6 -0
  3. package/dist/align-undo/index.js +6 -0
  4. package/dist/align-undo/index.js.map +1 -0
  5. package/dist/align-undo/log.d.ts +53 -0
  6. package/dist/align-undo/log.js +99 -0
  7. package/dist/align-undo/log.js.map +1 -0
  8. package/dist/align-undo/undo.d.ts +66 -0
  9. package/dist/align-undo/undo.js +187 -0
  10. package/dist/align-undo/undo.js.map +1 -0
  11. package/dist/attention/dedup.js +1 -47
  12. package/dist/attention/dedup.js.map +1 -1
  13. package/dist/drain/drain.d.ts +77 -0
  14. package/dist/drain/drain.js +464 -0
  15. package/dist/drain/drain.js.map +1 -0
  16. package/dist/drain/index.d.ts +5 -0
  17. package/dist/drain/index.js +5 -0
  18. package/dist/drain/index.js.map +1 -0
  19. package/dist/fix-align/index.d.ts +5 -0
  20. package/dist/fix-align/index.js +5 -0
  21. package/dist/fix-align/index.js.map +1 -0
  22. package/dist/fix-align/run.d.ts +99 -0
  23. package/dist/fix-align/run.js +258 -0
  24. package/dist/fix-align/run.js.map +1 -0
  25. package/dist/ground/alignment-pending.d.ts +28 -0
  26. package/dist/ground/alignment-pending.js +83 -0
  27. package/dist/ground/alignment-pending.js.map +1 -0
  28. package/dist/ground/anchor-map.d.ts +14 -0
  29. package/dist/ground/anchor-map.js +57 -0
  30. package/dist/ground/anchor-map.js.map +1 -0
  31. package/dist/ground/index.d.ts +9 -2
  32. package/dist/ground/index.js +8 -2
  33. package/dist/ground/index.js.map +1 -1
  34. package/dist/ground/paths.d.ts +21 -0
  35. package/dist/ground/paths.js +43 -0
  36. package/dist/ground/paths.js.map +1 -1
  37. package/dist/ground/schemas.d.ts +201 -0
  38. package/dist/ground/schemas.js +126 -1
  39. package/dist/ground/schemas.js.map +1 -1
  40. package/dist/ground/slug.d.ts +60 -0
  41. package/dist/ground/slug.js +103 -0
  42. package/dist/ground/slug.js.map +1 -0
  43. package/dist/ground/sot-bindings.d.ts +14 -0
  44. package/dist/ground/sot-bindings.js +80 -0
  45. package/dist/ground/sot-bindings.js.map +1 -0
  46. package/dist/ground/sot-cache.d.ts +18 -0
  47. package/dist/ground/sot-cache.js +63 -0
  48. package/dist/ground/sot-cache.js.map +1 -0
  49. package/dist/ground/topic-index.d.ts +20 -0
  50. package/dist/ground/topic-index.js +60 -0
  51. package/dist/ground/topic-index.js.map +1 -0
  52. package/dist/hooks/post-tool-use/index.d.ts +2 -0
  53. package/dist/hooks/post-tool-use/index.js +1 -0
  54. package/dist/hooks/post-tool-use/index.js.map +1 -1
  55. package/dist/hooks/post-tool-use/sot-align.d.ts +166 -0
  56. package/dist/hooks/post-tool-use/sot-align.js +1311 -0
  57. package/dist/hooks/post-tool-use/sot-align.js.map +1 -0
  58. package/dist/hooks/pre-commit/index.d.ts +8 -0
  59. package/dist/hooks/pre-commit/index.js +8 -0
  60. package/dist/hooks/pre-commit/index.js.map +1 -0
  61. package/dist/hooks/pre-commit/sot-align-precommit.d.ts +60 -0
  62. package/dist/hooks/pre-commit/sot-align-precommit.js +221 -0
  63. package/dist/hooks/pre-commit/sot-align-precommit.js.map +1 -0
  64. package/dist/hooks/runners/session-start.js +41 -0
  65. package/dist/hooks/runners/session-start.js.map +1 -1
  66. package/dist/hooks/sot-align-common.d.ts +39 -0
  67. package/dist/hooks/sot-align-common.js +152 -0
  68. package/dist/hooks/sot-align-common.js.map +1 -0
  69. package/dist/index.d.ts +5 -0
  70. package/dist/index.js +5 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/init/index.d.ts +4 -2
  73. package/dist/init/index.js +2 -1
  74. package/dist/init/index.js.map +1 -1
  75. package/dist/init/ingest-docs.d.ts +30 -47
  76. package/dist/init/ingest-docs.js +113 -410
  77. package/dist/init/ingest-docs.js.map +1 -1
  78. package/dist/init/init.d.ts +8 -0
  79. package/dist/init/init.js +58 -29
  80. package/dist/init/init.js.map +1 -1
  81. package/dist/init/phases/5-brand.js +1 -1
  82. package/dist/init/phases/5-brand.js.map +1 -1
  83. package/dist/init/phases/5b-topic-index.d.ts +30 -0
  84. package/dist/init/phases/5b-topic-index.js +62 -0
  85. package/dist/init/phases/5b-topic-index.js.map +1 -0
  86. package/dist/init/phases/6-docs-ingest.d.ts +4 -5
  87. package/dist/init/phases/6-docs-ingest.js +5 -6
  88. package/dist/init/phases/6-docs-ingest.js.map +1 -1
  89. package/dist/init/phases/index.d.ts +2 -0
  90. package/dist/init/phases/index.js +1 -0
  91. package/dist/init/phases/index.js.map +1 -1
  92. package/dist/init/phases/parallel-678.d.ts +14 -17
  93. package/dist/init/phases/parallel-678.js +77 -98
  94. package/dist/init/phases/parallel-678.js.map +1 -1
  95. package/dist/init/phases/source-comments-output-io.d.ts +16 -10
  96. package/dist/init/phases/source-comments-output-io.js +7 -10
  97. package/dist/init/phases/source-comments-output-io.js.map +1 -1
  98. package/dist/init/phases/types.d.ts +1 -1
  99. package/dist/init/phases/types.js +1 -0
  100. package/dist/init/phases/types.js.map +1 -1
  101. package/dist/init/rules-merge/discover.d.ts +8 -3
  102. package/dist/init/rules-merge/discover.js +7 -3
  103. package/dist/init/rules-merge/discover.js.map +1 -1
  104. package/dist/init/rules-merge/ingest.d.ts +81 -28
  105. package/dist/init/rules-merge/ingest.js +456 -162
  106. package/dist/init/rules-merge/ingest.js.map +1 -1
  107. package/dist/init/sot-emit.d.ts +84 -0
  108. package/dist/init/sot-emit.js +218 -0
  109. package/dist/init/sot-emit.js.map +1 -0
  110. package/dist/init/source-comments/classify.d.ts +12 -10
  111. package/dist/init/source-comments/classify.js +13 -25
  112. package/dist/init/source-comments/classify.js.map +1 -1
  113. package/dist/init/source-comments/index.d.ts +1 -1
  114. package/dist/init/source-comments/index.js +1 -1
  115. package/dist/init/source-comments/index.js.map +1 -1
  116. package/dist/init/source-comments/ingest.d.ts +91 -67
  117. package/dist/init/source-comments/ingest.js +392 -361
  118. package/dist/init/source-comments/ingest.js.map +1 -1
  119. package/dist/init/topic-index/index.d.ts +36 -0
  120. package/dist/init/topic-index/index.js +46 -0
  121. package/dist/init/topic-index/index.js.map +1 -0
  122. package/dist/init/topic-index/judge.d.ts +20 -0
  123. package/dist/init/topic-index/judge.js +65 -0
  124. package/dist/init/topic-index/judge.js.map +1 -0
  125. package/dist/init/topic-index/resolve.d.ts +50 -0
  126. package/dist/init/topic-index/resolve.js +196 -0
  127. package/dist/init/topic-index/resolve.js.map +1 -0
  128. package/dist/init/topic-index/walk.d.ts +43 -0
  129. package/dist/init/topic-index/walk.js +293 -0
  130. package/dist/init/topic-index/walk.js.map +1 -0
  131. package/dist/mcp/schemas.d.ts +45 -8
  132. package/dist/mcp/schemas.js +43 -7
  133. package/dist/mcp/schemas.js.map +1 -1
  134. package/dist/mcp/tools/align-drain.d.ts +7 -0
  135. package/dist/mcp/tools/align-drain.js +26 -0
  136. package/dist/mcp/tools/align-drain.js.map +1 -0
  137. package/dist/mcp/tools/index.js +3 -0
  138. package/dist/mcp/tools/index.js.map +1 -1
  139. package/dist/mcp/tools/init-phases.js +4 -1
  140. package/dist/mcp/tools/init-phases.js.map +1 -1
  141. package/dist/mcp/tools/resolve-attention.d.ts +2 -2
  142. package/dist/mcp/tools/resolve-attention.js +828 -5
  143. package/dist/mcp/tools/resolve-attention.js.map +1 -1
  144. package/dist/status-line/event-queue.d.ts +40 -0
  145. package/dist/status-line/event-queue.js +195 -0
  146. package/dist/status-line/event-queue.js.map +1 -0
  147. package/dist/status-line/format.d.ts +1 -1
  148. package/dist/status-line/format.js +49 -6
  149. package/dist/status-line/format.js.map +1 -1
  150. package/dist/status-line/index.d.ts +41 -0
  151. package/dist/status-line/index.js +14 -0
  152. package/dist/status-line/index.js.map +1 -1
  153. package/dist/status-line/reader.js +23 -18
  154. package/dist/status-line/reader.js.map +1 -1
  155. package/dist/status-line/writer.d.ts +1 -1
  156. package/dist/status-line/writer.js +5 -0
  157. package/dist/status-line/writer.js.map +1 -1
  158. package/dist/text/jaccard.d.ts +19 -0
  159. package/dist/text/jaccard.js +68 -0
  160. package/dist/text/jaccard.js.map +1 -0
  161. package/package.json +1 -1
  162. package/templates/.cairn/git-hooks/pre-commit +16 -3
@@ -1,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 { computeDecisionId, computeInvariantId, 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,141 +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 = computeDecisionId({
85
- title: cls.suggestedDecDraft,
86
- rationale: block.prose,
87
- capture_source: "init-source-comments",
88
- source_file: block.file,
89
- source_offset: block.startLine,
90
- raw: block.raw,
91
- }, existingIds);
92
- existingIds.add(id);
93
- const confidence = args.globs !== undefined
94
- ? scoreDecDraft({
95
- sourceFile: block.file,
96
- prose: block.prose,
97
- title: cls.suggestedDecDraft,
98
- rawComment: block.raw,
99
- globs: args.globs,
100
- ...(args.pilotModule !== undefined ? { pilotModule: args.pilotModule } : {}),
101
- })
102
- : undefined;
103
- if (args.dryRun !== true) {
104
- const written = writeDecDraft({
105
- repoRoot,
106
- id,
107
- block,
108
- classification: cls,
109
- generatedAt: nowIso,
110
- ...(confidence !== undefined ? { confidence } : {}),
111
- });
112
- decDraftsWritten.push({
113
- id,
114
- path: written.relPath,
115
- sourceFile: block.file,
116
- });
117
- }
118
- else {
119
- decDraftsWritten.push({
120
- id,
121
- path: `.cairn/ground/decisions/_inbox/${id}.draft.md`,
122
- sourceFile: block.file,
123
- });
124
- }
104
+ if (cls.kind !== "rationale" && cls.kind !== "constraint") {
105
+ skipped.push({ blockId: block.id, reason: `kind=${cls.kind}` });
106
+ continue;
125
107
  }
126
- if (cls.kind === "constraint" && cls.suggestedInvariant.length > 0) {
127
- invariantProposals.push({
128
- block_id: block.id,
129
- source_file: block.file,
130
- start_line: block.startLine,
131
- end_line: block.endLine,
132
- proposed: cls.suggestedInvariant,
133
- canonical_topic: cls.suggestedCanonicalTopic,
108
+ if (cls.failed) {
109
+ skipped.push({
110
+ blockId: block.id,
111
+ reason: `classifier failed: ${cls.errorMessage ?? "unknown"}`,
134
112
  });
135
- const invId = computeInvariantId({
136
- title: cls.suggestedInvariant,
137
- source_file: block.file,
138
- source_offset: block.startLine,
139
- raw: block.raw,
140
- }, existingInvariantIds);
141
- existingInvariantIds.add(invId);
142
- const invConfidence = args.globs !== undefined
143
- ? scoreInvariant({
144
- sourceFile: block.file,
145
- prose: cls.suggestedInvariant,
146
- title: cls.suggestedInvariant.split("\n")[0] ?? "",
147
- rawComment: block.raw,
148
- globs: args.globs,
149
- ...(args.pilotModule !== undefined ? { pilotModule: args.pilotModule } : {}),
150
- })
151
- : undefined;
152
- if (args.dryRun !== true) {
153
- const written = writeInvariantFile({
154
- repoRoot,
155
- id: invId,
156
- block,
157
- classification: cls,
158
- generatedAt: nowIso,
159
- ...(invConfidence !== undefined ? { confidence: invConfidence } : {}),
160
- });
161
- invariantsWritten.push({
162
- id: invId,
163
- path: written.relPath,
164
- sourceFile: block.file,
165
- });
166
- }
167
- else {
168
- invariantsWritten.push({
169
- id: invId,
170
- path: `.cairn/ground/invariants/${invId}.md`,
171
- sourceFile: block.file,
172
- });
173
- }
174
- // Stage strip-replace for the source comment so the file ends
175
- // up carrying `// §INV-NNNN` (or `# §INV-NNNN` in hash-comment
176
- // languages) instead of the original essay block. Run after
177
- // the loop so all items go through one applyStripReplace pass.
178
- 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({
179
238
  blockId: block.id,
180
- file: block.file,
181
- startOffset: block.startOffset,
182
- endOffset: block.endOffset,
183
- replacement: formatBareCitation(block.lang, invId),
184
- 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,
185
249
  });
186
250
  }
187
- if (cls.kind === "citation" && cls.suggestedCanonicalTopic.length > 0) {
188
- canonicalCitations.push({
189
- block_id: block.id,
190
- source_file: block.file,
191
- start_line: block.startLine,
192
- end_line: block.endLine,
193
- topic: cls.suggestedCanonicalTopic,
194
- 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,
195
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
+ }
196
328
  }
197
329
  }
330
+ // ── 8. Audit yaml ───────────────────────────────────────────────
198
331
  const auditRelPath = `.cairn/baseline/source-comments-${tsSlug}.yaml`;
199
332
  const auditPath = join(repoRoot, auditRelPath);
200
- let invariantProposalsPath = null;
201
- let canonicalCitationsPath = null;
202
333
  if (args.dryRun !== true) {
203
334
  writeYaml(auditPath, {
204
335
  run_at: nowIso,
@@ -213,6 +344,9 @@ export async function runSourceCommentsIngestion(args) {
213
344
  batches_failed: classifyResult.batchesFailed,
214
345
  input_tokens: classifyResult.inputTokens,
215
346
  output_tokens: classifyResult.outputTokens,
347
+ decs_written: decsWritten.length,
348
+ invs_written: invsWritten.length,
349
+ cites_emitted: citesEmitted.length,
216
350
  blocks: walk.blocks.map((b, idx) => ({
217
351
  block_id: b.id,
218
352
  file: b.file,
@@ -227,123 +361,36 @@ export async function runSourceCommentsIngestion(args) {
227
361
  end_offset: b.endOffset,
228
362
  raw: b.raw,
229
363
  classification: classifyResult.classifications[idx] ?? null,
364
+ resolution: serializeResolution(resolutionByBlockId.get(b.id)),
230
365
  })),
231
366
  });
232
- if (invariantProposals.length > 0) {
233
- const rel = `.cairn/baseline/invariant-proposals-${tsSlug}.yaml`;
234
- invariantProposalsPath = join(repoRoot, rel);
235
- writeYaml(invariantProposalsPath, {
236
- run_at: nowIso,
237
- proposals: invariantProposals,
238
- });
239
- }
240
- if (canonicalCitations.length > 0) {
241
- const rel = `.cairn/baseline/canonical-citations-${tsSlug}.yaml`;
242
- canonicalCitationsPath = join(repoRoot, rel);
243
- writeYaml(canonicalCitationsPath, {
244
- run_at: nowIso,
245
- citations: canonicalCitations,
246
- });
247
- }
248
367
  }
249
368
  log.info({
250
369
  files: walk.files.length,
251
370
  blocks: walk.blocks.length,
252
371
  kindCounts,
253
- decDrafts: decDraftsWritten.length,
254
- invariantProposals: invariantProposals.length,
255
- canonicalCitations: canonicalCitations.length,
372
+ decs: decsWritten.length,
373
+ invs: invsWritten.length,
374
+ cites: citesEmitted.length,
375
+ stripApplied: stripItemsApplied,
376
+ stripSkipped: stripItemsSkipped,
256
377
  inputTokens: classifyResult.inputTokens,
257
378
  outputTokens: classifyResult.outputTokens,
258
379
  }, "source-comments ingestion complete");
259
- // Rebuild the invariants ledger after writing new INV-<NNNN>.md files so
260
- // that §INV-NNNN tokens are resolvable before the strip-replace stage writes
261
- // bare citations into source. Lens + MCP read tools resolve through the
262
- // ledger, not by re-walking the dir.
263
- if (args.dryRun !== true && invariantsWritten.length > 0) {
264
- try {
265
- writeInvariantsLedger({ repoRoot });
266
- }
267
- catch (err) {
268
- log.warn({ err: err instanceof Error ? err.message : String(err) }, "invariants ledger rebuild failed");
269
- }
270
- }
271
- // Strip the original constraint comment from each source file and
272
- // replace with a bare `// §INV-NNNN` cite. Adoption assumes a clean
273
- // working tree (init phase 1's preflight); pass `overwrite` for
274
- // every file so dirty-skip doesn't bite us — the operator
275
- // consented to source mutation when they consented to adoption.
276
- let invariantStripFilesModified = 0;
277
- let invariantStripItemsApplied = 0;
278
- let invariantStripItemsSkipped = 0;
279
- let invariantStripOutcomes = [];
280
- let invariantStripError = null;
281
- if (args.dryRun !== true && invariantStripItems.length > 0) {
282
- log.info({
283
- items: invariantStripItems.length,
284
- files: [...new Set(invariantStripItems.map((it) => it.file))],
285
- }, "invariant strip-replace: starting");
286
- try {
287
- const dirtyDecisions = {};
288
- for (const item of invariantStripItems)
289
- dirtyDecisions[item.file] = "overwrite";
290
- const result = applyStripReplace({
291
- repoRoot,
292
- items: invariantStripItems,
293
- dirtyDecisions,
294
- });
295
- invariantStripFilesModified = result.filesModified;
296
- invariantStripItemsApplied = result.itemsApplied;
297
- invariantStripItemsSkipped = result.itemsSkipped;
298
- invariantStripOutcomes = result.files.map((o) => ({
299
- file: o.file,
300
- applied: o.itemsApplied,
301
- skipped: o.itemsSkipped.map((s) => ({ blockId: s.blockId, reason: s.reason })),
302
- fileSkipReason: o.fileSkipReason ?? null,
303
- }));
304
- log.info({
305
- filesModified: result.filesModified,
306
- itemsApplied: result.itemsApplied,
307
- itemsSkipped: result.itemsSkipped,
308
- outcomes: invariantStripOutcomes,
309
- }, "invariant strip-replace: complete");
310
- // Now that we know which §INV-NNNN landed in which file, populate
311
- // the scope-index for those files. Phase 3 mapper ran before any
312
- // invariants existed, so its scope_index entries for these files
313
- // had empty `invariants: []` arrays. Without this update the
314
- // read-enricher legend's "Invariants in scope" header stays blank
315
- // even though the source carries the cite tokens.
316
- try {
317
- updateScopeIndexFromStripItems(repoRoot, invariantStripItems);
318
- }
319
- catch (err) {
320
- log.warn({ err: err instanceof Error ? err.message : String(err) }, "scope-index update from strip items failed");
321
- }
322
- }
323
- catch (err) {
324
- invariantStripError = err instanceof Error ? err.message : String(err);
325
- log.warn({ err: invariantStripError }, "invariant strip-replace failed");
326
- }
327
- }
328
- else if (invariantStripItems.length === 0) {
329
- log.info("invariant strip-replace: no items (no constraint blocks classified)");
330
- }
331
380
  return {
332
381
  walk,
333
382
  classifications: classifyResult.classifications,
334
- decDraftsWritten,
335
- invariantsWritten,
336
- invariantStripFilesModified,
337
- invariantStripItemsApplied,
338
- invariantStripItemsSkipped,
339
- invariantStripOutcomes,
340
- invariantStripError,
341
- invariantProposalsAdded: invariantProposals.length,
342
- canonicalCitationsAdded: canonicalCitations.length,
383
+ decsWritten,
384
+ invsWritten,
385
+ citesEmitted,
386
+ skipped,
387
+ stripFilesModified,
388
+ stripItemsApplied,
389
+ stripItemsSkipped,
390
+ stripOutcomes,
391
+ stripError,
343
392
  auditPath,
344
393
  auditRelPath,
345
- invariantProposalsPath,
346
- canonicalCitationsPath,
347
394
  inputTokens: classifyResult.inputTokens,
348
395
  outputTokens: classifyResult.outputTokens,
349
396
  batchesRun: classifyResult.batchesRun,
@@ -352,56 +399,121 @@ export async function runSourceCommentsIngestion(args) {
352
399
  };
353
400
  }
354
401
  /* -------------------------------------------------------------------------- */
355
- /* Scope-index post-population */
402
+ /* Helpers */
356
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
+ }
357
456
  /**
358
- * After the strip-replace pass inserts `// §INV-NNNN` cites into source
359
- * files, fold those IDs into `.cairn/ground/scope-index.yaml`. The Phase
360
- * 3 mapper that originally seeded scope-index ran before any invariants
361
- * existed, so its `invariants: []` arrays for these files were correctly
362
- * empty. Now that the cite tokens are landed, the read-enricher's
363
- * "Invariants in scope" header should reflect them.
364
- *
365
- * Each file's existing invariants array gets unioned with the new IDs,
366
- * de-duplicated, and re-coerced (defense-in-depth). Files absent from
367
- * the scope-index get a fresh entry. Decisions arrays are left
368
- * untouched — DEC strips happen at accept-time via cairn-attention,
369
- * 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.
370
463
  */
371
464
  function updateScopeIndexFromStripItems(repoRoot, items) {
372
465
  if (items.length === 0)
373
466
  return;
374
- const idsByFile = new Map();
375
- const idMatch = (INV-[0-9a-f]{7,})/;
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,})/;
376
471
  for (const item of items) {
377
- const m = item.replacement.match(idMatch);
378
- if (m === null)
379
- continue;
380
- const id = m[1];
381
- if (id === undefined)
382
- continue;
383
- let set = idsByFile.get(item.file);
384
- if (set === undefined) {
385
- set = new Set();
386
- 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
+ }
387
495
  }
388
- set.add(id);
389
496
  }
390
- if (idsByFile.size === 0)
497
+ if (decsByFile.size === 0 && invsByFile.size === 0)
391
498
  return;
392
499
  const existing = readScopeIndex(repoRoot) ?? {
393
500
  generated: new Date().toISOString(),
394
501
  files: {},
395
502
  };
396
- for (const [file, ids] of idsByFile) {
503
+ const allFiles = new Set([...decsByFile.keys(), ...invsByFile.keys()]);
504
+ for (const file of allFiles) {
397
505
  const prior = existing.files[file];
398
- const merged = coerceInvariantIds([
506
+ const mergedDecs = coerceDecisionIds([
507
+ ...(prior?.decisions ?? []),
508
+ ...(decsByFile.get(file) ?? []),
509
+ ]);
510
+ const mergedInvs = coerceInvariantIds([
399
511
  ...(prior?.invariants ?? []),
400
- ...ids,
512
+ ...(invsByFile.get(file) ?? []),
401
513
  ]);
402
514
  const next = {
403
- decisions: prior?.decisions ?? [],
404
- invariants: merged,
515
+ decisions: mergedDecs,
516
+ invariants: mergedInvs,
405
517
  };
406
518
  if (prior?.unscoped === true)
407
519
  next.unscoped = true;
@@ -412,92 +524,11 @@ function updateScopeIndexFromStripItems(repoRoot, items) {
412
524
  files: existing.files,
413
525
  };
414
526
  writeScopeIndex(repoRoot, updated);
415
- log.info({ files: idsByFile.size }, "scope-index updated with §INV cite tokens from strip-replace");
416
- }
417
- function writeDecDraft(args) {
418
- const dir = decisionsDir(args.repoRoot);
419
- const inboxDir = join(dir, "_inbox");
420
- mkdirSync(inboxDir, { recursive: true });
421
- const filename = `${args.id}.draft.md`;
422
- const abs = join(inboxDir, filename);
423
- const rel = `.cairn/ground/decisions/_inbox/${filename}`;
424
- const fm = {
425
- id: args.id,
426
- title: args.classification.suggestedDecDraft || `(untitled — from ${args.block.file})`,
427
- type: "adr",
428
- status: "draft-from-source-comment",
429
- audience: "dual",
430
- generated: args.generatedAt,
431
- "verified-at": args.generatedAt,
432
- decided_at: args.generatedAt,
433
- decided_by: "cairn-init",
434
- capture_source: "init-source-comments",
435
- capture_confidence: args.confidence ?? "medium",
436
- sourceFile: args.block.file,
437
- sourceRange: `${args.block.startLine}-${args.block.endLine}`,
438
- blockId: args.block.id,
439
- canonicalTopic: args.classification.suggestedCanonicalTopic,
440
- };
441
- const lines = [];
442
- lines.push("---");
443
- lines.push(stringifyYaml(fm).trimEnd());
444
- lines.push("---");
445
- lines.push("");
446
- lines.push(`# ${args.id} — ${fm["title"]}`);
447
- lines.push("");
448
- lines.push("## Source comment");
449
- lines.push("");
450
- lines.push("```");
451
- lines.push(args.block.raw);
452
- lines.push("```");
453
- lines.push("");
454
- lines.push("## Proposed rationale");
455
- lines.push("");
456
- lines.push(args.block.prose);
457
- lines.push("");
458
- writeFileSync(abs, lines.join("\n"), "utf8");
459
- return { absPath: abs, relPath: rel };
460
- }
461
- function writeInvariantFile(args) {
462
- const dir = invariantsDir(args.repoRoot);
463
- mkdirSync(dir, { recursive: true });
464
- const filename = `${args.id}.md`;
465
- const abs = join(dir, filename);
466
- const rel = `.cairn/ground/invariants/${filename}`;
467
- const fm = {
468
- id: args.id,
469
- title: args.classification.suggestedInvariant.split("\n")[0]?.slice(0, 120) ?? args.id,
470
- type: "invariant",
471
- status: "active",
472
- audience: "dual",
473
- generated: args.generatedAt,
474
- "verified-at": args.generatedAt,
475
- capture_confidence: args.confidence ?? "medium",
476
- source_decision: null,
477
- sourceFile: args.block.file,
478
- sourceRange: `${args.block.startLine}-${args.block.endLine}`,
479
- blockId: args.block.id,
480
- canonicalTopic: args.classification.suggestedCanonicalTopic,
481
- };
482
- const lines = [];
483
- lines.push("---");
484
- lines.push(stringifyYaml(fm).trimEnd());
485
- lines.push("---");
486
- lines.push("");
487
- lines.push(`# §${args.id} — ${fm["title"]}`);
488
- lines.push("");
489
- lines.push("## Constraint");
490
- lines.push("");
491
- lines.push(args.classification.suggestedInvariant.trim());
492
- lines.push("");
493
- lines.push("## Source comment");
494
- lines.push("");
495
- lines.push("```");
496
- lines.push(args.block.raw);
497
- lines.push("```");
498
- lines.push("");
499
- writeFileSync(abs, lines.join("\n"), "utf8");
500
- 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");
501
532
  }
502
533
  function writeYaml(path, payload) {
503
534
  mkdirSync(dirname(path), { recursive: true });