@agentskit/doc-bridge 1.6.4 → 1.7.45

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 (64) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/CONTRIBUTING.md +6 -4
  3. package/action.yml +1 -1
  4. package/dist/cli/program.js +1139 -294
  5. package/dist/cli/program.js.map +1 -1
  6. package/dist/config/index.d.ts +1 -1
  7. package/dist/config/index.js +43 -5
  8. package/dist/config/index.js.map +1 -1
  9. package/dist/index-BUL0q7s8.d.ts +660 -0
  10. package/dist/index.d.ts +817 -2134
  11. package/dist/index.js +1154 -244
  12. package/dist/index.js.map +1 -1
  13. package/docs/PRD-enterprise-hardening.md +288 -0
  14. package/docs/RELEASE.md +22 -8
  15. package/docs/adr/0001-enterprise-verification-contract.md +35 -0
  16. package/docs/agent-corpus/INDEX.md +2 -2
  17. package/docs/agent-corpus/chat.md +2 -2
  18. package/docs/agent-corpus/cli.md +2 -2
  19. package/docs/agent-corpus/conformance.md +2 -2
  20. package/docs/agent-corpus/doc-bridge.md +1 -1
  21. package/docs/agent-corpus/doctor.md +2 -2
  22. package/docs/agent-corpus/gates.md +2 -2
  23. package/docs/agent-corpus/mcp.md +2 -2
  24. package/docs/agent-corpus/memory.md +2 -2
  25. package/docs/agent-corpus/query.md +2 -2
  26. package/docs/knowledge-engine-runbook.md +30 -2
  27. package/docs/spec/analyzer-plugin-v1.md +24 -0
  28. package/docs/spec/benchmark-v1.md +36 -0
  29. package/docs/spec/config-v1.md +156 -0
  30. package/docs/validation-cycle-plan.md +255 -0
  31. package/docs/verification-harness.md +37 -4
  32. package/mcpb/manifest.json +1 -1
  33. package/package.json +68 -70
  34. package/scripts/check-ecosystem-upstream.mjs +3 -2
  35. package/scripts/report-visual-check.mjs +64 -12
  36. package/scripts/verification-harness.mjs +216 -14
  37. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  38. package/src/agents/registry-adapter.ts +31 -7
  39. package/src/cli/demo.ts +2 -2
  40. package/src/cli/program.ts +59 -16
  41. package/src/config/index.ts +2 -0
  42. package/src/config/load-config.ts +7 -1
  43. package/src/config/schema.ts +60 -2
  44. package/src/conformance/documentation-standard-v1.ts +14 -8
  45. package/src/discovery/documentation.ts +90 -23
  46. package/src/discovery/repository.ts +147 -19
  47. package/src/doctor/run-doctor.ts +2 -15
  48. package/src/federation/llms.ts +72 -20
  49. package/src/fixes/proposals.ts +4 -3
  50. package/src/index-builder/human-adapters/fumadocs.ts +1 -1
  51. package/src/index-builder/watch-index.ts +1 -1
  52. package/src/index.ts +29 -0
  53. package/src/lib/bounded-text.ts +15 -10
  54. package/src/metrics/benchmark.ts +176 -0
  55. package/src/plugins/contract.ts +89 -0
  56. package/src/reconciliation/reconcile.ts +181 -5
  57. package/src/report/html.ts +318 -88
  58. package/src/rules/engine.ts +15 -2
  59. package/src/safety/repository.ts +1 -1
  60. package/src/schemas/knowledge.ts +21 -3
  61. package/src/validate.ts +7 -1
  62. package/src/version.ts +1 -1
  63. package/src/workflow/engine.ts +65 -9
  64. package/dist/index-DudNuwI5.d.ts +0 -2060
@@ -10,22 +10,43 @@ import {
10
10
  readdirSync,
11
11
  renameSync,
12
12
  rmSync,
13
- statSync,
14
13
  writeFileSync,
15
14
  } from 'node:fs'
16
15
  import { dirname, join, relative, resolve, sep } from 'node:path'
17
16
 
18
- const VERSION = '1.1.0'
19
- const STATES = new Set(['PLANNED', 'VERIFYING', 'AWAITING_HUMAN_APPROVAL', 'AWAITING_AUTHORIZATION', 'COMPLETE', 'BLOCKED', 'FAILED'])
20
- const PROFILES = new Set(['strict', 'poc', 'custom'])
17
+ const VERSION = '1.4.0'
18
+ const STATES = new Set(['CLARIFYING', 'PLANNED', 'VERIFYING', 'AWAITING_HUMAN_APPROVAL', 'AWAITING_AUTHORIZATION', 'COMPLETE', 'BLOCKED', 'FAILED'])
19
+ const PROFILES = new Set(['default', 'strict', 'poc', 'custom', 'enterprise'])
20
+ const SURFACES = ['logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs']
21
+ const PROFILE_POLICIES = {
22
+ default: { requiresExplicitExemptions: false, requiresAllSurfaces: false, requiresMeasurement: false, requiresTracking: false },
23
+ strict: { requiresExplicitExemptions: false, requiresAllSurfaces: false, requiresMeasurement: false, requiresTracking: false },
24
+ poc: { requiresExplicitExemptions: true, requiresAllSurfaces: false, requiresMeasurement: false, requiresTracking: false },
25
+ custom: { requiresExplicitExemptions: true, requiresAllSurfaces: false, requiresMeasurement: false, requiresTracking: false },
26
+ enterprise: { requiresExplicitExemptions: false, requiresAllSurfaces: true, requiresMeasurement: true, requiresTracking: true },
27
+ }
21
28
  const CATEGORIES = new Set(['build', 'test', 'lint', 'logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs', 'custom'])
22
29
  const INTENTS = new Set(['ok', 'certo', 'certa', 'aprovado', 'aprovada', 'approve', 'approved', 'confirmo', 'confirmado', 'confirmed', 'yes'])
23
30
  const DEFAULT_CONFIG = '.codex/verification.json'
31
+ const LEGAL_TRANSITIONS = {
32
+ null: ['PLANNED'],
33
+ CLARIFYING: ['PLANNED', 'BLOCKED', 'FAILED'],
34
+ PLANNED: ['CLARIFYING', 'VERIFYING', 'BLOCKED', 'FAILED'],
35
+ VERIFYING: ['AWAITING_HUMAN_APPROVAL', 'AWAITING_AUTHORIZATION', 'COMPLETE', 'BLOCKED', 'FAILED'],
36
+ AWAITING_HUMAN_APPROVAL: ['AWAITING_AUTHORIZATION', 'COMPLETE', 'BLOCKED', 'FAILED'],
37
+ AWAITING_AUTHORIZATION: ['COMPLETE', 'BLOCKED', 'FAILED'],
38
+ BLOCKED: [],
39
+ FAILED: [],
40
+ COMPLETE: [],
41
+ }
24
42
 
