@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
@@ -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,30 +125,127 @@ const gradeForScore = (score: number): DoctorReport['grade'] => {
52
125
  return 'F'
53
126
  }
54
127
 
55
- 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 => {
56
138
  let score = 0
57
139
 
58
- if (coverage.freshness.hasIndex) score += 15
59
- if (coverage.freshness.ok) score += 15
140
+ if (coverage.freshness.hasIndex) score += 10
141
+ if (coverage.freshness.ok) score += 10
60
142
 
61
143
  const { total, withAgentDoc, withHumanDoc } = coverage.packages
62
144
  if (total > 0) {
63
- score += Math.round((withAgentDoc / total) * 35)
64
- score += Math.round((withHumanDoc / total) * 20)
145
+ score += Math.round((withAgentDoc / total) * 20)
146
+ score += Math.round((withHumanDoc / total) * 10)
65
147
  } else if (coverage.agentDocs.indexed > 0) {
66
- score += 35
148
+ score += 20
67
149
  }
68
150
 
69
- if (coverage.gates.ok) score += 15
151
+ if (coverage.gates.ok) score += 10
70
152
  else {
71
153
  const passed = coverage.gates.results.filter((gate) => gate.ok).length
72
154
  const totalGates = coverage.gates.results.length || 1
73
- score += Math.round((passed / totalGates) * 10)
155
+ score += Math.round((passed / totalGates) * 7)
74
156
  }
75
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
+
76
162
  return Math.min(100, Math.max(0, score))
77
163
  }
78
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
+
79
249
  const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
80
250
  const issues: DoctorIssue[] = []
81
251
 
@@ -122,6 +292,47 @@ const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
122
292
  })
123
293
  }
124
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
+
125
336
  return issues
126
337
  }
127
338
 
@@ -157,9 +368,12 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
157
368
  let freshnessOk = false
158
369
  let freshnessMessage: string
159
370
 
371
+ // One discovery serves both the freshness rebuild and the reachability denominator.
372
+ const snapshot = discoverRepository({ root, config })
373
+
160
374
  try {
161
375
  index = loadDocBridgeIndex(root, config)
162
- const next = buildDocBridgeIndex({ root, config, write: false }).index.contentHash
376
+ const next = buildDocBridgeIndex({ root, config, write: false, snapshot }).index.contentHash
163
377
  freshnessOk = index.contentHash === next
164
378
  freshnessMessage = freshnessOk ? 'Index is fresh' : 'Index is stale. Run: ak-docs index'
165
379
  } catch (error) {
@@ -167,7 +381,7 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
167
381
  hasIndex = false
168
382
  freshnessOk = false
169
383
  freshnessMessage = error.message
170
- index = buildDocBridgeIndex({ root, config, write: false }).index
384
+ index = buildDocBridgeIndex({ root, config, write: false, snapshot }).index
171
385
  } else {
172
386
  throw error
173
387
  }
@@ -188,6 +402,9 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
188
402
  const gates = runGates(root, config)
189
403
 
190
404
  const coverage: DoctorCoverage = {
405
+ reachability: measureReachability(snapshot, index),
406
+ connectivity: measureConnectivity(index),
407
+ benchmark: measureBenchmark(root, config, index),
191
408
  packages: {
192
409
  total: ownership.length,
193
410
  withAgentDoc: ownership.length - missingAgentDoc.length,
@@ -210,14 +427,16 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
210
427
 
211
428
  const issues = buildIssues(coverage)
212
429
  const score = computeScore(coverage)
430
+ const { grade, grading } = gradeFor(score, coverage)
213
431
  const nextActions = buildNextActions(issues, coverage)
214
432
 
215
433
  const report: DoctorReport = {
216
434
  ok: issues.every((issue) => issue.severity !== 'error') && gates.ok,
217
435
  score,
218
- grade: gradeForScore(score),
436
+ grade,
437
+ grading,
219
438
  coverage,
220
- badge: { handoffPct: 0, bridgePct: 0, score, grade: gradeForScore(score), packages: 0 },
439
+ badge: { handoffPct: 0, bridgePct: 0, score, grade, packages: 0 },
221
440
  issues,
222
441
  nextActions,
223
442
  }
@@ -248,8 +467,21 @@ export const formatDoctorText = (report: DoctorReport): string[] => {
248
467
  ` Index freshness: ${coverage.freshness.ok ? 'fresh' : 'stale or missing'}`,
249
468
  ` Gates: ${coverage.gates.results.filter((g) => g.ok).length}/${coverage.gates.results.length} passing`,
250
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
+ }`,
251
479
  ]
252
480
 
481
+ if (report.grading.unmet.length) {
482
+ lines.push('', `Grade ${report.grade}: an A requires`, ...report.grading.unmet.map((reason) => ` • ${reason}`))
483
+ }
484
+
253
485
  if (coverage.packages.missingHumanDoc.length) {
254
486
  lines.push('', 'Missing humanDoc (bridge gap)', ...coverage.packages.missingHumanDoc.map((id) => ` • ${id}`))
255
487
  }
@@ -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
+ }