@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
package/src/doctor/run-doctor.ts
CHANGED
|
@@ -1,11 +1,33 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
1
4
|
import type { DocBridgeConfigV1 } from '../config/schema.js'
|
|
5
|
+
import { parseRetrievalSuite, runRetrievalBench } from '../bench/retrieval.js'
|
|
6
|
+
import { discoverRepository } from '../discovery/repository.js'
|
|
2
7
|
import { buildDocBridgeIndex } from '../index-builder/build-index.js'
|
|
3
8
|
import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
|
|
4
9
|
import { runGates, type GateRunResult } from '../gates/run-gates.js'
|
|
5
10
|
import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
|
|
6
11
|
import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
|
|
12
|
+
import type { DiscoverySnapshotV1 } from '../schemas/knowledge.js'
|
|
7
13
|
import { doctorBadgeMetrics, type DoctorBadgeMetrics } from './badge.js'
|
|
8
14
|
|
|
15
|
+
/** Where the golden retrieval suite lives unless `retrieval.benchmark.suite` says otherwise. */
|
|
16
|
+
export const DEFAULT_RETRIEVAL_SUITE = 'docs/bench/retrieval-suite-v1.json'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What an A requires beyond the score, as fractions the doctor measured rather than declared.
|
|
20
|
+
*
|
|
21
|
+
* Reachability must be complete: a document retrieval cannot find is a document the product does
|
|
22
|
+
* not deliver, and no other dimension compensates for it. Connectivity and the benchmark have a
|
|
23
|
+
* floor rather than a ceiling, because a repository with a few undocumented utility areas is
|
|
24
|
+
* still healthy and a suite that misses a hard case in ten is still a good ranker.
|
|
25
|
+
*/
|
|
26
|
+
export const A_GRADE_REQUIREMENTS = { reachabilityPct: 100, connectivityPct: 80, benchmarkHitAt3: 0.8 } as const
|
|
27
|
+
|
|
28
|
+
/** How many offenders a dimension lists, so the report stays readable on a large repository. */
|
|
29
|
+
const MAX_LISTED = 20
|
|
30
|
+
|
|
9
31
|
export type DoctorIssue = {
|
|
10
32
|
readonly severity: 'error' | 'warn' | 'info'
|
|
11
33
|
readonly code: string
|
|
@@ -13,7 +35,57 @@ export type DoctorIssue = {
|
|
|
13
35
|
readonly action?: string
|
|
14
36
|
}
|
|
15
37
|
|
|
38
|
+
/** The share of the snapshot's documents an agent can retrieve: present in the retrieval projection. */
|
|
39
|
+
export type DoctorReachability = {
|
|
40
|
+
readonly documentsTotal: number
|
|
41
|
+
readonly documentsReachable: number
|
|
42
|
+
readonly unreachable: readonly string[]
|
|
43
|
+
readonly pct: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether the graph connects documentation to code both ways: an area with at least one document
|
|
48
|
+
* that covers or mentions it, and a document with at least one edge into code (an area, a module
|
|
49
|
+
* or a package it covers or mentions). `pct` is the mean of the two shares.
|
|
50
|
+
*/
|
|
51
|
+
export type DoctorConnectivity = {
|
|
52
|
+
readonly areasTotal: number
|
|
53
|
+
readonly areasDocumented: number
|
|
54
|
+
readonly undocumentedAreas: readonly string[]
|
|
55
|
+
readonly documentsTotal: number
|
|
56
|
+
readonly documentsLinked: number
|
|
57
|
+
readonly unlinkedDocuments: readonly string[]
|
|
58
|
+
readonly pct: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type DoctorBenchmark =
|
|
62
|
+
| {
|
|
63
|
+
readonly status: 'measured'
|
|
64
|
+
readonly suite: string
|
|
65
|
+
readonly caseCount: number
|
|
66
|
+
readonly hitAt1: number
|
|
67
|
+
readonly hitAt3: number
|
|
68
|
+
readonly meanReciprocalRank: number
|
|
69
|
+
}
|
|
70
|
+
| {
|
|
71
|
+
/** No golden suite, or one that could not be run: reported, never silently omitted. */
|
|
72
|
+
readonly status: 'not-analyzed'
|
|
73
|
+
readonly suite: string
|
|
74
|
+
readonly reason: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Which of the three measured dimensions meet the bar an A requires, and why not when one does not. */
|
|
78
|
+
export type DoctorGrading = {
|
|
79
|
+
readonly reachability: boolean
|
|
80
|
+
readonly connectivity: boolean
|
|
81
|
+
readonly benchmark: boolean
|
|
82
|
+
readonly unmet: readonly string[]
|
|
83
|
+
}
|
|
84
|
+
|
|
16
85
|
export type DoctorCoverage = {
|
|
86
|
+
readonly reachability: DoctorReachability
|
|
87
|
+
readonly connectivity: DoctorConnectivity
|
|
88
|
+
readonly benchmark: DoctorBenchmark
|
|
17
89
|
readonly packages: {
|
|
18
90
|
readonly total: number
|
|
19
91
|
readonly withAgentDoc: number
|
|
@@ -38,6 +110,7 @@ export type DoctorReport = {
|
|
|
38
110
|
readonly ok: boolean
|
|
39
111
|
readonly score: number
|
|
40
112
|
readonly grade: 'A' | 'B' | 'C' | 'D' | 'F'
|
|
113
|
+
readonly grading: DoctorGrading
|
|
41
114
|
readonly coverage: DoctorCoverage
|
|
42
115
|
readonly badge: DoctorBadgeMetrics
|
|
43
116
|
readonly issues: readonly DoctorIssue[]
|
|
@@ -52,30 +125,127 @@ const gradeForScore = (score: number): DoctorReport['grade'] => {
|
|
|
52
125
|
return 'F'
|
|
53
126
|
}
|
|
54
127
|
|
|
55
|
-
|
|
128
|
+
/**
|
|
129
|
+
* One hundred points, each traceable to something measured.
|
|
130
|
+
*
|
|
131
|
+
* The index (present, fresh), the handoff corpus (agent docs, human guides) and the gates were
|
|
132
|
+
* the whole score once, and it reported 100/100 on a repository where most documents were
|
|
133
|
+
* unreachable, because nothing in it measured what retrieval could see. Forty of the hundred now
|
|
134
|
+
* come from reachability, connectivity and the benchmark; a suite that does not exist scores
|
|
135
|
+
* nothing, since an unmeasured ranker is not a good ranker.
|
|
136
|
+
*/
|
|
137
|
+
export const computeScore = (coverage: DoctorCoverage): number => {
|
|
56
138
|
let score = 0
|
|
57
139
|
|
|
58
|
-
if (coverage.freshness.hasIndex) score +=
|
|
59
|
-
if (coverage.freshness.ok) score +=
|
|
140
|
+
if (coverage.freshness.hasIndex) score += 10
|
|
141
|
+
if (coverage.freshness.ok) score += 10
|
|
60
142
|
|
|
61
143
|
const { total, withAgentDoc, withHumanDoc } = coverage.packages
|
|
62
144
|
if (total > 0) {
|
|
63
|
-
score += Math.round((withAgentDoc / total) *
|
|
64
|
-
score += Math.round((withHumanDoc / total) *
|
|
145
|
+
score += Math.round((withAgentDoc / total) * 20)
|
|
146
|
+
score += Math.round((withHumanDoc / total) * 10)
|
|
65
147
|
} else if (coverage.agentDocs.indexed > 0) {
|
|
66
|
-
score +=
|
|
148
|
+
score += 20
|
|
67
149
|
}
|
|
68
150
|
|
|
69
|
-
if (coverage.gates.ok) score +=
|
|
151
|
+
if (coverage.gates.ok) score += 10
|
|
70
152
|
else {
|
|
71
153
|
const passed = coverage.gates.results.filter((gate) => gate.ok).length
|
|
72
154
|
const totalGates = coverage.gates.results.length || 1
|
|
73
|
-
score += Math.round((passed / totalGates) *
|
|
155
|
+
score += Math.round((passed / totalGates) * 7)
|
|
74
156
|
}
|
|
75
157
|
|
|
158
|
+
score += Math.round((coverage.reachability.pct / 100) * 15)
|
|
159
|
+
score += Math.round((coverage.connectivity.pct / 100) * 15)
|
|
160
|
+
if (coverage.benchmark.status === 'measured') score += Math.round(coverage.benchmark.hitAt3 * 10)
|
|
161
|
+
|
|
76
162
|
return Math.min(100, Math.max(0, score))
|
|
77
163
|
}
|
|
78
164
|
|
|
165
|
+
const pct = (part: number, whole: number): number => (whole > 0 ? Math.round((part / whole) * 100) : 0)
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* An A requires all three measured dimensions, whatever the score says. A repository with no
|
|
169
|
+
* golden suite tops out at B: the ranker may be excellent, but nobody has checked.
|
|
170
|
+
*/
|
|
171
|
+
export const gradeFor = (score: number, coverage: DoctorCoverage): { readonly grade: DoctorReport['grade']; readonly grading: DoctorGrading } => {
|
|
172
|
+
const reachability = coverage.reachability.pct >= A_GRADE_REQUIREMENTS.reachabilityPct
|
|
173
|
+
const connectivity = coverage.connectivity.pct >= A_GRADE_REQUIREMENTS.connectivityPct
|
|
174
|
+
const benchmark = coverage.benchmark.status === 'measured' && coverage.benchmark.hitAt3 >= A_GRADE_REQUIREMENTS.benchmarkHitAt3
|
|
175
|
+
const unmet = [
|
|
176
|
+
...(reachability ? [] : [`reachability ${coverage.reachability.pct}% < ${A_GRADE_REQUIREMENTS.reachabilityPct}%`]),
|
|
177
|
+
...(connectivity ? [] : [`connectivity ${coverage.connectivity.pct}% < ${A_GRADE_REQUIREMENTS.connectivityPct}%`]),
|
|
178
|
+
...(benchmark
|
|
179
|
+
? []
|
|
180
|
+
: coverage.benchmark.status === 'measured'
|
|
181
|
+
? [`benchmark hit@3 ${(coverage.benchmark.hitAt3 * 100).toFixed(1)}% < ${A_GRADE_REQUIREMENTS.benchmarkHitAt3 * 100}%`]
|
|
182
|
+
: ['benchmark not-analyzed']),
|
|
183
|
+
]
|
|
184
|
+
const grade = gradeForScore(score)
|
|
185
|
+
return { grade: grade === 'A' && unmet.length ? 'B' : grade, grading: { reachability, connectivity, benchmark, unmet } }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The documents an agent can retrieve: those the projection carries, of those the snapshot observed. */
|
|
189
|
+
export const measureReachability = (snapshot: DiscoverySnapshotV1, index: DocBridgeIndexV1): DoctorReachability => {
|
|
190
|
+
const projected = new Set((index.projection?.entries ?? []).filter((entry) => entry.kind === 'document').map((entry) => entry.id))
|
|
191
|
+
const documents = snapshot.entities.filter((entity) => entity.kind === 'document').map((entity) => entity.id).sort()
|
|
192
|
+
const unreachable = documents.filter((id) => !projected.has(id))
|
|
193
|
+
return {
|
|
194
|
+
documentsTotal: documents.length,
|
|
195
|
+
documentsReachable: documents.length - unreachable.length,
|
|
196
|
+
unreachable: unreachable.slice(0, MAX_LISTED),
|
|
197
|
+
pct: pct(documents.length - unreachable.length, documents.length),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Both directions of the documentation graph, from the projection's own edges. */
|
|
202
|
+
export const measureConnectivity = (index: DocBridgeIndexV1): DoctorConnectivity => {
|
|
203
|
+
const entries = index.projection?.entries ?? []
|
|
204
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]))
|
|
205
|
+
const areas = entries.filter((entry) => entry.kind === 'area').sort((a, b) => a.id.localeCompare(b.id))
|
|
206
|
+
const undocumentedAreas = areas.filter((entry) => !entry.graph.coveredBy.length && !entry.graph.mentionedBy.length).map((entry) => entry.id)
|
|
207
|
+
const documents = entries.filter((entry) => entry.kind === 'document').sort((a, b) => a.id.localeCompare(b.id))
|
|
208
|
+
const linksToCode = (entry: (typeof entries)[number]): boolean =>
|
|
209
|
+
entry.graph.outbound.some((edge) => {
|
|
210
|
+
const target = byId.get(edge.id)
|
|
211
|
+
return target !== undefined && target.kind !== 'document'
|
|
212
|
+
})
|
|
213
|
+
const unlinkedDocuments = documents.filter((entry) => !linksToCode(entry)).map((entry) => entry.id)
|
|
214
|
+
const areaShare = areas.length ? (areas.length - undocumentedAreas.length) / areas.length : 0
|
|
215
|
+
const documentShare = documents.length ? (documents.length - unlinkedDocuments.length) / documents.length : 0
|
|
216
|
+
return {
|
|
217
|
+
areasTotal: areas.length,
|
|
218
|
+
areasDocumented: areas.length - undocumentedAreas.length,
|
|
219
|
+
undocumentedAreas: undocumentedAreas.slice(0, MAX_LISTED),
|
|
220
|
+
documentsTotal: documents.length,
|
|
221
|
+
documentsLinked: documents.length - unlinkedDocuments.length,
|
|
222
|
+
unlinkedDocuments: unlinkedDocuments.slice(0, MAX_LISTED),
|
|
223
|
+
pct: Math.round(((areaShare + documentShare) / 2) * 100),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** hit@3 over the golden suite when the repository has one; the honest `not-analyzed` when it does not. */
|
|
228
|
+
export const measureBenchmark = (root: string, config: DocBridgeConfigV1, index: DocBridgeIndexV1): DoctorBenchmark => {
|
|
229
|
+
const suite = config.retrieval?.benchmark?.suite ?? DEFAULT_RETRIEVAL_SUITE
|
|
230
|
+
const suitePath = resolve(root, suite)
|
|
231
|
+
if (!existsSync(suitePath)) return { status: 'not-analyzed', suite, reason: `No retrieval suite at ${suite}` }
|
|
232
|
+
if (!index.projection) return { status: 'not-analyzed', suite, reason: 'The index carries no retrieval projection. Run: ak-docs index' }
|
|
233
|
+
try {
|
|
234
|
+
const parsed = parseRetrievalSuite(JSON.parse(readFileSync(suitePath, 'utf8')) as unknown)
|
|
235
|
+
const result = runRetrievalBench({ index, suite: parsed })
|
|
236
|
+
return {
|
|
237
|
+
status: 'measured',
|
|
238
|
+
suite,
|
|
239
|
+
caseCount: result.metrics.caseCount,
|
|
240
|
+
hitAt1: result.metrics.hitAt1,
|
|
241
|
+
hitAt3: result.metrics.hitAt3,
|
|
242
|
+
meanReciprocalRank: result.metrics.meanReciprocalRank,
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
return { status: 'not-analyzed', suite, reason: `The retrieval suite could not be run: ${error instanceof Error ? error.message : String(error)}` }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
79
249
|
const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
|
|
80
250
|
const issues: DoctorIssue[] = []
|
|
81
251
|
|
|
@@ -122,6 +292,47 @@ const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
|
|
|
122
292
|
})
|
|
123
293
|
}
|
|
124
294
|
|
|
295
|
+
const { reachability, connectivity, benchmark } = coverage
|
|
296
|
+
if (reachability.pct < A_GRADE_REQUIREMENTS.reachabilityPct) {
|
|
297
|
+
issues.push({
|
|
298
|
+
severity: 'warn',
|
|
299
|
+
code: 'documents-unreachable',
|
|
300
|
+
message: `${reachability.documentsTotal - reachability.documentsReachable} of ${reachability.documentsTotal} documents are not in the retrieval projection (${reachability.pct}% reachable).`,
|
|
301
|
+
action: 'ak-docs index',
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
if (connectivity.undocumentedAreas.length) {
|
|
305
|
+
issues.push({
|
|
306
|
+
severity: 'info',
|
|
307
|
+
code: 'areas-undocumented',
|
|
308
|
+
message: `${connectivity.areasTotal - connectivity.areasDocumented} of ${connectivity.areasTotal} areas have no document that covers or mentions them.`,
|
|
309
|
+
action: 'ak-docs check --json --format finding',
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
if (connectivity.unlinkedDocuments.length) {
|
|
313
|
+
issues.push({
|
|
314
|
+
severity: 'info',
|
|
315
|
+
code: 'documents-unlinked',
|
|
316
|
+
message: `${connectivity.documentsTotal - connectivity.documentsLinked} of ${connectivity.documentsTotal} documents have no edge into code.`,
|
|
317
|
+
action: 'ak-docs audit documentation',
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
if (benchmark.status === 'not-analyzed') {
|
|
321
|
+
issues.push({
|
|
322
|
+
severity: 'info',
|
|
323
|
+
code: 'benchmark-not-analyzed',
|
|
324
|
+
message: benchmark.reason,
|
|
325
|
+
action: `ak-docs bench retrieval ${benchmark.suite}`,
|
|
326
|
+
})
|
|
327
|
+
} else if (benchmark.hitAt3 < A_GRADE_REQUIREMENTS.benchmarkHitAt3) {
|
|
328
|
+
issues.push({
|
|
329
|
+
severity: 'warn',
|
|
330
|
+
code: 'benchmark-below-target',
|
|
331
|
+
message: `Retrieval hit@3 is ${(benchmark.hitAt3 * 100).toFixed(1)}% over ${benchmark.caseCount} case(s); an A requires ${A_GRADE_REQUIREMENTS.benchmarkHitAt3 * 100}%.`,
|
|
332
|
+
action: `ak-docs bench retrieval ${benchmark.suite} --text`,
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
|
|
125
336
|
return issues
|
|
126
337
|
}
|
|
127
338
|
|
|
@@ -157,9 +368,12 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
|
|
|
157
368
|
let freshnessOk = false
|
|
158
369
|
let freshnessMessage: string
|
|
159
370
|
|
|
371
|
+
// One discovery serves both the freshness rebuild and the reachability denominator.
|
|
372
|
+
const snapshot = discoverRepository({ root, config })
|
|
373
|
+
|
|
160
374
|
try {
|
|
161
375
|
index = loadDocBridgeIndex(root, config)
|
|
162
|
-
const next = buildDocBridgeIndex({ root, config, write: false }).index.contentHash
|
|
376
|
+
const next = buildDocBridgeIndex({ root, config, write: false, snapshot }).index.contentHash
|
|
163
377
|
freshnessOk = index.contentHash === next
|
|
164
378
|
freshnessMessage = freshnessOk ? 'Index is fresh' : 'Index is stale. Run: ak-docs index'
|
|
165
379
|
} catch (error) {
|
|
@@ -167,7 +381,7 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
|
|
|
167
381
|
hasIndex = false
|
|
168
382
|
freshnessOk = false
|
|
169
383
|
freshnessMessage = error.message
|
|
170
|
-
index = buildDocBridgeIndex({ root, config, write: false }).index
|
|
384
|
+
index = buildDocBridgeIndex({ root, config, write: false, snapshot }).index
|
|
171
385
|
} else {
|
|
172
386
|
throw error
|
|
173
387
|
}
|
|
@@ -188,6 +402,9 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
|
|
|
188
402
|
const gates = runGates(root, config)
|
|
189
403
|
|
|
190
404
|
const coverage: DoctorCoverage = {
|
|
405
|
+
reachability: measureReachability(snapshot, index),
|
|
406
|
+
connectivity: measureConnectivity(index),
|
|
407
|
+
benchmark: measureBenchmark(root, config, index),
|
|
191
408
|
packages: {
|
|
192
409
|
total: ownership.length,
|
|
193
410
|
withAgentDoc: ownership.length - missingAgentDoc.length,
|
|
@@ -210,14 +427,16 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
|
|
|
210
427
|
|
|
211
428
|
const issues = buildIssues(coverage)
|
|
212
429
|
const score = computeScore(coverage)
|
|
430
|
+
const { grade, grading } = gradeFor(score, coverage)
|
|
213
431
|
const nextActions = buildNextActions(issues, coverage)
|
|
214
432
|
|
|
215
433
|
const report: DoctorReport = {
|
|
216
434
|
ok: issues.every((issue) => issue.severity !== 'error') && gates.ok,
|
|
217
435
|
score,
|
|
218
|
-
grade
|
|
436
|
+
grade,
|
|
437
|
+
grading,
|
|
219
438
|
coverage,
|
|
220
|
-
badge: { handoffPct: 0, bridgePct: 0, score, grade
|
|
439
|
+
badge: { handoffPct: 0, bridgePct: 0, score, grade, packages: 0 },
|
|
221
440
|
issues,
|
|
222
441
|
nextActions,
|
|
223
442
|
}
|
|
@@ -248,8 +467,21 @@ export const formatDoctorText = (report: DoctorReport): string[] => {
|
|
|
248
467
|
` Index freshness: ${coverage.freshness.ok ? 'fresh' : 'stale or missing'}`,
|
|
249
468
|
` Gates: ${coverage.gates.results.filter((g) => g.ok).length}/${coverage.gates.results.length} passing`,
|
|
250
469
|
` Badge: handoff ${report.badge.handoffPct}% · bridge ${report.badge.bridgePct}%`,
|
|
470
|
+
'',
|
|
471
|
+
'Retrieval',
|
|
472
|
+
` Reachability: ${coverage.reachability.documentsReachable}/${coverage.reachability.documentsTotal} documents in the retrieval projection (${coverage.reachability.pct}%)`,
|
|
473
|
+
` Connectivity: ${coverage.connectivity.areasDocumented}/${coverage.connectivity.areasTotal} areas documented · ${coverage.connectivity.documentsLinked}/${coverage.connectivity.documentsTotal} documents link to code (${coverage.connectivity.pct}%)`,
|
|
474
|
+
` Benchmark: ${
|
|
475
|
+
coverage.benchmark.status === 'measured'
|
|
476
|
+
? `hit@3 ${(coverage.benchmark.hitAt3 * 100).toFixed(1)}% · hit@1 ${(coverage.benchmark.hitAt1 * 100).toFixed(1)}% over ${coverage.benchmark.caseCount} case(s) (${coverage.benchmark.suite})`
|
|
477
|
+
: `not-analyzed — ${coverage.benchmark.reason}`
|
|
478
|
+
}`,
|
|
251
479
|
]
|
|
252
480
|
|
|
481
|
+
if (report.grading.unmet.length) {
|
|
482
|
+
lines.push('', `Grade ${report.grade}: an A requires`, ...report.grading.unmet.map((reason) => ` • ${reason}`))
|
|
483
|
+
}
|
|
484
|
+
|
|
253
485
|
if (coverage.packages.missingHumanDoc.length) {
|
|
254
486
|
lines.push('', 'Missing humanDoc (bridge gap)', ...coverage.packages.missingHumanDoc.map((id) => ` • ${id}`))
|
|
255
487
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
5
|
+
import { PeerMissingError, importPeer } from '../intelligence/peers.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Approvals through the ecosystem gate.
|
|
9
|
+
*
|
|
10
|
+
* `createApprovalGate` from `@agentskit/core/hitl` is the contract every AgentsKit surface uses
|
|
11
|
+
* to pause at a decision point and record what a person decided. Doc Bridge records its
|
|
12
|
+
* approvals through it — the enrichment overlay's, and `ak-docs fix approve`'s — so the CLI,
|
|
13
|
+
* MCP and a rendered review page share one record rather than three ways of writing "approved".
|
|
14
|
+
*
|
|
15
|
+
* The store is a directory of JSON files, one per approval, because the repository is the only
|
|
16
|
+
* persistence Doc Bridge has. The gate contract is mirrored here for the same reason every other
|
|
17
|
+
* ecosystem contract is: `@agentskit/core` is an optional peer, and the real `createApprovalGate`
|
|
18
|
+
* is used through `importPeer` when it is installed. A test runs both over the same store and
|
|
19
|
+
* asserts the same records.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type ApprovalDecision = 'approved' | 'rejected'
|
|
23
|
+
|
|
24
|
+
export type Approval<TPayload = unknown> = {
|
|
25
|
+
id: string
|
|
26
|
+
name: string
|
|
27
|
+
payload: TPayload
|
|
28
|
+
status: 'pending' | ApprovalDecision | 'cancelled'
|
|
29
|
+
createdAt: string
|
|
30
|
+
decidedAt?: string
|
|
31
|
+
decisionMetadata?: Record<string, unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ApprovalStore = {
|
|
35
|
+
put: <T>(approval: Approval<T>) => Promise<void>
|
|
36
|
+
get: <T>(id: string) => Promise<Approval<T> | null>
|
|
37
|
+
patch: <T>(id: string, update: Partial<Approval<T>>) => Promise<Approval<T> | null>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type RequestApprovalInput<TPayload> = { name: string; payload: TPayload; id: string }
|
|
41
|
+
|
|
42
|
+
export type ApprovalGate<TPayload = unknown> = {
|
|
43
|
+
request: (input: RequestApprovalInput<TPayload>) => Promise<Approval<TPayload>>
|
|
44
|
+
await: (id: string, options?: { timeoutMs?: number; pollMs?: number; signal?: AbortSignal }) => Promise<Approval<TPayload>>
|
|
45
|
+
decide: (id: string, decision: ApprovalDecision, metadata?: Record<string, unknown>) => Promise<Approval<TPayload>>
|
|
46
|
+
cancel: (id: string) => Promise<Approval<TPayload>>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const APPROVALS_DIR = '.doc-bridge/approvals'
|
|
50
|
+
export const ENRICHMENT_APPROVAL_GATE = 'doc-bridge.enrichment'
|
|
51
|
+
export const FIX_APPROVAL_GATE = 'doc-bridge.fix'
|
|
52
|
+
|
|
53
|
+
export const approvalsDir = (root: string): string => join(resolve(root), APPROVALS_DIR)
|
|
54
|
+
|
|
55
|
+
/** An approval id is a hash; anything else would be a path. */
|
|
56
|
+
const safeId = (id: string): string => {
|
|
57
|
+
if (!/^[a-f0-9]{16,64}$/.test(id)) throw new Error(`Approval ids are content hashes; received "${id}".`)
|
|
58
|
+
return id
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The id an enrichment approval binds to: the proposal and the exact content it was made about. */
|
|
62
|
+
export const enrichmentApprovalId = (proposalId: string, targetContentHash: string): string => sha256NormalizedV1({ proposalId, targetContentHash })
|
|
63
|
+
|
|
64
|
+
/** The id a fix approval binds to: the proposal id and the hash of its exact content. */
|
|
65
|
+
export const fixApprovalId = (proposalId: string, proposalHash: string): string => sha256NormalizedV1({ proposalId, proposalHash })
|
|
66
|
+
|
|
67
|
+
export const createFileApprovalStore = (dir: string): ApprovalStore => {
|
|
68
|
+
const pathFor = (id: string): string => join(dir, `${safeId(id)}.json`)
|
|
69
|
+
const write = (approval: Approval): void => {
|
|
70
|
+
mkdirSync(dir, { recursive: true })
|
|
71
|
+
const path = pathFor(approval.id)
|
|
72
|
+
const temporary = `${path}.tmp-${process.pid}`
|
|
73
|
+
writeFileSync(temporary, `${JSON.stringify(approval, null, 2)}\n`, 'utf8')
|
|
74
|
+
renameSync(temporary, path)
|
|
75
|
+
}
|
|
76
|
+
const read = (id: string): Approval | null => {
|
|
77
|
+
const path = pathFor(id)
|
|
78
|
+
if (!existsSync(path)) return null
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Approval
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
async put<T>(approval: Approval<T>): Promise<void> {
|
|
87
|
+
write(approval as Approval)
|
|
88
|
+
},
|
|
89
|
+
async get<T>(id: string): Promise<Approval<T> | null> {
|
|
90
|
+
return read(id) as Approval<T> | null
|
|
91
|
+
},
|
|
92
|
+
async patch<T>(id: string, update: Partial<Approval<T>>): Promise<Approval<T> | null> {
|
|
93
|
+
const current = read(id)
|
|
94
|
+
if (!current) return null
|
|
95
|
+
const next = { ...current, ...update } as Approval
|
|
96
|
+
write(next)
|
|
97
|
+
return next as Approval<T>
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every approval in the store, by id. For a reviewer listing what is waiting. */
|
|
103
|
+
export const listApprovals = (dir: string): Approval[] => {
|
|
104
|
+
if (!existsSync(dir)) return []
|
|
105
|
+
return readdirSync(dir)
|
|
106
|
+
.filter((name) => /^[a-f0-9]{16,64}\.json$/.test(name))
|
|
107
|
+
.sort()
|
|
108
|
+
.flatMap((name) => {
|
|
109
|
+
try {
|
|
110
|
+
return [JSON.parse(readFileSync(join(dir, name), 'utf8')) as Approval]
|
|
111
|
+
} catch {
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The mirror of `createApprovalGate`: create-or-load, poll, patch. */
|
|
118
|
+
export const createApprovalGateMirror = <TPayload = unknown>(store: ApprovalStore): ApprovalGate<TPayload> => {
|
|
119
|
+
const load = async (id: string): Promise<Approval<TPayload>> => {
|
|
120
|
+
const approval = await store.get<TPayload>(id)
|
|
121
|
+
if (!approval) throw new Error(`Unknown approval "${id}".`)
|
|
122
|
+
return approval
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
async request(input) {
|
|
126
|
+
const existing = await store.get<TPayload>(input.id)
|
|
127
|
+
if (existing) return existing
|
|
128
|
+
const approval: Approval<TPayload> = { id: input.id, name: input.name, payload: input.payload, status: 'pending', createdAt: new Date().toISOString() }
|
|
129
|
+
await store.put(approval)
|
|
130
|
+
return approval
|
|
131
|
+
},
|
|
132
|
+
async await(id, options = {}) {
|
|
133
|
+
const pollMs = options.pollMs ?? 500
|
|
134
|
+
const started = Date.now()
|
|
135
|
+
for (;;) {
|
|
136
|
+
const approval = await load(id)
|
|
137
|
+
if (approval.status !== 'pending') return approval
|
|
138
|
+
if (options.signal?.aborted) throw new Error(`Approval "${id}" wait aborted.`)
|
|
139
|
+
if (options.timeoutMs !== undefined && Date.now() - started >= options.timeoutMs) throw new Error(`Approval "${id}" timed out.`)
|
|
140
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, pollMs))
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
async decide(id, decision, metadata) {
|
|
144
|
+
const updated = await store.patch<TPayload>(id, { status: decision, decidedAt: new Date().toISOString(), ...(metadata ? { decisionMetadata: metadata } : {}) })
|
|
145
|
+
if (!updated) throw new Error(`Unknown approval "${id}".`)
|
|
146
|
+
return updated
|
|
147
|
+
},
|
|
148
|
+
async cancel(id) {
|
|
149
|
+
const updated = await store.patch<TPayload>(id, { status: 'cancelled', decidedAt: new Date().toISOString() })
|
|
150
|
+
if (!updated) throw new Error(`Unknown approval "${id}".`)
|
|
151
|
+
return updated
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type CoreHitl = { createApprovalGate: <T>(store: ApprovalStore) => ApprovalGate<T> }
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The gate over a store: the ecosystem's when the peer is installed, the mirror otherwise.
|
|
160
|
+
*
|
|
161
|
+
* Both write the same records to the same files, which is the point — a record written by one
|
|
162
|
+
* is read by the other, and by any AgentsKit surface that opens the same store.
|
|
163
|
+
*/
|
|
164
|
+
export const loadApprovalGate = async <TPayload = unknown>(store: ApprovalStore): Promise<{ readonly gate: ApprovalGate<TPayload>; readonly source: 'ecosystem' | 'mirror' }> => {
|
|
165
|
+
try {
|
|
166
|
+
const core = await importPeer<CoreHitl>('@agentskit/core/hitl')
|
|
167
|
+
if (typeof core.createApprovalGate === 'function') return { gate: core.createApprovalGate<TPayload>(store), source: 'ecosystem' }
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (!(error instanceof PeerMissingError)) throw error
|
|
170
|
+
}
|
|
171
|
+
return { gate: createApprovalGateMirror<TPayload>(store), source: 'mirror' }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type RecordedApproval = { readonly approval: Approval; readonly source: 'ecosystem' | 'mirror' }
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Record a decision a person already made — the shape `ak-docs fix approve` needs: request
|
|
178
|
+
* (idempotent, so a re-run finds the same record) then decide, in one call.
|
|
179
|
+
*/
|
|
180
|
+
export const recordApproval = async (
|
|
181
|
+
root: string,
|
|
182
|
+
input: { readonly id: string; readonly name: string; readonly payload: unknown; readonly decision: ApprovalDecision; readonly by: string; readonly reason?: string },
|
|
183
|
+
): Promise<RecordedApproval> => {
|
|
184
|
+
const store = createFileApprovalStore(approvalsDir(root))
|
|
185
|
+
const { gate, source } = await loadApprovalGate(store)
|
|
186
|
+
const existing = await gate.request({ id: input.id, name: input.name, payload: input.payload })
|
|
187
|
+
if (existing.status !== 'pending') throw new Error(`Approval ${input.id} was already ${existing.status}.`)
|
|
188
|
+
const approval = await gate.decide(input.id, input.decision, { by: input.by, ...(input.reason ? { reason: input.reason } : {}) })
|
|
189
|
+
return { approval, source }
|
|
190
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
|
|
6
|
+
import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
7
|
+
import type { EnrichmentTask } from './context-pack.js'
|
|
8
|
+
import { enrichmentCacheDir } from './overlay.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The persistent enrichment cache.
|
|
12
|
+
*
|
|
13
|
+
* The old deterministic cache was an in-process `Map`: it saved a second call inside one
|
|
14
|
+
* process and nothing across two. This one is keyed on everything that could change an answer —
|
|
15
|
+
* the task, the agent identity and version, the prompt version, and the pack hash, which covers
|
|
16
|
+
* the target and every neighbour by content hash — and lives under `.doc-bridge/enrich/cache/`,
|
|
17
|
+
* so an unchanged repository makes zero agent calls and a one-document change re-runs only the
|
|
18
|
+
* packs whose hash moved. An empty answer is cached like any other: "the agent had nothing to
|
|
19
|
+
* say about this" is an answer, and asking again costs the same.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type EnrichmentCacheKeyInput = {
|
|
23
|
+
readonly task: EnrichmentTask
|
|
24
|
+
readonly agentId: string
|
|
25
|
+
readonly agentVersion: string
|
|
26
|
+
readonly promptVersion: string
|
|
27
|
+
readonly packHash: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const enrichmentCacheKey = (input: EnrichmentCacheKeyInput): string =>
|
|
31
|
+
sha256NormalizedV1({ task: input.task, agentId: input.agentId, agentVersion: input.agentVersion, promptVersion: input.promptVersion, packHash: input.packHash })
|
|
32
|
+
|
|
33
|
+
const CacheEntrySchema = z
|
|
34
|
+
.object({
|
|
35
|
+
type: z.literal('enrichment-cache-entry'),
|
|
36
|
+
key: z.string().regex(/^[a-f0-9]{64}$/),
|
|
37
|
+
task: z.enum(['curate', 'review', 'adjudicate']),
|
|
38
|
+
agentId: z.string().min(1).max(256),
|
|
39
|
+
agentVersion: z.string().min(1).max(64),
|
|
40
|
+
promptVersion: z.string().min(1).max(64),
|
|
41
|
+
packHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
42
|
+
proposals: z.array(z.unknown()).max(1_024),
|
|
43
|
+
})
|
|
44
|
+
.strict()
|
|
45
|
+
export type EnrichmentCacheEntry = z.infer<typeof CacheEntrySchema>
|
|
46
|
+
|
|
47
|
+
export type EnrichmentCache = {
|
|
48
|
+
readonly read: (input: EnrichmentCacheKeyInput) => readonly unknown[] | undefined
|
|
49
|
+
readonly write: (input: EnrichmentCacheKeyInput, proposals: readonly unknown[]) => string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A file-backed cache under the enrich directory. A read never writes; a corrupt entry is a miss. */
|
|
53
|
+
export const createEnrichmentCache = (root: string): EnrichmentCache => {
|
|
54
|
+
const dir = enrichmentCacheDir(root)
|
|
55
|
+
const pathFor = (key: string): string => join(dir, `${key}.json`)
|
|
56
|
+
return {
|
|
57
|
+
read: (input) => {
|
|
58
|
+
const key = enrichmentCacheKey(input)
|
|
59
|
+
const path = pathFor(key)
|
|
60
|
+
if (!existsSync(path)) return undefined
|
|
61
|
+
try {
|
|
62
|
+
const entry = CacheEntrySchema.parse(JSON.parse(readFileSync(path, 'utf8')) as unknown)
|
|
63
|
+
// A file renamed into place under another key is not that key's answer.
|
|
64
|
+
return entry.key === key && entry.packHash === input.packHash && entry.agentId === input.agentId ? entry.proposals : undefined
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
write: (input, proposals) => {
|
|
70
|
+
const key = enrichmentCacheKey(input)
|
|
71
|
+
mkdirSync(dir, { recursive: true })
|
|
72
|
+
const entry: EnrichmentCacheEntry = { type: 'enrichment-cache-entry', key, ...input, proposals: [...proposals] }
|
|
73
|
+
const path = pathFor(key)
|
|
74
|
+
const temporary = `${path}.tmp-${process.pid}`
|
|
75
|
+
writeFileSync(temporary, `${JSON.stringify(entry, null, 2)}\n`, 'utf8')
|
|
76
|
+
renameSync(temporary, path)
|
|
77
|
+
return path
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** An in-memory cache with the same contract, for callers that must not touch disk. */
|
|
83
|
+
export const createMemoryEnrichmentCache = (): EnrichmentCache => {
|
|
84
|
+
const entries = new Map<string, readonly unknown[]>()
|
|
85
|
+
return {
|
|
86
|
+
read: (input) => entries.get(enrichmentCacheKey(input)),
|
|
87
|
+
write: (input, proposals) => {
|
|
88
|
+
const key = enrichmentCacheKey(input)
|
|
89
|
+
entries.set(key, [...proposals])
|
|
90
|
+
return key
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|