@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,410 @@
1
+ import { z } from 'zod'
2
+
3
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
4
+ import { jaroWinkler } from '../lib/fuzzy-match.js'
5
+ import { redactSecrets } from '../safety/repository.js'
6
+ import {
7
+ ENRICHMENT_POLICY,
8
+ EnrichmentAdjudicationV1Schema,
9
+ EnrichmentOriginSchema,
10
+ EnrichmentProposalV1Schema,
11
+ enrichmentAdjudicationId,
12
+ enrichmentProposalId,
13
+ isEnrichmentKind,
14
+ type AcceptedEnrichment,
15
+ type EnrichmentAdjudicationV1,
16
+ type EnrichmentProposalV1,
17
+ type EnrichmentRejectionReason,
18
+ type PendingEnrichment,
19
+ type RejectedEnrichment,
20
+ } from '../schemas/enrichment.js'
21
+ import type { DiscoverySnapshotV1, Evidence, KnowledgeEntity, ReconciliationReportV1 } from '../schemas/knowledge.js'
22
+
23
+ /**
24
+ * Deterministic validators: what a proposal must prove before anything stores it.
25
+ *
26
+ * The agent is untrusted by design. It may be wrong, it may be inventing, and it may be the
27
+ * same model on a different day; the only thing that makes its output usable is that every
28
+ * claim is checked against the artifacts it was given and against the kind's own rules, with no
29
+ * judgement call anywhere in the path. Every check here has a name in the rejection list, so a
30
+ * run can be graded — how many proposals were invented, how many collided — and two runs of the
31
+ * validators over one stored overlay produce one partition.
32
+ */
33
+
34
+ /** An alias closer than this to an existing name is a collision, not a synonym. */
35
+ export const ALIAS_COLLISION_THRESHOLD = 0.95
36
+
37
+ export type EnrichmentSnapshot = Pick<DiscoverySnapshotV1, 'contentHash' | 'entities' | 'relations'>
38
+
39
+ export type EnrichmentValidationContext = {
40
+ readonly snapshot: EnrichmentSnapshot
41
+ readonly report?: Pick<ReconciliationReportV1, 'diagnostics'>
42
+ /** Entries already in the overlay: what a new proposal must not collide with. */
43
+ readonly existing?: { readonly accepted?: readonly AcceptedEnrichment[]; readonly pending?: readonly PendingEnrichment[] }
44
+ }
45
+
46
+ export type EnrichmentVerdict =
47
+ | { readonly status: 'accepted'; readonly proposal: EnrichmentProposalV1 }
48
+ | { readonly status: 'pending'; readonly proposal: EnrichmentProposalV1; readonly note?: PendingEnrichment['note'] }
49
+ | { readonly status: 'rejected'; readonly rejection: RejectedEnrichment }
50
+
51
+ export type EnrichmentPartition = {
52
+ readonly accepted: readonly EnrichmentProposalV1[]
53
+ readonly pending: readonly { readonly proposal: EnrichmentProposalV1; readonly note?: PendingEnrichment['note'] }[]
54
+ readonly rejected: readonly RejectedEnrichment[]
55
+ }
56
+
57
+ const evidenceKey = (item: Evidence): string => `${item.source}:${item.path}:${item.lineStart ?? ''}:${item.lineEnd ?? ''}`
58
+
59
+ /**
60
+ * The hash an entry binds to: the entity's file hash when it has one, otherwise a hash of the
61
+ * entity as recorded — an area has no file, but it still has a content the proposal was about.
62
+ */
63
+ export const entityContentHash = (entity: KnowledgeEntity): string =>
64
+ entity.evidence[0]?.contentHash ?? sha256NormalizedV1({ id: entity.id, kind: entity.kind, path: entity.path ?? null, name: entity.name, metadata: entity.metadata ?? {} })
65
+
66
+ type Prepared = {
67
+ readonly entities: ReadonlyMap<string, KnowledgeEntity>
68
+ readonly evidenceKeys: ReadonlySet<string>
69
+ readonly diagnosticIds: ReadonlySet<string>
70
+ readonly observedRelations: ReadonlySet<string>
71
+ readonly names: readonly { readonly entity: string; readonly value: string }[]
72
+ }
73
+
74
+ const prepared = new WeakMap<EnrichmentSnapshot, Prepared>()
75
+
76
+ const prepare = (context: EnrichmentValidationContext): Prepared => {
77
+ const cached = prepared.get(context.snapshot)
78
+ if (cached && !context.report) return cached
79
+ const { snapshot } = context
80
+ const entities = new Map(snapshot.entities.map((entity) => [entity.id, entity]))
81
+ const evidenceKeys = new Set<string>()
82
+ for (const entity of snapshot.entities) for (const item of entity.evidence) evidenceKeys.add(evidenceKey(item))
83
+ for (const relation of snapshot.relations) for (const item of relation.evidence) evidenceKeys.add(evidenceKey(item))
84
+ const diagnosticIds = new Set<string>()
85
+ for (const diagnostic of context.report?.diagnostics ?? []) {
86
+ diagnosticIds.add(diagnostic.id)
87
+ for (const item of diagnostic.evidence) evidenceKeys.add(evidenceKey(item))
88
+ }
89
+ const observedRelations = new Set(snapshot.relations.map((relation) => `${relation.from}|${relation.kind}|${relation.to}`))
90
+ const names: { entity: string; value: string }[] = []
91
+ for (const entity of snapshot.entities) {
92
+ names.push({ entity: entity.id, value: entity.id }, { entity: entity.id, value: entity.name })
93
+ for (const alias of entity.aliases ?? []) names.push({ entity: entity.id, value: alias })
94
+ // A title and a filename are names a reader already uses: an alias that shadows one resolves nothing new.
95
+ if (typeof entity.metadata?.title === 'string') names.push({ entity: entity.id, value: entity.metadata.title })
96
+ const stem = entity.path?.split('/').pop()?.replace(/\.[A-Za-z0-9]+$/, '')
97
+ if (stem) names.push({ entity: entity.id, value: stem })
98
+ }
99
+ const value: Prepared = { entities, evidenceKeys, diagnosticIds, observedRelations, names }
100
+ if (!context.report) prepared.set(context.snapshot, value)
101
+ return value
102
+ }
103
+
104
+ const rejection = (raw: unknown, reason: EnrichmentRejectionReason, detail?: string): EnrichmentVerdict => {
105
+ const record = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
106
+ const origin = record.origin
107
+ const parsedOrigin = origin && typeof origin === 'object' ? EnrichmentOriginSchema.safeParse(origin) : undefined
108
+ return {
109
+ status: 'rejected',
110
+ rejection: {
111
+ // A proposal that failed before it had an id still needs a stable one, so the same bad
112
+ // output is the same rejection on the next run rather than a new line in the histogram.
113
+ proposalId: typeof record.proposalId === 'string' && record.proposalId ? record.proposalId.slice(0, 128) : sha256NormalizedV1(raw ?? null),
114
+ kind: typeof record.kind === 'string' && record.kind ? record.kind.slice(0, 128) : 'unknown',
115
+ ...(typeof record.entity === 'string' && record.entity ? { entity: record.entity.slice(0, 256) } : {}),
116
+ reason,
117
+ ...(detail ? { detail: detail.slice(0, 1_024) } : {}),
118
+ ...(parsedOrigin?.success ? { origin: parsedOrigin.data } : {}),
119
+ },
120
+ }
121
+ }
122
+
123
+ const pathInside = (path: string, entity: KnowledgeEntity): boolean =>
124
+ entity.path !== undefined && (path === entity.path || path.startsWith(`${entity.path.replace(/\/$/, '')}/`))
125
+
126
+ const normalizeText = (value: string): string => value.trim().replace(/\s+/g, ' ').toLowerCase()
127
+
128
+ /** The per-kind rule. Returns a rejection, or the verdict the policy dictates. */
129
+ const validateKind = (proposal: EnrichmentProposalV1, ready: Prepared, context: EnrichmentValidationContext): EnrichmentVerdict => {
130
+ const entity = ready.entities.get(proposal.entity) as KnowledgeEntity
131
+ const accepted = context.existing?.accepted ?? []
132
+ const pending = context.existing?.pending ?? []
133
+ const decided = (verdict: 'accepted' | 'pending', note?: PendingEnrichment['note']): EnrichmentVerdict =>
134
+ verdict === 'accepted' ? { status: 'accepted', proposal } : { status: 'pending', proposal, ...(note ? { note } : {}) }
135
+ const byPolicy = ENRICHMENT_POLICY[proposal.kind] === 'human' ? 'pending' : 'accepted'
136
+
137
+ switch (proposal.kind) {
138
+ case 'classify-document': {
139
+ if (entity.kind !== 'document') return rejection(proposal, 'entity-kind', `${proposal.entity} is a ${entity.kind}, not a document`)
140
+ return decided(byPolicy)
141
+ }
142
+ case 'summarize': {
143
+ if (redactSecrets(proposal.payload.summary) !== proposal.payload.summary) return rejection(proposal, 'redaction', 'summary contains a secret-shaped token')
144
+ const current = typeof entity.metadata?.summary === 'string' ? entity.metadata.summary : undefined
145
+ if (current !== undefined && normalizeText(current) === normalizeText(proposal.payload.summary)) return rejection(proposal, 'summary-unchanged')
146
+ return decided(byPolicy)
147
+ }
148
+ case 'add-alias': {
149
+ const alias = normalizeText(proposal.payload.alias)
150
+ const candidates = [
151
+ ...ready.names,
152
+ ...accepted.filter((entry) => entry.proposal.kind === 'add-alias').map((entry) => ({ entity: entry.proposal.entity, value: (entry.proposal.payload as { alias: string }).alias })),
153
+ ]
154
+ const collision = candidates.find((candidate) => {
155
+ const value = normalizeText(candidate.value)
156
+ return value === alias || jaroWinkler(value, alias) >= ALIAS_COLLISION_THRESHOLD
157
+ })
158
+ if (collision) return rejection(proposal, 'alias-collision', `"${proposal.payload.alias}" collides with "${collision.value}" (${collision.entity})`)
159
+ return decided(byPolicy)
160
+ }
161
+ case 'add-intent':
162
+ return decided(byPolicy)
163
+ case 'mark-canonical': {
164
+ if (entity.kind !== 'document') return rejection(proposal, 'entity-kind', `${proposal.entity} is a ${entity.kind}, not a document`)
165
+ if (!ready.entities.has(proposal.payload.scope)) return rejection(proposal, 'unknown-scope', proposal.payload.scope)
166
+ const acceptedForScope = accepted.find(
167
+ (entry) => entry.proposal.kind === 'mark-canonical' && (entry.proposal.payload as { scope: string }).scope === proposal.payload.scope && entry.proposal.entity !== proposal.entity,
168
+ )
169
+ if (acceptedForScope) return rejection(proposal, 'canonical-conflict', `${acceptedForScope.proposal.entity} is already canonical for ${proposal.payload.scope}`)
170
+ const pendingForScope = pending.some(
171
+ (entry) => entry.proposal.kind === 'mark-canonical' && (entry.proposal.payload as { scope: string }).scope === proposal.payload.scope && entry.proposal.entity !== proposal.entity,
172
+ )
173
+ return decided('pending', pendingForScope ? 'canonical-conflict' : undefined)
174
+ }
175
+ case 'propose-relation': {
176
+ const { from, to, kind } = proposal.payload
177
+ const fromEntity = ready.entities.get(from)
178
+ const toEntity = ready.entities.get(to)
179
+ if (!fromEntity || !toEntity) return rejection(proposal, 'unknown-endpoint', !fromEntity ? from : to)
180
+ if (proposal.entity !== from && proposal.entity !== to) return rejection(proposal, 'unknown-endpoint', `${proposal.entity} is neither endpoint`)
181
+ if (ready.observedRelations.has(`${from}|${kind}|${to}`)) return rejection(proposal, 'relation-already-observed')
182
+ if (!proposal.evidence.every((item) => pathInside(item.path, fromEntity) || pathInside(item.path, toEntity))) return rejection(proposal, 'evidence-outside-endpoints')
183
+ return decided(byPolicy)
184
+ }
185
+ case 'flag-contradiction': {
186
+ const against = ready.entities.get(proposal.payload.against)
187
+ if (!against) return rejection(proposal, 'unknown-endpoint', proposal.payload.against)
188
+ const inEntity = proposal.evidence.some((item) => pathInside(item.path, entity))
189
+ const inAgainst = proposal.evidence.some((item) => pathInside(item.path, against))
190
+ if (!inEntity || !inAgainst) return rejection(proposal, 'evidence-missing-for-endpoint', !inEntity ? proposal.entity : proposal.payload.against)
191
+ const disputed = pending.some(
192
+ (entry) =>
193
+ entry.proposal.kind === 'flag-contradiction' &&
194
+ entry.proposal.entity === proposal.entity &&
195
+ (entry.proposal.payload as { against: string }).against === proposal.payload.against &&
196
+ entry.proposal.origin.agentId !== proposal.origin.agentId,
197
+ )
198
+ return decided('pending', disputed ? 'disputed-contradiction' : undefined)
199
+ }
200
+ case 'flag-redundancy': {
201
+ const other = ready.entities.get(proposal.payload.with)
202
+ if (!other) return rejection(proposal, 'unknown-endpoint', proposal.payload.with)
203
+ if (entity.kind !== 'document' || other.kind !== 'document') return rejection(proposal, 'entity-kind', 'both entities must be documents')
204
+ if (entityContentHash(entity) === entityContentHash(other)) return rejection(proposal, 'already-duplicate')
205
+ return decided(byPolicy)
206
+ }
207
+ case 'flag-gap': {
208
+ const area = ready.entities.get(proposal.payload.area)
209
+ if (!area || area.kind !== 'area') return rejection(proposal, 'unknown-scope', proposal.payload.area)
210
+ const covered = accepted.some(
211
+ (entry) =>
212
+ entry.proposal.kind === 'flag-gap' &&
213
+ (entry.proposal.payload as { area: string }).area === proposal.payload.area &&
214
+ normalizeText((entry.proposal.payload as { missing: string }).missing) === normalizeText(proposal.payload.missing),
215
+ )
216
+ if (covered) return rejection(proposal, 'already-covered')
217
+ return decided(byPolicy)
218
+ }
219
+ case 'rank-hint':
220
+ return decided(byPolicy)
221
+ case 'suggest-area': {
222
+ const paths = [...ready.entities.values()].flatMap((item) => (item.path ? [item.path] : []))
223
+ const areas = [...ready.entities.values()].filter((item) => item.kind === 'area' && item.path)
224
+ for (const directory of proposal.payload.directories) {
225
+ const clean = directory.replace(/\/$/, '')
226
+ if (!paths.some((path) => path === clean || path.startsWith(`${clean}/`))) return rejection(proposal, 'unknown-directory', directory)
227
+ const overlap = areas.find((area) => area.path === clean || area.path?.startsWith(`${clean}/`) || clean.startsWith(`${area.path}/`))
228
+ if (overlap) return rejection(proposal, 'area-overlap', `${directory} overlaps ${overlap.id}`)
229
+ }
230
+ return decided(byPolicy)
231
+ }
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Validate one raw proposal at the boundary.
237
+ *
238
+ * Envelope first — kind, schema, evidence, entity, hashes, identity — then the kind's own rule.
239
+ * A rejection names the first thing that failed; a proposal that fails the envelope never
240
+ * reaches a kind validator, so a kind validator can assume its entity exists.
241
+ */
242
+ export const validateEnrichmentProposal = (raw: unknown, context: EnrichmentValidationContext): EnrichmentVerdict => {
243
+ const record = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : undefined
244
+ if (!record) return rejection(raw, 'schema', 'proposal is not an object')
245
+ if (!isEnrichmentKind(record.kind)) return rejection(raw, 'invalid-kind', typeof record.kind === 'string' ? record.kind : 'missing kind')
246
+ if (!Array.isArray(record.evidence) || record.evidence.length === 0) return rejection(raw, 'no-evidence')
247
+ const parsed = EnrichmentProposalV1Schema.safeParse(raw)
248
+ if (!parsed.success) return rejection(raw, 'schema', parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '))
249
+ const proposal = parsed.data
250
+ const ready = prepare(context)
251
+
252
+ if (proposal.baseSnapshotHash !== context.snapshot.contentHash) return rejection(proposal, 'base-snapshot-mismatch')
253
+ const entity = ready.entities.get(proposal.entity)
254
+ if (!entity) return rejection(proposal, 'unknown-entity', proposal.entity)
255
+ if (entityContentHash(entity) !== proposal.targetContentHash) return rejection(proposal, 'stale-target', `${proposal.entity} changed since the proposal was made`)
256
+ const unknownDiagnostic = (proposal.relatedDiagnosticIds ?? []).find((id) => !ready.diagnosticIds.has(id))
257
+ if (unknownDiagnostic) return rejection(proposal, 'unknown-diagnostic', unknownDiagnostic)
258
+ const outside = proposal.evidence.find((item) => !ready.evidenceKeys.has(evidenceKey(item)))
259
+ if (outside) return rejection(proposal, 'evidence-outside-artifacts', evidenceKey(outside))
260
+ if (proposal.proposalId !== enrichmentProposalId(proposal)) return rejection(proposal, 'proposal-id-mismatch')
261
+
262
+ return validateKind(proposal, ready, context)
263
+ }
264
+
265
+ /**
266
+ * Validate a batch and partition it.
267
+ *
268
+ * Each verdict is made against the context plus what the batch has already decided, in a fixed
269
+ * order (by proposal id), so two proposals for the same slot resolve the same way every run: the
270
+ * first survives, the second collides. A proposal already decided in the stored overlay keeps its
271
+ * decision — an accepted entry is not re-accepted, a pending one not re-requested.
272
+ */
273
+ export const partitionEnrichmentProposals = (raws: readonly unknown[], context: EnrichmentValidationContext): EnrichmentPartition => {
274
+ const ordered = [...raws].sort((left, right) => sha256NormalizedV1(left ?? null).localeCompare(sha256NormalizedV1(right ?? null)))
275
+ const accepted: EnrichmentProposalV1[] = []
276
+ const pending: { proposal: EnrichmentProposalV1; note?: PendingEnrichment['note'] }[] = []
277
+ const rejected: RejectedEnrichment[] = []
278
+ const seen = new Set<string>([
279
+ ...(context.existing?.accepted ?? []).map((entry) => entry.proposal.proposalId),
280
+ ...(context.existing?.pending ?? []).map((entry) => entry.proposal.proposalId),
281
+ ])
282
+ // Verdicts inside the batch see earlier verdicts in the batch: the merge is what must be consistent.
283
+ const running = {
284
+ accepted: [...(context.existing?.accepted ?? [])],
285
+ pending: [...(context.existing?.pending ?? [])],
286
+ }
287
+ for (const raw of ordered) {
288
+ // The same proposal twice — a cached pack next to a fresh one — is one proposal, decided once.
289
+ const claimedId = raw && typeof raw === 'object' ? (raw as { proposalId?: unknown }).proposalId : undefined
290
+ if (typeof claimedId === 'string' && seen.has(claimedId)) continue
291
+ const verdict = validateEnrichmentProposal(raw, { ...context, existing: running })
292
+ if (verdict.status === 'rejected') {
293
+ rejected.push(verdict.rejection)
294
+ continue
295
+ }
296
+ if (seen.has(verdict.proposal.proposalId)) continue
297
+ seen.add(verdict.proposal.proposalId)
298
+ if (verdict.status === 'accepted') {
299
+ accepted.push(verdict.proposal)
300
+ running.accepted.push({ proposal: verdict.proposal, acceptedAt: '1970-01-01T00:00:00.000Z', acceptedBy: 'policy' })
301
+ } else {
302
+ pending.push({ proposal: verdict.proposal, ...(verdict.note ? { note: verdict.note } : {}) })
303
+ running.pending.push({ proposal: verdict.proposal, approvalId: '0'.repeat(64), ...(verdict.note ? { note: verdict.note } : {}) })
304
+ /*
305
+ * A second canonical marker for one scope makes the first one conflicted too: the
306
+ * conflict is a property of the pair, and the adjudicator must see both.
307
+ */
308
+ if (verdict.note === 'canonical-conflict') {
309
+ const scope = (verdict.proposal.payload as { scope: string }).scope
310
+ for (const entry of pending) {
311
+ if (entry.proposal.kind === 'mark-canonical' && (entry.proposal.payload as { scope: string }).scope === scope) entry.note = 'canonical-conflict'
312
+ }
313
+ }
314
+ }
315
+ }
316
+ const byId = (left: { proposalId: string }, right: { proposalId: string }): number => left.proposalId.localeCompare(right.proposalId)
317
+ return {
318
+ accepted: accepted.sort(byId),
319
+ pending: pending.sort((left, right) => byId(left.proposal, right.proposal)),
320
+ rejected: rejected.sort(byId),
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Re-run the validators over a stored overlay.
326
+ *
327
+ * Reproducibility is the guardrail: the partition an overlay records must be the partition the
328
+ * validators produce from its proposals against the same snapshot. Anything else means the
329
+ * overlay was edited by hand, or the validators changed under it, and either way the file is
330
+ * not evidence of a decision any more.
331
+ */
332
+ export const revalidateEnrichmentOverlay = (
333
+ overlay: { readonly accepted: readonly AcceptedEnrichment[]; readonly pending: readonly PendingEnrichment[] },
334
+ context: Omit<EnrichmentValidationContext, 'existing'>,
335
+ ): { readonly accepted: readonly string[]; readonly pending: readonly string[]; readonly rejected: readonly RejectedEnrichment[] } => {
336
+ const partition = partitionEnrichmentProposals(
337
+ [...overlay.accepted.map((entry) => entry.proposal), ...overlay.pending.map((entry) => entry.proposal)],
338
+ context,
339
+ )
340
+ return {
341
+ accepted: partition.accepted.map((proposal) => proposal.proposalId),
342
+ pending: partition.pending.map((entry) => entry.proposal.proposalId),
343
+ rejected: partition.rejected,
344
+ }
345
+ }
346
+
347
+ export type AdjudicationVerdict =
348
+ | { readonly status: 'accepted'; readonly adjudication: EnrichmentAdjudicationV1 }
349
+ | { readonly status: 'rejected'; readonly reason: EnrichmentRejectionReason; readonly detail?: string }
350
+
351
+ /**
352
+ * Validate an adjudication against the pending entries it claims to judge.
353
+ *
354
+ * The one rule that cannot be configured away: an agent may not judge its own proposal. An
355
+ * adjudication whose origin matches any judged proposal's origin is rejected outright, whatever
356
+ * the roles say — a different role name over the same identity is still the same agent.
357
+ */
358
+ export const validateEnrichmentAdjudication = (raw: unknown, pending: readonly PendingEnrichment[]): AdjudicationVerdict => {
359
+ const parsed = EnrichmentAdjudicationV1Schema.safeParse(raw)
360
+ if (!parsed.success) return { status: 'rejected', reason: 'schema', detail: parsed.error.issues.map((issue) => issue.message).join('; ') }
361
+ const adjudication = parsed.data
362
+ if (adjudication.adjudicationId !== enrichmentAdjudicationId(adjudication)) return { status: 'rejected', reason: 'proposal-id-mismatch' }
363
+ const byId = new Map(pending.map((entry) => [entry.proposal.proposalId, entry]))
364
+ for (const id of adjudication.judges) {
365
+ const entry = byId.get(id)
366
+ if (!entry) return { status: 'rejected', reason: 'unknown-entity', detail: `${id} is not pending` }
367
+ if (entry.proposal.origin.agentId === adjudication.origin.agentId) return { status: 'rejected', reason: 'self-adjudication', detail: `${adjudication.origin.agentId} judged its own proposal ${id}` }
368
+ }
369
+ return { status: 'accepted', adjudication }
370
+ }
371
+
372
+ /**
373
+ * Apply an adjudication to the pending set: the losers are rejected as `adjudicated`, the
374
+ * survivors stay pending with their conflict note cleared. Nothing an agent decides becomes
375
+ * accepted — the winner still waits for the person the kind's policy requires.
376
+ */
377
+ export const applyEnrichmentAdjudication = (
378
+ pending: readonly PendingEnrichment[],
379
+ adjudication: EnrichmentAdjudicationV1,
380
+ ): { readonly pending: PendingEnrichment[]; readonly rejected: RejectedEnrichment[] } => {
381
+ const keep = new Set(adjudication.keep)
382
+ const judged = new Set(adjudication.judges)
383
+ const next: PendingEnrichment[] = []
384
+ const rejected: RejectedEnrichment[] = []
385
+ for (const entry of pending) {
386
+ if (!judged.has(entry.proposal.proposalId)) {
387
+ next.push(entry)
388
+ continue
389
+ }
390
+ if (keep.has(entry.proposal.proposalId)) {
391
+ const { note: _note, ...rest } = entry
392
+ next.push(rest)
393
+ continue
394
+ }
395
+ rejected.push({
396
+ proposalId: entry.proposal.proposalId,
397
+ kind: entry.proposal.kind,
398
+ entity: entry.proposal.entity,
399
+ reason: 'adjudicated',
400
+ detail: `${adjudication.origin.agentId}: ${adjudication.reason}`.slice(0, 1_024),
401
+ origin: entry.proposal.origin,
402
+ })
403
+ }
404
+ return { pending: next, rejected }
405
+ }
406
+
407
+ /** Shared by the schema tests: the strict parser for one proposal, for callers that already know the kind. */
408
+ export const parseEnrichmentProposal = (raw: unknown): EnrichmentProposalV1 => EnrichmentProposalV1Schema.parse(raw)
409
+
410
+ export const EnrichmentProposalListSchema = z.array(z.unknown()).max(1_024)
@@ -4,6 +4,7 @@ import { resolve } from 'node:path'
4
4
  import type { DocBridgeConfigV1 } from '../config/schema.js'
5
5
  import { slugFromPath } from '../lib/markdown.js'
6
6
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
7
+ import { hasSearchToken, tokenizeSearchText } from '../query/text.js'
7
8
  import {
8
9
  retrieveDocBridgeChunks,
9
10
  type DocBridgeRetrievedChunk,
@@ -16,12 +17,9 @@ export type FederatedRetrieverOptions = {
16
17
  readonly limit?: number
17
18
  }
18
19
 
19
- const tokenize = (value: string): string[] =>
20
- value.toLowerCase().split(/[^a-z0-9]+/).filter((token) => token.length >= 2)
21
-
22
20
  const scoreText = (query: string, text: string): number => {
23
21
  const hay = text.toLowerCase()
24
- return tokenize(query).reduce((score, token) => score + (hay.includes(token) ? token.length : 0), 0)
22
+ return tokenizeSearchText(query).reduce((score, token) => score + (hasSearchToken(hay, token) ? token.length : 0), 0)
25
23
  }
26
24
 
27
25
  const defaultFetchText: FetchText = async (url) => {
@@ -0,0 +1,103 @@
1
+ import type { Evidence, FindingStatus, KnowledgeDiagnostic } from '../schemas/knowledge.js'
2
+
3
+ /**
4
+ * The canonical finding, as the rest of the ecosystem consumes it.
5
+ *
6
+ * `Finding` and `SEVERITY_ORDER` mirror `@agentskit/core/finding`, which is an optional peer:
7
+ * Code Review, AKOS and dashboards read this shape, so a Doc Bridge diagnostic reported in it
8
+ * needs no parser of its own. A test imports the real package and asserts that what is emitted
9
+ * here is assignable to it and that the severities are drawn from the real order.
10
+ *
11
+ * This is a reporter, not a migration. `KnowledgeDiagnostic`, `RuleFinding` and
12
+ * `DocumentationAuditFinding` keep their shapes; all three carry the fields the reporter reads.
13
+ */
14
+
15
+ /** Ordered most to least severe, as the ecosystem orders them. */
16
+ export const SEVERITY_ORDER = ['critical', 'high', 'medium', 'low', 'info'] as const
17
+
18
+ export type Severity = (typeof SEVERITY_ORDER)[number]
19
+
20
+ export type Finding = {
21
+ id: string
22
+ severity: Severity
23
+ title: string
24
+ detail: string
25
+ category?: string
26
+ location?: string
27
+ confidence?: number
28
+ remediation?: string
29
+ ref?: string
30
+ metadata?: Record<string, unknown>
31
+ }
32
+
33
+ /** What the three internal shapes have in common, which is all the reporter needs. */
34
+ export type ReportableDiagnostic = Pick<KnowledgeDiagnostic, 'id' | 'code' | 'status' | 'severity' | 'message' | 'evidence'> & {
35
+ readonly entityIds?: readonly string[] | undefined
36
+ readonly relationIds?: readonly string[] | undefined
37
+ readonly remediation?: string | undefined
38
+ }
39
+
40
+ /**
41
+ * Internal severities are a linter's (`error`, `warn`, `info`, `off`); the ecosystem's run from
42
+ * `critical` to `info`. Nothing Doc Bridge reports is `critical`: a documentation finding never
43
+ * takes a system down, and a reporter that says otherwise trains readers to ignore it.
44
+ */
45
+ const SEVERITY: Readonly<Record<KnowledgeDiagnostic['severity'], Severity>> = {
46
+ error: 'high',
47
+ warn: 'medium',
48
+ info: 'low',
49
+ off: 'info',
50
+ }
51
+
52
+ /**
53
+ * How sure the finding is real, from its status. A confirmed or undocumented relation was observed
54
+ * in code; a conflict names two declarations that disagree; something stale or unverified may have
55
+ * been fixed since it was declared; a coverage gap is not a finding about the repository at all,
56
+ * only about what the scan could see.
57
+ */
58
+ const CONFIDENCE: Readonly<Record<FindingStatus, number>> = {
59
+ confirmed: 1,
60
+ undocumented: 0.9,
61
+ conflict: 0.9,
62
+ unresolved: 0.8,
63
+ 'stale-or-unverified': 0.6,
64
+ 'not-analyzed': 0.3,
65
+ }
66
+
67
+ const title = (code: string): string => {
68
+ const words = code.replace(/^DOCBRIDGE_/, '').toLowerCase().split('_').filter(Boolean)
69
+ const first = words[0] ?? code.toLowerCase()
70
+ return [first.charAt(0).toUpperCase() + first.slice(1), ...words.slice(1)].join(' ')
71
+ }
72
+
73
+ const location = (evidence: readonly Evidence[]): string | undefined => {
74
+ const first = evidence[0]
75
+ if (!first) return undefined
76
+ return first.lineStart === undefined ? first.path : `${first.path}:${first.lineStart}`
77
+ }
78
+
79
+ export const findingFromDiagnostic = (diagnostic: ReportableDiagnostic): Finding => ({
80
+ id: diagnostic.id,
81
+ severity: SEVERITY[diagnostic.severity],
82
+ title: title(diagnostic.code),
83
+ detail: diagnostic.message,
84
+ category: diagnostic.status,
85
+ ...(location(diagnostic.evidence) !== undefined ? { location: location(diagnostic.evidence) as string } : {}),
86
+ confidence: CONFIDENCE[diagnostic.status],
87
+ ...(diagnostic.remediation ? { remediation: diagnostic.remediation } : {}),
88
+ ref: diagnostic.code,
89
+ metadata: {
90
+ code: diagnostic.code,
91
+ status: diagnostic.status,
92
+ severity: diagnostic.severity,
93
+ evidence: diagnostic.evidence,
94
+ ...(diagnostic.entityIds?.length ? { entityIds: diagnostic.entityIds } : {}),
95
+ ...(diagnostic.relationIds?.length ? { relationIds: diagnostic.relationIds } : {}),
96
+ },
97
+ })
98
+
99
+ const rank = (severity: Severity): number => SEVERITY_ORDER.indexOf(severity)
100
+
101
+ /** Every diagnostic as a finding, most severe first and by id within a severity, so output is stable. */
102
+ export const findingsFromDiagnostics = (diagnostics: readonly ReportableDiagnostic[]): Finding[] =>
103
+ diagnostics.map(findingFromDiagnostic).sort((a, b) => rank(a.severity) - rank(b.severity) || a.id.localeCompare(b.id))