@agentskit/doc-bridge 1.7.45 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +488 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/README.md +87 -40
  4. package/SECURITY.md +7 -0
  5. package/action.yml +1 -1
  6. package/bin/ak-docs.js +2 -2
  7. package/bin/ak-verify.js +13 -7
  8. package/dist/cli/program.d.ts +3 -1
  9. package/dist/cli/program.js +15871 -6218
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +95 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
  15. package/dist/index.d.ts +9880 -1062
  16. package/dist/index.js +16143 -6127
  17. package/dist/index.js.map +1 -1
  18. package/docs/MARKETPLACE.md +1 -1
  19. package/docs/PRD-documentation-efficiency-study.md +406 -0
  20. package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
  21. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  22. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  23. package/docs/adr/0004-controlled-study-runner.md +25 -0
  24. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  25. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  26. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  27. package/docs/adr/0008-study-verification-boundary.md +21 -0
  28. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  29. package/docs/agent-corpus/INDEX.md +12 -1
  30. package/docs/agent-corpus/OVERVIEW.md +25 -0
  31. package/docs/agent-corpus/chat.md +5 -1
  32. package/docs/agent-corpus/cli.md +16 -0
  33. package/docs/agent-corpus/conformance.md +12 -0
  34. package/docs/agent-corpus/doc-bridge.md +47 -0
  35. package/docs/agent-corpus/doctor.md +8 -0
  36. package/docs/agent-corpus/gates.md +4 -0
  37. package/docs/agent-corpus/mcp.md +13 -0
  38. package/docs/agent-corpus/memory.md +4 -0
  39. package/docs/agent-corpus/query.md +33 -0
  40. package/docs/bench/README.md +122 -0
  41. package/docs/bench/retrieval-baseline-v1.json +28 -0
  42. package/docs/bench/retrieval-suite-v1.json +1033 -0
  43. package/docs/chat-and-rag.md +3 -2
  44. package/docs/for-agents.md +9 -1
  45. package/docs/getting-started.md +4 -11
  46. package/docs/guides/gate-ci.md +11 -1
  47. package/docs/guides/install-and-run.md +9 -65
  48. package/docs/index.md +22 -1
  49. package/docs/knowledge-engine-runbook.md +38 -3
  50. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  51. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  52. package/docs/landing/index.html +119 -5
  53. package/docs/loop-workflow.md +117 -0
  54. package/docs/mcp.md +6 -1
  55. package/docs/parity/public-claims-v1.json +145 -0
  56. package/docs/playbook/doc-bridge-pattern.md +1 -1
  57. package/docs/query.md +90 -2
  58. package/docs/recipes/index-pipeline.md +1 -1
  59. package/docs/schemas/agent-handoff-v1.md +15 -0
  60. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  61. package/docs/spec/benchmark-v1.md +33 -1
  62. package/docs/spec/cli.md +30 -10
  63. package/docs/spec/config-v1.md +157 -8
  64. package/docs/spec/documentation-audit-v1.md +61 -0
  65. package/docs/spec/enrichment-overlay-v1.md +241 -0
  66. package/docs/spec/graph-signals-v1.md +92 -0
  67. package/docs/spec/incremental-scan-v1.md +102 -0
  68. package/docs/spec/markdown-analyzer-v1.md +73 -0
  69. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  70. package/docs/spec/measured-enrichment-v1.md +229 -0
  71. package/docs/spec/public-parity-v1.md +119 -0
  72. package/docs/spec/registry-agents.md +6 -0
  73. package/docs/spec/render-v1.md +122 -0
  74. package/docs/spec/retrieval-index-v1.md +164 -0
  75. package/docs/spec/study-metrics-v1.md +58 -0
  76. package/docs/spec/study-protocol-v1.md +46 -0
  77. package/docs/spec/study-provider-cli-v1.md +116 -0
  78. package/docs/spec/study-runner-v1.md +35 -0
  79. package/docs/spec/study-task-suite-v1.md +41 -0
  80. package/docs/spec/study-verification-v1.md +40 -0
  81. package/docs/study/README.md +84 -0
  82. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  83. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  86. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  87. package/docs/study/ab-baseline-plan-v1.json +33 -0
  88. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-result-v1.json +79 -0
  90. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  91. package/docs/study/historical-evidence-v1.json +252 -0
  92. package/docs/study/observation-ledger-v1.json +30632 -0
  93. package/docs/study/phase3-task-coverage-v1.json +34 -0
  94. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  95. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  96. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  97. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  98. package/docs/study/pilot-round-2026-08-31.json +46 -0
  99. package/docs/study/protocol-v1.json +90 -0
  100. package/docs/study/publication-gate-v1.md +45 -0
  101. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  102. package/docs/study/quality-scorecard-v1.json +38 -0
  103. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  104. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  105. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  106. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  107. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  108. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  109. package/docs/study/round-4-confirmation-v1.json +75 -0
  110. package/docs/study/round-4-confirmation-v1.md +55 -0
  111. package/docs/study/run-plan-v1.json +33 -0
  112. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  113. package/docs/study/task-suite-v1.json +96 -0
  114. package/docs/study/token-efficiency-plan-v1.md +337 -0
  115. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  116. package/docs/study/verification-binding-v1.json +27 -0
  117. package/docs/validation-cycle-plan.md +14 -0
  118. package/docs/verification-harness.md +11 -6
  119. package/ecosystem-claims.json +2 -2
  120. package/ecosystem-upstream.json +2 -2
  121. package/ecosystem.json +4 -4
  122. package/mcpb/manifest.json +9 -1
  123. package/package.json +34 -15
  124. package/scripts/check-ecosystem-upstream.mjs +34 -6
  125. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  126. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  127. package/src/agents/registry-adapter.ts +192 -24
  128. package/src/audit/documentation.ts +513 -0
  129. package/src/bench/baseline.ts +198 -0
  130. package/src/bench/overlay-delta.ts +139 -0
  131. package/src/bench/retrieval.ts +319 -0
  132. package/src/budget/compile.ts +91 -0
  133. package/src/budget/sections.ts +70 -0
  134. package/src/cli/program.ts +684 -74
  135. package/src/cli/usage.ts +71 -0
  136. package/src/config/defaults.ts +1 -0
  137. package/src/config/index.ts +4 -0
  138. package/src/config/schema.ts +126 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +197 -0
  141. package/src/discovery/documentation.ts +211 -5
  142. package/src/discovery/identity.ts +24 -0
  143. package/src/discovery/incremental.ts +314 -0
  144. package/src/discovery/inputs.ts +110 -0
  145. package/src/discovery/markdown.ts +481 -0
  146. package/src/discovery/repository.ts +485 -101
  147. package/src/doctor/run-doctor.ts +244 -12
  148. package/src/enrich/approvals.ts +190 -0
  149. package/src/enrich/cache.ts +93 -0
  150. package/src/enrich/context-pack.ts +272 -0
  151. package/src/enrich/overlay.ts +255 -0
  152. package/src/enrich/review.ts +106 -0
  153. package/src/enrich/stage.ts +374 -0
  154. package/src/enrich/stats.ts +100 -0
  155. package/src/enrich/validate.ts +410 -0
  156. package/src/federation/llms.ts +2 -4
  157. package/src/findings/report.ts +103 -0
  158. package/src/graph/build.ts +356 -0
  159. package/src/graph/memory.ts +208 -0
  160. package/src/index-builder/build-handoffs.ts +22 -11
  161. package/src/index-builder/build-index.ts +132 -3
  162. package/src/index-builder/llms-txt.ts +48 -8
  163. package/src/index-builder/project-corpus.ts +111 -0
  164. package/src/index.ts +630 -2
  165. package/src/lib/fuzzy-match.ts +235 -0
  166. package/src/mcp/knowledge.ts +554 -0
  167. package/src/mcp/server.ts +113 -18
  168. package/src/metrics/benchmark.ts +21 -0
  169. package/src/parity/check.ts +309 -0
  170. package/src/parity/claims.ts +259 -0
  171. package/src/parity/resolve.ts +160 -0
  172. package/src/query/handoff.ts +326 -0
  173. package/src/query/load-index.ts +53 -1
  174. package/src/query/query.ts +92 -59
  175. package/src/query/search.ts +289 -92
  176. package/src/query/text.ts +155 -0
  177. package/src/reconciliation/reconcile.ts +101 -10
  178. package/src/render/data.ts +356 -0
  179. package/src/render/engine.ts +398 -0
  180. package/src/render/generated.ts +77 -0
  181. package/src/render/render.ts +209 -0
  182. package/src/render/template-source.ts +52 -0
  183. package/src/render/templates.ts +289 -0
  184. package/src/report/html.ts +2 -2
  185. package/src/retrieval/bm25.ts +161 -0
  186. package/src/retrieval/project.ts +495 -0
  187. package/src/retrieval/rank.ts +383 -0
  188. package/src/retrieval/weights.ts +39 -0
  189. package/src/retriever/doc-bridge-retriever.ts +100 -15
  190. package/src/rules/engine.ts +30 -10
  191. package/src/schemas/agent-handoff.ts +56 -0
  192. package/src/schemas/budget.ts +37 -0
  193. package/src/schemas/doc-bridge-index.ts +53 -2
  194. package/src/schemas/enrichment.ts +369 -0
  195. package/src/schemas/json-schemas.ts +39 -2
  196. package/src/schemas/knowledge.ts +14 -1
  197. package/src/schemas/retrieval-index.ts +152 -0
  198. package/src/shims/graphology.d.ts +91 -0
  199. package/src/study/adjudication.ts +196 -0
  200. package/src/study/execution.ts +350 -0
  201. package/src/study/expectations.ts +219 -0
  202. package/src/study/metrics.ts +467 -0
  203. package/src/study/protocol.ts +271 -0
  204. package/src/study/provider-cli.ts +115 -0
  205. package/src/study/provider-telemetry.ts +47 -0
  206. package/src/study/quality-scorecard.ts +164 -0
  207. package/src/study/runner.ts +461 -0
  208. package/src/study/task-suite.ts +321 -0
  209. package/src/study/verification.ts +134 -0
  210. package/src/validate.ts +1 -4
  211. package/src/version.ts +1 -1
  212. package/src/workflow/engine.ts +36 -11
  213. package/scripts/verification-harness.mjs +0 -482
