@agentskit/doc-bridge 1.7.45 → 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 (213) hide show
  1. package/CHANGELOG.md +465 -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 +15861 -6223
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +86 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-Beor6Yhi.d.ts} +133 -1
  15. package/dist/index.d.ts +9871 -1062
  16. package/dist/index.js +16126 -6125
  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 +147 -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 +25 -6
  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 +117 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +182 -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 +484 -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,326 @@
1
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
2
+ import { defaultChecksForTarget } from '../lib/package-manager.js'
3
+ import { normalizeAgentHandoff, type AgentHandoffV1, type HandoffRelated } from '../schemas/agent-handoff.js'
4
+ import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
5
+ import type { RetrievalEntry, RetrievalIndexV1 } from '../schemas/retrieval-index.js'
6
+
7
+ /**
8
+ * A handoff for any entity the projection knows.
9
+ *
10
+ * `handoffForPackage` could answer for an ownership record and nothing else: an agent asking
11
+ * about a module, an area or a document got "unknown package". Now the answer is derived from
12
+ * the graph — which documents cover the target, which mention it, what its code depends on — and
13
+ * every field says which relations produced it, so a handoff that points somewhere odd is a
14
+ * finding about the repository rather than a mystery about the tool.
15
+ */
16
+
17
+ export type HandoffOptions = {
18
+ /** The project root, for the package-script fallback when nothing declares checks. */
19
+ readonly root?: string
20
+ }
21
+
22
+ type OwnershipRecord = NonNullable<NonNullable<DocBridgeIndexV1['lookup']>['ownership']>[string]
23
+
24
+ const MAX_RELATED = 3
25
+ const MAX_READ_BEFORE = 2
26
+ const MAX_RELATED_EVIDENCE = 3
27
+
28
+ const dirname = (path: string): string => path.split('/').slice(0, -1).join('/') || '.'
29
+
30
+ const isHttp = (value: string): boolean => /^https?:\/\//.test(value)
31
+
32
+ const bridgeFor = (config: DocBridgeConfigV1, id: string, humanDoc: string | undefined): AgentHandoffV1['bridge'] | undefined =>
33
+ humanDoc
34
+ ? { humanDoc: isHttp(humanDoc) ? 'external' : 'linked' }
35
+ : config.corpus.human
36
+ ? { humanDoc: 'missing', action: 'ak-docs bootstrap agent-docs', bootstrap: `docs/for-agents/human/${id}.md` }
37
+ : undefined
38
+
39
+ /**
40
+ * The handoff for an index built before the projection existed: an ownership record, or nothing.
41
+ */
42
+ const legacyHandoff = (index: DocBridgeIndexV1, id: string, config: DocBridgeConfigV1): AgentHandoffV1 => {
43
+ const fromIndex = index.handoffs?.[id]
44
+ if (fromIndex) return normalizeAgentHandoff(fromIndex)
45
+
46
+ const owner = index.lookup?.ownership?.[id]
47
+ if (!owner) throw new Error(`Unknown package/ownership id "${id}". Try: ak-docs list packages`)
48
+ const bridge = bridgeFor(config, id, owner.humanDoc)
49
+
50
+ return normalizeAgentHandoff({
51
+ type: 'agent-handoff',
52
+ source: config.index?.outFile ?? '.doc-bridge/index.json',
53
+ target: {
54
+ type: 'package',
55
+ id,
56
+ path: owner.path,
57
+ ...(owner.group ? { group: owner.group } : {}),
58
+ ...(owner.layer ? { layer: owner.layer } : {}),
59
+ },
60
+ startHere: owner.agentDoc ?? config.corpus.agent.index ?? '',
61
+ readBeforeEditing: [owner.agentDoc, 'AGENTS.md'].filter(Boolean),
62
+ editRoots: [owner.path],
63
+ checks: [...owner.checks],
64
+ ...(owner.humanDoc ? { humanDoc: owner.humanDoc } : {}),
65
+ ...(bridge ? { bridge } : {}),
66
+ notes: [
67
+ ...(owner.purpose ? [owner.purpose] : []),
68
+ ...(!owner.humanDoc && config.corpus.human ? [`Human guide missing for ${id}. Run: ak-docs bootstrap agent-docs`] : []),
69
+ ],
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Which entry an identifier names: an entity id, an ownership id, an alias, or a path.
75
+ *
76
+ * Ownership ids win over aliases because they are what a routing command has always accepted;
77
+ * a path is accepted last, since `src/query` is a directory before it is a name.
78
+ */
79
+ export const resolveHandoffEntry = (projection: RetrievalIndexV1, id: string): RetrievalEntry | undefined => {
80
+ const trimmed = id.replace(/\/$/, '')
81
+ return (
82
+ projection.entries.find((entry) => entry.id === trimmed) ??
83
+ projection.entries.find((entry) => entry.ownershipId === trimmed) ??
84
+ projection.entries.find((entry) => entry.aliases.includes(trimmed)) ??
85
+ projection.entries.find((entry) => entry.path === trimmed && (entry.kind === 'area' || entry.kind === 'package')) ??
86
+ projection.entries.find((entry) => entry.path === trimmed)
87
+ )
88
+ }
89
+
90
+ type DocumentPick = { readonly path: string; readonly reason: string }
91
+
92
+ /**
93
+ * Where to start reading, and why.
94
+ *
95
+ * Order of authority: a document that declares it covers the target; one that mentions it; one
96
+ * that links to those; and, failing all of that, the most canonical document the unit's
97
+ * ownership names. Within each tier the more canonical page comes first, so the entry point
98
+ * other pages point at beats the leaf note that happens to mention the target too.
99
+ */
100
+ const documentsFor = (
101
+ projection: RetrievalIndexV1,
102
+ byId: ReadonlyMap<string, RetrievalEntry>,
103
+ target: RetrievalEntry,
104
+ ownership: OwnershipRecord | undefined,
105
+ ): DocumentPick[] => {
106
+ const picks: DocumentPick[] = []
107
+ const seen = new Set<string>()
108
+ const add = (entry: RetrievalEntry | undefined, reason: string): void => {
109
+ if (!entry || entry.kind !== 'document' || seen.has(entry.path)) return
110
+ seen.add(entry.path)
111
+ picks.push({ path: entry.path, reason })
112
+ }
113
+ const byRank = (ids: readonly string[]): RetrievalEntry[] =>
114
+ ids
115
+ .map((id) => byId.get(id))
116
+ .filter((entry): entry is RetrievalEntry => Boolean(entry))
117
+ .sort((a, b) => b.graph.pagerank - a.graph.pagerank || a.id.localeCompare(b.id))
118
+
119
+ if (ownership?.agentDoc) add(projection.entries.find((entry) => entry.path === ownership.agentDoc), `ownership ${ownership.id} names it as the agent document`)
120
+ for (const entry of byRank(target.graph.coveredBy)) add(entry, `covers ${target.id}`)
121
+ for (const entry of byRank(target.graph.mentionedBy)) add(entry, `mentions ${target.id}`)
122
+ if (target.kind === 'document') {
123
+ add(target, 'the target itself')
124
+ for (const edge of target.graph.outbound.filter((item) => item.kind === 'links-to')) add(byId.get(edge.id), `${target.id} links to it`)
125
+ }
126
+
127
+ // One links-to hop from what covers or mentions the target: the page that introduces the page.
128
+ for (const near of [...picks]) {
129
+ const document = projection.entries.find((entry) => entry.path === near.path)
130
+ for (const edge of document?.graph.inbound.filter((item) => item.kind === 'links-to') ?? []) add(byId.get(edge.id), `links to ${document?.id}`)
131
+ }
132
+
133
+ return picks
134
+ }
135
+
136
+ /** The strongest import relationships between this unit and the other areas, both directions. */
137
+ const relatedAreas = (projection: RetrievalIndexV1, byId: ReadonlyMap<string, RetrievalEntry>, target: RetrievalEntry): HandoffRelated[] => {
138
+ const unitOf = (entry: RetrievalEntry): string | undefined =>
139
+ target.kind === 'package' ? entry.graph.packageId : (entry.graph.areaId ?? entry.graph.packageId)
140
+ const mine = target.kind === 'module' ? unitOf(target) : target.id
141
+ if (!mine) return []
142
+
143
+ const crossings = new Map<string, { direction: HandoffRelated['direction']; count: number; examples: string[] }>()
144
+ const note = (unit: string, direction: HandoffRelated['direction'], example: string): void => {
145
+ const key = `${direction}:${unit}`
146
+ const existing = crossings.get(key)
147
+ if (existing) {
148
+ existing.count += 1
149
+ if (existing.examples.length < MAX_RELATED_EVIDENCE) existing.examples.push(example)
150
+ } else crossings.set(key, { direction, count: 1, examples: [example] })
151
+ }
152
+
153
+ for (const module of projection.entries) {
154
+ if (module.kind !== 'module') continue
155
+ const from = unitOf(module)
156
+ if (!from) continue
157
+ for (const edge of module.graph.outbound) {
158
+ if (edge.kind !== 'imports' && edge.kind !== 're-exports') continue
159
+ const imported = byId.get(edge.id)
160
+ const to = imported ? unitOf(imported) : undefined
161
+ if (!to || to === from) continue
162
+ if (from === mine) note(to, 'imports', `${module.path} → ${imported?.path ?? edge.id}`)
163
+ else if (to === mine) note(from, 'imported-by', `${module.path} → ${imported?.path ?? edge.id}`)
164
+ }
165
+ }
166
+
167
+ const rows = [...crossings.entries()]
168
+ .map(([key, value]) => ({ unit: key.slice(key.indexOf(':') + 1), ...value }))
169
+ .sort((a, b) => b.count - a.count || a.unit.localeCompare(b.unit))
170
+ const pick = (direction: HandoffRelated['direction']): HandoffRelated[] =>
171
+ rows
172
+ .filter((row) => row.direction === direction)
173
+ .slice(0, MAX_RELATED)
174
+ .map((row) => ({
175
+ id: row.unit,
176
+ path: byId.get(row.unit)?.path ?? row.unit,
177
+ direction,
178
+ strength: row.count,
179
+ evidence: row.examples,
180
+ }))
181
+ return [...pick('imports'), ...pick('imported-by')]
182
+ }
183
+
184
+ type Checks = { readonly checks: readonly string[]; readonly source: string; readonly reason: string }
185
+
186
+ /**
187
+ * Which checks to run, and where that answer came from.
188
+ *
189
+ * An ownership override wins; then whatever the index recorded when it merged frontmatter,
190
+ * package scripts and defaults (it recorded the source at the time); then the package-manager
191
+ * default for the unit's package, which needs the project root to read package.json.
192
+ */
193
+ const checksFor = (
194
+ index: DocBridgeIndexV1,
195
+ config: DocBridgeConfigV1,
196
+ target: RetrievalEntry,
197
+ ownership: OwnershipRecord | undefined,
198
+ unitPath: string,
199
+ packageEntry: RetrievalEntry | undefined,
200
+ options: HandoffOptions,
201
+ ): Checks => {
202
+ const override = ownership ? config.routing?.options?.ownership?.[ownership.id]?.checks : undefined
203
+ if (override?.length) return { checks: override, source: 'ownership', reason: `routing.options.ownership.${ownership?.id}.checks` }
204
+ if (ownership?.checks.length) {
205
+ const source = ownership.checksSource ?? 'default'
206
+ return { checks: ownership.checks, source, reason: `ownership ${ownership.id} recorded its checks from ${source}` }
207
+ }
208
+ // A module or area inside an owned package inherits that package's checks.
209
+ const owningRecord = packageEntry?.ownershipId ? index.lookup?.ownership?.[packageEntry.ownershipId] : undefined
210
+ if (owningRecord?.checks.length) {
211
+ const source = owningRecord.checksSource ?? 'default'
212
+ return { checks: owningRecord.checks, source, reason: `inherited from ownership ${owningRecord.id} (${source})` }
213
+ }
214
+ if (options.root) {
215
+ const strict = (config.gates?.preset ?? 'minimal') !== 'minimal'
216
+ const packageName = packageEntry?.aliases.find((alias) => alias.includes('/')) ?? packageEntry?.title
217
+ const checks = defaultChecksForTarget(options.root, {
218
+ packageId: packageEntry?.id ?? target.id,
219
+ packagePath: packageEntry?.path ?? unitPath,
220
+ ...(packageName ? { packageName } : {}),
221
+ strict,
222
+ })
223
+ return { checks, source: 'default', reason: 'package-manager default for the unit\'s package' }
224
+ }
225
+ return { checks: [], source: 'default', reason: 'nothing declares checks and no project root was given to read package scripts' }
226
+ }
227
+
228
+ const targetType = (kind: RetrievalEntry['kind']): AgentHandoffV1['target']['type'] =>
229
+ kind === 'area' ? 'area' : kind === 'document' ? 'document' : kind === 'module' ? 'module' : 'package'
230
+
231
+ /**
232
+ * Build the handoff for a package, an area, a module or a document.
233
+ *
234
+ * `editRoots` is the unit an agent may write in: the area or package itself, a module's area,
235
+ * a document's own path. `startHere` and `readBeforeEditing` come from the documentation graph.
236
+ * `related` names the areas this unit's code depends on and that depend on it, with the import
237
+ * that proves each. `explain` says which relation produced each field.
238
+ */
239
+ export const handoffForEntity = (index: DocBridgeIndexV1, id: string, config: DocBridgeConfigV1, options: HandoffOptions = {}): AgentHandoffV1 => {
240
+ const projection = index.projection
241
+ if (!projection) return legacyHandoff(index, id, config)
242
+
243
+ const target = resolveHandoffEntry(projection, id)
244
+ if (!target) {
245
+ const fallback = index.lookup?.ownership?.[id] ? legacyHandoff(index, id, config) : undefined
246
+ if (fallback) return fallback
247
+ throw new Error(`Unknown entity "${id}". Try: ak-docs list packages, or ak-docs search "${id}"`)
248
+ }
249
+ if (target.kind === 'intent' || target.kind === 'change') {
250
+ throw new Error(`"${id}" is a ${target.kind} route. Try: ak-docs query ${target.kind} ${target.id} --agent`)
251
+ }
252
+
253
+ const byId = new Map(projection.entries.map((entry) => [entry.id, entry]))
254
+ const ownership = target.ownershipId ? index.lookup?.ownership?.[target.ownershipId] : undefined
255
+ const area = target.graph.areaId ? byId.get(target.graph.areaId) : undefined
256
+ const packageEntry = target.graph.packageId ? byId.get(target.graph.packageId) : target.kind === 'package' ? target : undefined
257
+ const explain: Record<string, string[]> = {}
258
+
259
+ // The unit an agent may write in.
260
+ const unit =
261
+ target.kind === 'module'
262
+ ? area
263
+ ? { path: area.path, reason: `contained by area ${area.id}` }
264
+ : packageEntry
265
+ ? { path: packageEntry.path, reason: `contained by package ${packageEntry.id}` }
266
+ : { path: dirname(target.path), reason: 'the directory of the module; no area or package contains it' }
267
+ : { path: ownership?.path ?? target.path, reason: ownership ? `ownership ${ownership.id} path` : `the ${target.kind} itself` }
268
+ explain.editRoots = [unit.reason]
269
+
270
+ const documents = documentsFor(projection, byId, target, ownership)
271
+ const startHere = documents[0]?.path ?? config.corpus.agent.index ?? target.path
272
+ explain.startHere = [documents[0] ? documents[0].reason : 'no document covers, mentions or links to the target; the corpus index is the fallback']
273
+ const readBefore = documents.slice(1, 1 + MAX_READ_BEFORE)
274
+ explain.readBeforeEditing = readBefore.map((pick) => `${pick.path}: ${pick.reason}`)
275
+
276
+ const checks = checksFor(index, config, target, ownership, unit.path, packageEntry, options)
277
+ explain.checks = [checks.reason]
278
+
279
+ const related = target.kind === 'document' ? [] : relatedAreas(projection, byId, target)
280
+ if (related.length) explain.related = related.map((row) => `${row.direction} ${row.id}: ${row.strength} import(s), e.g. ${row.evidence[0] ?? ''}`.trim())
281
+
282
+ const humanDoc = ownership?.humanDoc
283
+ const bridge = bridgeFor(config, ownership?.id ?? target.id, humanDoc)
284
+ const evidence: NonNullable<AgentHandoffV1['evidence']> = [
285
+ {
286
+ source: target.kind === 'module' ? 'code' : target.kind === 'document' ? 'documentation' : 'derived',
287
+ path: target.path,
288
+ contentHash: target.contentHash,
289
+ context: `${target.kind} ${target.id}`,
290
+ },
291
+ ...documents.slice(0, 1 + MAX_READ_BEFORE).map((pick) => ({ source: 'documentation' as const, path: pick.path, context: pick.reason })),
292
+ ]
293
+
294
+ return normalizeAgentHandoff({
295
+ type: 'agent-handoff',
296
+ source: config.index?.outFile ?? '.doc-bridge/index.json',
297
+ target: {
298
+ type: targetType(target.kind),
299
+ id: ownership?.id ?? target.id,
300
+ path: target.path,
301
+ ...(ownership?.group ? { group: ownership.group } : {}),
302
+ ...(ownership?.layer ? { layer: ownership.layer } : {}),
303
+ },
304
+ startHere,
305
+ readBeforeEditing: [...new Set([...readBefore.map((pick) => pick.path), 'AGENTS.md'])],
306
+ editRoots: [unit.path],
307
+ checks: [...checks.checks],
308
+ ...(humanDoc ? { humanDoc } : {}),
309
+ ...(bridge ? { bridge } : {}),
310
+ notes: [
311
+ ...(target.summary ? [target.summary] : []),
312
+ ...(!humanDoc && config.corpus.human && ownership ? [`Human guide missing for ${ownership.id}. Run: ak-docs bootstrap agent-docs`] : []),
313
+ ],
314
+ ...(related.length ? { related } : {}),
315
+ explain,
316
+ evidence,
317
+ metadata: {
318
+ entityId: target.id,
319
+ kind: target.kind,
320
+ checksSource: checks.source,
321
+ confidence: target.confidence,
322
+ ...(area ? { areaId: area.id } : {}),
323
+ ...(packageEntry ? { packageId: packageEntry.id } : {}),
324
+ },
325
+ })
326
+ }
@@ -4,6 +4,10 @@ import { join, resolve } from 'node:path'
4
4
  import type { DocBridgeConfigV1 } from '../config/schema.js'
5
5
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
6
6
  import { parseDocBridgeIndex } from '../validate.js'
7
+ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
8
+ import { repositoryInputs } from '../index-builder/project-corpus.js'
9
+ import { GRAPH_ANALYZER_VERSION } from '../graph/build.js'
10
+ import { SEARCH_LEXICON_VERSION } from './text.js'
7
11
 
8
12
  export class IndexNotFoundError extends Error {
9
13
  constructor(readonly path: string) {
@@ -12,6 +16,13 @@ export class IndexNotFoundError extends Error {
12
16
  }
13
17
  }
14
18
 
19
+ export class IndexStaleError extends Error {
20
+ constructor(readonly path: string, readonly actual: string, readonly expected: string) {
21
+ super(`Index is stale at ${path}. Run: ak-docs index`)
22
+ this.name = 'IndexStaleError'
23
+ }
24
+ }
25
+
15
26
  export const indexFilePath = (root: string, config: DocBridgeConfigV1): string =>
16
27
  join(root, config.index?.outFile ?? '.doc-bridge/index.json')
17
28
 
@@ -22,4 +33,45 @@ export const loadDocBridgeIndex = (root: string, config: DocBridgeConfigV1): Doc
22
33
  return parseDocBridgeIndex(raw)
23
34
  }
24
35
 
25
- export const resolveRoot = (cwd?: string): string => resolve(cwd ?? process.cwd())
36
+ /**
37
+ * Load only an index whose content matches the current repository inputs.
38
+ *
39
+ * Every query goes through here, so the check has to be cheap. An index that records its inputs
40
+ * is verified by re-hashing those inputs — one walk — instead of rebuilding the whole index,
41
+ * which on a repository of any size meant projecting and parsing the corpus on every search. The
42
+ * hash covers the input files and the configuration, and the recorded lexicon version is checked
43
+ * too, because a changed stopword list changes ranking without changing a single file.
44
+ *
45
+ * An index built before inputs were recorded falls back to the full rebuild, so an older artifact
46
+ * is still validated rather than trusted.
47
+ */
48
+ export const loadFreshDocBridgeIndex = (root: string, config: DocBridgeConfigV1): DocBridgeIndexV1 => {
49
+ const index = loadDocBridgeIndex(root, config)
50
+
51
+ if (index.inputs && index.retrieval) {
52
+ const inputs = repositoryInputs(root, config)
53
+ /*
54
+ * The projection is a function of the snapshot, the overlay and the configuration under a
55
+ * given lexicon and graph-metrics version; a change to either version changes ranking without
56
+ * changing a file, so both are checked next to the inputs.
57
+ */
58
+ const projectionFresh =
59
+ !index.projection ||
60
+ (index.projection.lexiconVersion === SEARCH_LEXICON_VERSION && index.projection.graphMetricsVersion === GRAPH_ANALYZER_VERSION)
61
+ const fresh =
62
+ index.inputs.hash === inputs.hash &&
63
+ index.inputs.projectionVersion === inputs.projectionVersion &&
64
+ index.retrieval.lexiconVersion === SEARCH_LEXICON_VERSION &&
65
+ projectionFresh
66
+ if (!fresh) throw new IndexStaleError(indexFilePath(root, config), index.inputs.hash, inputs.hash)
67
+ return index
68
+ }
69
+
70
+ const expected = buildDocBridgeIndex({ root, config, write: false }).index
71
+ if (index.contentHash !== expected.contentHash) {
72
+ throw new IndexStaleError(indexFilePath(root, config), index.contentHash, expected.contentHash)
73
+ }
74
+ return index
75
+ }
76
+
77
+ export const resolveRoot = (cwd?: string): string => resolve(cwd ?? process.cwd())
@@ -1,11 +1,15 @@
1
1
  import type { DocBridgeConfigV1 } from '../config/schema.js'
2
2
  import {
3
3
  normalizeAgentHandoff,
4
+ AgentQueryModeSchema,
4
5
  type AgentHandoffV1,
6
+ type AgentQueryMode,
5
7
  type AgentSearchV1,
6
8
  } from '../schemas/agent-handoff.js'
7
9
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
10
+ import { handoffForEntity, type HandoffOptions } from './handoff.js'
8
11
  import { searchIndex } from './search.js'
12
+ import { Buffer } from 'node:buffer'
9
13
 
10
14
  export type QueryKind = 'package' | 'ownership' | 'intent' | 'change' | 'search'
11
15
 
@@ -14,6 +18,10 @@ export type QueryRequest = {
14
18
  readonly id?: string
15
19
  readonly term?: string
16
20
  readonly agent?: boolean
21
+ /** Attach the matched terms and scoring components to search results. Never changes the ranking. */
22
+ readonly explain?: boolean
23
+ readonly mode?: AgentQueryMode
24
+ readonly contextBudgetTokens?: number
17
25
  }
18
26
 
19
27
  export type QueryResult =
@@ -21,63 +29,91 @@ export type QueryResult =
21
29
  | AgentHandoffV1
22
30
  | AgentSearchV1
23
31
 
24
- const handoffForPackage = (
25
- index: DocBridgeIndexV1,
26
- id: string,
27
- config: DocBridgeConfigV1,
28
- ): AgentHandoffV1 => {
29
- const fromIndex = index.handoffs?.[id]
30
- if (fromIndex) return normalizeAgentHandoff(fromIndex)
31
-
32
- const owner = index.lookup?.ownership?.[id]
33
- if (!owner) throw new Error(`Unknown package/ownership id "${id}". Try: ak-docs list packages`)
32
+ export const DEFAULT_AGENT_CONTEXT_BUDGET_TOKENS = 32
34
33
 
35
- const bridge = owner.humanDoc
36
- ? /^https?:\/\//.test(owner.humanDoc)
37
- ? { humanDoc: 'external' as const }
38
- : { humanDoc: 'linked' as const }
39
- : config.corpus.human
40
- ? {
41
- humanDoc: 'missing' as const,
42
- action: 'ak-docs bootstrap agent-docs',
43
- bootstrap: `docs/for-agents/human/${id}.md`,
44
- }
45
- : undefined
34
+ const modeLimits: Record<AgentQueryMode, { readonly matches: number; readonly nextCommands: number }> = {
35
+ discovery: { matches: 8, nextCommands: 5 },
36
+ editing: { matches: 5, nextCommands: 3 },
37
+ debugging: { matches: 6, nextCommands: 4 },
38
+ documentation: { matches: 6, nextCommands: 3 },
39
+ }
46
40
 
47
- return normalizeAgentHandoff({
48
- type: 'agent-handoff',
49
- source: config.index?.outFile ?? '.doc-bridge/index.json',
50
- target: {
51
- type: 'package',
52
- id,
53
- path: owner.path,
54
- ...(owner.group ? { group: owner.group } : {}),
55
- ...(owner.layer ? { layer: owner.layer } : {}),
56
- },
57
- startHere: owner.agentDoc ?? config.corpus.agent.index ?? '',
58
- readBeforeEditing: [owner.agentDoc, 'AGENTS.md'].filter(Boolean),
59
- editRoots: [owner.path],
60
- checks: [...owner.checks],
61
- ...(owner.humanDoc ? { humanDoc: owner.humanDoc } : {}),
62
- ...(bridge ? { bridge } : {}),
63
- notes: [
64
- ...(owner.purpose ? [owner.purpose] : []),
65
- ...(!owner.humanDoc && config.corpus.human
66
- ? [`Human guide missing for ${id}. Run: ak-docs bootstrap agent-docs`]
67
- : []),
68
- ],
69
- })
41
+ const boundedAgentContext = (
42
+ matches: readonly AgentSearchV1['matches'][number][],
43
+ nextCommands: readonly string[],
44
+ contextBudgetTokens: number,
45
+ ): { readonly matches: AgentSearchV1['matches']; readonly nextCommands: string[]; readonly contextBytes: number; readonly truncated: boolean } => {
46
+ if (!Number.isInteger(contextBudgetTokens) || contextBudgetTokens < 1 || contextBudgetTokens > 1_000_000) throw new Error('contextBudgetTokens must be an integer between 1 and 1000000.')
47
+ const maxBytes = contextBudgetTokens * 4
48
+ let boundedMatches = matches.map((match) => ({ ...match }))
49
+ let boundedCommands = [...nextCommands]
50
+ let truncated = false
51
+ const contextBytes = () => Buffer.byteLength(JSON.stringify({ matches: boundedMatches, nextCommands: boundedCommands }), 'utf8')
52
+ while (contextBytes() > maxBytes) {
53
+ const summaryIndex = [...boundedMatches].map((match) => match.summary !== undefined).lastIndexOf(true)
54
+ if (summaryIndex >= 0) {
55
+ const match = boundedMatches[summaryIndex]
56
+ if (match) {
57
+ const { summary: _summary, ...withoutSummary } = match
58
+ boundedMatches[summaryIndex] = withoutSummary
59
+ truncated = true
60
+ continue
61
+ }
62
+ }
63
+ if (boundedCommands.length > 1) {
64
+ boundedCommands.pop()
65
+ truncated = true
66
+ continue
67
+ }
68
+ if (boundedMatches.length > 1) {
69
+ boundedMatches.pop()
70
+ truncated = true
71
+ continue
72
+ }
73
+ if (boundedCommands.length > 0) {
74
+ boundedCommands.pop()
75
+ truncated = true
76
+ continue
77
+ }
78
+ break
79
+ }
80
+ const finalContextBytes = contextBytes()
81
+ if (finalContextBytes > maxBytes) throw new Error(`contextBudgetTokens ${contextBudgetTokens} is too small for the minimum grounded result.`)
82
+ return { matches: boundedMatches, nextCommands: boundedCommands, contextBytes: finalContextBytes, truncated }
70
83
  }
71
84
 
72
85
  export const runQuery = (
73
86
  index: DocBridgeIndexV1,
74
87
  config: DocBridgeConfigV1,
75
88
  req: QueryRequest,
89
+ options: HandoffOptions = {},
76
90
  ): QueryResult => {
77
91
  if (req.kind === 'search') {
78
92
  const term = req.term ?? req.id ?? ''
79
- const matches = searchIndex(index, term)
93
+ const matches = searchIndex(index, term, 20, { ...(req.explain ? { explain: true } : {}), ...(req.agent ? { agent: true } : {}) })
80
94
  if (req.agent) {
95
+ const mode = AgentQueryModeSchema.parse(req.mode ?? 'discovery')
96
+ const limits = modeLimits[mode]
97
+ const focusedMatches = matches[0] && (matches[0].type === 'intent' || matches[0].type === 'change')
98
+ ? matches.filter((match) => match.type === matches[0]?.type).slice(0, 3)
99
+ : matches.slice(0, limits.matches)
100
+ const agentMatches = focusedMatches.map((m) => ({
101
+ type: m.type,
102
+ id: m.id,
103
+ path: m.path,
104
+ ...(m.summary ? { summary: m.summary } : {}),
105
+ }))
106
+ const nextCommands = [...new Set(focusedMatches.slice(0, limits.nextCommands).map((m) =>
107
+ m.type === 'intent'
108
+ ? `ak-docs query intent ${m.id} --agent`
109
+ : m.type === 'change'
110
+ ? `ak-docs query change ${m.id} --agent`
111
+ : m.type === 'ownership' || index.lookup?.ownership?.[m.id]
112
+ ? `ak-docs query ownership ${m.id} --agent`
113
+ : 'ak-docs list knowledge --text',
114
+ ))]
115
+ const budget = req.contextBudgetTokens ?? DEFAULT_AGENT_CONTEXT_BUDGET_TOKENS
116
+ const bounded = boundedAgentContext(agentMatches, nextCommands, budget)
81
117
  const payload: AgentSearchV1 = {
82
118
  type: 'agent-search',
83
119
  schemaVersion: 1,
@@ -90,21 +126,18 @@ export const runQuery = (
90
126
  id: matches[0].id,
91
127
  path: matches[0].path,
92
128
  ...(matches[0].summary ? { summary: matches[0].summary } : {}),
93
- score: matches[0].score,
94
129
  }
95
130
  : null,
96
- matches: matches.slice(0, 8).map((m) => ({
97
- type: m.type,
98
- id: m.id,
99
- path: m.path,
100
- ...(m.summary ? { summary: m.summary } : {}),
101
- score: m.score,
102
- })),
103
- nextCommands: [...new Set(matches.slice(0, 5).map((m) =>
104
- m.type === 'ownership' || index.lookup?.ownership?.[m.id]
105
- ? `ak-docs query ownership ${m.id} --agent`
106
- : 'ak-docs list knowledge --text',
107
- ))],
131
+ matches: bounded.matches,
132
+ nextCommands: bounded.nextCommands,
133
+ telemetry: {
134
+ contextBytes: bounded.contextBytes,
135
+ estimatedTokens: Math.ceil(bounded.contextBytes / 4),
136
+ tokenMethod: 'estimate',
137
+ contextBudgetTokens: budget,
138
+ mode,
139
+ truncated: bounded.truncated,
140
+ },
108
141
  }
109
142
  return payload
110
143
  }
@@ -115,7 +148,7 @@ export const runQuery = (
115
148
  if (!id) throw new Error(`Missing id for query kind "${req.kind}"`)
116
149
 
117
150
  if (req.kind === 'package' || req.kind === 'ownership') {
118
- if (req.agent) return handoffForPackage(index, id, config)
151
+ if (req.agent) return handoffForEntity(index, id, config, options)
119
152
  const owner = index.lookup?.ownership?.[id]
120
153
  return { type: req.kind, data: owner ?? index.handoffs?.[id] ?? null }
121
154
  }