25
43
  const fail = (message) => { throw new Error(message) }
26
44
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
27
45
  const now = () => new Date().toISOString()
28
46
  const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
47
+ const assertKnownKeys = (value, allowed, label) => {
48
+ for (const key of Object.keys(value ?? {})) if (!allowed.has(key)) fail(`${label}.${key} is not supported.`)
49
+ }
29
50
  const writeAtomic = (path, value) => {
30
51
  mkdirSync(dirname(path), { recursive: true })
31
52
  const temp = `${path}.tmp-${process.pid}`
@@ -58,6 +79,7 @@ const inside = (root, path) => {
58
79
  const surfaceRequired = (value, name) => {
59
80
  if (typeof value === 'boolean') return { required: value, reason: value ? undefined : `${name} is not part of this verification target.` }
60
81
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`surfaces.${name} must be a boolean or { required, reason }.`)
82
+ assertKnownKeys(value, new Set(['required', 'reason']), `surfaces.${name}`)
61
83
  if (typeof value.required !== 'boolean') fail(`surfaces.${name}.required must be boolean.`)
62
84
  if (!value.required && typeof value.reason !== 'string') fail(`surfaces.${name}.reason is required when the surface is not required.`)
63
85
  return { required: value.required, ...(value.reason ? { reason: value.reason } : {}) }
@@ -65,35 +87,91 @@ const surfaceRequired = (value, name) => {
65
87
 
66
88
  const validateConfig = (raw, configPath) => {
67
89
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail(`Invalid verification config: ${configPath}`)
90
+ assertKnownKeys(raw, new Set(['schemaVersion', 'project', 'root', 'profile', 'contract', 'surfaces', 'checks', 'exemptions', 'measurement', 'tracking', 'cleanup', 'overrides']), 'verification config')
68
91
  if (raw.schemaVersion !== 1) fail('verification config schemaVersion must be 1.')
69
92
  if (typeof raw.project !== 'string' || !raw.project) fail('verification config project is required.')
70
93
  if (raw.root !== undefined && typeof raw.root !== 'string') fail('verification config root must be a string.')
71
- if (!PROFILES.has(raw.profile)) fail(`verification config profile must be one of: ${[...PROFILES].join(', ')}.`)
94
+ const profile = raw.profile ?? 'default'
95
+ if (!PROFILES.has(profile)) fail(`verification config profile must be one of: ${[...PROFILES].join(', ')}.`)
96
+ const policy = PROFILE_POLICIES[profile]
97
+ if (raw.overrides !== undefined) {
98
+ if (!raw.overrides || typeof raw.overrides !== 'object' || Array.isArray(raw.overrides)) fail('overrides must be an object.')
99
+ assertKnownKeys(raw.overrides, new Set(['profile']), 'overrides')
100
+ if (raw.overrides.profile !== undefined) fail('overrides.profile is not supported; set profile explicitly.')
101
+ }
72
102
  if (!Array.isArray(raw.checks) || raw.checks.length === 0) fail('verification config requires at least one check.')
73
103
  const checks = raw.checks.map((check, index) => {
74
104
  if (!check || typeof check !== 'object' || Array.isArray(check)) fail(`checks[${index}] must be an object.`)
105
+ assertKnownKeys(check, new Set(['id', 'category', 'command', 'required', 'timeoutMs', 'execution', 'capabilities']), `checks[${index}]`)
75
106
  if (typeof check.id !== 'string' || !check.id) fail(`checks[${index}].id is required.`)
76
107
  if (typeof check.command !== 'string' || !check.command) fail(`checks[${index}].command is required.`)
77
108
  if (!CATEGORIES.has(check.category)) fail(`checks[${index}].category is invalid.`)
78
109
  if (check.required !== undefined && typeof check.required !== 'boolean') fail(`checks[${index}].required must be boolean.`)
79
110
  if (check.timeoutMs !== undefined && (!Number.isInteger(check.timeoutMs) || check.timeoutMs < 1)) fail(`checks[${index}].timeoutMs must be a positive integer.`)
80
111
  if (['endpoint', 'database', 'cli', 'mcp', 'ui'].includes(check.category) && check.execution !== 'real') fail(`checks[${index}] requires execution: "real".`)
112
+ if (check.capabilities !== undefined && (!Array.isArray(check.capabilities) || check.capabilities.some((item) => typeof item !== 'string' || !item.trim()))) fail(`checks[${index}].capabilities must contain non-empty strings.`)
113
+ if (check.category === 'ui') {
114
+ for (const capability of ['real-browser', 'screenshot']) if (!check.capabilities?.includes(capability)) fail(`checks[${index}] requires capability "${capability}".`)
115
+ }
81
116
  return { required: true, timeoutMs: 120_000, ...check }
82
117
  })
118
+ if (new Set(checks.map((check) => check.id)).size !== checks.length) fail('check ids must be unique.')
119
+ if (!raw.contract || typeof raw.contract !== 'object' || Array.isArray(raw.contract)) fail('verification contract is required.')
120
+ assertKnownKeys(raw.contract, new Set(['intent', 'outcomes']), 'contract')
121
+ if (typeof raw.contract.intent !== 'string' || !raw.contract.intent.trim()) fail('contract.intent is required.')
122
+ if (!Array.isArray(raw.contract.outcomes) || raw.contract.outcomes.length === 0) fail('contract.outcomes requires at least one outcome.')
123
+ const checkIds = new Set(checks.map((check) => check.id))
124
+ const outcomes = raw.contract.outcomes.map((outcome, index) => {
125
+ if (!outcome || typeof outcome !== 'object' || Array.isArray(outcome)) fail(`contract.outcomes[${index}] must be an object.`)
126
+ assertKnownKeys(outcome, new Set(['id', 'statement', 'checks']), `contract.outcomes[${index}]`)
127
+ if (typeof outcome.id !== 'string' || !outcome.id.trim()) fail(`contract.outcomes[${index}].id is required.`)
128
+ if (typeof outcome.statement !== 'string' || !outcome.statement.trim()) fail(`contract.outcomes[${index}].statement is required.`)
129
+ if (!Array.isArray(outcome.checks) || outcome.checks.length === 0) fail(`contract.outcomes[${index}].checks requires at least one check id.`)
130
+ if (outcome.checks.some((checkId) => typeof checkId !== 'string' || !checkIds.has(checkId))) fail(`contract.outcomes[${index}] references an unknown check.`)
131
+ if (outcome.checks.some((checkId) => !checks.find((check) => check.id === checkId)?.required)) fail(`contract.outcomes[${index}] references a non-required check.`)
132
+ return { id: outcome.id, statement: outcome.statement, checks: [...new Set(outcome.checks)] }
133
+ })
134
+ if (new Set(outcomes.map((outcome) => outcome.id)).size !== outcomes.length) fail('contract outcome ids must be unique.')
135
+ if (raw.surfaces !== undefined && (!raw.surfaces || typeof raw.surfaces !== 'object' || Array.isArray(raw.surfaces))) fail('surfaces must be an object.')
136
+ if (raw.surfaces) for (const name of Object.keys(raw.surfaces)) if (!SURFACES.includes(name)) fail(`surfaces.${name} is not supported.`)
83
137
  const surfaces = {}
84
- for (const name of ['logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs']) surfaces[name] = surfaceRequired(raw.surfaces?.[name] ?? (name === 'logic'), name)
138
+ for (const name of SURFACES) {
139
+ if (policy.requiresAllSurfaces && !Object.hasOwn(raw.surfaces ?? {}, name)) fail(`enterprise profile requires surfaces.${name} to be declared.`)
140
+ surfaces[name] = surfaceRequired(raw.surfaces?.[name] ?? (name === 'logic'), name)
141
+ }
85
142
  for (const [name, surface] of Object.entries(surfaces)) {
86
143
  const matching = checks.some((check) => check.category === name && check.required)
87
144
  if (surface.required && !matching) fail(`Required surface "${name}" has no required check.`)
88
145
  }
89
- if (raw.profile !== 'strict' && (!Array.isArray(raw.exemptions) || raw.exemptions.length === 0)) fail('Non-strict profiles require explicit exemptions.')
146
+ if (policy.requiresExplicitExemptions && (!Array.isArray(raw.exemptions) || raw.exemptions.length === 0)) fail('This profile requires explicit exemptions.')
90
147
  if (raw.exemptions && (!Array.isArray(raw.exemptions) || raw.exemptions.some((item) => typeof item !== 'string' || !item.trim()))) fail('exemptions must be non-empty strings.')
91
- const tracking = raw.tracking ?? { required: true, authorization: 'ask' }
148
+ const measurement = raw.measurement ?? { required: false }
149
+ if (!measurement || typeof measurement !== 'object' || Array.isArray(measurement)) fail('measurement must be an object.')
150
+ assertKnownKeys(measurement, new Set(['required', 'checkId', 'baseline']), 'measurement')
151
+ if (typeof measurement.required !== 'boolean') fail('measurement.required must be boolean.')
152
+ if (policy.requiresMeasurement && !measurement.required) fail('enterprise profile requires measurement.required to be true.')
153
+ if (measurement.required) {
154
+ if (typeof measurement.checkId !== 'string' || !measurement.checkId.trim()) fail('measurement.checkId is required when measurement is required.')
155
+ const measurementCheck = checks.find((check) => check.id === measurement.checkId)
156
+ if (!measurementCheck) fail(`measurement.checkId references an unknown check: ${measurement.checkId}.`)
157
+ if (!measurementCheck.required) fail('measurement.checkId must reference a required check.')
158
+ if (typeof measurement.baseline !== 'string' || !measurement.baseline.trim()) fail('measurement.baseline is required when measurement is required.')
159
+ }
160
+ const tracking = raw.tracking ?? (policy.requiresTracking
161
+ ? { required: true, authorization: 'ask' }
162
+ : { required: false, reason: 'tracking is not configured for this run.' })
163
+ assertKnownKeys(tracking, new Set(['required', 'authorization', 'target', 'reason']), 'tracking')
92
164
  if (typeof tracking.required !== 'boolean') fail('tracking.required must be boolean.')
165
+ if (policy.requiresTracking && !tracking.required) fail('enterprise profile requires tracking.required to be true.')
93
166
  if (tracking.required && tracking.authorization !== 'ask') fail('tracking.authorization must be "ask".')
94
167
  if (tracking.required && typeof tracking.target !== 'string') fail('tracking.target is required when tracking is required.')
95
168
  if (!tracking.required && typeof tracking.reason !== 'string') fail('tracking.reason is required when tracking is not required.')
96
- return { ...raw, checks, surfaces, tracking, configPath }
169
+ if (raw.cleanup !== undefined) {
170
+ if (!raw.cleanup || typeof raw.cleanup !== 'object' || Array.isArray(raw.cleanup)) fail('cleanup must be an object.')
171
+ assertKnownKeys(raw.cleanup, new Set(['roots']), 'cleanup')
172
+ if (raw.cleanup.roots !== undefined && (!Array.isArray(raw.cleanup.roots) || raw.cleanup.roots.some((root) => typeof root !== 'string' || !root.trim()))) fail('cleanup.roots must contain non-empty strings.')
173
+ }
174
+ return { ...raw, profile, checks, contract: { intent: raw.contract.intent.trim(), outcomes }, surfaces, tracking, measurement, configPath, profilePolicy: policy }
97
175
  }
98
176
 
99
177
  const sourceRevision = (root) => {
@@ -143,6 +221,53 @@ const commandResult = (root, check) => new Promise((resolveResult) => {
143
221
  })
144
222
  })
145
223
 
224
+ const validateUiEvidence = (root, run) => ({
225
+ ...run,
226
+ checks: run.checks.map((check) => {
227
+ if (check.category !== 'ui' || check.status === 'failed') return check
228
+ const evidence = check.verification
229
+ const failures = []
230
+ if (!evidence || evidence.capability !== 'real-browser') failures.push('UI evidence must declare capability "real-browser"')
231
+ if (!Array.isArray(evidence?.artifacts) || evidence.artifacts.length === 0) failures.push('UI evidence must include screenshot artifacts')
232
+ for (const artifact of evidence?.artifacts ?? []) {
233
+ if (artifact?.type !== 'screenshot' || typeof artifact.path !== 'string' || typeof artifact.sha256 !== 'string' || typeof artifact.viewport !== 'string') {
234
+ failures.push('Each UI artifact must include type=screenshot, path, sha256, and viewport')
235
+ continue
236
+ }
237
+ const path = resolve(root, artifact.path)
238
+ if (!inside(root, path) || !existsSync(path)) failures.push(`Screenshot artifact is missing or outside the project: ${artifact.path}`)
239
+ else if (createHash('sha256').update(readFileSync(path)).digest('hex') !== artifact.sha256) failures.push(`Screenshot hash mismatch: ${artifact.path}`)
240
+ }
241
+ const criterionIds = run.contract.outcomes.filter((outcome) => outcome.checks.includes(check.id)).map((outcome) => outcome.id)
242
+ if (!evidence?.criteria || typeof evidence.criteria !== 'object' || Array.isArray(evidence.criteria)) failures.push('UI evidence must include criterion-level results')
243
+ for (const id of criterionIds) if (evidence?.criteria?.[id]?.status !== 'passed') failures.push(`UI criterion did not pass: ${id}`)
244
+ if (!failures.length) return check
245
+ return { ...check, status: 'failed', verificationStatus: 'failed', verification: { ...(evidence ?? {}), status: 'failed', failures } }
246
+ }),
247
+ })
248
+
249
+ const validateMeasurementResult = (run, measurement) => {
250
+ if (!measurement.required) return run
251
+ const check = run.checks.find((item) => item.id === measurement.checkId)
252
+ const verification = check?.verification
253
+ const failures = []
254
+ if (check?.status !== 'passed') failures.push(`measurement check ${measurement.checkId} did not pass`)
255
+ if (!verification || typeof verification.metrics !== 'object' || Array.isArray(verification.metrics)) failures.push('measurement evidence must include an object named metrics')
256
+ if (typeof verification?.baselineHash !== 'string' || !verification.baselineHash) failures.push('measurement evidence must include baselineHash')
257
+ if (!Array.isArray(verification?.regressions)) failures.push('measurement evidence must include a regressions array')
258
+ else if (verification.regressions.length) failures.push(`measurement regressions detected: ${verification.regressions.join('; ')}`)
259
+ if (!failures.length) return run
260
+ return {
261
+ ...run,
262
+ checks: run.checks.map((item) => item.id === measurement.checkId ? {
263
+ ...item,
264
+ status: 'failed',
265
+ verificationStatus: 'failed',
266
+ verification: { status: 'failed', failures },
267
+ } : item),
268
+ }
269
+ }
270
+
146
271
  const stateDirFor = (root) => join(root, '.codex', 'verification')
147
272
  const runDirFor = (root, runId) => join(stateDirFor(root), 'runs', runId)
148
273
  const latestPathFor = (root) => join(stateDirFor(root), 'latest.json')
@@ -152,7 +277,23 @@ const saveRun = (root, run) => {
152
277
  writeAtomic(join(dir, 'run.json'), run)
153
278
  writeAtomic(latestPathFor(root), { runId: run.runId, state: run.state, path: relative(root, join(dir, 'run.json')), updatedAt: now() })
154
279
  }
155
- const transition = (run, state, reason) => ({ ...run, state, transitions: [...run.transitions, { from: run.state, to: state, at: now(), ...(reason ? { reason } : {}) }] })
280
+ const transition = (run, state, reason) => {
281
+ if (!STATES.has(state)) fail(`Unknown verification state: ${state}.`)
282
+ const allowed = LEGAL_TRANSITIONS[String(run.state)] ?? []
283
+ if (!allowed.includes(state)) fail(`Illegal verification transition ${run.state} -> ${state}.`)
284
+ return { ...run, state, transitions: [...run.transitions, { from: run.state, to: state, at: now(), ...(reason ? { reason } : {}) }] }
285
+ }
286
+
287
+ const attachEvidence = (run) => {
288
+ const evidenceReferences = run.checks.map((check) => ({
289
+ checkId: check.id,
290
+ status: check.status,
291
+ ...(check.verificationStatus ? { verificationStatus: check.verificationStatus } : {}),
292
+ evidenceHash: hash({ id: check.id, status: check.status, stdout: check.stdout, stderr: check.stderr, verification: check.verification }),
293
+ }))
294
+ const metrics = Object.fromEntries(run.checks.flatMap((check) => Object.entries(check.verification?.metrics ?? {})))
295
+ return { ...run, evidenceReferences, metrics, outputHash: hash({ checks: run.checks, outcomes: run.outcomes, evidenceReferences, metrics }) }
296
+ }
156
297
 
157
298
  const runVerification = async (root, config, runId) => {
158
299
  const source = sourceRevision(root)
@@ -173,12 +314,16 @@ const runVerification = async (root, config, runId) => {
173
314
  runId: id,
174
315
  project: config.project,
175
316
  profile: config.profile,
317
+ profilePolicy: config.profilePolicy,
176
318
  sourceRevision: source,
319
+ contractHash: hash(config.contract),
177
320
  inputHash,
178
321
  state: 'PLANNED',
179
322
  configPath: relative(root, config.configPath),
180
- checks: config.checks.map(({ id: checkId, category, command, required, timeoutMs }) => ({ id: checkId, category, command, required, timeoutMs, status: 'pending' })),
323
+ contract: config.contract,
324
+ checks: config.checks.map(({ id: checkId, category, command, required, timeoutMs, capabilities }) => ({ id: checkId, category, command, required, timeoutMs, ...(capabilities ? { capabilities } : {}), status: 'pending' })),
181
325
  surfaces: config.surfaces,
326
+ applicability: config.surfaces,
182
327
  tracking: config.tracking,
183
328
  exemptions: config.exemptions ?? [],
184
329
  transitions: [{ from: null, to: 'PLANNED', at: now() }],
@@ -191,8 +336,29 @@ const runVerification = async (root, config, runId) => {
191
336
  run = { ...run, checks: run.checks.map((item, itemIndex) => itemIndex === index ? { ...item, ...result } : item) }
192
337
  saveRun(root, run)
193
338
  }
339
+ run = validateUiEvidence(root, run)
340
+ run = {
341
+ ...run,
342
+ outcomes: run.contract.outcomes.map((outcome) => {
343
+ const checks = run.checks.filter((check) => outcome.checks.includes(check.id))
344
+ const status = checks.some((check) => check.status === 'failed') ? 'failed' : checks.some((check) => check.status === 'awaiting-human-approval') ? 'awaiting-human-approval' : 'passed'
345
+ return { ...outcome, status }
346
+ }),
347
+ }
348
+ run = validateMeasurementResult(run, config.measurement)
349
+ run = {
350
+ ...run,
351
+ outcomes: run.contract.outcomes.map((outcome) => {
352
+ const checks = run.checks.filter((check) => outcome.checks.includes(check.id))
353
+ const status = checks.some((check) => check.status === 'failed') ? 'failed' : checks.some((check) => check.status === 'awaiting-human-approval') ? 'awaiting-human-approval' : 'passed'
354
+ return { ...outcome, status }
355
+ }),
356
+ }
357
+ run = attachEvidence(run)
358
+ const failedOutcomes = run.outcomes.filter((outcome) => outcome.status === 'failed')
194
359
  const failed = run.checks.filter((check) => check.required && !['passed', 'awaiting-human-approval'].includes(check.status))
195
360
  if (failed.length) run = transition(run, 'BLOCKED', `Required checks failed: ${failed.map((check) => check.id).join(', ')}`)
361
+ else if (failedOutcomes.length) run = transition(run, 'BLOCKED', `Contract outcomes failed: ${failedOutcomes.map((outcome) => outcome.id).join(', ')}`)
196
362
  else if (config.surfaces.ui.required) run = transition(run, 'AWAITING_HUMAN_APPROVAL', 'Visual UI approval is required.')
197
363
  else if (config.tracking.required) run = transition(run, 'AWAITING_AUTHORIZATION', `Tracking authorization is required for ${config.tracking.target}.`)
198
364
  else run = transition(run, 'COMPLETE', 'All configured verification gates passed.')
@@ -211,13 +377,43 @@ const updateApproval = (root, run, type, rawIntent, by) => {
211
377
  if (run.state !== expectedState) fail(`Cannot record ${type} while run is ${run.state}.`)
212
378
  const approvedIntent = intent(rawIntent)
213
379
  const dir = runDirFor(root, run.runId)
214
- writeAtomic(join(dir, `${type}.json`), { type, runId: run.runId, runInputHash: run.inputHash, intent: approvedIntent, by: by ?? 'human', at: now() })
380
+ writeAtomic(join(dir, `${type}.json`), {
381
+ type,
382
+ runId: run.runId,
383
+ runInputHash: run.inputHash,
384
+ runSourceRevision: run.sourceRevision,
385
+ runContractHash: run.contractHash,
386
+ runOutputHash: run.outputHash,
387
+ intent: approvedIntent,
388
+ by: by ?? 'human',
389
+ at: now(),
390
+ })
391
+ if (type === 'human-approval') run = { ...run, outcomes: run.outcomes.map((outcome) => outcome.status === 'awaiting-human-approval' ? { ...outcome, status: 'passed' } : outcome) }
215
392
  if (type === 'human-approval' && run.state === 'AWAITING_HUMAN_APPROVAL') run = run.tracking.required ? transition(run, 'AWAITING_AUTHORIZATION', `Human approval recorded for ${run.runId}.`) : transition(run, 'COMPLETE', 'Human approval recorded and all gates passed.')
216
393
  if (type === 'tracking-authorization' && run.state === 'AWAITING_AUTHORIZATION') run = transition(run, 'COMPLETE', `Tracking authorization recorded for ${run.tracking.target}.`)
217
394
  saveRun(root, run)
218
395
  return run
219
396
  }
220
397
 
398
+ const replaceBaseline = (root, config, sourcePath, rawIntent, by) => {
399
+ if (!config.measurement.required) fail('Baseline replacement requires measurement.required to be true.')
400
+ if (!config.measurement.baseline) fail('Baseline replacement requires measurement.baseline.')
401
+ if (!by) fail('Baseline replacement requires --by.')
402
+ const approvedIntent = intent(rawIntent)
403
+ const target = resolve(root, config.measurement.baseline)
404
+ const source = resolve(root, sourcePath ?? '')
405
+ if (!inside(root, target) || !inside(root, source)) fail('Baseline source and target must be inside the project root.')
406
+ if (!existsSync(source)) fail(`Baseline source not found: ${sourcePath}`)
407
+ if (source === target) fail('Baseline source must differ from the configured baseline target.')
408
+ const value = readJson(source)
409
+ writeAtomic(target, value)
410
+ const auditPath = join(stateDirFor(root), 'baseline-audit.jsonl')
411
+ mkdirSync(dirname(auditPath), { recursive: true })
412
+ const entry = { action: 'replace-baseline', source: relative(root, source), target: relative(root, target), baselineHash: hash(value), intent: approvedIntent, by, at: now() }
413
+ appendFileSync(auditPath, `${JSON.stringify(entry)}\n`, 'utf8')
414
+ return { status: 'baseline-replaced', ...entry }
415
+ }
416
+
221
417
  const clean = (root, config, periodic) => {
222
418
  const manifestPath = join(stateDirFor(root), 'owned-artifacts.json')
223
419
  if (!existsSync(manifestPath)) return { removed: [], skipped: [], periodic, message: 'No task-owned artifacts are registered.' }
@@ -240,13 +436,19 @@ const main = async (argv) => {
240
436
  const { flags, values, positional } = parseArgs(argv)
241
437
  const command = positional[0] ?? 'help'
242
438
  if (flags.has('help') || command === 'help') {
243
- process.stdout.write('ak-verify run|status|approve <run-id> <intent>|authorize <run-id> <intent>|clean [--periodic] [--config <path>] [--json]\n')
439
+ process.stdout.write('ak-verify run|status|approve <run-id> <intent>|authorize <run-id> <intent>|baseline replace <source> <intent> [--by <actor>]|clean [--periodic] [--config <path>] [--json]\n')
244
440
  return 0
245
441
  }
246
442
  const configPath = resolve(values.get('config') ?? DEFAULT_CONFIG)
247
443
  if (!existsSync(configPath)) fail(`Verification contract not found: ${configPath}`)
248
444
  const root = projectRoot(configPath, readJson(configPath))
249
445
  const config = validateConfig(readJson(configPath), configPath)
446
+ if (command === 'baseline') {
447
+ if (positional[1] !== 'replace') fail('Use: baseline replace <source> <intent> --by <actor>.')
448
+ const result = replaceBaseline(root, config, positional[2], positional[3], values.get('by'))
449
+ output(result, flags.has('json'))
450
+ return 0
451
+ }
250
452
  if (command === 'run') {
251
453
  const run = await runVerification(root, config, values.get('run-id'))
252
454
  output(run, flags.has('json'))
@@ -277,4 +479,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
277
479
  catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 2 }
278
480
  }
279
481
 
280
- export { INTENTS, main, validateConfig }
482
+ export { INTENTS, LEGAL_TRANSITIONS, PROFILES, STATES, main, transition, validateConfig }
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from 'node:child_process'
4
4
  import { isAbsolute } from 'node:path'
5
5
 
6
- const VERSION = '1.6.4'
6
+ const VERSION = '1.7.45'
7
7
  const kinds = new Set(['package', 'ownership'])
8
8
  const args = process.argv.slice(2)
9
9
  const id = args[0]
@@ -5,7 +5,7 @@ import { z } from 'zod'
5
5
 
6
6
  import type { DocBridgeConfigV1 } from '../config/schema.js'
7
7
  import { AgentProposalV1Schema, type AgentProposalV1, type DiscoverySnapshotV1, type ReconciliationReportV1 } from '../schemas/knowledge.js'
8
- import { contentHashForArtifactV1 } from '../index-builder/content-hash.js'
8
+ import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
9
9
  import { containedPath, redactValue } from '../safety/repository.js'
10
10
 
11
11
  export const DEFAULT_REGISTRY_AGENT_ID = 'ecosystem-doc-bridge-corpus-scanner'
@@ -27,6 +27,7 @@ export type RegistryAgentContext = {
27
27
  readonly capabilities: readonly ['snapshot.read', 'evidence.read', 'proposal.write']
28
28
  readonly network: false
29
29
  readonly shell: false
30
+ readonly deterministic: boolean
30
31
  }
31
32
 
32
33
  export type RegistryAgentRunner = (context: RegistryAgentContext) => Promise<unknown> | unknown
@@ -73,15 +74,38 @@ export const loadRegistryAgentMetadata = (root: string, config: DocBridgeConfigV
73
74
  export const createRegistryAgentAdapter = (root: string, config: DocBridgeConfigV1, runner: RegistryAgentRunner): RegistryAgentAdapter => {
74
75
  if (!registryConfig(config)?.enabled) throw new Error('Registry agents are disabled. Set intelligence.registry.enabled: true to run an assisted workflow.')
75
76
  const metadata = loadRegistryAgentMetadata(resolve(root), config)
77
+ const settings = registryConfig(config) ?? {}
78
+ const timeoutMs = settings.timeoutMs ?? 120_000
79
+ const maxResponseBytes = settings.maxResponseBytes ?? 256_000
80
+ const maxTokens = settings.maxTokens ?? Math.ceil(maxResponseBytes / 4)
81
+ const maxConcurrency = settings.maxConcurrency ?? 1
82
+ let active = 0
83
+ const deterministicCache = new Map<string, AgentProposalV1>()
76
84
  return {
77
85
  metadata,
78
86
  run: async (snapshot, report, evidence = report.diagnostics.flatMap((diagnostic) => diagnostic.evidence).slice(0, 64)) => {
79
- const context = deepFreeze({ snapshot: redactValue(snapshot), report: redactValue(report), evidence: redactValue(evidence), capabilities: ['snapshot.read', 'evidence.read', 'proposal.write'] as const, network: false as const, shell: false as const }) as RegistryAgentContext
80
- const proposal = AgentProposalV1Schema.parse(await runner(context))
81
- if (proposal.contentHash !== contentHashForArtifactV1(proposal)) throw new Error('Registry agent proposal contentHash does not match its canonical contents.')
82
- if (proposal.baseSnapshotHash !== snapshot.contentHash || proposal.baseReportHash !== report.contentHash) throw new Error('Registry agent proposal is not based on the supplied snapshot/report hashes.')
83
- if (proposal.origin.kind !== 'registry-agent' || proposal.origin.id !== metadata.id) throw new Error(`Registry agent proposal origin must be ${metadata.id}.`)
84
- return proposal
87
+ if (active >= maxConcurrency) throw new Error(`Registry agent concurrency limit ${maxConcurrency} exceeded.`)
88
+ const cacheKey = sha256NormalizedV1({ snapshotHash: snapshot.contentHash, reportHash: report.contentHash, agentId: metadata.id, agentVersion: metadata.version, evidence })
89
+ if (settings.deterministic && deterministicCache.has(cacheKey)) return deterministicCache.get(cacheKey) as AgentProposalV1
90
+ active += 1
91
+ let timer: ReturnType<typeof setTimeout> | undefined
92
+ try {
93
+ const context = deepFreeze({ snapshot: redactValue(snapshot), report: redactValue(report), evidence: redactValue(evidence), capabilities: ['snapshot.read', 'evidence.read', 'proposal.write'] as const, network: false as const, shell: false as const, deterministic: settings.deterministic ?? true }) as RegistryAgentContext
94
+ const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`Registry agent timed out after ${timeoutMs}ms.`)), timeoutMs) })
95
+ const raw = await Promise.race([Promise.resolve(runner(context)), timeout])
96
+ const responseBytes = Buffer.byteLength(JSON.stringify(raw))
97
+ if (responseBytes > maxResponseBytes) throw new Error(`Registry agent response limit ${maxResponseBytes} bytes exceeded.`)
98
+ if (Math.ceil(responseBytes / 4) > maxTokens) throw new Error(`Registry agent token budget ${maxTokens} exceeded.`)
99
+ const proposal = AgentProposalV1Schema.parse(raw)
100
+ if (proposal.contentHash !== contentHashForArtifactV1(proposal)) throw new Error('Registry agent proposal contentHash does not match its canonical contents.')
101
+ if (proposal.baseSnapshotHash !== snapshot.contentHash || proposal.baseReportHash !== report.contentHash) throw new Error('Registry agent proposal is not based on the supplied snapshot/report hashes.')
102
+ if (proposal.origin.kind !== 'registry-agent' || proposal.origin.id !== metadata.id) throw new Error(`Registry agent proposal origin must be ${metadata.id}.`)
103
+ if (settings.deterministic) deterministicCache.set(cacheKey, proposal)
104
+ return proposal
105
+ } finally {
106
+ if (timer) clearTimeout(timer)
107
+ active -= 1
108
+ }
85
109
  },
86
110
  }
87
111
  }
package/src/cli/demo.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
1
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { dirname, join, resolve } from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
@@ -140,4 +140,4 @@ export const withDemoWorkspace = (
140
140
  } finally {
141
141
  rmSync(dir, { recursive: true, force: true })
142
142
  }
143
- }
143
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
2
2
  import { dirname, relative, resolve } from 'node:path'
