@agentskit/doc-bridge 1.7.44 → 1.9.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 (223) hide show
  1. package/CHANGELOG.md +471 -0
  2. package/CONTRIBUTING.md +29 -4
  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 +15888 -6061
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +91 -9
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/index-Beor6Yhi.d.ts +792 -0
  15. package/dist/index.d.ts +9979 -3257
  16. package/dist/index.js +15954 -5774
  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/RELEASE.md +22 -8
  22. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  23. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  24. package/docs/adr/0004-controlled-study-runner.md +25 -0
  25. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  26. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  27. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  28. package/docs/adr/0008-study-verification-boundary.md +21 -0
  29. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  30. package/docs/agent-corpus/INDEX.md +14 -3
  31. package/docs/agent-corpus/OVERVIEW.md +25 -0
  32. package/docs/agent-corpus/chat.md +7 -3
  33. package/docs/agent-corpus/cli.md +18 -2
  34. package/docs/agent-corpus/conformance.md +14 -2
  35. package/docs/agent-corpus/doc-bridge.md +48 -1
  36. package/docs/agent-corpus/doctor.md +10 -2
  37. package/docs/agent-corpus/gates.md +6 -2
  38. package/docs/agent-corpus/mcp.md +15 -2
  39. package/docs/agent-corpus/memory.md +6 -2
  40. package/docs/agent-corpus/query.md +35 -2
  41. package/docs/bench/README.md +122 -0
  42. package/docs/bench/retrieval-baseline-v1.json +28 -0
  43. package/docs/bench/retrieval-suite-v1.json +1033 -0
  44. package/docs/chat-and-rag.md +3 -2
  45. package/docs/for-agents.md +9 -1
  46. package/docs/getting-started.md +4 -11
  47. package/docs/guides/gate-ci.md +11 -1
  48. package/docs/guides/install-and-run.md +9 -65
  49. package/docs/index.md +22 -1
  50. package/docs/knowledge-engine-runbook.md +51 -4
  51. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  52. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  53. package/docs/landing/index.html +119 -5
  54. package/docs/loop-workflow.md +117 -0
  55. package/docs/mcp.md +6 -1
  56. package/docs/parity/public-claims-v1.json +145 -0
  57. package/docs/playbook/doc-bridge-pattern.md +1 -1
  58. package/docs/query.md +90 -2
  59. package/docs/recipes/index-pipeline.md +1 -1
  60. package/docs/schemas/agent-handoff-v1.md +15 -0
  61. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  62. package/docs/spec/benchmark-v1.md +39 -1
  63. package/docs/spec/cli.md +30 -10
  64. package/docs/spec/config-v1.md +192 -8
  65. package/docs/spec/documentation-audit-v1.md +61 -0
  66. package/docs/spec/enrichment-overlay-v1.md +241 -0
  67. package/docs/spec/graph-signals-v1.md +92 -0
  68. package/docs/spec/incremental-scan-v1.md +102 -0
  69. package/docs/spec/markdown-analyzer-v1.md +73 -0
  70. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  71. package/docs/spec/measured-enrichment-v1.md +229 -0
  72. package/docs/spec/public-parity-v1.md +119 -0
  73. package/docs/spec/registry-agents.md +6 -0
  74. package/docs/spec/render-v1.md +122 -0
  75. package/docs/spec/retrieval-index-v1.md +164 -0
  76. package/docs/spec/study-metrics-v1.md +58 -0
  77. package/docs/spec/study-protocol-v1.md +46 -0
  78. package/docs/spec/study-provider-cli-v1.md +116 -0
  79. package/docs/spec/study-runner-v1.md +35 -0
  80. package/docs/spec/study-task-suite-v1.md +41 -0
  81. package/docs/spec/study-verification-v1.md +40 -0
  82. package/docs/study/README.md +84 -0
  83. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  86. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  87. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  88. package/docs/study/ab-baseline-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  90. package/docs/study/ab-baseline-result-v1.json +79 -0
  91. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  92. package/docs/study/historical-evidence-v1.json +252 -0
  93. package/docs/study/observation-ledger-v1.json +30632 -0
  94. package/docs/study/phase3-task-coverage-v1.json +34 -0
  95. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  96. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  97. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  98. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  99. package/docs/study/pilot-round-2026-08-31.json +46 -0
  100. package/docs/study/protocol-v1.json +90 -0
  101. package/docs/study/publication-gate-v1.md +45 -0
  102. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  103. package/docs/study/quality-scorecard-v1.json +38 -0
  104. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  105. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  106. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  107. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  108. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  109. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  110. package/docs/study/round-4-confirmation-v1.json +75 -0
  111. package/docs/study/round-4-confirmation-v1.md +55 -0
  112. package/docs/study/run-plan-v1.json +33 -0
  113. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  114. package/docs/study/task-suite-v1.json +96 -0
  115. package/docs/study/token-efficiency-plan-v1.md +337 -0
  116. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  117. package/docs/study/verification-binding-v1.json +27 -0
  118. package/docs/validation-cycle-plan.md +33 -0
  119. package/docs/verification-harness.md +15 -6
  120. package/ecosystem-claims.json +2 -2
  121. package/ecosystem-upstream.json +2 -2
  122. package/ecosystem.json +4 -4
  123. package/mcpb/manifest.json +9 -1
  124. package/package.json +89 -72
  125. package/scripts/check-ecosystem-upstream.mjs +36 -7
  126. package/scripts/report-visual-check.mjs +20 -3
  127. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  128. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  129. package/src/agents/registry-adapter.ts +192 -24
  130. package/src/audit/documentation.ts +513 -0
  131. package/src/bench/baseline.ts +198 -0
  132. package/src/bench/overlay-delta.ts +139 -0
  133. package/src/bench/retrieval.ts +319 -0
  134. package/src/budget/compile.ts +91 -0
  135. package/src/budget/sections.ts +70 -0
  136. package/src/cli/demo.ts +2 -2
  137. package/src/cli/program.ts +699 -79
  138. package/src/cli/usage.ts +71 -0
  139. package/src/config/defaults.ts +1 -0
  140. package/src/config/index.ts +4 -0
  141. package/src/config/load-config.ts +7 -1
  142. package/src/config/schema.ts +121 -4
  143. package/src/conformance/documentation-standard-v1.ts +22 -14
  144. package/src/discovery/areas.ts +182 -0
  145. package/src/discovery/documentation.ts +255 -23
  146. package/src/discovery/identity.ts +24 -0
  147. package/src/discovery/incremental.ts +314 -0
  148. package/src/discovery/inputs.ts +110 -0
  149. package/src/discovery/markdown.ts +481 -0
  150. package/src/discovery/repository.ts +557 -125
  151. package/src/doctor/run-doctor.ts +246 -27
  152. package/src/enrich/approvals.ts +190 -0
  153. package/src/enrich/cache.ts +93 -0
  154. package/src/enrich/context-pack.ts +272 -0
  155. package/src/enrich/overlay.ts +255 -0
  156. package/src/enrich/review.ts +106 -0
  157. package/src/enrich/stage.ts +374 -0
  158. package/src/enrich/stats.ts +100 -0
  159. package/src/enrich/validate.ts +410 -0
  160. package/src/federation/llms.ts +74 -24
  161. package/src/findings/report.ts +103 -0
  162. package/src/fixes/proposals.ts +4 -3
  163. package/src/graph/build.ts +356 -0
  164. package/src/graph/memory.ts +208 -0
  165. package/src/index-builder/build-handoffs.ts +22 -11
  166. package/src/index-builder/build-index.ts +132 -3
  167. package/src/index-builder/human-adapters/fumadocs.ts +1 -1
  168. package/src/index-builder/llms-txt.ts +48 -8
  169. package/src/index-builder/project-corpus.ts +111 -0
  170. package/src/index-builder/watch-index.ts +1 -1
  171. package/src/index.ts +630 -2
  172. package/src/lib/bounded-text.ts +15 -10
  173. package/src/lib/fuzzy-match.ts +235 -0
  174. package/src/mcp/knowledge.ts +554 -0
  175. package/src/mcp/server.ts +113 -18
  176. package/src/metrics/benchmark.ts +21 -0
  177. package/src/parity/check.ts +309 -0
  178. package/src/parity/claims.ts +259 -0
  179. package/src/parity/resolve.ts +160 -0
  180. package/src/query/handoff.ts +326 -0
  181. package/src/query/load-index.ts +53 -1
  182. package/src/query/query.ts +92 -59
  183. package/src/query/search.ts +289 -92
  184. package/src/query/text.ts +155 -0
  185. package/src/reconciliation/reconcile.ts +148 -15
  186. package/src/render/data.ts +356 -0
  187. package/src/render/engine.ts +398 -0
  188. package/src/render/generated.ts +77 -0
  189. package/src/render/render.ts +209 -0
  190. package/src/render/template-source.ts +52 -0
  191. package/src/render/templates.ts +289 -0
  192. package/src/report/html.ts +23 -17
  193. package/src/retrieval/bm25.ts +161 -0
  194. package/src/retrieval/project.ts +495 -0
  195. package/src/retrieval/rank.ts +383 -0
  196. package/src/retrieval/weights.ts +39 -0
  197. package/src/retriever/doc-bridge-retriever.ts +100 -15
  198. package/src/rules/engine.ts +45 -12
  199. package/src/safety/repository.ts +1 -1
  200. package/src/schemas/agent-handoff.ts +56 -0
  201. package/src/schemas/budget.ts +37 -0
  202. package/src/schemas/doc-bridge-index.ts +53 -2
  203. package/src/schemas/enrichment.ts +369 -0
  204. package/src/schemas/json-schemas.ts +39 -2
  205. package/src/schemas/knowledge.ts +19 -3
  206. package/src/schemas/retrieval-index.ts +152 -0
  207. package/src/shims/graphology.d.ts +91 -0
  208. package/src/study/adjudication.ts +196 -0
  209. package/src/study/execution.ts +350 -0
  210. package/src/study/expectations.ts +219 -0
  211. package/src/study/metrics.ts +467 -0
  212. package/src/study/protocol.ts +271 -0
  213. package/src/study/provider-cli.ts +115 -0
  214. package/src/study/provider-telemetry.ts +47 -0
  215. package/src/study/quality-scorecard.ts +164 -0
  216. package/src/study/runner.ts +461 -0
  217. package/src/study/task-suite.ts +321 -0
  218. package/src/study/verification.ts +134 -0
  219. package/src/validate.ts +8 -5
  220. package/src/version.ts +1 -1
  221. package/src/workflow/engine.ts +36 -11
  222. package/dist/index-C2PCQSrB.d.ts +0 -2251
  223. package/scripts/verification-harness.mjs +0 -483
