@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,356 @@
1
+ import Graph, { type GraphologyGraph } from 'graphology'
2
+ import louvain from 'graphology-communities-louvain'
3
+ import hasCycle from 'graphology-dag/has-cycle.js'
4
+ import betweennessCentrality from 'graphology-metrics/centrality/betweenness.js'
5
+ import pagerank from 'graphology-metrics/centrality/pagerank.js'
6
+ import { singleSourceLength } from 'graphology-shortest-path/unweighted.js'
7
+
8
+ import type { Coverage, DiscoverySnapshotV1, Evidence, KnowledgeRelation } from '../schemas/knowledge.js'
9
+
10
+ /**
11
+ * Graph signals over the knowledge snapshot.
12
+ *
13
+ * The rules engine used to derive `centrality-risk` from how many undocumented-relation findings
14
+ * were attached to an entity — a measure of documentation debt wearing the name of an
15
+ * architectural signal. A module can be the single point every import path runs through and carry
16
+ * no findings at all; a well-connected but thoroughly documented module carried the highest score.
17
+ * This module computes the real thing: betweenness over the import graph, PageRank over the
18
+ * documentation graph, bounded shortest paths, and cycles.
19
+ *
20
+ * The graph is a working structure. It is built from the snapshot on demand, never serialised, and
21
+ * `DiscoverySnapshotV1` does not learn about graphology. Nodes are inserted in sorted order and
22
+ * every score is rounded before it is returned, so two runs over the same snapshot agree exactly
23
+ * and an artifact built from these numbers keeps its hash.
24
+ */
25
+
26
+ export const GRAPH_ANALYZER_VERSION = '1.0.0'
27
+
28
+ /** Edges that say "this documentation points at that": the canonicality signal. */
29
+ export const DOCUMENTATION_EDGE_KINDS = ['links-to', 'covers'] as const
30
+
31
+ /** Edges that say "this code needs that": the centrality and cycle signal. */
32
+ export const IMPORT_EDGE_KINDS = ['imports', 're-exports'] as const
33
+
34
+ /** Enough precision to rank, little enough to compare exactly across runs and platforms. */
35
+ const PRECISION = 1e6
36
+
37
+ const round = (value: number): number => Math.round(value * PRECISION) / PRECISION
38
+
39
+ export type BuildGraphOptions = {
40
+ /** Relation kinds to include. Every other relation is left out of this view. */
41
+ readonly kinds?: readonly string[]
42
+ /** Undirected graphs are for community detection; everything else is directed. */
43
+ readonly undirected?: boolean
44
+ /** Keep external and unresolved endpoints. Off by default: they are not project architecture. */
45
+ readonly includeExternal?: boolean
46
+ }
47
+
48
+ const isInternal = (id: string): boolean => !id.startsWith('external:') && !id.startsWith('unresolved:')
49
+
50
+ /**
51
+ * Build a view of the snapshot as a graph.
52
+ *
53
+ * Nodes are added in sorted order, then edges in sorted order, so the library's internal iteration
54
+ * order is a function of the snapshot's content and not of how the snapshot happened to be built.
55
+ * That is what makes a metric reproducible after the input order is shuffled.
56
+ */
57
+ export const buildKnowledgeGraph = (
58
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
59
+ options: BuildGraphOptions = {},
60
+ ): GraphologyGraph => {
61
+ const kinds = options.kinds ? new Set(options.kinds) : undefined
62
+ const includeExternal = options.includeExternal ?? false
63
+ const graph = new Graph({ type: options.undirected ? 'undirected' : 'directed', multi: false, allowSelfLoops: false })
64
+
65
+ const relations = [...snapshot.relations]
66
+ .filter((relation) => !kinds || kinds.has(relation.kind))
67
+ .filter((relation) => includeExternal || (isInternal(relation.from) && isInternal(relation.to)))
68
+ .filter((relation) => relation.from !== relation.to)
69
+ .sort((a, b) => a.id.localeCompare(b.id))
70
+
71
+ const referenced = new Set(relations.flatMap((relation) => [relation.from, relation.to]))
72
+ const entities = new Set(snapshot.entities.map((entity) => entity.id))
73
+ for (const id of [...referenced].sort()) {
74
+ // An endpoint with no entity is still a node: the relation observed it, and dropping it
75
+ // silently would change a path length without saying so.
76
+ if (!graph.hasNode(id)) graph.addNode(id, { known: entities.has(id) })
77
+ }
78
+ for (const relation of relations) {
79
+ if (graph.hasEdge(relation.from, relation.to)) continue
80
+ graph.addEdge(relation.from, relation.to, { kind: relation.kind, relationId: relation.id })
81
+ }
82
+ return graph
83
+ }
84
+
85
+ export type GraphSignal = ReadonlyMap<string, number>
86
+
87
+ /**
88
+ * Canonicality: PageRank over the documentation graph.
89
+ *
90
+ * A page many documents link to, or that covers many entities, is where a reader should start. A
91
+ * leaf note nothing points at is not, however recently it was edited. PageRank says that without
92
+ * anyone maintaining a list of entry points.
93
+ */
94
+ export const canonicality = (
95
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
96
+ options: { readonly alpha?: number } = {},
97
+ ): GraphSignal => {
98
+ const graph = buildKnowledgeGraph(snapshot, { kinds: DOCUMENTATION_EDGE_KINDS })
99
+ if (!graph.order) return new Map()
100
+ const scores = pagerank(graph, { getEdgeWeight: null, alpha: options.alpha ?? 0.85 })
101
+ return new Map(
102
+ Object.entries(scores)
103
+ .map(([id, score]): [string, number] => [id, round(score)])
104
+ .sort(([a], [b]) => a.localeCompare(b)),
105
+ )
106
+ }
107
+
108
+ /**
109
+ * Centrality: betweenness over the import graph.
110
+ *
111
+ * How much of the repository's dependency structure runs through this module. High betweenness is
112
+ * a review signal — a change here reaches further than its diff suggests — and nothing more: it is
113
+ * a static count of shortest paths, not a statement about runtime availability.
114
+ */
115
+ export const centrality = (
116
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
117
+ options: { readonly kinds?: readonly string[] } = {},
118
+ ): GraphSignal => {
119
+ const graph = buildKnowledgeGraph(snapshot, { kinds: options.kinds ?? IMPORT_EDGE_KINDS })
120
+ if (!graph.order) return new Map()
121
+ const scores = betweennessCentrality(graph, { getEdgeWeight: null, normalized: true })
122
+ return new Map(
123
+ Object.entries(scores)
124
+ .map(([id, score]): [string, number] => [id, round(score)])
125
+ .sort(([a], [b]) => a.localeCompare(b)),
126
+ )
127
+ }
128
+
129
+ export type ProximityOptions = {
130
+ readonly kinds?: readonly string[]
131
+ /** Hops beyond which two entities are not usefully related. */
132
+ readonly maxDepth?: number
133
+ }
134
+
135
+ export const DEFAULT_PROXIMITY_DEPTH = 3
136
+
137
+ /**
138
+ * Relation kinds that make two entities *related*, as opposed to merely filed together.
139
+ *
140
+ * `contains` is left out on purpose: it is hierarchy, and including it puts every module in an
141
+ * area two hops from every other one, which is true and tells a reader nothing. What makes a
142
+ * module close to a document is that the document covers or mentions it; what makes two modules
143
+ * close is that one imports the other.
144
+ */
145
+ export const PROXIMITY_EDGE_KINDS = [
146
+ ...IMPORT_EDGE_KINDS,
147
+ ...DOCUMENTATION_EDGE_KINDS,
148
+ 'mentions',
149
+ 'mentions-symbol',
150
+ 'depends-on',
151
+ ] as const
152
+
153
+ /**
154
+ * Proximity: how many hops from one entity to another, bounded.
155
+ *
156
+ * Bounded because an unbounded answer is not useful — at ten hops everything is related to
157
+ * everything — and because ranking and handoff selection need a cheap neighbourhood, not a
158
+ * complete distance matrix.
159
+ */
160
+ export const proximity = (
161
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
162
+ from: string,
163
+ options: ProximityOptions = {},
164
+ ): GraphSignal => {
165
+ const maxDepth = options.maxDepth ?? DEFAULT_PROXIMITY_DEPTH
166
+ const graph = buildKnowledgeGraph(snapshot, {
167
+ kinds: options.kinds ?? PROXIMITY_EDGE_KINDS,
168
+ undirected: true,
169
+ })
170
+ if (!graph.hasNode(from)) return new Map()
171
+ const lengths = singleSourceLength(graph, from)
172
+ // Nearest first, then by id: a proximity map is read from the top, and ties must not depend on
173
+ // the order the snapshot happened to arrive in.
174
+ return new Map(
175
+ Object.entries(lengths)
176
+ .filter(([id, length]) => id !== from && length <= maxDepth)
177
+ .sort(([leftId, left], [rightId, right]) => left - right || leftId.localeCompare(rightId)),
178
+ )
179
+ }
180
+
181
+ export type ImportCycle = {
182
+ /** The nodes of the cycle, rotated so the lexicographically smallest is first. */
183
+ readonly nodes: readonly string[]
184
+ readonly relationIds: readonly string[]
185
+ readonly evidence: readonly Evidence[]
186
+ }
187
+
188
+ /** A cycle beyond this many is the same architectural problem reported again. */
189
+ export const MAX_REPORTED_CYCLES = 16
190
+
191
+ const rotated = (nodes: readonly string[]): readonly string[] => {
192
+ let pivot = 0
193
+ for (let index = 1; index < nodes.length; index += 1) {
194
+ if ((nodes[index] as string) < (nodes[pivot] as string)) pivot = index
195
+ }
196
+ return [...nodes.slice(pivot), ...nodes.slice(0, pivot)]
197
+ }
198
+
199
+ /**
200
+ * Import cycles, with the edges that form them.
201
+ *
202
+ * `graphology-dag` answers whether a cycle exists; reporting one needs the path, because a
203
+ * diagnostic without the edges is a claim a reader cannot check. This is a depth-first search that
204
+ * records the stack when it closes a loop, bounded in both count and depth, and deterministic
205
+ * because the graph iterates in sorted order.
206
+ */
207
+ export const importCycles = (
208
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
209
+ options: { readonly kinds?: readonly string[]; readonly limit?: number } = {},
210
+ ): readonly ImportCycle[] => {
211
+ const kinds = options.kinds ?? IMPORT_EDGE_KINDS
212
+ const graph = buildKnowledgeGraph(snapshot, { kinds })
213
+ if (!graph.order || !hasCycle(graph)) return []
214
+
215
+ const relations = new Map(snapshot.relations.map((relation) => [relation.id, relation]))
216
+ const limit = options.limit ?? MAX_REPORTED_CYCLES
217
+ const found = new Map<string, ImportCycle>()
218
+ const onStack = new Set<string>()
219
+ const visited = new Set<string>()
220
+ const stack: string[] = []
221
+
222
+ const edgeOf = (from: string, to: string): KnowledgeRelation | undefined => {
223
+ const attributes = graph.getEdgeAttributes(from, to) as { relationId?: string }
224
+ return attributes.relationId ? relations.get(attributes.relationId) : undefined
225
+ }
226
+
227
+ const record = (cycle: readonly string[]): void => {
228
+ const nodes = rotated(cycle)
229
+ const key = nodes.join('→')
230
+ if (found.has(key) || found.size >= limit) return
231
+ const edges = nodes.map((node, index) => edgeOf(node, nodes[(index + 1) % nodes.length] as string))
232
+ found.set(key, {
233
+ nodes,
234
+ relationIds: edges.flatMap((edge) => (edge ? [edge.id] : [])),
235
+ evidence: edges.flatMap((edge) => (edge?.evidence[0] ? [edge.evidence[0]] : [])),
236
+ })
237
+ }
238
+
239
+ const visit = (node: string): void => {
240
+ if (found.size >= limit) return
241
+ visited.add(node)
242
+ onStack.add(node)
243
+ stack.push(node)
244
+ for (const next of [...graph.outNeighbors(node)].sort()) {
245
+ if (onStack.has(next)) {
246
+ const start = stack.indexOf(next)
247
+ if (start >= 0) record(stack.slice(start))
248
+ continue
249
+ }
250
+ if (!visited.has(next)) visit(next)
251
+ }
252
+ stack.pop()
253
+ onStack.delete(node)
254
+ }
255
+
256
+ for (const node of [...graph.nodes()].sort()) if (!visited.has(node)) visit(node)
257
+ return [...found.values()].sort((a, b) => a.nodes.join().localeCompare(b.nodes.join()))
258
+ }
259
+
260
+ /** Deterministic pseudo-randomness, so a community run is reproducible. */
261
+ export const seededRandom = (seed: number): (() => number) => {
262
+ let state = seed | 0
263
+ return () => {
264
+ state = (state + 0x6d2b79f5) | 0
265
+ let t = Math.imul(state ^ (state >>> 15), 1 | state)
266
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
267
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
268
+ }
269
+ }
270
+
271
+ export const DEFAULT_COMMUNITY_SEED = 20_260_401
272
+
273
+ export type AreaSuggestion = {
274
+ /** Suggested area label, from the deepest directory the members share. */
275
+ readonly label: string
276
+ readonly members: readonly string[]
277
+ }
278
+
279
+ const commonDirectory = (paths: readonly string[]): string | undefined => {
280
+ if (!paths.length) return undefined
281
+ const split = paths.map((path) => path.split('/').slice(0, -1))
282
+ const first = split[0] as string[]
283
+ const shared: string[] = []
284
+ for (let index = 0; index < first.length; index += 1) {
285
+ const segment = first[index] as string
286
+ if (!split.every((parts) => parts[index] === segment)) break
287
+ shared.push(segment)
288
+ }
289
+ return shared.length ? shared.join('/') : undefined
290
+ }
291
+
292
+ /**
293
+ * Area suggestions from seeded Louvain communities over the import graph.
294
+ *
295
+ * A community is a hypothesis: these modules move together, so perhaps they are one unit. It is
296
+ * never an area on its own — an area is derived from the repository's own structure or declared by
297
+ * a human, and a clustering algorithm is neither. Suggestions travel as `coverage` entries for a
298
+ * person or the curator agent to promote into configuration, which keeps a guess out of the graph
299
+ * while still surfacing it.
300
+ */
301
+ export const areaSuggestions = (
302
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
303
+ options: { readonly seed?: number; readonly minMembers?: number; readonly limit?: number } = {},
304
+ ): readonly AreaSuggestion[] => {
305
+ const graph = buildKnowledgeGraph(snapshot, { kinds: IMPORT_EDGE_KINDS, undirected: true })
306
+ if (graph.order < 2 || !graph.size) return []
307
+
308
+ const communities = louvain(graph, { rng: seededRandom(options.seed ?? DEFAULT_COMMUNITY_SEED) }) as Record<string, number>
309
+ const paths = new Map(snapshot.entities.flatMap((entity) => (entity.path ? [[entity.id, entity.path] as const] : [])))
310
+ const grouped = new Map<number, string[]>()
311
+ for (const [id, community] of Object.entries(communities).sort(([a], [b]) => a.localeCompare(b))) {
312
+ const members = grouped.get(community) ?? []
313
+ members.push(id)
314
+ grouped.set(community, members)
315
+ }
316
+
317
+ const minMembers = options.minMembers ?? 3
318
+ const suggestions: AreaSuggestion[] = []
319
+ for (const [, members] of [...grouped.entries()].sort(([a], [b]) => a - b)) {
320
+ if (members.length < minMembers) continue
321
+ const label = commonDirectory(members.flatMap((id) => (paths.get(id) ? [paths.get(id) as string] : [])))
322
+ if (!label) continue
323
+ suggestions.push({ label, members: [...members].sort() })
324
+ }
325
+ return suggestions
326
+ .sort((a, b) => b.members.length - a.members.length || a.label.localeCompare(b.label))
327
+ .slice(0, options.limit ?? 8)
328
+ }
329
+
330
+ /**
331
+ * Community suggestions as coverage.
332
+ *
333
+ * `status: 'not-analyzed'` is the honest status: the clustering ran, but whether the cluster is an
334
+ * area is a question nobody has answered yet.
335
+ */
336
+ export const areaSuggestionCoverage = (
337
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
338
+ options: { readonly seed?: number; readonly minMembers?: number; readonly limit?: number } = {},
339
+ ): readonly Coverage[] => {
340
+ const declared = new Set(
341
+ snapshot.entities.filter((entity) => entity.kind === 'area').map((entity) => entity.path ?? ''),
342
+ )
343
+ return areaSuggestions(snapshot, options)
344
+ .filter((suggestion) => !declared.has(suggestion.label))
345
+ .map((suggestion) => ({
346
+ analyzer: 'graph',
347
+ analyzerVersion: GRAPH_ANALYZER_VERSION,
348
+ scope: `area-suggestion:${suggestion.label}`,
349
+ status: 'not-analyzed' as const,
350
+ reason: `${suggestion.members.length} modules cluster around ${suggestion.label}; promote it to an area in configuration to make it part of the graph.`,
351
+ evidence: suggestion.members.slice(0, 8).flatMap((id) => {
352
+ const path = snapshot.entities.find((entity) => entity.id === id)?.path
353
+ return path ? [{ source: 'derived' as const, path }] : []
354
+ }),
355
+ }))
356
+ }
@@ -0,0 +1,208 @@
1
+ import type { DiscoverySnapshotV1, KnowledgeEntity, KnowledgeRelation } from '../schemas/knowledge.js'
2
+
3
+ /**
4
+ * The repository graph behind the ecosystem's `GraphMemory` contract.
5
+ *
6
+ * An agent built on AgentsKit already walks its own memory with `getNode`, `findEdges` and
7
+ * `neighbors`. This exposes the repository's structure through the same three calls, so asking
8
+ * "what does this module import, and what documents cover it" needs no Doc Bridge-specific client.
9
+ *
10
+ * The contract is mirrored rather than imported: `@agentskit/memory` is an optional peer, and a
11
+ * projection that only typechecked when an optional package happened to be installed would be a
12
+ * published type that breaks on a clean install. `tests/graph.test.ts` asserts this implementation
13
+ * is assignable to the real `GraphMemory` and behaves like `createInMemoryGraph` on the same
14
+ * inputs, so the mirror cannot drift.
15
+ */
16
+
17
+ export type GraphNode<TProps = Record<string, unknown>> = {
18
+ id: string
19
+ /** Type or label — here the entity kind: `module`, `document`, `area`, `package`. */
20
+ kind: string
21
+ properties?: TProps
22
+ createdAt?: string
23
+ updatedAt?: string
24
+ }
25
+
26
+ export type GraphEdge<TProps = Record<string, unknown>> = {
27
+ id: string
28
+ /** Verb — here the relation kind: `imports`, `covers`, `links-to`, `contains`. */
29
+ label: string
30
+ from: string
31
+ to: string
32
+ weight?: number
33
+ properties?: TProps
34
+ }
35
+
36
+ export type GraphQuery = {
37
+ kind?: string
38
+ label?: string
39
+ from?: string
40
+ to?: string
41
+ }
42
+
43
+ export type GraphMemory = {
44
+ upsertNode: <T>(node: GraphNode<T>) => Promise<GraphNode<T>>
45
+ upsertEdge: <T>(edge: GraphEdge<T>) => Promise<GraphEdge<T>>
46
+ getNode: <T>(id: string) => Promise<GraphNode<T> | null>
47
+ findNodes: <T>(query?: GraphQuery) => Promise<GraphNode<T>[]>
48
+ findEdges: <T>(query?: GraphQuery) => Promise<GraphEdge<T>[]>
49
+ /** Breadth-first neighbours of `id` up to `depth`, in both directions. Default 1. */
50
+ neighbors: <T>(id: string, options?: { depth?: number; label?: string }) => Promise<GraphNode<T>[]>
51
+ deleteNode: (id: string) => Promise<void>
52
+ deleteEdge: (id: string) => Promise<void>
53
+ clear?: () => Promise<void>
54
+ }
55
+
56
+ /**
57
+ * Extra entities and relations layered over the observed snapshot.
58
+ *
59
+ * Same vocabulary as the snapshot, so the approved enrichment overlay plugs in unchanged when it
60
+ * exists. Overlay records win over observed ones with the same id, which is what "approved" has to
61
+ * mean for it to be worth approving.
62
+ */
63
+ export type KnowledgeOverlay = {
64
+ readonly entities?: readonly KnowledgeEntity[]
65
+ readonly relations?: readonly KnowledgeRelation[]
66
+ }
67
+
68
+ const nodeOf = (entity: KnowledgeEntity): GraphNode => ({
69
+ id: entity.id,
70
+ kind: entity.kind,
71
+ properties: {
72
+ name: entity.name,
73
+ ...(entity.path !== undefined ? { path: entity.path } : {}),
74
+ provenance: entity.provenance,
75
+ ...(entity.aliases?.length ? { aliases: [...entity.aliases] } : {}),
76
+ ...(entity.metadata ?? {}),
77
+ evidenceCount: entity.evidence.length,
78
+ },
79
+ })
80
+
81
+ const edgeOf = (relation: KnowledgeRelation): GraphEdge => ({
82
+ id: relation.id,
83
+ label: relation.kind,
84
+ from: relation.from,
85
+ to: relation.to,
86
+ properties: {
87
+ provenance: relation.provenance,
88
+ ...(relation.discriminator !== undefined ? { discriminator: relation.discriminator } : {}),
89
+ ...(relation.metadata ?? {}),
90
+ evidenceCount: relation.evidence.length,
91
+ },
92
+ })
93
+
94
+ const matchesNode = (node: GraphNode, query?: GraphQuery): boolean => !query?.kind || node.kind === query.kind
95
+
96
+ const matchesEdge = (edge: GraphEdge, query?: GraphQuery): boolean => {
97
+ if (!query) return true
98
+ if (query.label && edge.label !== query.label) return false
99
+ if (query.from && edge.from !== query.from) return false
100
+ if (query.to && edge.to !== query.to) return false
101
+ return true
102
+ }
103
+
104
+ /**
105
+ * Project a snapshot, and an optional overlay, as a `GraphMemory`.
106
+ *
107
+ * Writes are accepted and kept in process: the snapshot is an observation and cannot be edited by
108
+ * a caller, so `upsertNode` and `upsertEdge` land in a working layer above it and `deleteNode`
109
+ * masks rather than erases. That keeps the contract honest in both directions — an agent can
110
+ * annotate what it is exploring without any of it being mistaken for something the repository
111
+ * said. `clear` drops the working layer, never the projection.
112
+ */
113
+ export const createDocBridgeGraphMemory = (
114
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations'>,
115
+ overlay: KnowledgeOverlay = {},
116
+ ): GraphMemory => {
117
+ const projectedNodes = new Map<string, GraphNode>()
118
+ const projectedEdges = new Map<string, GraphEdge>()
119
+ for (const entity of [...snapshot.entities, ...(overlay.entities ?? [])]) projectedNodes.set(entity.id, nodeOf(entity))
120
+ for (const relation of [...snapshot.relations, ...(overlay.relations ?? [])]) projectedEdges.set(relation.id, edgeOf(relation))
121
+
122
+ const writtenNodes = new Map<string, GraphNode>()
123
+ const writtenEdges = new Map<string, GraphEdge>()
124
+ const maskedNodes = new Set<string>()
125
+ const maskedEdges = new Set<string>()
126
+
127
+ const nodes = (): GraphNode[] => {
128
+ const merged = new Map(projectedNodes)
129
+ for (const [id, node] of writtenNodes) merged.set(id, node)
130
+ for (const id of maskedNodes) merged.delete(id)
131
+ return [...merged.values()].sort((a, b) => a.id.localeCompare(b.id))
132
+ }
133
+
134
+ const edges = (): GraphEdge[] => {
135
+ const merged = new Map(projectedEdges)
136
+ for (const [id, edge] of writtenEdges) merged.set(id, edge)
137
+ for (const id of maskedEdges) merged.delete(id)
138
+ return [...merged.values()]
139
+ .filter((edge) => !maskedNodes.has(edge.from) && !maskedNodes.has(edge.to))
140
+ .sort((a, b) => a.id.localeCompare(b.id))
141
+ }
142
+
143
+ return {
144
+ async upsertNode<T>(node: GraphNode<T>): Promise<GraphNode<T>> {
145
+ const now = new Date().toISOString()
146
+ const existing = writtenNodes.get(node.id) ?? projectedNodes.get(node.id)
147
+ const merged = { ...(existing ?? {}), ...node, createdAt: existing?.createdAt ?? now, updatedAt: now }
148
+ maskedNodes.delete(node.id)
149
+ writtenNodes.set(node.id, merged as GraphNode)
150
+ return merged as GraphNode<T>
151
+ },
152
+ async upsertEdge<T>(edge: GraphEdge<T>): Promise<GraphEdge<T>> {
153
+ maskedEdges.delete(edge.id)
154
+ writtenEdges.set(edge.id, edge as GraphEdge)
155
+ return edge
156
+ },
157
+ async getNode<T>(id: string): Promise<GraphNode<T> | null> {
158
+ if (maskedNodes.has(id)) return null
159
+ const node = writtenNodes.get(id) ?? projectedNodes.get(id)
160
+ return node ? ({ ...node } as GraphNode<T>) : null
161
+ },
162
+ async findNodes<T>(query?: GraphQuery): Promise<GraphNode<T>[]> {
163
+ return nodes().filter((node) => matchesNode(node, query)).map((node) => ({ ...node }) as GraphNode<T>)
164
+ },
165
+ async findEdges<T>(query?: GraphQuery): Promise<GraphEdge<T>[]> {
166
+ return edges().filter((edge) => matchesEdge(edge, query)).map((edge) => ({ ...edge }) as GraphEdge<T>)
167
+ },
168
+ async neighbors<T>(id: string, options: { depth?: number; label?: string } = {}): Promise<GraphNode<T>[]> {
169
+ const depth = Math.max(1, options.depth ?? 1)
170
+ const all = edges().filter((edge) => !options.label || edge.label === options.label)
171
+ const visited = new Set([id])
172
+ let frontier = new Set([id])
173
+ for (let step = 0; step < depth; step += 1) {
174
+ const next = new Set<string>()
175
+ for (const edge of all) {
176
+ if (frontier.has(edge.from) && !visited.has(edge.to)) next.add(edge.to)
177
+ if (frontier.has(edge.to) && !visited.has(edge.from)) next.add(edge.from)
178
+ }
179
+ for (const node of next) visited.add(node)
180
+ frontier = next
181
+ if (!next.size) break
182
+ }
183
+ visited.delete(id)
184
+ const byId = new Map(nodes().map((node) => [node.id, node]))
185
+ return [...visited]
186
+ .sort()
187
+ .flatMap((nodeId) => {
188
+ const node = byId.get(nodeId)
189
+ return node ? [{ ...node } as GraphNode<T>] : []
190
+ })
191
+ },
192
+ async deleteNode(id: string): Promise<void> {
193
+ writtenNodes.delete(id)
194
+ maskedNodes.add(id)
195
+ for (const edge of edges()) if (edge.from === id || edge.to === id) maskedEdges.add(edge.id)
196
+ },
197
+ async deleteEdge(id: string): Promise<void> {
198
+ writtenEdges.delete(id)
199
+ maskedEdges.add(id)
200
+ },
201
+ async clear(): Promise<void> {
202
+ writtenNodes.clear()
203
+ writtenEdges.clear()
204
+ maskedNodes.clear()
205
+ maskedEdges.clear()
206
+ },
207
+ }
208
+ }
@@ -24,6 +24,7 @@ export type OwnershipRecord = {
24
24
  layer?: string
25
25
  purpose?: string
26
26
  checks: string[]
27
+ checksSource?: 'ownership' | 'frontmatter' | 'package-scripts' | 'default'
27
28
  agentDoc?: string
28
29
  humanDoc?: string
29
30
  readme?: string
@@ -161,22 +162,32 @@ export const buildLookup = (
161
162
  const startHere = agentDoc ?? ''
162
163
  const purpose = override?.purpose ?? fm?.purpose
163
164
  const path = override?.path ?? fm?.path ?? pkg.path
164
- const checks = [
165
- ...(override?.checks ??
166
- fm?.checks ??
167
- pkg.checks ??
168
- defaultChecksForTarget(root, {
169
- packageId: pkg.id,
170
- packagePath: path,
171
- ...(pkg.name ? { packageName: pkg.name } : {}),
172
- strict,
173
- })),
174
- ]
165
+ /*
166
+ * Where the checks come from is decided here, and recorded here: a handoff that reports its
167
+ * checks' origin cannot reconstruct it later from the merged list.
168
+ */
169
+ const resolvedChecks: { readonly checks: readonly string[]; readonly source: OwnershipRecord['checksSource'] } = override?.checks
170
+ ? { checks: override.checks, source: 'ownership' }
171
+ : fm?.checks
172
+ ? { checks: fm.checks, source: 'frontmatter' }
173
+ : pkg.checks
174
+ ? { checks: pkg.checks, source: 'package-scripts' }
175
+ : {
176
+ checks: defaultChecksForTarget(root, {
177
+ packageId: pkg.id,
178
+ packagePath: path,
179
+ ...(pkg.name ? { packageName: pkg.name } : {}),
180
+ strict,
181
+ }),
182
+ source: 'default',
183
+ }
184
+ const checks = [...resolvedChecks.checks]
175
185
  const humanDoc = resolveHumanDoc(pkg.id, override?.humanDoc, fm?.humanDoc, humanDocs)
176
186
  const record: OwnershipRecord = {
177
187
  id: pkg.id,
178
188
  path,
179
189
  checks,
190
+ ...(resolvedChecks.source ? { checksSource: resolvedChecks.source } : {}),
180
191
  ...(override?.group ? { group: override.group } : {}),
181
192
  ...(override?.layer ? { layer: override.layer } : {}),
182
193
  ...(purpose ? { purpose } : {}),