@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.
- package/CHANGELOG.md +488 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15871 -6218
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +95 -6
- package/dist/config/index.js.map +1 -1
- package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
- package/dist/index.d.ts +9880 -1062
- package/dist/index.js +16143 -6127
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +12 -1
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +5 -1
- package/docs/agent-corpus/cli.md +16 -0
- package/docs/agent-corpus/conformance.md +12 -0
- package/docs/agent-corpus/doc-bridge.md +47 -0
- package/docs/agent-corpus/doctor.md +8 -0
- package/docs/agent-corpus/gates.md +4 -0
- package/docs/agent-corpus/mcp.md +13 -0
- package/docs/agent-corpus/memory.md +4 -0
- package/docs/agent-corpus/query.md +33 -0
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +38 -3
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +33 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +157 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +14 -0
- package/docs/verification-harness.md +11 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +34 -15
- package/scripts/check-ecosystem-upstream.mjs +34 -6
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/program.ts +684 -74
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/schema.ts +126 -2
- package/src/conformance/documentation-standard-v1.ts +8 -6
- package/src/discovery/areas.ts +197 -0
- package/src/discovery/documentation.ts +211 -5
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +485 -101
- package/src/doctor/run-doctor.ts +244 -12
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +2 -4
- package/src/findings/report.ts +103 -0
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index.ts +630 -2
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +101 -10
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +2 -2
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +30 -10
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +14 -1
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +1 -4
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- 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
|
+
]
|