@@ -1,11 +1,33 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
1
4
  import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { parseRetrievalSuite, runRetrievalBench } from '../bench/retrieval.js'
6
+ import { discoverRepository } from '../discovery/repository.js'
2
7
  import { buildDocBridgeIndex } from '../index-builder/build-index.js'
3
8
  import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
4
9
  import { runGates, type GateRunResult } from '../gates/run-gates.js'
5
10
  import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
6
11
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
12
+ import type { DiscoverySnapshotV1 } from '../schemas/knowledge.js'
7
13
  import { doctorBadgeMetrics, type DoctorBadgeMetrics } from './badge.js'
8
14
 
15
+ /** Where the golden retrieval suite lives unless `retrieval.benchmark.suite` says otherwise. */
16
+ export const DEFAULT_RETRIEVAL_SUITE = 'docs/bench/retrieval-suite-v1.json'
17
+
18
+ /**
19
+ * What an A requires beyond the score, as fractions the doctor measured rather than declared.
20
+ *
21
+ * Reachability must be complete: a document retrieval cannot find is a document the product does
22
+ * not deliver, and no other dimension compensates for it. Connectivity and the benchmark have a
23
+ * floor rather than a ceiling, because a repository with a few undocumented utility areas is
24
+ * still healthy and a suite that misses a hard case in ten is still a good ranker.
25
+ */
26
+ export const A_GRADE_REQUIREMENTS = { reachabilityPct: 100, connectivityPct: 80, benchmarkHitAt3: 0.8 } as const
27
+
28
+ /** How many offenders a dimension lists, so the report stays readable on a large repository. */
29
+ const MAX_LISTED = 20
30
+
9
31
  export type DoctorIssue = {
10
32
  readonly severity: 'error' | 'warn' | 'info'
11
33
  readonly code: string
@@ -13,7 +35,57 @@ export type DoctorIssue = {
13
35
  readonly action?: string
14
36
  }
15
37
 
38
+ /** The share of the snapshot's documents an agent can retrieve: present in the retrieval projection. */
39
+ export type DoctorReachability = {
40
+ readonly documentsTotal: number
41
+ readonly documentsReachable: number
42
+ readonly unreachable: readonly string[]
43
+ readonly pct: number
44
+ }
45
+
46
+ /**
47
+ * Whether the graph connects documentation to code both ways: an area with at least one document
48
+ * that covers or mentions it, and a document with at least one edge into code (an area, a module
49
+ * or a package it covers or mentions). `pct` is the mean of the two shares.
50
+ */
51
+ export type DoctorConnectivity = {
52
+ readonly areasTotal: number
53
+ readonly areasDocumented: number
54
+ readonly undocumentedAreas: readonly string[]
55
+ readonly documentsTotal: number
56
+ readonly documentsLinked: number
57
+ readonly unlinkedDocuments: readonly string[]
58
+ readonly pct: number
59
+ }
60
+
61
+ export type DoctorBenchmark =
62
+ | {
63
+ readonly status: 'measured'
64
+ readonly suite: string
65
+ readonly caseCount: number
66
+ readonly hitAt1: number
67
+ readonly hitAt3: number
68
+ readonly meanReciprocalRank: number
69
+ }
70
+ | {
71
+ /** No golden suite, or one that could not be run: reported, never silently omitted. */
72
+ readonly status: 'not-analyzed'
73
+ readonly suite: string
74
+ readonly reason: string
75
+ }
76
+
77
+ /** Which of the three measured dimensions meet the bar an A requires, and why not when one does not. */
78
+ export type DoctorGrading = {
79
+ readonly reachability: boolean
80
+ readonly connectivity: boolean
81
+ readonly benchmark: boolean
82
+ readonly unmet: readonly string[]
83
+ }
84
+
16
85
  export type DoctorCoverage = {
86
+ readonly reachability: DoctorReachability
87
+ readonly connectivity: DoctorConnectivity
88
+ readonly benchmark: DoctorBenchmark
17
89
  readonly packages: {
18
90
  readonly total: number
19
91
  readonly withAgentDoc: number
@@ -38,6 +110,7 @@ export type DoctorReport = {
38
110
  readonly ok: boolean
39
111
  readonly score: number
40
112
  readonly grade: 'A' | 'B' | 'C' | 'D' | 'F'
113
+ readonly grading: DoctorGrading
41
114
  readonly coverage: DoctorCoverage
42
115
  readonly badge: DoctorBadgeMetrics
43
116
  readonly issues: readonly DoctorIssue[]
@@ -52,41 +125,127 @@ const gradeForScore = (score: number): DoctorReport['grade'] => {
52
125
  return 'F'
53
126
  }
54
127
 
55
- const agentDocPaths = (index: DocBridgeIndexV1): Set<string> => {
56
- const paths = new Set<string>()
57
- for (const owner of Object.values(index.lookup?.ownership ?? {})) {
58
- if (owner.agentDoc) paths.add(owner.agentDoc)
59
- }
60
- for (const handoff of Object.values(index.handoffs ?? {})) {
61
- if (handoff.startHere) paths.add(handoff.startHere)
62
- }
63
- return paths
64
- }
65
-
66
- const computeScore = (coverage: DoctorCoverage): number => {
128
+ /**
129
+ * One hundred points, each traceable to something measured.
130
+ *
131
+ * The index (present, fresh), the handoff corpus (agent docs, human guides) and the gates were
132
+ * the whole score once, and it reported 100/100 on a repository where most documents were
133
+ * unreachable, because nothing in it measured what retrieval could see. Forty of the hundred now
134
+ * come from reachability, connectivity and the benchmark; a suite that does not exist scores
135
+ * nothing, since an unmeasured ranker is not a good ranker.
136
+ */
137
+ export const computeScore = (coverage: DoctorCoverage): number => {
67
138
  let score = 0
68
139
 
69
- if (coverage.freshness.hasIndex) score += 15
70
- if (coverage.freshness.ok) score += 15
140
+ if (coverage.freshness.hasIndex) score += 10
141
+ if (coverage.freshness.ok) score += 10
71
142
 
72
143
  const { total, withAgentDoc, withHumanDoc } = coverage.packages
73
144
  if (total > 0) {
74
- score += Math.round((withAgentDoc / total) * 35)
75
- score += Math.round((withHumanDoc / total) * 20)
145
+ score += Math.round((withAgentDoc / total) * 20)
146
+ score += Math.round((withHumanDoc / total) * 10)
76
147
  } else if (coverage.agentDocs.indexed > 0) {
77
- score += 35
148
+ score += 20
78
149
  }
79
150
 
80
- if (coverage.gates.ok) score += 15
151
+ if (coverage.gates.ok) score += 10
81
152
  else {
82
153
  const passed = coverage.gates.results.filter((gate) => gate.ok).length
83
154
  const totalGates = coverage.gates.results.length || 1
84
- score += Math.round((passed / totalGates) * 10)
155
+ score += Math.round((passed / totalGates) * 7)
85
156
  }
86
157
 
158
+ score += Math.round((coverage.reachability.pct / 100) * 15)
159
+ score += Math.round((coverage.connectivity.pct / 100) * 15)
160
+ if (coverage.benchmark.status === 'measured') score += Math.round(coverage.benchmark.hitAt3 * 10)
161
+
87
162
  return Math.min(100, Math.max(0, score))
88
163
  }
89
164
 
165
+ const pct = (part: number, whole: number): number => (whole > 0 ? Math.round((part / whole) * 100) : 0)
166
+
167
+ /**
168
+ * An A requires all three measured dimensions, whatever the score says. A repository with no
169
+ * golden suite tops out at B: the ranker may be excellent, but nobody has checked.
170
+ */
171
+ export const gradeFor = (score: number, coverage: DoctorCoverage): { readonly grade: DoctorReport['grade']; readonly grading: DoctorGrading } => {
172
+ const reachability = coverage.reachability.pct >= A_GRADE_REQUIREMENTS.reachabilityPct
173
+ const connectivity = coverage.connectivity.pct >= A_GRADE_REQUIREMENTS.connectivityPct
174
+ const benchmark = coverage.benchmark.status === 'measured' && coverage.benchmark.hitAt3 >= A_GRADE_REQUIREMENTS.benchmarkHitAt3
175
+ const unmet = [
176
+ ...(reachability ? [] : [`reachability ${coverage.reachability.pct}% < ${A_GRADE_REQUIREMENTS.reachabilityPct}%`]),
177
+ ...(connectivity ? [] : [`connectivity ${coverage.connectivity.pct}% < ${A_GRADE_REQUIREMENTS.connectivityPct}%`]),
178
+ ...(benchmark
179
+ ? []
180
+ : coverage.benchmark.status === 'measured'
181
+ ? [`benchmark hit@3 ${(coverage.benchmark.hitAt3 * 100).toFixed(1)}% < ${A_GRADE_REQUIREMENTS.benchmarkHitAt3 * 100}%`]
182
+ : ['benchmark not-analyzed']),
183
+ ]
184
+ const grade = gradeForScore(score)
185
+ return { grade: grade === 'A' && unmet.length ? 'B' : grade, grading: { reachability, connectivity, benchmark, unmet } }
186
+ }
187
+
188
+ /** The documents an agent can retrieve: those the projection carries, of those the snapshot observed. */
189
+ export const measureReachability = (snapshot: DiscoverySnapshotV1, index: DocBridgeIndexV1): DoctorReachability => {
190
+ const projected = new Set((index.projection?.entries ?? []).filter((entry) => entry.kind === 'document').map((entry) => entry.id))
191
+ const documents = snapshot.entities.filter((entity) => entity.kind === 'document').map((entity) => entity.id).sort()
192
+ const unreachable = documents.filter((id) => !projected.has(id))
193
+ return {
194
+ documentsTotal: documents.length,
195
+ documentsReachable: documents.length - unreachable.length,
196
+ unreachable: unreachable.slice(0, MAX_LISTED),
197
+ pct: pct(documents.length - unreachable.length, documents.length),
198
+ }
199
+ }
200
+
201
+ /** Both directions of the documentation graph, from the projection's own edges. */
202
+ export const measureConnectivity = (index: DocBridgeIndexV1): DoctorConnectivity => {
203
+ const entries = index.projection?.entries ?? []
204
+ const byId = new Map(entries.map((entry) => [entry.id, entry]))
205
+ const areas = entries.filter((entry) => entry.kind === 'area').sort((a, b) => a.id.localeCompare(b.id))
206
+ const undocumentedAreas = areas.filter((entry) => !entry.graph.coveredBy.length && !entry.graph.mentionedBy.length).map((entry) => entry.id)
207
+ const documents = entries.filter((entry) => entry.kind === 'document').sort((a, b) => a.id.localeCompare(b.id))
208
+ const linksToCode = (entry: (typeof entries)[number]): boolean =>
209
+ entry.graph.outbound.some((edge) => {
210
+ const target = byId.get(edge.id)
211
+ return target !== undefined && target.kind !== 'document'
212
+ })
213
+ const unlinkedDocuments = documents.filter((entry) => !linksToCode(entry)).map((entry) => entry.id)
214
+ const areaShare = areas.length ? (areas.length - undocumentedAreas.length) / areas.length : 0
215
+ const documentShare = documents.length ? (documents.length - unlinkedDocuments.length) / documents.length : 0
216
+ return {
217
+ areasTotal: areas.length,
218
+ areasDocumented: areas.length - undocumentedAreas.length,
219
+ undocumentedAreas: undocumentedAreas.slice(0, MAX_LISTED),
220
+ documentsTotal: documents.length,
221
+ documentsLinked: documents.length - unlinkedDocuments.length,
222
+ unlinkedDocuments: unlinkedDocuments.slice(0, MAX_LISTED),
223
+ pct: Math.round(((areaShare + documentShare) / 2) * 100),
224
+ }
225
+ }
226
+
227
+ /** hit@3 over the golden suite when the repository has one; the honest `not-analyzed` when it does not. */
228
+ export const measureBenchmark = (root: string, config: DocBridgeConfigV1, index: DocBridgeIndexV1): DoctorBenchmark => {
229
+ const suite = config.retrieval?.benchmark?.suite ?? DEFAULT_RETRIEVAL_SUITE
230
+ const suitePath = resolve(root, suite)
231
+ if (!existsSync(suitePath)) return { status: 'not-analyzed', suite, reason: `No retrieval suite at ${suite}` }
232
+ if (!index.projection) return { status: 'not-analyzed', suite, reason: 'The index carries no retrieval projection. Run: ak-docs index' }
233
+ try {
234
+ const parsed = parseRetrievalSuite(JSON.parse(readFileSync(suitePath, 'utf8')) as unknown)
235
+ const result = runRetrievalBench({ index, suite: parsed })
236
+ return {
237
+ status: 'measured',
238
+ suite,
239
+ caseCount: result.metrics.caseCount,
240
+ hitAt1: result.metrics.hitAt1,
241
+ hitAt3: result.metrics.hitAt3,
242
+ meanReciprocalRank: result.metrics.meanReciprocalRank,
243
+ }
244
+ } catch (error) {
245
+ return { status: 'not-analyzed', suite, reason: `The retrieval suite could not be run: ${error instanceof Error ? error.message : String(error)}` }
246
+ }
247
+ }
248
+
90
249
  const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
91
250
  const issues: DoctorIssue[] = []
92
251
 
@@ -133,6 +292,47 @@ const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
133
292
  })
134
293
  }
135
294
 
295
+ const { reachability, connectivity, benchmark } = coverage
296
+ if (reachability.pct < A_GRADE_REQUIREMENTS.reachabilityPct) {
297
+ issues.push({
298
+ severity: 'warn',
299
+ code: 'documents-unreachable',
300
+ message: `${reachability.documentsTotal - reachability.documentsReachable} of ${reachability.documentsTotal} documents are not in the retrieval projection (${reachability.pct}% reachable).`,
301
+ action: 'ak-docs index',
302
+ })
303
+ }
304
+ if (connectivity.undocumentedAreas.length) {
305
+ issues.push({
306
+ severity: 'info',
307
+ code: 'areas-undocumented',
308
+ message: `${connectivity.areasTotal - connectivity.areasDocumented} of ${connectivity.areasTotal} areas have no document that covers or mentions them.`,
309
+ action: 'ak-docs check --json --format finding',
310
+ })
311
+ }
312
+ if (connectivity.unlinkedDocuments.length) {
313
+ issues.push({
314
+ severity: 'info',
315
+ code: 'documents-unlinked',
316
+ message: `${connectivity.documentsTotal - connectivity.documentsLinked} of ${connectivity.documentsTotal} documents have no edge into code.`,
317
+ action: 'ak-docs audit documentation',
318
+ })
319
+ }
320
+ if (benchmark.status === 'not-analyzed') {
321
+ issues.push({
322
+ severity: 'info',
323
+ code: 'benchmark-not-analyzed',
324
+ message: benchmark.reason,
325
+ action: `ak-docs bench retrieval ${benchmark.suite}`,
326
+ })
327
+ } else if (benchmark.hitAt3 < A_GRADE_REQUIREMENTS.benchmarkHitAt3) {
328
+ issues.push({
329
+ severity: 'warn',
330
+ code: 'benchmark-below-target',
331
+ message: `Retrieval hit@3 is ${(benchmark.hitAt3 * 100).toFixed(1)}% over ${benchmark.caseCount} case(s); an A requires ${A_GRADE_REQUIREMENTS.benchmarkHitAt3 * 100}%.`,
332
+ action: `ak-docs bench retrieval ${benchmark.suite} --text`,
333
+ })
334
+ }
335
+
136
336
  return issues
137
337
  }
138
338
 
@@ -166,11 +366,14 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
166
366
  let index: DocBridgeIndexV1 | undefined
167
367
  let hasIndex = true
168
368
  let freshnessOk = false
169
- let freshnessMessage = 'Index is fresh'
369
+ let freshnessMessage: string
370
+
371
+ // One discovery serves both the freshness rebuild and the reachability denominator.
372
+ const snapshot = discoverRepository({ root, config })
170
373
 
171
374
  try {
172
375
  index = loadDocBridgeIndex(root, config)
173
- const next = buildDocBridgeIndex({ root, config, write: false }).index.contentHash
376
+ const next = buildDocBridgeIndex({ root, config, write: false, snapshot }).index.contentHash
174
377
  freshnessOk = index.contentHash === next
175
378
  freshnessMessage = freshnessOk ? 'Index is fresh' : 'Index is stale. Run: ak-docs index'
176
379
  } catch (error) {
@@ -178,7 +381,7 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
178
381
  hasIndex = false
179
382
  freshnessOk = false
180
383
  freshnessMessage = error.message
181
- index = buildDocBridgeIndex({ root, config, write: false }).index
384
+ index = buildDocBridgeIndex({ root, config, write: false, snapshot }).index
182
385
  } else {
183
386
  throw error
184
387
  }
@@ -191,8 +394,6 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
191
394
  const missingHumanDoc = ownership.filter(([, owner]) => !owner.humanDoc).map(([id]) => id)
192
395
 
193
396
  const indexedPaths = new Set(index.knowledge.map((entry) => entry.path))
194
- const expectedAgentDocs = agentDocPaths(index)
195
- const unindexed = [...expectedAgentDocs].filter((path) => !indexedPaths.has(path))
196
397
 
197
398
  const corpusDocs = scanAgentCorpus(root, config).filter(
198
399
  (doc) => doc.path !== config.corpus.agent.index,
@@ -201,6 +402,9 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
201
402
  const gates = runGates(root, config)
202
403
 
203
404
  const coverage: DoctorCoverage = {
405
+ reachability: measureReachability(snapshot, index),
406
+ connectivity: measureConnectivity(index),
407
+ benchmark: measureBenchmark(root, config, index),
204
408
  packages: {
205
409
  total: ownership.length,
206
410
  withAgentDoc: ownership.length - missingAgentDoc.length,
@@ -223,14 +427,16 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
223
427
 
224
428
  const issues = buildIssues(coverage)
225
429
  const score = computeScore(coverage)
430
+ const { grade, grading } = gradeFor(score, coverage)
226
431
  const nextActions = buildNextActions(issues, coverage)
227
432
 
228
433
  const report: DoctorReport = {
229
434
  ok: issues.every((issue) => issue.severity !== 'error') && gates.ok,
230
435
  score,
231
- grade: gradeForScore(score),
436
+ grade,
437
+ grading,
232
438
  coverage,
233
- badge: { handoffPct: 0, bridgePct: 0, score, grade: gradeForScore(score), packages: 0 },
439
+ badge: { handoffPct: 0, bridgePct: 0, score, grade, packages: 0 },
234
440
  issues,
235
441
  nextActions,
236
442
  }
@@ -261,8 +467,21 @@ export const formatDoctorText = (report: DoctorReport): string[] => {
261
467
  ` Index freshness: ${coverage.freshness.ok ? 'fresh' : 'stale or missing'}`,
262
468
  ` Gates: ${coverage.gates.results.filter((g) => g.ok).length}/${coverage.gates.results.length} passing`,
263
469
  ` Badge: handoff ${report.badge.handoffPct}% · bridge ${report.badge.bridgePct}%`,
470
+ '',
471
+ 'Retrieval',
472
+ ` Reachability: ${coverage.reachability.documentsReachable}/${coverage.reachability.documentsTotal} documents in the retrieval projection (${coverage.reachability.pct}%)`,
473
+ ` Connectivity: ${coverage.connectivity.areasDocumented}/${coverage.connectivity.areasTotal} areas documented · ${coverage.connectivity.documentsLinked}/${coverage.connectivity.documentsTotal} documents link to code (${coverage.connectivity.pct}%)`,
474
+ ` Benchmark: ${
475
+ coverage.benchmark.status === 'measured'
476
+ ? `hit@3 ${(coverage.benchmark.hitAt3 * 100).toFixed(1)}% · hit@1 ${(coverage.benchmark.hitAt1 * 100).toFixed(1)}% over ${coverage.benchmark.caseCount} case(s) (${coverage.benchmark.suite})`
477
+ : `not-analyzed — ${coverage.benchmark.reason}`
478
+ }`,
264
479
  ]
265
480
 
481
+ if (report.grading.unmet.length) {
482
+ lines.push('', `Grade ${report.grade}: an A requires`, ...report.grading.unmet.map((reason) => ` • ${reason}`))
483
+ }
484
+
266
485
  if (coverage.packages.missingHumanDoc.length) {
267
486
  lines.push('', 'Missing humanDoc (bridge gap)', ...coverage.packages.missingHumanDoc.map((id) => ` • ${id}`))
268
487
  }
@@ -283,4 +502,4 @@ export const formatDoctorText = (report: DoctorReport): string[] => {
283
502
 
284
503
  lines.push('', 'Next actions', ...report.nextActions.map((action) => ` → ${action}`))
285
504
  return lines
286
- }
505
+ }
@@ -0,0 +1,190 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
5
+ import { PeerMissingError, importPeer } from '../intelligence/peers.js'
6
+
7
+ /**
8
+ * Approvals through the ecosystem gate.
9
+ *
10
+ * `createApprovalGate` from `@agentskit/core/hitl` is the contract every AgentsKit surface uses
11
+ * to pause at a decision point and record what a person decided. Doc Bridge records its
12
+ * approvals through it — the enrichment overlay's, and `ak-docs fix approve`'s — so the CLI,
13
+ * MCP and a rendered review page share one record rather than three ways of writing "approved".
14
+ *
15
+ * The store is a directory of JSON files, one per approval, because the repository is the only
16
+ * persistence Doc Bridge has. The gate contract is mirrored here for the same reason every other
17
+ * ecosystem contract is: `@agentskit/core` is an optional peer, and the real `createApprovalGate`
18
+ * is used through `importPeer` when it is installed. A test runs both over the same store and
19
+ * asserts the same records.
20
+ */
21
+
22
+ export type ApprovalDecision = 'approved' | 'rejected'
23
+
24
+ export type Approval<TPayload = unknown> = {
25
+ id: string
26
+ name: string
27
+ payload: TPayload
28
+ status: 'pending' | ApprovalDecision | 'cancelled'
29
+ createdAt: string
30
+ decidedAt?: string
31
+ decisionMetadata?: Record<string, unknown>
32
+ }
33
+
34
+ export type ApprovalStore = {
35
+ put: <T>(approval: Approval<T>) => Promise<void>
36
+ get: <T>(id: string) => Promise<Approval<T> | null>
37
+ patch: <T>(id: string, update: Partial<Approval<T>>) => Promise<Approval<T> | null>
38
+ }
39
+
40
+ export type RequestApprovalInput<TPayload> = { name: string; payload: TPayload; id: string }
41
+
42
+ export type ApprovalGate<TPayload = unknown> = {
43
+ request: (input: RequestApprovalInput<TPayload>) => Promise<Approval<TPayload>>
44
+ await: (id: string, options?: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal }) => Promise<Approval<TPayload>>
45
+ decide: (id: string, decision: ApprovalDecision, metadata?: Record<string, unknown>) => Promise<Approval<TPayload>>
46
+ cancel: (id: string) => Promise<Approval<TPayload>>
47
+ }
48
+
49
+ export const APPROVALS_DIR = '.doc-bridge/approvals'
50
+ export const ENRICHMENT_APPROVAL_GATE = 'doc-bridge.enrichment'
51
+ export const FIX_APPROVAL_GATE = 'doc-bridge.fix'
52
+
53
+ export const approvalsDir = (root: string): string => join(resolve(root), APPROVALS_DIR)
54
+
55
+ /** An approval id is a hash; anything else would be a path. */
56
+ const safeId = (id: string): string => {
57
+ if (!/^[a-f0-9]{16,64}$/.test(id)) throw new Error(`Approval ids are content hashes; received "${id}".`)
58
+ return id
59
+ }
60
+
61
+ /** The id an enrichment approval binds to: the proposal and the exact content it was made about. */
62
+ export const enrichmentApprovalId = (proposalId: string, targetContentHash: string): string => sha256NormalizedV1({ proposalId, targetContentHash })
63
+
64
+ /** The id a fix approval binds to: the proposal id and the hash of its exact content. */
65
+ export const fixApprovalId = (proposalId: string, proposalHash: string): string => sha256NormalizedV1({ proposalId, proposalHash })
66
+
67
+ export const createFileApprovalStore = (dir: string): ApprovalStore => {
68
+ const pathFor = (id: string): string => join(dir, `${safeId(id)}.json`)
69
+ const write = (approval: Approval): void => {
70
+ mkdirSync(dir, { recursive: true })
71
+ const path = pathFor(approval.id)
72
+ const temporary = `${path}.tmp-${process.pid}`
73
+ writeFileSync(temporary, `${JSON.stringify(approval, null, 2)}\n`, 'utf8')
74
+ renameSync(temporary, path)
75
+ }
76
+ const read = (id: string): Approval | null => {
77
+ const path = pathFor(id)
78
+ if (!existsSync(path)) return null
79
+ try {
80
+ return JSON.parse(readFileSync(path, 'utf8')) as Approval
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+ return {
86
+ async put<T>(approval: Approval<T>): Promise<void> {
87
+ write(approval as Approval)
88
+ },
89
+ async get<T>(id: string): Promise<Approval<T> | null> {
90
+ return read(id) as Approval<T> | null
91
+ },
92
+ async patch<T>(id: string, update: Partial<Approval<T>>): Promise<Approval<T> | null> {
93
+ const current = read(id)
94
+ if (!current) return null
95
+ const next = { ...current, ...update } as Approval
96
+ write(next)
97
+ return next as Approval<T>
98
+ },
99
+ }
100
+ }
101
+
102
+ /** Every approval in the store, by id. For a reviewer listing what is waiting. */
103
+ export const listApprovals = (dir: string): Approval[] => {
104
+ if (!existsSync(dir)) return []
105
+ return readdirSync(dir)
106
+ .filter((name) => /^[a-f0-9]{16,64}\.json$/.test(name))
107
+ .sort()
108
+ .flatMap((name) => {
109
+ try {
110
+ return [JSON.parse(readFileSync(join(dir, name), 'utf8')) as Approval]
111
+ } catch {
112
+ return []
113
+ }
114
+ })
115
+ }
116
+
117
+ /** The mirror of `createApprovalGate`: create-or-load, poll, patch. */
118
+ export const createApprovalGateMirror = <TPayload = unknown>(store: ApprovalStore): ApprovalGate<TPayload> => {
119
+ const load = async (id: string): Promise<Approval<TPayload>> => {
120
+ const approval = await store.get<TPayload>(id)
121
+ if (!approval) throw new Error(`Unknown approval "${id}".`)
122
+ return approval
123
+ }
124
+ return {
125
+ async request(input) {
126
+ const existing = await store.get<TPayload>(input.id)
127
+ if (existing) return existing
128
+ const approval: Approval<TPayload> = { id: input.id, name: input.name, payload: input.payload, status: 'pending', createdAt: new Date().toISOString() }
129
+ await store.put(approval)
130
+ return approval
131
+ },
132
+ async await(id, options = {}) {
133
+ const pollMs = options.pollMs ?? 500
134
+ const started = Date.now()
135
+ for (;;) {
136
+ const approval = await load(id)
137
+ if (approval.status !== 'pending') return approval
138
+ if (options.signal?.aborted) throw new Error(`Approval "${id}" wait aborted.`)
139
+ if (options.timeoutMs !== undefined && Date.now() - started >= options.timeoutMs) throw new Error(`Approval "${id}" timed out.`)
140
+ await new Promise((resolveWait) => setTimeout(resolveWait, pollMs))
141
+ }
142
+ },
143
+ async decide(id, decision, metadata) {
144
+ const updated = await store.patch<TPayload>(id, { status: decision, decidedAt: new Date().toISOString(), ...(metadata ? { decisionMetadata: metadata } : {}) })
145
+ if (!updated) throw new Error(`Unknown approval "${id}".`)
146
+ return updated
147
+ },
148
+ async cancel(id) {
149
+ const updated = await store.patch<TPayload>(id, { status: 'cancelled', decidedAt: new Date().toISOString() })
150
+ if (!updated) throw new Error(`Unknown approval "${id}".`)
151
+ return updated
152
+ },
153
+ }
154
+ }
155
+
156
+ type CoreHitl = { createApprovalGate: <T>(store: ApprovalStore) => ApprovalGate<T> }
157
+
158
+ /**
159
+ * The gate over a store: the ecosystem's when the peer is installed, the mirror otherwise.
160
+ *
161
+ * Both write the same records to the same files, which is the point — a record written by one
162
+ * is read by the other, and by any AgentsKit surface that opens the same store.
163
+ */
164
+ export const loadApprovalGate = async <TPayload = unknown>(store: ApprovalStore): Promise<{ readonly gate: ApprovalGate<TPayload>; readonly source: 'ecosystem' | 'mirror' }> => {
165
+ try {
166
+ const core = await importPeer<CoreHitl>('@agentskit/core/hitl')
167
+ if (typeof core.createApprovalGate === 'function') return { gate: core.createApprovalGate<TPayload>(store), source: 'ecosystem' }
168
+ } catch (error) {
169
+ if (!(error instanceof PeerMissingError)) throw error
170
+ }
171
+ return { gate: createApprovalGateMirror<TPayload>(store), source: 'mirror' }
172
+ }
173
+
174
+ export type RecordedApproval = { readonly approval: Approval; readonly source: 'ecosystem' | 'mirror' }
175
+
176
+ /**
177
+ * Record a decision a person already made — the shape `ak-docs fix approve` needs: request
178
+ * (idempotent, so a re-run finds the same record) then decide, in one call.
179
+ */
180
+ export const recordApproval = async (
181
+ root: string,
182
+ input: { readonly id: string; readonly name: string; readonly payload: unknown; readonly decision: ApprovalDecision; readonly by: string; readonly reason?: string },
183
+ ): Promise<RecordedApproval> => {
184
+ const store = createFileApprovalStore(approvalsDir(root))
185
+ const { gate, source } = await loadApprovalGate(store)
186
+ const existing = await gate.request({ id: input.id, name: input.name, payload: input.payload })
187
+ if (existing.status !== 'pending') throw new Error(`Approval ${input.id} was already ${existing.status}.`)
188
+ const approval = await gate.decide(input.id, input.decision, { by: input.by, ...(input.reason ? { reason: input.reason } : {}) })
189
+ return { approval, source }
190
+ }
@@ -0,0 +1,93 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { z } from 'zod'
5
+
6
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
7
+ import type { EnrichmentTask } from './context-pack.js'
8
+ import { enrichmentCacheDir } from './overlay.js'
9
+
10
+ /**
11
+ * The persistent enrichment cache.
12
+ *
13
+ * The old deterministic cache was an in-process `Map`: it saved a second call inside one
14
+ * process and nothing across two. This one is keyed on everything that could change an answer —
15
+ * the task, the agent identity and version, the prompt version, and the pack hash, which covers
16
+ * the target and every neighbour by content hash — and lives under `.doc-bridge/enrich/cache/`,
17
+ * so an unchanged repository makes zero agent calls and a one-document change re-runs only the
18
+ * packs whose hash moved. An empty answer is cached like any other: "the agent had nothing to
19
+ * say about this" is an answer, and asking again costs the same.
20
+ */
21
+
22
+ export type EnrichmentCacheKeyInput = {
23
+ readonly task: EnrichmentTask
24
+ readonly agentId: string
25
+ readonly agentVersion: string
26
+ readonly promptVersion: string
27
+ readonly packHash: string
28
+ }
29
+
30
+ export const enrichmentCacheKey = (input: EnrichmentCacheKeyInput): string =>
31
+ sha256NormalizedV1({ task: input.task, agentId: input.agentId, agentVersion: input.agentVersion, promptVersion: input.promptVersion, packHash: input.packHash })
32
+
33
+ const CacheEntrySchema = z
34
+ .object({
35
+ type: z.literal('enrichment-cache-entry'),
36
+ key: z.string().regex(/^[a-f0-9]{64}$/),
37
+ task: z.enum(['curate', 'review', 'adjudicate']),
38
+ agentId: z.string().min(1).max(256),
39
+ agentVersion: z.string().min(1).max(64),
40
+ promptVersion: z.string().min(1).max(64),
41
+ packHash: z.string().regex(/^[a-f0-9]{64}$/),
42
+ proposals: z.array(z.unknown()).max(1_024),
43
+ })
44
+ .strict()
45
+ export type EnrichmentCacheEntry = z.infer<typeof CacheEntrySchema>
46
+
47
+ export type EnrichmentCache = {
48
+ readonly read: (input: EnrichmentCacheKeyInput) => readonly unknown[] | undefined
49
+ readonly write: (input: EnrichmentCacheKeyInput, proposals: readonly unknown[]) => string
50
+ }
51
+
52
+ /** A file-backed cache under the enrich directory. A read never writes; a corrupt entry is a miss. */
53
+ export const createEnrichmentCache = (root: string): EnrichmentCache => {
54
+ const dir = enrichmentCacheDir(root)
55
+ const pathFor = (key: string): string => join(dir, `${key}.json`)
56
+ return {
57
+ read: (input) => {
58
+ const key = enrichmentCacheKey(input)
59
+ const path = pathFor(key)
60
+ if (!existsSync(path)) return undefined
61
+ try {
62
+ const entry = CacheEntrySchema.parse(JSON.parse(readFileSync(path, 'utf8')) as unknown)
63
+ // A file renamed into place under another key is not that key's answer.
64
+ return entry.key === key && entry.packHash === input.packHash && entry.agentId === input.agentId ? entry.proposals : undefined
65
+ } catch {
66
+ return undefined
67
+ }
68
+ },
69
+ write: (input, proposals) => {
70
+ const key = enrichmentCacheKey(input)
71
+ mkdirSync(dir, { recursive: true })
72
+ const entry: EnrichmentCacheEntry = { type: 'enrichment-cache-entry', key, ...input, proposals: [...proposals] }
73
+ const path = pathFor(key)
74
+ const temporary = `${path}.tmp-${process.pid}`
75
+ writeFileSync(temporary, `${JSON.stringify(entry, null, 2)}\n`, 'utf8')
76
+ renameSync(temporary, path)
77
+ return path
78
+ },
79
+ }
80
+ }
81
+
82
+ /** An in-memory cache with the same contract, for callers that must not touch disk. */
83
+ export const createMemoryEnrichmentCache = (): EnrichmentCache => {
84
+ const entries = new Map<string, readonly unknown[]>()
85
+ return {
86
+ read: (input) => entries.get(enrichmentCacheKey(input)),
87
+ write: (input, proposals) => {
88
+ const key = enrichmentCacheKey(input)
89
+ entries.set(key, [...proposals])
90
+ return key
91
+ },
92
+ }
93
+ }