@@ -0,0 +1,374 @@
1
+ import { basename, resolve } from 'node:path'
2
+
3
+ import { DEFAULT_REGISTRY_AGENT_ID, createRegistryAgentAdapter, loadRegistryAgentMetadata, loadRegistryAgentRunner, type RegistryAgentAdapter, type RegistryAgentRunner } from '../agents/registry-adapter.js'
4
+ import type { DocBridgeConfigV1 } from '../config/schema.js'
5
+ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
6
+ import {
7
+ ENRICHMENT_POLICY,
8
+ ENRICHMENT_SCHEMA_VERSION,
9
+ type AcceptedEnrichment,
10
+ type EnrichmentOverlayV1,
11
+ type EnrichmentStats,
12
+ type PendingEnrichment,
13
+ type RejectedEnrichment,
14
+ } from '../schemas/enrichment.js'
15
+ import type { DiscoverySnapshotV1, ReconciliationReportV1 } from '../schemas/knowledge.js'
16
+ import { approvalsDir, createFileApprovalStore, enrichmentApprovalId, loadApprovalGate, ENRICHMENT_APPROVAL_GATE, type ApprovalGate } from './approvals.js'
17
+ import { createEnrichmentCache, type EnrichmentCache } from './cache.js'
18
+ import { cacheHitRate, enrichmentStability, formatEnrichmentStatsText, inventedReferenceCount, type EnrichmentStability } from './stats.js'
19
+ import { batchContextPacks, buildContextPacks, type ContextPack, type EnrichmentTask } from './context-pack.js'
20
+ import { effectiveEnrichment, readEnrichmentOverlay, sealEnrichmentOverlay, writeEnrichmentOverlay } from './overlay.js'
21
+ import { applyEnrichmentAdjudication, partitionEnrichmentProposals, validateEnrichmentAdjudication } from './validate.js'
22
+
23
+ /**
24
+ * The enrich stage: packs out, proposals in, validators between, an overlay at the end.
25
+ *
26
+ * It runs only when asked — `ak-docs enrich`, `check --enrich` — and never sits on the path of
27
+ * `check`, `index`, `search` or `query`. What it writes is advisory until a validator or a person
28
+ * accepts it, expires with the entity it describes, and adds to the observed graph without ever
29
+ * subtracting from it. Its cost is proportional to what changed: a pack whose hash has not moved
30
+ * is answered by the cache, so an unchanged repository makes no agent call at all.
31
+ */
32
+
33
+ export type EnrichmentRole = 'curator' | 'reviewer' | 'adjudicator'
34
+
35
+ export const ROLE_TASK: Readonly<Record<EnrichmentRole, EnrichmentTask>> = { curator: 'curate', reviewer: 'review', adjudicator: 'adjudicate' }
36
+
37
+ /** What each role is for. The curator reads documents; the reviewer reads structure. */
38
+ export const ROLE_TARGET_KINDS: Readonly<Record<Exclude<EnrichmentRole, 'adjudicator'>, readonly string[]>> = {
39
+ curator: ['document'],
40
+ reviewer: ['document', 'area'],
41
+ }
42
+
43
+ export type ResolvedRole = { readonly role: EnrichmentRole; readonly agentId: string; readonly promptVersion: string }
44
+
45
+ /**
46
+ * Roles from configuration. The default is the configured agent as curator only — the existing
47
+ * corpus scanner, doing the one job it did before, over packs instead of the snapshot.
48
+ */
49
+ export const resolveEnrichmentRoles = (config: DocBridgeConfigV1): readonly ResolvedRole[] => {
50
+ const registry = config.intelligence?.registry
51
+ const defaultAgent = registry?.agentId ?? DEFAULT_REGISTRY_AGENT_ID
52
+ const configured = registry?.roles
53
+ const roles: ResolvedRole[] = []
54
+ const add = (role: EnrichmentRole, enabledByDefault: boolean): void => {
55
+ const setting = configured?.[role]
56
+ const enabled = setting?.enabled ?? (setting ? true : enabledByDefault)
57
+ if (!enabled) return
58
+ roles.push({ role, agentId: setting?.agentId ?? defaultAgent, promptVersion: setting?.promptVersion ?? '1' })
59
+ }
60
+ add('curator', true)
61
+ add('reviewer', false)
62
+ add('adjudicator', false)
63
+ const adjudicator = roles.find((item) => item.role === 'adjudicator')
64
+ if (adjudicator && roles.some((item) => item.role !== 'adjudicator' && item.agentId === adjudicator.agentId)) {
65
+ throw new Error(`The adjudicator must be a different agent identity from the curator and the reviewer; "${adjudicator.agentId}" plays both.`)
66
+ }
67
+ return roles
68
+ }
69
+
70
+ /** The seam the stage calls an agent through. The CLI wires the Registry adapter; a test wires a function. */
71
+ export type EnrichmentAgent = (input: { readonly role: ResolvedRole; readonly task: EnrichmentTask; readonly packs: readonly ContextPack[]; readonly agentVersion: string }) => Promise<readonly unknown[]>
72
+
73
+ export type EnrichmentRunOptions = {
74
+ readonly root: string
75
+ readonly config: DocBridgeConfigV1
76
+ readonly snapshot: DiscoverySnapshotV1
77
+ readonly report: ReconciliationReportV1
78
+ /** Replaces the Registry adapter. `agentVersion` is what the cache and the origin check use. */
79
+ readonly agent?: { readonly call: EnrichmentAgent; readonly version: (role: ResolvedRole) => string }
80
+ readonly cache?: EnrichmentCache
81
+ readonly gate?: ApprovalGate
82
+ /** File contents by path, for packs. Defaults to reading under `root`. */
83
+ readonly readFile?: (path: string) => string | undefined
84
+ readonly now?: () => string
85
+ /** Monotonic milliseconds, for wall time. A test passes a stub so the figure is deterministic. */
86
+ readonly clock?: () => number
87
+ /** Persist the overlay under `.doc-bridge/enrich/`. Default true. */
88
+ readonly write?: boolean
89
+ }
90
+
91
+ export type EnrichmentRunResult = {
92
+ readonly overlay: EnrichmentOverlayV1
93
+ readonly overlayPath?: string
94
+ readonly agentCalls: number
95
+ readonly cacheHits: number
96
+ readonly packs: number
97
+ /** Target ids whose packs were sent to an agent this run. */
98
+ readonly rerun: readonly string[]
99
+ readonly expired: number
100
+ readonly roles: readonly ResolvedRole[]
101
+ /** This run against the one before it: one hash for a deterministic agent, a share for a live one. */
102
+ readonly stability: EnrichmentStability
103
+ }
104
+
105
+ const emptyStats = (): EnrichmentStats => ({
106
+ byKind: {},
107
+ rejectionReasons: {},
108
+ inventedReferences: 0,
109
+ agentRuns: 0,
110
+ cacheHits: 0,
111
+ cacheHitRate: 0,
112
+ packs: 0,
113
+ inputBytes: 0,
114
+ outputBytes: 0,
115
+ wallTimeMs: 0,
116
+ expired: 0,
117
+ })
118
+
119
+ const configWithAgent = (config: DocBridgeConfigV1, agentId: string): DocBridgeConfigV1 => ({
120
+ ...config,
121
+ intelligence: { ...(config.intelligence ?? {}), registry: { ...(config.intelligence?.registry ?? {}), agentId } },
122
+ })
123
+
124
+ /** One adapter per role, each loading its own installed agent. Created lazily: a role with nothing to do costs nothing. */
125
+ const registryAgent = async (root: string, config: DocBridgeConfigV1): Promise<NonNullable<EnrichmentRunOptions['agent']>> => {
126
+ const adapters = new Map<string, RegistryAgentAdapter>()
127
+ const adapterFor = async (agentId: string): Promise<RegistryAgentAdapter> => {
128
+ const known = adapters.get(agentId)
129
+ if (known) return known
130
+ const scoped = configWithAgent(config, agentId)
131
+ const runner: RegistryAgentRunner | undefined = scoped.intelligence?.registry?.cli ? undefined : await loadRegistryAgentRunner(root, scoped)
132
+ const adapter = createRegistryAgentAdapter(root, scoped, runner)
133
+ adapters.set(agentId, adapter)
134
+ return adapter
135
+ }
136
+ return {
137
+ call: async ({ role, task, packs }) => (await adapterFor(role.agentId)).enrich(task, packs, { role: role.role, promptVersion: role.promptVersion }),
138
+ version: (role) => loadRegistryAgentMetadata(resolve(root), configWithAgent(config, role.agentId)).version,
139
+ }
140
+ }
141
+
142
+ const overlayBase = (options: EnrichmentRunOptions): Omit<EnrichmentOverlayV1, 'contentHash' | 'accepted' | 'pending' | 'rejected' | 'stats'> => ({
143
+ type: 'enrichment-overlay',
144
+ schemaVersion: ENRICHMENT_SCHEMA_VERSION,
145
+ contentHashAlgo: 'sha256-normalized-v1',
146
+ project: options.snapshot.project,
147
+ sourceRevision: options.snapshot.sourceRevision,
148
+ sourceRevisionKind: options.snapshot.sourceRevisionKind,
149
+ configurationHash: options.snapshot.configurationHash,
150
+ pipelineVersion: options.snapshot.pipelineVersion,
151
+ analyzerVersions: options.snapshot.analyzerVersions,
152
+ baseSnapshotHash: options.snapshot.contentHash,
153
+ })
154
+
155
+ /** Which pack a proposal belongs to: the one whose target it names. Anything else was not asked about. */
156
+ const attribute = (proposals: readonly unknown[], packs: readonly ContextPack[]): { readonly byPack: Map<string, unknown[]>; readonly stray: unknown[] } => {
157
+ const byPack = new Map<string, unknown[]>(packs.map((pack) => [pack.target.id, []]))
158
+ const stray: unknown[] = []
159
+ for (const proposal of proposals) {
160
+ const entity = proposal && typeof proposal === 'object' ? (proposal as { entity?: unknown }).entity : undefined
161
+ const list = typeof entity === 'string' ? byPack.get(entity) : undefined
162
+ if (list) list.push(proposal)
163
+ else stray.push(proposal)
164
+ }
165
+ return { byPack, stray }
166
+ }
167
+
168
+ /**
169
+ * Run enrichment over a snapshot and report.
170
+ *
171
+ * 1. Build packs for every role's target kinds; batch by area.
172
+ * 2. Answer each pack from the cache or from the agent, caching what the agent said per pack —
173
+ * including nothing, so silence is not asked for twice.
174
+ * 3. Validate everything through the partition, against the snapshot, the report and the
175
+ * overlay already on disk; policy kinds are accepted, human kinds requested from the gate.
176
+ * 4. Adjudicate what two roles could not settle, if a third identity is configured.
177
+ * 5. Merge with the stored overlay — decisions people made survive while their target does —
178
+ * seal, write.
179
+ */
180
+ export const runEnrichment = async (options: EnrichmentRunOptions): Promise<EnrichmentRunResult> => {
181
+ const { root, config, snapshot, report } = options
182
+ if (!config.intelligence?.registry?.enabled) throw new Error('Registry agents are disabled. Set intelligence.registry.enabled: true to run enrichment.')
183
+ const roles = resolveEnrichmentRoles(config)
184
+ // Wall time is measured, not derived: it is the number a person weighs the overlay's cost against.
185
+ const startedAt = options.clock?.() ?? Date.now()
186
+ const now = options.now ?? (() => new Date().toISOString())
187
+ const cache = options.cache ?? createEnrichmentCache(root)
188
+ const agent = options.agent ?? (await registryAgent(root, config))
189
+ const gate = options.gate ?? (await loadApprovalGate(createFileApprovalStore(approvalsDir(root)))).gate
190
+ const previous = readEnrichmentOverlay(root)
191
+
192
+ // What the stored overlay still says about this snapshot. Expired entries leave here, never on a read.
193
+ const { live, expired } = previous ? effectiveEnrichment(previous, snapshot) : { live: [], expired: [] }
194
+ const existingAccepted: AcceptedEnrichment[] = [...live]
195
+ const existingPending: PendingEnrichment[] = (previous?.pending ?? []).filter((entry) => {
196
+ const entity = snapshot.entities.find((item) => item.id === entry.proposal.entity)
197
+ return entity !== undefined && effectiveEnrichment({ accepted: [{ ...entry, acceptedAt: now(), acceptedBy: 'policy' }] }, snapshot).live.length === 1
198
+ })
199
+ // A decision that settled a proposal — a person's, or an adjudicator's — is not reopened by the cache replaying it.
200
+ const settled = (previous?.rejected ?? []).filter((entry) => entry.reason === 'human-rejected' || entry.reason === 'adjudicated')
201
+ const humanRejected = new Set(settled.map((entry) => entry.proposalId))
202
+
203
+ const stats = emptyStats()
204
+ stats.expired = expired.length
205
+ const rerun: string[] = []
206
+ const raw: unknown[] = []
207
+ const strayRejections: RejectedEnrichment[] = []
208
+ const versions = new Map<string, string>()
209
+ const versionOf = (role: ResolvedRole): string => {
210
+ const known = versions.get(role.agentId)
211
+ if (known) return known
212
+ const version = agent.version(role)
213
+ versions.set(role.agentId, version)
214
+ return version
215
+ }
216
+
217
+ for (const role of roles) {
218
+ if (role.role === 'adjudicator') continue
219
+ const task = ROLE_TASK[role.role]
220
+ const packs = buildContextPacks({ snapshot, report, config, kinds: ROLE_TARGET_KINDS[role.role], root, ...(options.readFile ? { readFile: options.readFile } : {}) })
221
+ stats.packs += packs.length
222
+ const agentVersion = versionOf(role)
223
+ for (const batch of batchContextPacks(packs)) {
224
+ const keyFor = (pack: ContextPack) => ({ task, agentId: role.agentId, agentVersion, promptVersion: role.promptVersion, packHash: pack.packHash })
225
+ const needed: ContextPack[] = []
226
+ for (const pack of batch.packs) {
227
+ const cached = cache.read(keyFor(pack))
228
+ if (cached) {
229
+ stats.cacheHits += 1
230
+ raw.push(...cached)
231
+ } else needed.push(pack)
232
+ }
233
+ if (!needed.length) continue
234
+ stats.agentRuns += 1
235
+ stats.inputBytes += Buffer.byteLength(JSON.stringify(needed), 'utf8')
236
+ const answered = await agent.call({ role, task, packs: needed, agentVersion })
237
+ stats.outputBytes += Buffer.byteLength(JSON.stringify(answered), 'utf8')
238
+ const { byPack, stray } = attribute(answered, needed)
239
+ for (const pack of needed) {
240
+ const proposals = byPack.get(pack.target.id) ?? []
241
+ cache.write(keyFor(pack), proposals)
242
+ raw.push(...proposals)
243
+ rerun.push(pack.target.id)
244
+ }
245
+ for (const proposal of stray) {
246
+ const record = proposal && typeof proposal === 'object' ? (proposal as Record<string, unknown>) : {}
247
+ strayRejections.push({
248
+ proposalId: typeof record.proposalId === 'string' && record.proposalId ? record.proposalId : sha256NormalizedV1(proposal ?? null),
249
+ kind: typeof record.kind === 'string' && record.kind ? record.kind : 'unknown',
250
+ ...(typeof record.entity === 'string' && record.entity ? { entity: record.entity } : {}),
251
+ reason: 'entity-outside-pack',
252
+ })
253
+ }
254
+ }
255
+ }
256
+
257
+ /*
258
+ * Origin must be a role that was asked: a proposal claiming another agent's identity is that
259
+ * agent's to make. A proposal with no origin at all goes on to the validators, which name what
260
+ * is wrong with it — an unknown kind is `invalid-kind` before it is anything else.
261
+ */
262
+ const roleIds = new Set(roles.map((role) => role.agentId))
263
+ const identityChecked = raw.filter((proposal) => {
264
+ const origin = proposal && typeof proposal === 'object' ? (proposal as { origin?: { agentId?: unknown } }).origin : undefined
265
+ const claimed = typeof origin?.agentId === 'string' ? origin.agentId : undefined
266
+ if (claimed === undefined || roleIds.has(claimed)) return true
267
+ strayRejections.push({ proposalId: sha256NormalizedV1(proposal ?? null), kind: 'unknown', reason: 'schema', detail: `origin.agentId "${claimed}" is not a configured role` })
268
+ return false
269
+ })
270
+
271
+ const partition = partitionEnrichmentProposals(identityChecked, { snapshot, report, existing: { accepted: existingAccepted, pending: existingPending } })
272
+
273
+ const acceptedAt = now()
274
+ const accepted: AcceptedEnrichment[] = [
275
+ ...existingAccepted,
276
+ ...partition.accepted.filter((proposal) => !humanRejected.has(proposal.proposalId)).map((proposal) => ({ proposal, acceptedAt, acceptedBy: 'policy' })),
277
+ ]
278
+ let pending: PendingEnrichment[] = [
279
+ ...existingPending,
280
+ ...partition.pending
281
+ .filter((entry) => !humanRejected.has(entry.proposal.proposalId))
282
+ .map((entry) => ({ proposal: entry.proposal, approvalId: enrichmentApprovalId(entry.proposal.proposalId, entry.proposal.targetContentHash), ...(entry.note ? { note: entry.note } : {}) })),
283
+ ]
284
+ const rejected: RejectedEnrichment[] = [
285
+ ...partition.rejected,
286
+ ...strayRejections,
287
+ ...settled,
288
+ ...expired.map((entry) => ({ proposalId: entry.proposal.proposalId, kind: entry.proposal.kind, entity: entry.proposal.entity, reason: 'expired' as const, origin: entry.proposal.origin })),
289
+ ]
290
+
291
+ // Every pending entry has an approval record waiting for a person. Requesting is idempotent.
292
+ for (const entry of pending) {
293
+ await gate.request({
294
+ id: entry.approvalId,
295
+ name: ENRICHMENT_APPROVAL_GATE,
296
+ payload: { proposalId: entry.proposal.proposalId, targetContentHash: entry.proposal.targetContentHash, kind: entry.proposal.kind, entity: entry.proposal.entity, policy: ENRICHMENT_POLICY[entry.proposal.kind] },
297
+ })
298
+ }
299
+
300
+ // Adjudication: only for what two roles could not settle, only by a third identity.
301
+ const adjudicator = roles.find((role) => role.role === 'adjudicator')
302
+ const disputed = pending.filter((entry) => entry.note !== undefined)
303
+ if (adjudicator && disputed.length) {
304
+ const targets = new Set(disputed.map((entry) => entry.proposal.entity))
305
+ const packs = buildContextPacks({ snapshot, report, config, kinds: ['document', 'area', 'module', 'package'], targets, root, ...(options.readFile ? { readFile: options.readFile } : {}) })
306
+ const disputes = disputed.map((entry) => ({ proposal: entry.proposal, note: entry.note }))
307
+ const packHash = sha256NormalizedV1({ packs: packs.map((pack) => pack.packHash), disputes: disputes.map((item) => item.proposal.proposalId) })
308
+ const key = { task: 'adjudicate' as const, agentId: adjudicator.agentId, agentVersion: versionOf(adjudicator), promptVersion: adjudicator.promptVersion, packHash }
309
+ let verdicts = cache.read(key)
310
+ if (verdicts) stats.cacheHits += 1
311
+ else {
312
+ stats.agentRuns += 1
313
+ const payload = [...packs, { type: 'adjudication-request', disputes }] as unknown as readonly ContextPack[]
314
+ verdicts = await agent.call({ role: adjudicator, task: 'adjudicate', packs: payload, agentVersion: key.agentVersion })
315
+ cache.write(key, verdicts)
316
+ }
317
+ for (const verdict of verdicts) {
318
+ const checked = validateEnrichmentAdjudication(verdict, pending)
319
+ if (checked.status === 'rejected') {
320
+ rejected.push({ proposalId: sha256NormalizedV1(verdict ?? null), kind: 'enrichment-adjudication', reason: checked.reason, ...(checked.detail ? { detail: checked.detail } : {}) })
321
+ continue
322
+ }
323
+ const applied = applyEnrichmentAdjudication(pending, checked.adjudication)
324
+ pending = applied.pending
325
+ rejected.push(...applied.rejected)
326
+ }
327
+ }
328
+
329
+ for (const entry of accepted) count(stats, entry.proposal.kind, 'accepted')
330
+ for (const entry of pending) count(stats, entry.proposal.kind, 'pending')
331
+ for (const entry of rejected) {
332
+ count(stats, entry.kind, 'rejected')
333
+ stats.rejectionReasons[entry.reason] = (stats.rejectionReasons[entry.reason] ?? 0) + 1
334
+ }
335
+ stats.byKind = Object.fromEntries(Object.entries(stats.byKind).sort(([left], [right]) => left.localeCompare(right)))
336
+ stats.rejectionReasons = Object.fromEntries(Object.entries(stats.rejectionReasons).sort(([left], [right]) => left.localeCompare(right)))
337
+ stats.inventedReferences = inventedReferenceCount(rejected)
338
+ stats.cacheHitRate = cacheHitRate(stats.cacheHits, stats.agentRuns)
339
+ stats.wallTimeMs = Math.max(0, (options.clock?.() ?? Date.now()) - startedAt)
340
+
341
+ const overlay = sealEnrichmentOverlay({ ...overlayBase(options), accepted, pending, rejected, stats })
342
+ const overlayPath = options.write === false ? undefined : writeEnrichmentOverlay(root, overlay)
343
+ return {
344
+ overlay,
345
+ ...(overlayPath ? { overlayPath } : {}),
346
+ agentCalls: stats.agentRuns,
347
+ cacheHits: stats.cacheHits,
348
+ packs: stats.packs,
349
+ rerun: [...new Set(rerun)].sort(),
350
+ expired: expired.length,
351
+ roles,
352
+ stability: enrichmentStability(overlay, previous),
353
+ }
354
+ }
355
+
356
+ const count = (stats: EnrichmentStats, kind: string, bucket: 'accepted' | 'pending' | 'rejected'): void => {
357
+ const current = stats.byKind[kind] ?? { proposed: 0, accepted: 0, pending: 0, rejected: 0 }
358
+ current[bucket] += 1
359
+ current.proposed += 1
360
+ stats.byKind[kind] = current
361
+ }
362
+
363
+ /** A human-readable line per stat, for `--text`. */
364
+ export const formatEnrichmentText = (result: EnrichmentRunResult): string[] => {
365
+ const { overlay } = result
366
+ return [
367
+ `Roles: ${result.roles.map((role) => `${role.role}=${role.agentId}`).join(', ')}`,
368
+ `Packs: ${result.packs} (agent calls ${result.agentCalls}, cache hits ${result.cacheHits}, re-run ${result.rerun.length})`,
369
+ `Accepted: ${overlay.accepted.length} Pending: ${overlay.pending.length} Rejected: ${overlay.rejected.length} Expired: ${result.expired}`,
370
+ ...Object.entries(overlay.stats.byKind).map(([kind, counts]) => ` ${kind}: proposed ${counts.proposed}, accepted ${counts.accepted}, pending ${counts.pending}, rejected ${counts.rejected}`),
371
+ ...formatEnrichmentStatsText(overlay.stats, result.stability),
372
+ `Overlay: ${overlay.contentHash}${result.overlayPath ? ` (${basename(result.overlayPath)})` : ''}`,
373
+ ]
374
+ }
@@ -0,0 +1,100 @@
1
+ import {
2
+ INVENTED_RELATION_REASONS,
3
+ type EnrichmentOverlayV1,
4
+ type EnrichmentStats,
5
+ type RejectedEnrichment,
6
+ } from '../schemas/enrichment.js'
7
+
8
+ /**
9
+ * What the overlay cost and whether it can be trusted.
10
+ *
11
+ * An enrichment stage that reports only what it accepted is unauditable: an agent that proposes
12
+ * a hundred things and has ninety rejected looks the same as one that proposes ten good ones. So
13
+ * the overlay carries the whole shape of the run — per kind, per rejection reason, what it
14
+ * invented, what it cost — and two numbers that say whether the thing is stable at all.
15
+ */
16
+
17
+ /** Rejections that named something the repository does not contain. */
18
+ export const inventedReferenceCount = (rejected: readonly RejectedEnrichment[]): number =>
19
+ rejected.filter((entry) => (INVENTED_RELATION_REASONS as readonly string[]).includes(entry.reason)).length
20
+
21
+ const round = (value: number): number => Math.round(value * 1_000_000) / 1_000_000
22
+
23
+ export const cacheHitRate = (cacheHits: number, agentRuns: number): number =>
24
+ cacheHits + agentRuns === 0 ? 0 : round(cacheHits / (cacheHits + agentRuns))
25
+
26
+ export type EnrichmentStability = {
27
+ /**
28
+ * Whether the two runs decided identically. A deterministic agent must reach this; a live model
29
+ * usually will not, which is why the proposal-identifier share is reported next to it.
30
+ */
31
+ readonly overlayHashIdentical: boolean
32
+ readonly previousOverlayHash?: string
33
+ readonly overlayHash: string
34
+ /** Proposal identifiers present in both runs, over the identifiers of the union. */
35
+ readonly proposalIdShare: number
36
+ readonly sharedProposalIds: number
37
+ readonly proposalIds: number
38
+ readonly previousProposalIds: number
39
+ }
40
+
41
+ /** Every proposal identifier an overlay decided on, whatever the decision was. */
42
+ export const overlayProposalIds = (overlay: Pick<EnrichmentOverlayV1, 'accepted' | 'pending' | 'rejected'>): ReadonlySet<string> =>
43
+ new Set([
44
+ ...overlay.accepted.map((entry) => entry.proposal.proposalId),
45
+ ...overlay.pending.map((entry) => entry.proposal.proposalId),
46
+ ...overlay.rejected.map((entry) => entry.proposalId),
47
+ ])
48
+
49
+ /**
50
+ * Compare this run with the one before it.
51
+ *
52
+ * Both halves of the issue's stability requirement fall out of the same comparison: two
53
+ * deterministic runs over an unchanged repository produce one overlay hash, and two live-model
54
+ * runs produce a share of identical proposal identifiers. The share is over the union, so a run
55
+ * that merely proposes fewer things does not score as more stable.
56
+ */
57
+ export const enrichmentStability = (
58
+ current: EnrichmentOverlayV1,
59
+ previous: EnrichmentOverlayV1 | undefined,
60
+ ): EnrichmentStability => {
61
+ const currentIds = overlayProposalIds(current)
62
+ const previousIds = previous ? overlayProposalIds(previous) : new Set<string>()
63
+ const union = new Set([...currentIds, ...previousIds])
64
+ const shared = [...currentIds].filter((id) => previousIds.has(id)).length
65
+ return {
66
+ overlayHashIdentical: previous !== undefined && previous.contentHash === current.contentHash,
67
+ ...(previous ? { previousOverlayHash: previous.contentHash } : {}),
68
+ overlayHash: current.contentHash,
69
+ proposalIdShare: union.size === 0 ? (previous === undefined ? 0 : 1) : round(shared / union.size),
70
+ sharedProposalIds: shared,
71
+ proposalIds: currentIds.size,
72
+ previousProposalIds: previousIds.size,
73
+ }
74
+ }
75
+
76
+ /** The cost side of `stats`, as its own object for a reader that only wants the bill. */
77
+ export const enrichmentCost = (stats: EnrichmentStats): {
78
+ readonly agentRuns: number
79
+ readonly inputBytes: number
80
+ readonly outputBytes: number
81
+ readonly cacheHits: number
82
+ readonly cacheHitRate: number
83
+ readonly wallTimeMs: number
84
+ } => ({
85
+ agentRuns: stats.agentRuns,
86
+ inputBytes: stats.inputBytes,
87
+ outputBytes: stats.outputBytes,
88
+ cacheHits: stats.cacheHits,
89
+ cacheHitRate: stats.cacheHitRate,
90
+ wallTimeMs: stats.wallTimeMs,
91
+ })
92
+
93
+ export const formatEnrichmentStatsText = (stats: EnrichmentStats, stability: EnrichmentStability): readonly string[] => [
94
+ `Cost: ${stats.agentRuns} agent run(s), ${stats.inputBytes} bytes in, ${stats.outputBytes} bytes out, cache ${(stats.cacheHitRate * 100).toFixed(1)}% (${stats.cacheHits} hit(s)), ${stats.wallTimeMs} ms`,
95
+ `Invented references: ${stats.inventedReferences}`,
96
+ `Stability: overlay hash ${stability.overlayHashIdentical ? 'identical to the previous run' : stability.previousOverlayHash ? 'changed' : 'first run'}, proposal ids ${(stability.proposalIdShare * 100).toFixed(1)}% shared (${stability.sharedProposalIds} of ${stability.proposalIds})`,
97
+ ...(Object.keys(stats.rejectionReasons).length
98
+ ? [`Rejections: ${Object.entries(stats.rejectionReasons).map(([reason, value]) => `${reason}=${value}`).join(', ')}`]
99
+ : []),
100
+ ]