3
3
  import { createInterface } from 'node:readline/promises'
4
4
 
@@ -25,7 +25,7 @@ import { ingestMemoryCandidates } from '../memory/ingest.js'
25
25
  import { classifyMemoryCandidates, draftMemoryPromotion } from '../memory/pipeline.js'
26
26
  import { promoteMemoryToGithubPr } from '../memory/github-pr.js'
27
27
  import { watchDocBridgeIndex } from '../index-builder/watch-index.js'
28
- import { loadWorkflowManifest, loadWorkflowStepOutput, runWorkflow, type WorkflowExecutionResult } from '../workflow/engine.js'
28
+ import { loadWorkflowStepOutput, runWorkflow, type WorkflowExecutionResult } from '../workflow/engine.js'
29
29
  import {
30
30
  formatDoctorBadgeJson,
31
31
  formatDoctorBadgeMarkdown,
@@ -47,6 +47,7 @@ import { sha256NormalizedV1 } from '../index-builder/content-hash.js'
47
47
  import { applyFixProposal, approveFixProposal, createArtifactNormalizationProposal, createMarkdownLinkFixProposal } from '../fixes/proposals.js'
48
48
  import { createRegistryAgentAdapter, loadRegistryAgentRunner, persistRegistryAgentProposal } from '../agents/registry-adapter.js'
49
49
  import { renderOfflineReportArtifact } from '../report/html.js'
50
+ import { benchmarkFixture, formatBenchmarkText, measureBenchmark } from '../metrics/benchmark.js'
50
51
  import { PACKAGE_VERSION } from '../version.js'
51
52
 
52
53
  type Command =
@@ -60,6 +61,7 @@ type Command =
60
61
  | 'playbook'
61
62
  | 'registry'
62
63
  | 'discover'
64
+ | 'benchmark'
63
65
  | 'scan'
64
66
  | 'reconcile'
65
67
  | 'check'
@@ -89,6 +91,7 @@ Core (no API key):
89
91
  ak-docs doctor [--text] [--badge] [--write-badge]
90
92
  ak-docs index [--watch]
91
93
  ak-docs discover [--text|--json]
94
+ ak-docs benchmark <fixture.json> <observation.json> [--text|--json]
92
95
  ak-docs scan | reconcile | check | map [--text|--json] [--html] [--report-threshold <bytes>]
93
96
  ak-docs fix propose links|normalize <artifact> [--output <file>]
94
97
  ak-docs fix approve|apply <proposal.json> [--by <name>]
@@ -163,6 +166,7 @@ const parseArgs = (argv: readonly string[]) => {
163
166
  else if (positional[0] === 'playbook') command = 'playbook'
164
167
  else if (positional[0] === 'registry') command = 'registry'
165
168
  else if (positional[0] === 'discover') command = 'discover'
169
+ else if (positional[0] === 'benchmark') command = 'benchmark'
166
170
  else if (positional[0] === 'scan') command = 'scan'
167
171
  else if (positional[0] === 'reconcile') command = 'reconcile'
168
172
  else if (positional[0] === 'check') command = 'check'
@@ -463,20 +467,24 @@ const workflowOptions = (
463
467
  sourceRevision: string,
464
468
  stage: 'collect' | 'normalize' | 'reconcile' | 'evaluate' | 'report',
465
469
  handlers: Parameters<typeof runWorkflow>[0]['handlers'],
470
+ versions?: Pick<Parameters<typeof runWorkflow>[0], 'pipelineVersion' | 'analyzerVersions'>,
466
471
  ): Parameters<typeof runWorkflow>[0] => ({
467
472
  root,
468
473
  ...(config.workflow?.stateDir ? { stateDir: config.workflow.stateDir } : {}),
469
474
  sourceRevision,
470
475
  configurationHash: sha256NormalizedV1(config),
471
- toolVersion: '1.0.1',
476
+ toolVersion: PACKAGE_VERSION,
477
+ ...(versions?.pipelineVersion ? { pipelineVersion: versions.pipelineVersion } : {}),
478
+ ...(versions?.analyzerVersions ? { analyzerVersions: versions.analyzerVersions } : {}),
472
479
  stage,
473
480
  handlers,
474
481
  })
475
482
 
476
483
  const scanWorkflow = (root: string, config: DocBridgeConfigV1): WorkflowExecutionResult => {
477
484
  const discovered = discoverRepository({ root, config })
478
- runWorkflow(workflowOptions(root, config, discovered.sourceRevision, 'collect', { collect: () => discovered }))
479
- return runWorkflow(workflowOptions(root, config, discovered.sourceRevision, 'normalize', { normalize: ({ input }) => input }))
485
+ const versions = { pipelineVersion: discovered.pipelineVersion, analyzerVersions: discovered.analyzerVersions }
486
+ runWorkflow(workflowOptions(root, config, discovered.sourceRevision, 'collect', { collect: () => discovered }, versions))
487
+ return runWorkflow(workflowOptions(root, config, discovered.sourceRevision, 'normalize', { normalize: ({ input }) => input }, versions))
480
488
  }
481
489
 
482
490
  const documentationInputs = (root: string, snapshot: DiscoverySnapshotV1) => snapshot.entities
@@ -486,18 +494,24 @@ const documentationInputs = (root: string, snapshot: DiscoverySnapshotV1) => sna
486
494
  const reconcileWorkflow = (root: string, config: DocBridgeConfigV1): WorkflowExecutionResult => {
487
495
  const scanned = scanWorkflow(root, config)
488
496
  const snapshot = parseDiscoverySnapshot(loadWorkflowStepOutput(scanned.stateDir, 'normalize'))
489
- const declared = applyDocumentationDeclarations(snapshot, documentationInputs(root, snapshot)).snapshot
490
- const report = reconcileKnowledge(snapshot, declared, config.reconciliation?.requiredRelationKinds === undefined
491
- ? {}
492
- : { requiredRelationKinds: config.reconciliation.requiredRelationKinds })
493
- return runWorkflow(workflowOptions(root, config, snapshot.sourceRevision, 'reconcile', { reconcile: () => report }))
497
+ const declared = applyDocumentationDeclarations(snapshot, documentationInputs(root, snapshot), {
498
+ agentRoot: config.corpus.agent.root,
499
+ }).snapshot
500
+ const report = reconcileKnowledge(snapshot, declared, {
501
+ ...(config.reconciliation?.scope === undefined ? {} : { scope: config.reconciliation.scope }),
502
+ ...(config.reconciliation?.requiredRelationKinds === undefined ? {} : { requiredRelationKinds: config.reconciliation.requiredRelationKinds }),
503
+ ...(config.reconciliation?.requiredRelationTargets === undefined ? {} : { requiredRelationTargets: config.reconciliation.requiredRelationTargets }),
504
+ ...(config.reconciliation?.includeOrphanedDocuments === undefined ? {} : { includeOrphanedDocuments: config.reconciliation.includeOrphanedDocuments }),
505
+ })
506
+ return runWorkflow(workflowOptions(root, config, snapshot.sourceRevision, 'reconcile', { reconcile: () => report }, { pipelineVersion: snapshot.pipelineVersion, analyzerVersions: snapshot.analyzerVersions }))
494
507
  }
495
508
 
496
509
  const checkWorkflow = (root: string, config: DocBridgeConfigV1): WorkflowExecutionResult => {
497
510
  const reconciled = reconcileWorkflow(root, config)
498
511
  const report = parseReconciliationReport(loadWorkflowStepOutput(reconciled.stateDir, 'reconcile'))
499
- const evaluated = runWorkflow(workflowOptions(root, config, report.sourceRevision, 'evaluate', { evaluate: () => evaluateRules(report, { ...(config.rules ? { config: config.rules } : {}) }) }))
500
- return runWorkflow(workflowOptions(root, config, report.sourceRevision, 'report', { report: ({ input }) => input }))
512
+ const versions = { pipelineVersion: report.pipelineVersion, analyzerVersions: report.analyzerVersions }
513
+ runWorkflow(workflowOptions(root, config, report.sourceRevision, 'evaluate', { evaluate: () => evaluateRules(report, { ...(config.rules ? { config: config.rules } : {}) }) }, versions))
514
+ return runWorkflow(workflowOptions(root, config, report.sourceRevision, 'report', { report: ({ input }) => input }, versions))
501
515
  }
502
516
 
503
517
  const workflowOutput = (result: WorkflowExecutionResult): Record<string, unknown> => {
@@ -593,7 +607,7 @@ const runWorkflowCommand = (
593
607
  const thresholdValue = optionValues(argv, '--report-threshold')[0]
594
608
  const thresholdBytes = thresholdValue === undefined ? undefined : Number(thresholdValue)
595
609
  if (thresholdBytes !== undefined && (!Number.isSafeInteger(thresholdBytes) || thresholdBytes < 1)) throw new Error('--report-threshold must be a positive integer.')
596
- const artifact = renderOfflineReportArtifact({ snapshot, report }, { ...(thresholdBytes === undefined ? {} : { thresholdBytes }) })
610
+ const artifact = renderOfflineReportArtifact({ snapshot, report }, { ...(config.report?.privacy ? { privacy: config.report.privacy } : {}), ...(thresholdBytes === undefined ? {} : { thresholdBytes }) })
597
611
  output.htmlPath = writeReportArtifact(htmlPath, artifact)
598
612
  output.htmlMode = artifact.mode
599
613
  }
@@ -707,10 +721,19 @@ const runSuggestCommand = async (flags: ReadonlySet<string>, configPath: string
707
721
  }
708
722
 
709
723
  const writeIfMissing = (path: string, contents: string): boolean => {
710
- if (existsSync(path)) return false
711
724
  mkdirSync(dirname(path), { recursive: true })
712
- writeFileSync(path, contents, 'utf8')
713
- return true
725
+ try {
726
+ const fd = openSync(path, 'wx')
727
+ try {
728
+ writeFileSync(fd, contents, 'utf8')
729
+ return true
730
+ } finally {
731
+ closeSync(fd)
732
+ }
733
+ } catch (error) {
734
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false
735
+ throw error
736
+ }
714
737
  }
715
738
 
716
739
  const demoOwnership = {
@@ -947,6 +970,26 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
947
970
  }
948
971
  }
949
972
 
973
+ if (command === 'benchmark') {
974
+ const fixturePath = positional[1]
975
+ const observationPath = positional[2]
976
+ if (!fixturePath || !observationPath) {
977
+ process.stderr.write('Usage: ak-docs benchmark <fixture.json> <observation.json> [--text|--json]\n')
978
+ return 1
979
+ }
980
+ try {
981
+ const fixture = benchmarkFixture(JSON.parse(readFileSync(resolve(fixturePath), 'utf8')) as unknown)
982
+ const observation = JSON.parse(readFileSync(resolve(observationPath), 'utf8')) as Parameters<typeof measureBenchmark>[0]
983
+ const result = measureBenchmark(observation, fixture)
984
+ if (flags.has('--text')) writeLines([formatBenchmarkText(result)])
985
+ else writeJson(result)
986
+ return result.regressions.length ? 1 : 0
987
+ } catch (error) {
988
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
989
+ return 2
990
+ }
991
+ }
992
+
950
993
  if (command === 'scan' || command === 'reconcile' || command === 'check' || command === 'map') {
951
994
  return runWorkflowCommand(command, flags, configPath, argv)
952
995
  }
@@ -15,9 +15,11 @@ export {
15
15
  DocumentationStandardV1ConfigSchema,
16
16
  EcosystemContractEvidenceSchema,
17
17
  ConformanceConfigSchema,
18
+ ReportConfigSchema,
18
19
  type DocBridgeConfigV1,
19
20
  type AgentCorpusConfig,
20
21
  type DocumentationStandardRuleId,
21
22
  type DocumentationStandardV1Config,
22
23
  type ReconciliationConfig,
24
+ type ReportConfig,
23
25
  } from './schema.js'
@@ -79,7 +79,13 @@ const parseConfig = (input: unknown): DocBridgeConfigV1 => {
79
79
  if (result.success) return result.data
80
80
  throw new Error(
81
81
  `Invalid doc-bridge config:\n${result.error.issues.map((issue) =>
82
- ` - ${issue.path.join('.') || '(root)'}: ${issue.message}`,
82
+ ` - ${issue.path.join('.') || '(root)'}: ${
83
+ issue.code === 'invalid_type' && issue.message.endsWith('received undefined')
84
+ ? 'Required'
85
+ : issue.code === 'invalid_value' && 'values' in issue
86
+ ? 'Invalid enum value'
87
+ : issue.message
88
+ }`,
83
89
  ).join('\n')}`,
84
90
  )
85
91
  }