@agentskit/doc-bridge 1.7.45 → 1.10.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 (213) hide show
  1. package/CHANGELOG.md +488 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/README.md +87 -40
  4. package/SECURITY.md +7 -0
  5. package/action.yml +1 -1
  6. package/bin/ak-docs.js +2 -2
  7. package/bin/ak-verify.js +13 -7
  8. package/dist/cli/program.d.ts +3 -1
  9. package/dist/cli/program.js +15871 -6218
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +95 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
  15. package/dist/index.d.ts +9880 -1062
  16. package/dist/index.js +16143 -6127
  17. package/dist/index.js.map +1 -1
  18. package/docs/MARKETPLACE.md +1 -1
  19. package/docs/PRD-documentation-efficiency-study.md +406 -0
  20. package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
  21. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  22. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  23. package/docs/adr/0004-controlled-study-runner.md +25 -0
  24. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  25. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  26. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  27. package/docs/adr/0008-study-verification-boundary.md +21 -0
  28. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  29. package/docs/agent-corpus/INDEX.md +12 -1
  30. package/docs/agent-corpus/OVERVIEW.md +25 -0
  31. package/docs/agent-corpus/chat.md +5 -1
  32. package/docs/agent-corpus/cli.md +16 -0
  33. package/docs/agent-corpus/conformance.md +12 -0
  34. package/docs/agent-corpus/doc-bridge.md +47 -0
  35. package/docs/agent-corpus/doctor.md +8 -0
  36. package/docs/agent-corpus/gates.md +4 -0
  37. package/docs/agent-corpus/mcp.md +13 -0
  38. package/docs/agent-corpus/memory.md +4 -0
  39. package/docs/agent-corpus/query.md +33 -0
  40. package/docs/bench/README.md +122 -0
  41. package/docs/bench/retrieval-baseline-v1.json +28 -0
  42. package/docs/bench/retrieval-suite-v1.json +1033 -0
  43. package/docs/chat-and-rag.md +3 -2
  44. package/docs/for-agents.md +9 -1
  45. package/docs/getting-started.md +4 -11
  46. package/docs/guides/gate-ci.md +11 -1
  47. package/docs/guides/install-and-run.md +9 -65
  48. package/docs/index.md +22 -1
  49. package/docs/knowledge-engine-runbook.md +38 -3
  50. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  51. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  52. package/docs/landing/index.html +119 -5
  53. package/docs/loop-workflow.md +117 -0
  54. package/docs/mcp.md +6 -1
  55. package/docs/parity/public-claims-v1.json +145 -0
  56. package/docs/playbook/doc-bridge-pattern.md +1 -1
  57. package/docs/query.md +90 -2
  58. package/docs/recipes/index-pipeline.md +1 -1
  59. package/docs/schemas/agent-handoff-v1.md +15 -0
  60. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  61. package/docs/spec/benchmark-v1.md +33 -1
  62. package/docs/spec/cli.md +30 -10
  63. package/docs/spec/config-v1.md +157 -8
  64. package/docs/spec/documentation-audit-v1.md +61 -0
  65. package/docs/spec/enrichment-overlay-v1.md +241 -0
  66. package/docs/spec/graph-signals-v1.md +92 -0
  67. package/docs/spec/incremental-scan-v1.md +102 -0
  68. package/docs/spec/markdown-analyzer-v1.md +73 -0
  69. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  70. package/docs/spec/measured-enrichment-v1.md +229 -0
  71. package/docs/spec/public-parity-v1.md +119 -0
  72. package/docs/spec/registry-agents.md +6 -0
  73. package/docs/spec/render-v1.md +122 -0
  74. package/docs/spec/retrieval-index-v1.md +164 -0
  75. package/docs/spec/study-metrics-v1.md +58 -0
  76. package/docs/spec/study-protocol-v1.md +46 -0
  77. package/docs/spec/study-provider-cli-v1.md +116 -0
  78. package/docs/spec/study-runner-v1.md +35 -0
  79. package/docs/spec/study-task-suite-v1.md +41 -0
  80. package/docs/spec/study-verification-v1.md +40 -0
  81. package/docs/study/README.md +84 -0
  82. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  83. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  86. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  87. package/docs/study/ab-baseline-plan-v1.json +33 -0
  88. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-result-v1.json +79 -0
  90. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  91. package/docs/study/historical-evidence-v1.json +252 -0
  92. package/docs/study/observation-ledger-v1.json +30632 -0
  93. package/docs/study/phase3-task-coverage-v1.json +34 -0
  94. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  95. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  96. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  97. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  98. package/docs/study/pilot-round-2026-08-31.json +46 -0
  99. package/docs/study/protocol-v1.json +90 -0
  100. package/docs/study/publication-gate-v1.md +45 -0
  101. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  102. package/docs/study/quality-scorecard-v1.json +38 -0
  103. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  104. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  105. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  106. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  107. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  108. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  109. package/docs/study/round-4-confirmation-v1.json +75 -0
  110. package/docs/study/round-4-confirmation-v1.md +55 -0
  111. package/docs/study/run-plan-v1.json +33 -0
  112. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  113. package/docs/study/task-suite-v1.json +96 -0
  114. package/docs/study/token-efficiency-plan-v1.md +337 -0
  115. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  116. package/docs/study/verification-binding-v1.json +27 -0
  117. package/docs/validation-cycle-plan.md +14 -0
  118. package/docs/verification-harness.md +11 -6
  119. package/ecosystem-claims.json +2 -2
  120. package/ecosystem-upstream.json +2 -2
  121. package/ecosystem.json +4 -4
  122. package/mcpb/manifest.json +9 -1
  123. package/package.json +34 -15
  124. package/scripts/check-ecosystem-upstream.mjs +34 -6
  125. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  126. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  127. package/src/agents/registry-adapter.ts +192 -24
  128. package/src/audit/documentation.ts +513 -0
  129. package/src/bench/baseline.ts +198 -0
  130. package/src/bench/overlay-delta.ts +139 -0
  131. package/src/bench/retrieval.ts +319 -0
  132. package/src/budget/compile.ts +91 -0
  133. package/src/budget/sections.ts +70 -0
  134. package/src/cli/program.ts +684 -74
  135. package/src/cli/usage.ts +71 -0
  136. package/src/config/defaults.ts +1 -0
  137. package/src/config/index.ts +4 -0
  138. package/src/config/schema.ts +126 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +197 -0
  141. package/src/discovery/documentation.ts +211 -5
  142. package/src/discovery/identity.ts +24 -0
  143. package/src/discovery/incremental.ts +314 -0
  144. package/src/discovery/inputs.ts +110 -0
  145. package/src/discovery/markdown.ts +481 -0
  146. package/src/discovery/repository.ts +485 -101
  147. package/src/doctor/run-doctor.ts +244 -12
  148. package/src/enrich/approvals.ts +190 -0
  149. package/src/enrich/cache.ts +93 -0
  150. package/src/enrich/context-pack.ts +272 -0
  151. package/src/enrich/overlay.ts +255 -0
  152. package/src/enrich/review.ts +106 -0
  153. package/src/enrich/stage.ts +374 -0
  154. package/src/enrich/stats.ts +100 -0
  155. package/src/enrich/validate.ts +410 -0
  156. package/src/federation/llms.ts +2 -4
  157. package/src/findings/report.ts +103 -0
  158. package/src/graph/build.ts +356 -0
  159. package/src/graph/memory.ts +208 -0
  160. package/src/index-builder/build-handoffs.ts +22 -11
  161. package/src/index-builder/build-index.ts +132 -3
  162. package/src/index-builder/llms-txt.ts +48 -8
  163. package/src/index-builder/project-corpus.ts +111 -0
  164. package/src/index.ts +630 -2
  165. package/src/lib/fuzzy-match.ts +235 -0
  166. package/src/mcp/knowledge.ts +554 -0
  167. package/src/mcp/server.ts +113 -18
  168. package/src/metrics/benchmark.ts +21 -0
  169. package/src/parity/check.ts +309 -0
  170. package/src/parity/claims.ts +259 -0
  171. package/src/parity/resolve.ts +160 -0
  172. package/src/query/handoff.ts +326 -0
  173. package/src/query/load-index.ts +53 -1
  174. package/src/query/query.ts +92 -59
  175. package/src/query/search.ts +289 -92
  176. package/src/query/text.ts +155 -0
  177. package/src/reconciliation/reconcile.ts +101 -10
  178. package/src/render/data.ts +356 -0
  179. package/src/render/engine.ts +398 -0
  180. package/src/render/generated.ts +77 -0
  181. package/src/render/render.ts +209 -0
  182. package/src/render/template-source.ts +52 -0
  183. package/src/render/templates.ts +289 -0
  184. package/src/report/html.ts +2 -2
  185. package/src/retrieval/bm25.ts +161 -0
  186. package/src/retrieval/project.ts +495 -0
  187. package/src/retrieval/rank.ts +383 -0
  188. package/src/retrieval/weights.ts +39 -0
  189. package/src/retriever/doc-bridge-retriever.ts +100 -15
  190. package/src/rules/engine.ts +30 -10
  191. package/src/schemas/agent-handoff.ts +56 -0
  192. package/src/schemas/budget.ts +37 -0
  193. package/src/schemas/doc-bridge-index.ts +53 -2
  194. package/src/schemas/enrichment.ts +369 -0
  195. package/src/schemas/json-schemas.ts +39 -2
  196. package/src/schemas/knowledge.ts +14 -1
  197. package/src/schemas/retrieval-index.ts +152 -0
  198. package/src/shims/graphology.d.ts +91 -0
  199. package/src/study/adjudication.ts +196 -0
  200. package/src/study/execution.ts +350 -0
  201. package/src/study/expectations.ts +219 -0
  202. package/src/study/metrics.ts +467 -0
  203. package/src/study/protocol.ts +271 -0
  204. package/src/study/provider-cli.ts +115 -0
  205. package/src/study/provider-telemetry.ts +47 -0
  206. package/src/study/quality-scorecard.ts +164 -0
  207. package/src/study/runner.ts +461 -0
  208. package/src/study/task-suite.ts +321 -0
  209. package/src/study/verification.ts +134 -0
  210. package/src/validate.ts +1 -4
  211. package/src/version.ts +1 -1
  212. package/src/workflow/engine.ts +36 -11
  213. package/scripts/verification-harness.mjs +0 -482
@@ -0,0 +1,272 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
6
+ import { redactSecrets, redactValue } from '../safety/repository.js'
7
+ import type { DiscoverySnapshotV1, Evidence, KnowledgeDiagnostic, KnowledgeEntity, ReconciliationReportV1 } from '../schemas/knowledge.js'
8
+ import { entityContentHash } from './validate.js'
9
+
10
+ /**
11
+ * Context packs: what an agent receives instead of the snapshot.
12
+ *
13
+ * `ak-docs suggest` sent the whole redacted snapshot — 740 KB on this repository — in one call,
14
+ * for every question. A pack is one entity, its depth-one neighbours, the open diagnostics that
15
+ * touch it and a bounded excerpt of its own file, built in a fixed order and truncated in a fixed
16
+ * order under a byte budget. Cost becomes proportional to what changed, because the pack hash
17
+ * covers exactly the content hashes the pack is built from, and a pack whose hash has not moved
18
+ * is a pack the cache already answered.
19
+ */
20
+
21
+ export const CONTEXT_PACK_VERSION = 1 as const
22
+ export const DEFAULT_PACK_BYTES = 64 * 1024
23
+ export const MAX_PACK_NEIGHBOURS = 32
24
+ const MAX_PACK_DIAGNOSTICS = 16
25
+ const MAX_EXCERPT_BYTES = 12 * 1024
26
+ const MAX_MESSAGE = 512
27
+ const MAX_METADATA_STRING = 512
28
+
29
+ export type EnrichmentTask = 'curate' | 'review' | 'adjudicate'
30
+
31
+ export type PackNeighbour = {
32
+ readonly id: string
33
+ readonly kind: string
34
+ readonly name: string
35
+ readonly path?: string
36
+ readonly contentHash: string
37
+ readonly relation: string
38
+ readonly direction: 'out' | 'in'
39
+ }
40
+
41
+ export type PackDiagnostic = Pick<KnowledgeDiagnostic, 'id' | 'code' | 'status' | 'severity'> & { readonly message: string }
42
+
43
+ export type PackEvidence = {
44
+ readonly path: string
45
+ readonly contentHash: string
46
+ /** Redacted, bounded, possibly truncated — `truncated` says so. */
47
+ readonly excerpt: string
48
+ readonly truncated: boolean
49
+ }
50
+
51
+ export type ContextPack = {
52
+ readonly type: 'context-pack'
53
+ readonly schemaVersion: typeof CONTEXT_PACK_VERSION
54
+ /** Over the target's and every neighbour's content hash: the pack's identity for the cache. */
55
+ readonly packHash: string
56
+ readonly baseSnapshotHash: string
57
+ readonly areaId: string
58
+ readonly target: {
59
+ readonly id: string
60
+ readonly kind: string
61
+ readonly name: string
62
+ readonly path?: string
63
+ readonly contentHash: string
64
+ readonly aliases: readonly string[]
65
+ /** The entity's own evidence items: what a proposal about it may cite, verbatim. */
66
+ readonly evidence: readonly Pick<Evidence, 'source' | 'path' | 'lineStart' | 'lineEnd'>[]
67
+ readonly metadata: Readonly<Record<string, unknown>>
68
+ }
69
+ readonly neighbours: readonly PackNeighbour[]
70
+ readonly diagnostics: readonly PackDiagnostic[]
71
+ readonly evidence: readonly PackEvidence[]
72
+ /**
73
+ * `bytes` is the serialised size measured with this field at its widest (`maxBytes`), so the
74
+ * value written is never smaller than the pack it describes and never exceeds the budget.
75
+ */
76
+ readonly budget: { readonly maxBytes: number; readonly bytes: number; readonly dropped: readonly string[] }
77
+ }
78
+
79
+ export type BuildContextPacksOptions = {
80
+ readonly snapshot: Pick<DiscoverySnapshotV1, 'contentHash' | 'entities' | 'relations'>
81
+ readonly report?: Pick<ReconciliationReportV1, 'diagnostics'>
82
+ readonly config?: DocBridgeConfigV1
83
+ /** Which entity kinds to build packs for. Defaults to documents. */
84
+ readonly kinds?: readonly string[]
85
+ /** Only these entity ids. Unset means every entity of the selected kinds. */
86
+ readonly targets?: ReadonlySet<string>
87
+ /** File contents by repository-relative path. Defaults to reading under `root`. */
88
+ readonly readFile?: (path: string) => string | undefined
89
+ readonly root?: string
90
+ }
91
+
92
+ export const packByteBudget = (config: DocBridgeConfigV1 | undefined): number => config?.intelligence?.registry?.maxPackBytes ?? DEFAULT_PACK_BYTES
93
+
94
+ const bytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), 'utf8')
95
+
96
+ const boundedMetadata = (metadata: Record<string, unknown> | undefined): Record<string, unknown> => {
97
+ const out: Record<string, unknown> = {}
98
+ for (const key of Object.keys(metadata ?? {}).sort()) {
99
+ const value = metadata?.[key]
100
+ if (typeof value === 'string') out[key] = redactSecrets(value).slice(0, MAX_METADATA_STRING)
101
+ else if (typeof value === 'number' || typeof value === 'boolean') out[key] = value
102
+ else if (Array.isArray(value)) out[key] = redactValue(value.slice(0, 64))
103
+ else if (value && typeof value === 'object') out[key] = redactValue(value)
104
+ }
105
+ return out
106
+ }
107
+
108
+ const defaultReader = (root: string | undefined) => (path: string): string | undefined => {
109
+ if (!root) return undefined
110
+ try {
111
+ return readFileSync(join(resolve(root), path), 'utf8')
112
+ } catch {
113
+ return undefined
114
+ }
115
+ }
116
+
117
+ /** The area a path belongs to: the most specific area entity whose path prefixes it. */
118
+ const areaFor = (path: string | undefined, areas: readonly KnowledgeEntity[]): string => {
119
+ if (!path) return 'root'
120
+ let best: KnowledgeEntity | undefined
121
+ for (const area of areas) {
122
+ const areaPath = area.path?.replace(/\/$/, '')
123
+ if (!areaPath) continue
124
+ if (path === areaPath || path.startsWith(`${areaPath}/`)) {
125
+ if (!best || areaPath.length > (best.path?.length ?? 0)) best = area
126
+ }
127
+ }
128
+ return best?.id ?? 'root'
129
+ }
130
+
131
+ /**
132
+ * Fit a pack under its budget by dropping in a declared order: excerpt bytes first (halved
133
+ * until it fits, then dropped), then diagnostics from the end, then neighbours from the end.
134
+ * The target itself is never dropped — a pack with no target is not a pack.
135
+ *
136
+ * This mirrors `compileBudget` from `@agentskit/core` with a byte counter and the
137
+ * `drop-oldest` strategy over sections ordered least-important-first; a test cross-checks the
138
+ * two so the mirror cannot drift, and the mirror is what runs, because a pack must be the same
139
+ * pack whether or not an optional peer is installed.
140
+ */
141
+ export const fitContextPack = (pack: ContextPack, maxBytes: number): ContextPack => {
142
+ let current: ContextPack = { ...pack, budget: { maxBytes, bytes: maxBytes, dropped: [] } }
143
+ const dropped: string[] = []
144
+ const measure = (): number => bytes({ ...current, budget: { maxBytes, bytes: maxBytes, dropped } })
145
+ let size = measure()
146
+ while (size > maxBytes && current.evidence.some((item) => item.excerpt.length > 0)) {
147
+ current = {
148
+ ...current,
149
+ evidence: current.evidence.map((item) => {
150
+ if (!item.excerpt.length) return item
151
+ const next = item.excerpt.length > 256 ? item.excerpt.slice(0, Math.floor(item.excerpt.length / 2)) : ''
152
+ return { ...item, excerpt: next, truncated: true }
153
+ }),
154
+ }
155
+ if (!dropped.includes('evidence.excerpt')) dropped.push('evidence.excerpt')
156
+ size = measure()
157
+ }
158
+ while (size > maxBytes && current.diagnostics.length) {
159
+ current = { ...current, diagnostics: current.diagnostics.slice(0, -1) }
160
+ if (!dropped.includes('diagnostics')) dropped.push('diagnostics')
161
+ size = measure()
162
+ }
163
+ while (size > maxBytes && current.neighbours.length) {
164
+ current = { ...current, neighbours: current.neighbours.slice(0, -1) }
165
+ if (!dropped.includes('neighbours')) dropped.push('neighbours')
166
+ size = measure()
167
+ }
168
+ return { ...current, budget: { maxBytes, bytes: measure(), dropped } }
169
+ }
170
+
171
+ /** The pack hash: the target and its neighbours by content hash, nothing else. */
172
+ export const contextPackHash = (target: { readonly id: string; readonly contentHash: string }, neighbours: readonly Pick<PackNeighbour, 'id' | 'contentHash'>[]): string =>
173
+ sha256NormalizedV1({
174
+ version: CONTEXT_PACK_VERSION,
175
+ target: { id: target.id, contentHash: target.contentHash },
176
+ neighbours: [...neighbours].map((item) => ({ id: item.id, contentHash: item.contentHash })).sort((left, right) => left.id.localeCompare(right.id)),
177
+ })
178
+
179
+ /**
180
+ * Build one pack per target entity, in id order.
181
+ *
182
+ * Neighbours are the other ends of every relation touching the target, sorted by kind then id
183
+ * and capped; diagnostics are the open ones naming the target or citing its file; the excerpt is
184
+ * the target's own file, read only if its bytes still hash to what the snapshot recorded.
185
+ */
186
+ export const buildContextPacks = (options: BuildContextPacksOptions): ContextPack[] => {
187
+ const { snapshot } = options
188
+ const kinds = new Set(options.kinds ?? ['document'])
189
+ const readFile = options.readFile ?? defaultReader(options.root)
190
+ const maxBytes = packByteBudget(options.config)
191
+ const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
192
+ const areas = snapshot.entities.filter((entity) => entity.kind === 'area')
193
+ const touching = new Map<string, { readonly other: string; readonly relation: string; readonly direction: 'out' | 'in' }[]>()
194
+ const push = (id: string, item: { other: string; relation: string; direction: 'out' | 'in' }): void => {
195
+ const list = touching.get(id)
196
+ if (list) list.push(item)
197
+ else touching.set(id, [item])
198
+ }
199
+ for (const relation of snapshot.relations) {
200
+ push(relation.from, { other: relation.to, relation: relation.kind, direction: 'out' })
201
+ push(relation.to, { other: relation.from, relation: relation.kind, direction: 'in' })
202
+ }
203
+ const open = (options.report?.diagnostics ?? []).filter((diagnostic) => diagnostic.status !== 'confirmed')
204
+
205
+ const packs: ContextPack[] = []
206
+ for (const entity of [...snapshot.entities].sort((left, right) => left.id.localeCompare(right.id))) {
207
+ if (!kinds.has(entity.kind)) continue
208
+ if (options.targets && !options.targets.has(entity.id)) continue
209
+ const contentHash = entityContentHash(entity)
210
+ const neighbours: PackNeighbour[] = (touching.get(entity.id) ?? [])
211
+ .flatMap((item) => {
212
+ const other = entities.get(item.other)
213
+ return other
214
+ ? [{ id: other.id, kind: other.kind, name: other.name, ...(other.path ? { path: other.path } : {}), contentHash: entityContentHash(other), relation: item.relation, direction: item.direction }]
215
+ : []
216
+ })
217
+ .sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id) || left.relation.localeCompare(right.relation))
218
+ .filter((item, index, all) => index === 0 || item.id !== all[index - 1]?.id || item.relation !== all[index - 1]?.relation)
219
+ .slice(0, MAX_PACK_NEIGHBOURS)
220
+ const diagnostics: PackDiagnostic[] = open
221
+ .filter((diagnostic) => diagnostic.entityIds?.includes(entity.id) || (entity.path !== undefined && diagnostic.evidence.some((item) => item.path === entity.path)))
222
+ .sort((left, right) => left.id.localeCompare(right.id))
223
+ .slice(0, MAX_PACK_DIAGNOSTICS)
224
+ .map((diagnostic) => ({ id: diagnostic.id, code: diagnostic.code, status: diagnostic.status, severity: diagnostic.severity, message: redactSecrets(diagnostic.message).slice(0, MAX_MESSAGE) }))
225
+ const evidence: PackEvidence[] = []
226
+ if (entity.path && entity.evidence[0]?.contentHash) {
227
+ const raw = readFile(entity.path)
228
+ // A file that no longer hashes to the entity is not the entity's file: the pack describes the snapshot.
229
+ if (raw !== undefined && sha256NormalizedV1(raw.replace(/^/, '')) === entity.evidence[0].contentHash) {
230
+ const redacted = redactSecrets(raw)
231
+ const truncated = Buffer.byteLength(redacted, 'utf8') > MAX_EXCERPT_BYTES
232
+ evidence.push({ path: entity.path, contentHash: entity.evidence[0].contentHash, excerpt: truncated ? redacted.slice(0, MAX_EXCERPT_BYTES) : redacted, truncated })
233
+ }
234
+ }
235
+ const draft: ContextPack = {
236
+ type: 'context-pack',
237
+ schemaVersion: CONTEXT_PACK_VERSION,
238
+ packHash: contextPackHash({ id: entity.id, contentHash }, neighbours),
239
+ baseSnapshotHash: snapshot.contentHash,
240
+ areaId: entity.kind === 'area' ? entity.id : areaFor(entity.path, areas),
241
+ target: {
242
+ id: entity.id,
243
+ kind: entity.kind,
244
+ name: redactSecrets(entity.name),
245
+ ...(entity.path ? { path: entity.path } : {}),
246
+ contentHash,
247
+ aliases: [...(entity.aliases ?? [])].map(redactSecrets).sort(),
248
+ evidence: entity.evidence.slice(0, 8).map((item) => ({ source: item.source, path: item.path, ...(item.lineStart !== undefined ? { lineStart: item.lineStart } : {}), ...(item.lineEnd !== undefined ? { lineEnd: item.lineEnd } : {}) })),
249
+ metadata: boundedMetadata(entity.metadata),
250
+ },
251
+ neighbours,
252
+ diagnostics,
253
+ evidence,
254
+ budget: { maxBytes, bytes: 0, dropped: [] },
255
+ }
256
+ packs.push(fitContextPack(draft, maxBytes))
257
+ }
258
+ return packs
259
+ }
260
+
261
+ /** Packs grouped by area, areas in id order, packs in id order within each: one batch per call. */
262
+ export const batchContextPacks = (packs: readonly ContextPack[]): readonly { readonly areaId: string; readonly packs: readonly ContextPack[] }[] => {
263
+ const groups = new Map<string, ContextPack[]>()
264
+ for (const pack of packs) {
265
+ const list = groups.get(pack.areaId)
266
+ if (list) list.push(pack)
267
+ else groups.set(pack.areaId, [pack])
268
+ }
269
+ return [...groups.entries()]
270
+ .sort(([left], [right]) => left.localeCompare(right))
271
+ .map(([areaId, list]) => ({ areaId, packs: [...list].sort((left, right) => left.target.id.localeCompare(right.target.id)) }))
272
+ }
@@ -0,0 +1,255 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
5
+ import type { RetrievalOverlayInput } from '../retrieval/project.js'
6
+ import {
7
+ ENRICHMENT_POLICY,
8
+ EnrichmentOverlayV1Schema,
9
+ enrichmentOverlayContentHash,
10
+ type AcceptedEnrichment,
11
+ type EnrichmentOverlayV1,
12
+ type EnrichmentProposalOf,
13
+ } from '../schemas/enrichment.js'
14
+ import type { DiscoverySnapshotV1, KnowledgeEntity, KnowledgeRelation } from '../schemas/knowledge.js'
15
+ import { entityContentHash } from './validate.js'
16
+
17
+ /**
18
+ * The overlay on disk, and what the deterministic layer reads from it.
19
+ *
20
+ * The file is written by the enrich stage and by an approval; it is never written by a read.
21
+ * `index`, `search`, `query` and MCP consult it through `projectEnrichmentOverlay`, which is a
22
+ * pure function of the overlay and the snapshot: an accepted entry whose target hash no longer
23
+ * matches its entity is expired *in the result*, not in the file, so a stale entry costs nothing
24
+ * and a read leaves no trace.
25
+ */
26
+
27
+ export const ENRICHMENT_DIR = '.doc-bridge/enrich'
28
+ export const ENRICHMENT_OVERLAY_FILE = 'overlay.json'
29
+
30
+ export const enrichmentDir = (root: string): string => join(resolve(root), ENRICHMENT_DIR)
31
+ export const enrichmentOverlayPath = (root: string): string => join(enrichmentDir(root), ENRICHMENT_OVERLAY_FILE)
32
+ export const enrichmentCacheDir = (root: string): string => join(enrichmentDir(root), 'cache')
33
+
34
+ /**
35
+ * Read the overlay, or nothing.
36
+ *
37
+ * Missing, unreadable, malformed or failing its own hash all mean the same thing to a reader:
38
+ * there is no overlay. Failing closed here is what keeps a corrupt file from changing a `check`
39
+ * result — a reader that threw would make the deterministic layer depend on the enrichment one.
40
+ */
41
+ export const readEnrichmentOverlay = (root: string): EnrichmentOverlayV1 | undefined => {
42
+ const path = enrichmentOverlayPath(root)
43
+ if (!existsSync(path)) return undefined
44
+ try {
45
+ const parsed = EnrichmentOverlayV1Schema.parse(JSON.parse(readFileSync(path, 'utf8')) as unknown)
46
+ return parsed.contentHash === enrichmentOverlayContentHash(parsed) ? parsed : undefined
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
51
+
52
+ /** Parse an overlay from memory with the same rules as a read: schema and hash, or nothing. */
53
+ export const parseEnrichmentOverlay = (value: unknown): EnrichmentOverlayV1 | undefined => {
54
+ const parsed = EnrichmentOverlayV1Schema.safeParse(value)
55
+ return parsed.success && parsed.data.contentHash === enrichmentOverlayContentHash(parsed.data) ? parsed.data : undefined
56
+ }
57
+
58
+ export const writeEnrichmentOverlay = (root: string, overlay: EnrichmentOverlayV1): string => {
59
+ const path = enrichmentOverlayPath(root)
60
+ mkdirSync(enrichmentDir(root), { recursive: true })
61
+ const sealed = EnrichmentOverlayV1Schema.parse({ ...overlay, contentHash: enrichmentOverlayContentHash(overlay) })
62
+ const temporary = `${path}.tmp-${process.pid}`
63
+ writeFileSync(temporary, `${JSON.stringify(sealed, null, 2)}\n`, 'utf8')
64
+ renameSync(temporary, path)
65
+ return path
66
+ }
67
+
68
+ /** Seal an overlay: every list sorted, the hash recomputed. The shape every writer goes through. */
69
+ export const sealEnrichmentOverlay = (overlay: Omit<EnrichmentOverlayV1, 'contentHash'>): EnrichmentOverlayV1 => {
70
+ const sorted = {
71
+ ...overlay,
72
+ accepted: [...overlay.accepted].sort((left, right) => left.proposal.proposalId.localeCompare(right.proposal.proposalId)),
73
+ pending: [...overlay.pending].sort((left, right) => left.proposal.proposalId.localeCompare(right.proposal.proposalId)),
74
+ rejected: [...overlay.rejected].sort((left, right) => left.proposalId.localeCompare(right.proposalId) || left.reason.localeCompare(right.reason)),
75
+ }
76
+ return EnrichmentOverlayV1Schema.parse({ ...sorted, contentHash: enrichmentOverlayContentHash(sorted) })
77
+ }
78
+
79
+ export type EffectiveOverlay = {
80
+ /** Accepted entries whose target still has the hash they were made against. */
81
+ readonly live: readonly AcceptedEnrichment[]
82
+ /** Accepted entries whose target moved: excluded from every projection, left in the file. */
83
+ readonly expired: readonly AcceptedEnrichment[]
84
+ }
85
+
86
+ /**
87
+ * Per-entry staleness.
88
+ *
89
+ * An entry binds to the content hash of the entity it describes, not to the snapshot hash, so one
90
+ * changed file expires one entry and its siblings survive. An entity that no longer exists expires
91
+ * its entries too: there is nothing left to describe.
92
+ */
93
+ export const effectiveEnrichment = (overlay: Pick<EnrichmentOverlayV1, 'accepted'>, snapshot: Pick<DiscoverySnapshotV1, 'entities'>): EffectiveOverlay => {
94
+ const hashes = new Map(snapshot.entities.map((entity) => [entity.id, entityContentHash(entity)]))
95
+ const live: AcceptedEnrichment[] = []
96
+ const expired: AcceptedEnrichment[] = []
97
+ for (const entry of overlay.accepted) {
98
+ const current = hashes.get(entry.proposal.entity)
99
+ const still = current !== undefined && current === entry.proposal.targetContentHash && endpointsPresent(entry, hashes)
100
+ ;(still ? live : expired).push(entry)
101
+ }
102
+ return { live, expired }
103
+ }
104
+
105
+ /** A relation or a flag names a second entity; if that one is gone, the entry has nothing to point at. */
106
+ const endpointsPresent = (entry: AcceptedEnrichment, hashes: ReadonlyMap<string, string>): boolean => {
107
+ const { proposal } = entry
108
+ switch (proposal.kind) {
109
+ case 'propose-relation':
110
+ return hashes.has(proposal.payload.from) && hashes.has(proposal.payload.to)
111
+ case 'mark-canonical':
112
+ return hashes.has(proposal.payload.scope)
113
+ case 'flag-contradiction':
114
+ return hashes.has(proposal.payload.against)
115
+ case 'flag-redundancy':
116
+ return hashes.has(proposal.payload.with)
117
+ case 'flag-gap':
118
+ return hashes.has(proposal.payload.area)
119
+ default:
120
+ return true
121
+ }
122
+ }
123
+
124
+ /** The hash of what an overlay contributes: the live accepted set. Empty when nothing is live. */
125
+ export const enrichmentOverlayHash = (live: readonly AcceptedEnrichment[]): string =>
126
+ sha256NormalizedV1({
127
+ accepted: [...live]
128
+ .map((entry) => ({ proposalId: entry.proposal.proposalId, targetContentHash: entry.proposal.targetContentHash }))
129
+ .sort((left, right) => left.proposalId.localeCompare(right.proposalId)),
130
+ })
131
+
132
+ /** Signals are shares of the bounded weight: a strong hint is the full share, a canonical marker most of it, a weak hint half. */
133
+ const SIGNAL_STRONG = 1
134
+ const SIGNAL_CANONICAL = 0.8
135
+ const SIGNAL_WEAK = 0.5
136
+
137
+ const relationOf = (proposal: EnrichmentProposalOf<'propose-relation'>): KnowledgeRelation => ({
138
+ id: `relation:${proposal.payload.from}|${proposal.payload.kind}|${proposal.payload.to}|proposed:${proposal.proposalId.slice(0, 12)}`,
139
+ kind: proposal.payload.kind,
140
+ from: proposal.payload.from,
141
+ to: proposal.payload.to,
142
+ provenance: 'proposed',
143
+ evidence: proposal.evidence,
144
+ metadata: { proposalId: proposal.proposalId, detection: proposal.payload.detection, confidence: 'proposed', agentId: proposal.origin.agentId },
145
+ })
146
+
147
+ /**
148
+ * What the projection reads from an overlay: aliases, summaries, intents, canonical markers,
149
+ * rank hints and relations, each already expired against the snapshot.
150
+ *
151
+ * Findings (`flag-*`) and area suggestions contribute nothing here on purpose. A gap is a thing to
152
+ * review, not a fact to rank on; an area suggestion becomes configuration or nothing.
153
+ */
154
+ export const projectEnrichmentOverlay = (
155
+ overlay: EnrichmentOverlayV1 | undefined,
156
+ snapshot: Pick<DiscoverySnapshotV1, 'entities'>,
157
+ ): RetrievalOverlayInput | undefined => {
158
+ if (!overlay) return undefined
159
+ const { live } = effectiveEnrichment(overlay, snapshot)
160
+ const signals = new Map<string, number>()
161
+ const aliases = new Map<string, string[]>()
162
+ const summaries = new Map<string, string>()
163
+ const canonical = new Map<string, string>()
164
+ const intents: { id: string; title: string; paths: string[]; language: string }[] = []
165
+ const relations: KnowledgeRelation[] = []
166
+ const paths = new Map(snapshot.entities.map((entity) => [entity.id, entity.path]))
167
+ const bump = (id: string, share: number): void => {
168
+ signals.set(id, Math.min(1, (signals.get(id) ?? 0) + share))
169
+ }
170
+
171
+ for (const entry of live) {
172
+ const { proposal } = entry
173
+ switch (proposal.kind) {
174
+ case 'add-alias':
175
+ aliases.set(proposal.entity, [...(aliases.get(proposal.entity) ?? []), proposal.payload.alias])
176
+ break
177
+ case 'summarize':
178
+ if (!summaries.has(proposal.entity)) summaries.set(proposal.entity, proposal.payload.summary)
179
+ break
180
+ case 'add-intent':
181
+ intents.push({
182
+ id: `intent:proposed:${proposal.proposalId.slice(0, 16)}`,
183
+ title: proposal.payload.phrase,
184
+ paths: [paths.get(proposal.entity) ?? proposal.entity],
185
+ language: proposal.payload.language,
186
+ })
187
+ break
188
+ case 'mark-canonical':
189
+ canonical.set(proposal.entity, proposal.payload.scope)
190
+ bump(proposal.entity, SIGNAL_CANONICAL)
191
+ break
192
+ case 'rank-hint':
193
+ bump(proposal.entity, proposal.payload.relevance === 'strong' ? SIGNAL_STRONG : SIGNAL_WEAK)
194
+ break
195
+ case 'propose-relation':
196
+ relations.push(relationOf(proposal))
197
+ break
198
+ default:
199
+ break
200
+ }
201
+ }
202
+ for (const list of aliases.values()) list.sort()
203
+ intents.sort((left, right) => left.id.localeCompare(right.id))
204
+ relations.sort((left, right) => left.id.localeCompare(right.id))
205
+ return {
206
+ hash: enrichmentOverlayHash(live),
207
+ ...(signals.size ? { signals } : {}),
208
+ ...(aliases.size ? { aliases } : {}),
209
+ ...(summaries.size ? { summaries } : {}),
210
+ ...(canonical.size ? { canonical } : {}),
211
+ ...(intents.length ? { intents } : {}),
212
+ ...(relations.length ? { relations } : {}),
213
+ }
214
+ }
215
+
216
+ /**
217
+ * The snapshot plus the live accepted relations, for the graph, the report and the memory view.
218
+ *
219
+ * Additive only: every observed entity and relation is carried unchanged, and a proposed relation
220
+ * whose id collides with an observed one is dropped rather than allowed to shadow it. The
221
+ * assertion below is what the projection tests run; it is here so any consumer can run it.
222
+ */
223
+ export const withAcceptedRelations = <T extends Pick<DiscoverySnapshotV1, 'entities' | 'relations'>>(
224
+ snapshot: T,
225
+ overlay: EnrichmentOverlayV1 | undefined,
226
+ ): T => {
227
+ if (!overlay) return snapshot
228
+ const { live } = effectiveEnrichment(overlay, snapshot)
229
+ const observedIds = new Set(snapshot.relations.map((relation) => relation.id))
230
+ const proposed = live
231
+ .flatMap((entry) => (entry.proposal.kind === 'propose-relation' ? [relationOf(entry.proposal as EnrichmentProposalOf<'propose-relation'>)] : []))
232
+ .filter((relation) => !observedIds.has(relation.id))
233
+ .sort((left, right) => left.id.localeCompare(right.id))
234
+ if (!proposed.length) return snapshot
235
+ const merged = { ...snapshot, relations: [...snapshot.relations, ...proposed] }
236
+ assertObservedSurvive(snapshot, merged)
237
+ return merged
238
+ }
239
+
240
+ /** Every observed entity and relation must be present, unchanged, after enrichment. */
241
+ export const assertObservedSurvive = (
242
+ observed: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
243
+ enriched: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
244
+ ): void => {
245
+ const entities = new Map(enriched.entities.map((entity) => [entity.id, entity]))
246
+ const relations = new Map(enriched.relations.map((relation) => [relation.id, relation]))
247
+ const same = (left: KnowledgeEntity | KnowledgeRelation, right: KnowledgeEntity | KnowledgeRelation | undefined): boolean =>
248
+ right !== undefined && sha256NormalizedV1(left) === sha256NormalizedV1(right)
249
+ for (const entity of observed.entities) if (!same(entity, entities.get(entity.id))) throw new Error(`Enrichment removed or altered observed entity ${entity.id}.`)
250
+ for (const relation of observed.relations) if (!same(relation, relations.get(relation.id))) throw new Error(`Enrichment removed or altered observed relation ${relation.id}.`)
251
+ }
252
+
253
+ /** Accepted entries that are findings rather than facts, for a reviewer or a renderer. */
254
+ export const enrichmentFindings = (overlay: EnrichmentOverlayV1): readonly AcceptedEnrichment[] =>
255
+ overlay.accepted.filter((entry) => ENRICHMENT_POLICY[entry.proposal.kind] === 'finding')
@@ -0,0 +1,106 @@
1
+ import { ENRICHMENT_POLICY, type AcceptedEnrichment, type EnrichmentOverlayV1, type PendingEnrichment, type RejectedEnrichment } from '../schemas/enrichment.js'
2
+ import type { DiscoverySnapshotV1 } from '../schemas/knowledge.js'
3
+ import { approvalsDir, createFileApprovalStore, enrichmentApprovalId, loadApprovalGate, ENRICHMENT_APPROVAL_GATE, type ApprovalGate } from './approvals.js'
4
+ import { effectiveEnrichment, readEnrichmentOverlay, sealEnrichmentOverlay, writeEnrichmentOverlay } from './overlay.js'
5
+ import { entityContentHash } from './validate.js'
6
+
7
+ /**
8
+ * Reviewing the overlay: what a person does with it.
9
+ *
10
+ * Shared by `ak-docs enrich list|approve|reject`, the MCP `docbridge.proposals` tool and a
11
+ * rendered review page, so there is one way to decide and one record of the decision. A
12
+ * decision goes through the approval gate first and the overlay second: if the gate refuses —
13
+ * already decided, unknown — the overlay is untouched.
14
+ */
15
+
16
+ export type EnrichmentReview = {
17
+ readonly overlayHash: string
18
+ readonly baseSnapshotHash: string
19
+ readonly accepted: readonly AcceptedEnrichment[]
20
+ readonly pending: readonly PendingEnrichment[]
21
+ readonly rejected: readonly RejectedEnrichment[]
22
+ readonly stats: EnrichmentOverlayV1['stats']
23
+ }
24
+
25
+ export const listEnrichment = (root: string): EnrichmentReview | undefined => {
26
+ const overlay = readEnrichmentOverlay(root)
27
+ if (!overlay) return undefined
28
+ return { overlayHash: overlay.contentHash, baseSnapshotHash: overlay.baseSnapshotHash, accepted: overlay.accepted, pending: overlay.pending, rejected: overlay.rejected, stats: overlay.stats }
29
+ }
30
+
31
+ export type DecideEnrichmentOptions = {
32
+ readonly root: string
33
+ readonly proposalId: string
34
+ readonly decision: 'approved' | 'rejected'
35
+ /** Who decided. Must not be the proposal's author, and must not be `policy`. */
36
+ readonly by: string
37
+ readonly reason?: string
38
+ /** When given, the proposal must still describe the entity as it is now. */
39
+ readonly snapshot?: Pick<DiscoverySnapshotV1, 'entities'>
40
+ readonly gate?: ApprovalGate
41
+ readonly now?: () => string
42
+ }
43
+
44
+ export type DecideEnrichmentResult = {
45
+ readonly overlay: EnrichmentOverlayV1
46
+ readonly approvalId: string
47
+ readonly entry: AcceptedEnrichment | RejectedEnrichment
48
+ readonly gateSource: 'ecosystem' | 'mirror' | 'supplied'
49
+ }
50
+
51
+ /**
52
+ * Approve or reject one pending proposal.
53
+ *
54
+ * Binding is to both the proposal id and the target content hash: the approval record's id is
55
+ * their hash, so an approval given for one version of a document cannot be replayed against the
56
+ * next one. A person approving their own agent's output is refused by identity, and `policy` is
57
+ * not a person.
58
+ */
59
+ export const decideEnrichment = async (options: DecideEnrichmentOptions): Promise<DecideEnrichmentResult> => {
60
+ const overlay = readEnrichmentOverlay(options.root)
61
+ if (!overlay) throw new Error('No enrichment overlay to review. Run `ak-docs enrich` first.')
62
+ const entry = overlay.pending.find((item) => item.proposal.proposalId === options.proposalId || item.proposal.proposalId.startsWith(options.proposalId))
63
+ if (!entry) throw new Error(`No pending enrichment proposal "${options.proposalId}".`)
64
+ const { proposal } = entry
65
+ if (options.by === 'policy') throw new Error(`${proposal.kind} requires a person; "policy" cannot approve it.`)
66
+ if (options.by === proposal.origin.agentId) throw new Error(`Rejected: "${options.by}" proposed ${proposal.proposalId} and cannot approve its own output.`)
67
+ if (options.snapshot) {
68
+ const entity = options.snapshot.entities.find((item) => item.id === proposal.entity)
69
+ if (!entity || entityContentHash(entity) !== proposal.targetContentHash) throw new Error(`Proposal ${proposal.proposalId} is stale: ${proposal.entity} changed since it was made. Re-run enrichment.`)
70
+ if (effectiveEnrichment({ accepted: [{ proposal, acceptedAt: '1970-01-01T00:00:00.000Z', acceptedBy: options.by }] }, options.snapshot).live.length !== 1) throw new Error(`Proposal ${proposal.proposalId} names an entity that no longer exists.`)
71
+ }
72
+
73
+ const approvalId = enrichmentApprovalId(proposal.proposalId, proposal.targetContentHash)
74
+ const loaded = options.gate ? { gate: options.gate, source: 'supplied' as const } : await loadApprovalGate(createFileApprovalStore(approvalsDir(options.root)))
75
+ const existing = await loaded.gate.request({
76
+ id: approvalId,
77
+ name: ENRICHMENT_APPROVAL_GATE,
78
+ payload: { proposalId: proposal.proposalId, targetContentHash: proposal.targetContentHash, kind: proposal.kind, entity: proposal.entity, policy: ENRICHMENT_POLICY[proposal.kind] },
79
+ })
80
+ if (existing.status !== 'pending') throw new Error(`Approval ${approvalId} was already ${existing.status}.`)
81
+ const approval = await loaded.gate.decide(approvalId, options.decision, { by: options.by, proposalId: proposal.proposalId, targetContentHash: proposal.targetContentHash, ...(options.reason ? { reason: options.reason } : {}) })
82
+
83
+ const pending = overlay.pending.filter((item) => item.proposal.proposalId !== proposal.proposalId)
84
+ const decided: AcceptedEnrichment | RejectedEnrichment =
85
+ options.decision === 'approved'
86
+ ? { proposal, acceptedAt: approval.decidedAt ?? (options.now ?? (() => new Date().toISOString()))(), acceptedBy: options.by, approvalId }
87
+ : { proposalId: proposal.proposalId, kind: proposal.kind, entity: proposal.entity, reason: 'human-rejected', detail: `${options.by}${options.reason ? `: ${options.reason}` : ''}`.slice(0, 1_024), origin: proposal.origin }
88
+ const stats = { ...overlay.stats, byKind: { ...overlay.stats.byKind }, rejectionReasons: { ...overlay.stats.rejectionReasons } }
89
+ const counts = { ...(stats.byKind[proposal.kind] ?? { proposed: 0, accepted: 0, pending: 0, rejected: 0 }) }
90
+ counts.pending = Math.max(0, counts.pending - 1)
91
+ if (options.decision === 'approved') counts.accepted += 1
92
+ else {
93
+ counts.rejected += 1
94
+ stats.rejectionReasons['human-rejected'] = (stats.rejectionReasons['human-rejected'] ?? 0) + 1
95
+ }
96
+ stats.byKind[proposal.kind] = counts
97
+ const next = sealEnrichmentOverlay({
98
+ ...overlay,
99
+ pending,
100
+ accepted: options.decision === 'approved' ? [...overlay.accepted, decided as AcceptedEnrichment] : overlay.accepted,
101
+ rejected: options.decision === 'rejected' ? [...overlay.rejected, decided as RejectedEnrichment] : overlay.rejected,
102
+ stats,
103
+ })
104
+ writeEnrichmentOverlay(options.root, next)
105
+ return { overlay: next, approvalId, entry: decided, gateSource: loaded.source }
106
+ }