@agentskit/doc-bridge 1.6.3 → 1.7.44

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 (38) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/action.yml +1 -1
  3. package/dist/cli/program.js +793 -137
  4. package/dist/cli/program.js.map +1 -1
  5. package/dist/config/index.d.ts +1 -1
  6. package/dist/config/index.js +38 -2
  7. package/dist/config/index.js.map +1 -1
  8. package/dist/{index-DudNuwI5.d.ts → index-C2PCQSrB.d.ts} +216 -25
  9. package/dist/index.d.ts +837 -67
  10. package/dist/index.js +858 -127
  11. package/dist/index.js.map +1 -1
  12. package/docs/PRD-enterprise-hardening.md +288 -0
  13. package/docs/adr/0001-enterprise-verification-contract.md +35 -0
  14. package/docs/knowledge-engine-runbook.md +18 -2
  15. package/docs/spec/analyzer-plugin-v1.md +24 -0
  16. package/docs/spec/benchmark-v1.md +30 -0
  17. package/docs/spec/config-v1.md +111 -0
  18. package/docs/validation-cycle-plan.md +236 -0
  19. package/docs/verification-harness.md +33 -4
  20. package/mcpb/manifest.json +1 -1
  21. package/package.json +1 -1
  22. package/scripts/report-visual-check.mjs +63 -17
  23. package/scripts/verification-harness.mjs +216 -13
  24. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  25. package/src/agents/registry-adapter.ts +31 -7
  26. package/src/cli/program.ts +44 -11
  27. package/src/config/index.ts +2 -0
  28. package/src/config/schema.ts +56 -0
  29. package/src/discovery/documentation.ts +46 -5
  30. package/src/discovery/repository.ts +95 -16
  31. package/src/index.ts +29 -0
  32. package/src/metrics/benchmark.ts +176 -0
  33. package/src/plugins/contract.ts +89 -0
  34. package/src/reconciliation/reconcile.ts +137 -3
  35. package/src/report/html.ts +302 -78
  36. package/src/schemas/knowledge.ts +16 -1
  37. package/src/version.ts +1 -1
  38. package/src/workflow/engine.ts +65 -9
@@ -15,17 +15,39 @@ import {
15
15
  } from 'node:fs'
16
16
  import { dirname, join, relative, resolve, sep } from 'node:path'
17
17
 
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'])
18
+ const VERSION = '1.4.0'
19
+ const STATES = new Set(['CLARIFYING', 'PLANNED', 'VERIFYING', 'AWAITING_HUMAN_APPROVAL', 'AWAITING_AUTHORIZATION', 'COMPLETE', 'BLOCKED', 'FAILED'])
20
+ const PROFILES = new Set(['default', 'strict', 'poc', 'custom', 'enterprise'])
21
+ const SURFACES = ['logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs']
22
+ const PROFILE_POLICIES = {
23
+ default: { requiresExplicitExemptions: false, requiresAllSurfaces: false, requiresMeasurement: false, requiresTracking: false },
24
+ strict: { requiresExplicitExemptions: false, requiresAllSurfaces: false, requiresMeasurement: false, requiresTracking: false },
25
+ poc: { requiresExplicitExemptions: true, requiresAllSurfaces: false, requiresMeasurement: false, requiresTracking: false },
26
+ custom: { requiresExplicitExemptions: true, requiresAllSurfaces: false, requiresMeasurement: false, requiresTracking: false },
27
+ enterprise: { requiresExplicitExemptions: false, requiresAllSurfaces: true, requiresMeasurement: true, requiresTracking: true },
28
+ }
21
29
  const CATEGORIES = new Set(['build', 'test', 'lint', 'logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs', 'custom'])
22
30
  const INTENTS = new Set(['ok', 'certo', 'certa', 'aprovado', 'aprovada', 'approve', 'approved', 'confirmo', 'confirmado', 'confirmed', 'yes'])
23
31
  const DEFAULT_CONFIG = '.codex/verification.json'
32
+ const LEGAL_TRANSITIONS = {
33
+ null: ['PLANNED'],
34
+ CLARIFYING: ['PLANNED', 'BLOCKED', 'FAILED'],
35
+ PLANNED: ['CLARIFYING', 'VERIFYING', 'BLOCKED', 'FAILED'],
36
+ VERIFYING: ['AWAITING_HUMAN_APPROVAL', 'AWAITING_AUTHORIZATION', 'COMPLETE', 'BLOCKED', 'FAILED'],
37
+ AWAITING_HUMAN_APPROVAL: ['AWAITING_AUTHORIZATION', 'COMPLETE', 'BLOCKED', 'FAILED'],
38
+ AWAITING_AUTHORIZATION: ['COMPLETE', 'BLOCKED', 'FAILED'],
39
+ BLOCKED: [],
40
+ FAILED: [],
41
+ COMPLETE: [],
42
+ }
24
43
 
25
44
  const fail = (message) => { throw new Error(message) }
26
45
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
27
46
  const now = () => new Date().toISOString()
28
47
  const readJson = (path) => JSON.parse(readFileSync(path, 'utf8'))
48
+ const assertKnownKeys = (value, allowed, label) => {
49
+ for (const key of Object.keys(value ?? {})) if (!allowed.has(key)) fail(`${label}.${key} is not supported.`)
50
+ }
29
51
  const writeAtomic = (path, value) => {
30
52
  mkdirSync(dirname(path), { recursive: true })
31
53
  const temp = `${path}.tmp-${process.pid}`
@@ -58,6 +80,7 @@ const inside = (root, path) => {
58
80
  const surfaceRequired = (value, name) => {
59
81
  if (typeof value === 'boolean') return { required: value, reason: value ? undefined : `${name} is not part of this verification target.` }
60
82
  if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`surfaces.${name} must be a boolean or { required, reason }.`)
83
+ assertKnownKeys(value, new Set(['required', 'reason']), `surfaces.${name}`)
61
84
  if (typeof value.required !== 'boolean') fail(`surfaces.${name}.required must be boolean.`)
62
85
  if (!value.required && typeof value.reason !== 'string') fail(`surfaces.${name}.reason is required when the surface is not required.`)
63
86
  return { required: value.required, ...(value.reason ? { reason: value.reason } : {}) }
@@ -65,35 +88,91 @@ const surfaceRequired = (value, name) => {
65
88
 
66
89
  const validateConfig = (raw, configPath) => {
67
90
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail(`Invalid verification config: ${configPath}`)
91
+ assertKnownKeys(raw, new Set(['schemaVersion', 'project', 'root', 'profile', 'contract', 'surfaces', 'checks', 'exemptions', 'measurement', 'tracking', 'cleanup', 'overrides']), 'verification config')
68
92
  if (raw.schemaVersion !== 1) fail('verification config schemaVersion must be 1.')
69
93
  if (typeof raw.project !== 'string' || !raw.project) fail('verification config project is required.')
70
94
  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(', ')}.`)
95
+ const profile = raw.profile ?? 'default'
96
+ if (!PROFILES.has(profile)) fail(`verification config profile must be one of: ${[...PROFILES].join(', ')}.`)
97
+ const policy = PROFILE_POLICIES[profile]
98
+ if (raw.overrides !== undefined) {
99
+ if (!raw.overrides || typeof raw.overrides !== 'object' || Array.isArray(raw.overrides)) fail('overrides must be an object.')
100
+ assertKnownKeys(raw.overrides, new Set(['profile']), 'overrides')
101
+ if (raw.overrides.profile !== undefined) fail('overrides.profile is not supported; set profile explicitly.')
102
+ }
72
103
  if (!Array.isArray(raw.checks) || raw.checks.length === 0) fail('verification config requires at least one check.')
73
104
  const checks = raw.checks.map((check, index) => {
74
105
  if (!check || typeof check !== 'object' || Array.isArray(check)) fail(`checks[${index}] must be an object.`)
106
+ assertKnownKeys(check, new Set(['id', 'category', 'command', 'required', 'timeoutMs', 'execution', 'capabilities']), `checks[${index}]`)
75
107
  if (typeof check.id !== 'string' || !check.id) fail(`checks[${index}].id is required.`)
76
108
  if (typeof check.command !== 'string' || !check.command) fail(`checks[${index}].command is required.`)
77
109
  if (!CATEGORIES.has(check.category)) fail(`checks[${index}].category is invalid.`)
78
110
  if (check.required !== undefined && typeof check.required !== 'boolean') fail(`checks[${index}].required must be boolean.`)
79
111
  if (check.timeoutMs !== undefined && (!Number.isInteger(check.timeoutMs) || check.timeoutMs < 1)) fail(`checks[${index}].timeoutMs must be a positive integer.`)
80
112
  if (['endpoint', 'database', 'cli', 'mcp', 'ui'].includes(check.category) && check.execution !== 'real') fail(`checks[${index}] requires execution: "real".`)
113
+ 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.`)
114
+ if (check.category === 'ui') {
115
+ for (const capability of ['real-browser', 'screenshot']) if (!check.capabilities?.includes(capability)) fail(`checks[${index}] requires capability "${capability}".`)
116
+ }
81
117
  return { required: true, timeoutMs: 120_000, ...check }
82
118
  })
119
+ if (new Set(checks.map((check) => check.id)).size !== checks.length) fail('check ids must be unique.')
120
+ if (!raw.contract || typeof raw.contract !== 'object' || Array.isArray(raw.contract)) fail('verification contract is required.')
121
+ assertKnownKeys(raw.contract, new Set(['intent', 'outcomes']), 'contract')
122
+ if (typeof raw.contract.intent !== 'string' || !raw.contract.intent.trim()) fail('contract.intent is required.')
123
+ if (!Array.isArray(raw.contract.outcomes) || raw.contract.outcomes.length === 0) fail('contract.outcomes requires at least one outcome.')
124
+ const checkIds = new Set(checks.map((check) => check.id))
125
+ const outcomes = raw.contract.outcomes.map((outcome, index) => {
126
+ if (!outcome || typeof outcome !== 'object' || Array.isArray(outcome)) fail(`contract.outcomes[${index}] must be an object.`)
127
+ assertKnownKeys(outcome, new Set(['id', 'statement', 'checks']), `contract.outcomes[${index}]`)
128
+ if (typeof outcome.id !== 'string' || !outcome.id.trim()) fail(`contract.outcomes[${index}].id is required.`)
129
+ if (typeof outcome.statement !== 'string' || !outcome.statement.trim()) fail(`contract.outcomes[${index}].statement is required.`)
130
+ if (!Array.isArray(outcome.checks) || outcome.checks.length === 0) fail(`contract.outcomes[${index}].checks requires at least one check id.`)
131
+ if (outcome.checks.some((checkId) => typeof checkId !== 'string' || !checkIds.has(checkId))) fail(`contract.outcomes[${index}] references an unknown check.`)
132
+ if (outcome.checks.some((checkId) => !checks.find((check) => check.id === checkId)?.required)) fail(`contract.outcomes[${index}] references a non-required check.`)
133
+ return { id: outcome.id, statement: outcome.statement, checks: [...new Set(outcome.checks)] }
134
+ })
135
+ if (new Set(outcomes.map((outcome) => outcome.id)).size !== outcomes.length) fail('contract outcome ids must be unique.')
136
+ if (raw.surfaces !== undefined && (!raw.surfaces || typeof raw.surfaces !== 'object' || Array.isArray(raw.surfaces))) fail('surfaces must be an object.')
137
+ if (raw.surfaces) for (const name of Object.keys(raw.surfaces)) if (!SURFACES.includes(name)) fail(`surfaces.${name} is not supported.`)
83
138
  const surfaces = {}
84
- for (const name of ['logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs']) surfaces[name] = surfaceRequired(raw.surfaces?.[name] ?? (name === 'logic'), name)
139
+ for (const name of SURFACES) {
140
+ if (policy.requiresAllSurfaces && !Object.hasOwn(raw.surfaces ?? {}, name)) fail(`enterprise profile requires surfaces.${name} to be declared.`)
141
+ surfaces[name] = surfaceRequired(raw.surfaces?.[name] ?? (name === 'logic'), name)
142
+ }
85
143
  for (const [name, surface] of Object.entries(surfaces)) {
86
144
  const matching = checks.some((check) => check.category === name && check.required)
87
145
  if (surface.required && !matching) fail(`Required surface "${name}" has no required check.`)
88
146
  }
89
- if (raw.profile !== 'strict' && (!Array.isArray(raw.exemptions) || raw.exemptions.length === 0)) fail('Non-strict profiles require explicit exemptions.')
147
+ if (policy.requiresExplicitExemptions && (!Array.isArray(raw.exemptions) || raw.exemptions.length === 0)) fail('This profile requires explicit exemptions.')
90
148
  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' }
149
+ const measurement = raw.measurement ?? { required: false }
150
+ if (!measurement || typeof measurement !== 'object' || Array.isArray(measurement)) fail('measurement must be an object.')
151
+ assertKnownKeys(measurement, new Set(['required', 'checkId', 'baseline']), 'measurement')
152
+ if (typeof measurement.required !== 'boolean') fail('measurement.required must be boolean.')
153
+ if (policy.requiresMeasurement && !measurement.required) fail('enterprise profile requires measurement.required to be true.')
154
+ if (measurement.required) {
155
+ if (typeof measurement.checkId !== 'string' || !measurement.checkId.trim()) fail('measurement.checkId is required when measurement is required.')
156
+ const measurementCheck = checks.find((check) => check.id === measurement.checkId)
157
+ if (!measurementCheck) fail(`measurement.checkId references an unknown check: ${measurement.checkId}.`)
158
+ if (!measurementCheck.required) fail('measurement.checkId must reference a required check.')
159
+ if (typeof measurement.baseline !== 'string' || !measurement.baseline.trim()) fail('measurement.baseline is required when measurement is required.')
160
+ }
161
+ const tracking = raw.tracking ?? (policy.requiresTracking
162
+ ? { required: true, authorization: 'ask' }
163
+ : { required: false, reason: 'tracking is not configured for this run.' })
164
+ assertKnownKeys(tracking, new Set(['required', 'authorization', 'target', 'reason']), 'tracking')
92
165
  if (typeof tracking.required !== 'boolean') fail('tracking.required must be boolean.')
166
+ if (policy.requiresTracking && !tracking.required) fail('enterprise profile requires tracking.required to be true.')
93
167
  if (tracking.required && tracking.authorization !== 'ask') fail('tracking.authorization must be "ask".')
94
168
  if (tracking.required && typeof tracking.target !== 'string') fail('tracking.target is required when tracking is required.')
95
169
  if (!tracking.required && typeof tracking.reason !== 'string') fail('tracking.reason is required when tracking is not required.')
96
- return { ...raw, checks, surfaces, tracking, configPath }
170
+ if (raw.cleanup !== undefined) {
171
+ if (!raw.cleanup || typeof raw.cleanup !== 'object' || Array.isArray(raw.cleanup)) fail('cleanup must be an object.')
172
+ assertKnownKeys(raw.cleanup, new Set(['roots']), 'cleanup')
173
+ 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.')
174
+ }
175
+ return { ...raw, profile, checks, contract: { intent: raw.contract.intent.trim(), outcomes }, surfaces, tracking, measurement, configPath, profilePolicy: policy }
97
176
  }
98
177
 
99
178
  const sourceRevision = (root) => {
@@ -143,6 +222,53 @@ const commandResult = (root, check) => new Promise((resolveResult) => {
143
222
  })
144
223
  })
145
224
 
225
+ const validateUiEvidence = (root, run) => ({
226
+ ...run,
227
+ checks: run.checks.map((check) => {
228
+ if (check.category !== 'ui' || check.status === 'failed') return check
229
+ const evidence = check.verification
230
+ const failures = []
231
+ if (!evidence || evidence.capability !== 'real-browser') failures.push('UI evidence must declare capability "real-browser"')
232
+ if (!Array.isArray(evidence?.artifacts) || evidence.artifacts.length === 0) failures.push('UI evidence must include screenshot artifacts')
233
+ for (const artifact of evidence?.artifacts ?? []) {
234
+ if (artifact?.type !== 'screenshot' || typeof artifact.path !== 'string' || typeof artifact.sha256 !== 'string' || typeof artifact.viewport !== 'string') {
235
+ failures.push('Each UI artifact must include type=screenshot, path, sha256, and viewport')
236
+ continue
237
+ }
238
+ const path = resolve(root, artifact.path)
239
+ if (!inside(root, path) || !existsSync(path)) failures.push(`Screenshot artifact is missing or outside the project: ${artifact.path}`)
240
+ else if (createHash('sha256').update(readFileSync(path)).digest('hex') !== artifact.sha256) failures.push(`Screenshot hash mismatch: ${artifact.path}`)
241
+ }
242
+ const criterionIds = run.contract.outcomes.filter((outcome) => outcome.checks.includes(check.id)).map((outcome) => outcome.id)
243
+ if (!evidence?.criteria || typeof evidence.criteria !== 'object' || Array.isArray(evidence.criteria)) failures.push('UI evidence must include criterion-level results')
244
+ for (const id of criterionIds) if (evidence?.criteria?.[id]?.status !== 'passed') failures.push(`UI criterion did not pass: ${id}`)
245
+ if (!failures.length) return check
246
+ return { ...check, status: 'failed', verificationStatus: 'failed', verification: { ...(evidence ?? {}), status: 'failed', failures } }
247
+ }),
248
+ })
249
+
250
+ const validateMeasurementResult = (run, measurement) => {
251
+ if (!measurement.required) return run
252
+ const check = run.checks.find((item) => item.id === measurement.checkId)
253
+ const verification = check?.verification
254
+ const failures = []
255
+ if (check?.status !== 'passed') failures.push(`measurement check ${measurement.checkId} did not pass`)
256
+ if (!verification || typeof verification.metrics !== 'object' || Array.isArray(verification.metrics)) failures.push('measurement evidence must include an object named metrics')
257
+ if (typeof verification?.baselineHash !== 'string' || !verification.baselineHash) failures.push('measurement evidence must include baselineHash')
258
+ if (!Array.isArray(verification?.regressions)) failures.push('measurement evidence must include a regressions array')
259
+ else if (verification.regressions.length) failures.push(`measurement regressions detected: ${verification.regressions.join('; ')}`)
260
+ if (!failures.length) return run
261
+ return {
262
+ ...run,
263
+ checks: run.checks.map((item) => item.id === measurement.checkId ? {
264
+ ...item,
265
+ status: 'failed',
266
+ verificationStatus: 'failed',
267
+ verification: { status: 'failed', failures },
268
+ } : item),
269
+ }
270
+ }
271
+
146
272
  const stateDirFor = (root) => join(root, '.codex', 'verification')
147
273
  const runDirFor = (root, runId) => join(stateDirFor(root), 'runs', runId)
148
274
  const latestPathFor = (root) => join(stateDirFor(root), 'latest.json')
@@ -152,7 +278,23 @@ const saveRun = (root, run) => {
152
278
  writeAtomic(join(dir, 'run.json'), run)
153
279
  writeAtomic(latestPathFor(root), { runId: run.runId, state: run.state, path: relative(root, join(dir, 'run.json')), updatedAt: now() })
154
280
  }
155
- const transition = (run, state, reason) => ({ ...run, state, transitions: [...run.transitions, { from: run.state, to: state, at: now(), ...(reason ? { reason } : {}) }] })
281
+ const transition = (run, state, reason) => {
282
+ if (!STATES.has(state)) fail(`Unknown verification state: ${state}.`)
283
+ const allowed = LEGAL_TRANSITIONS[String(run.state)] ?? []
284
+ if (!allowed.includes(state)) fail(`Illegal verification transition ${run.state} -> ${state}.`)
285
+ return { ...run, state, transitions: [...run.transitions, { from: run.state, to: state, at: now(), ...(reason ? { reason } : {}) }] }
286
+ }
287
+
288
+ const attachEvidence = (run) => {
289
+ const evidenceReferences = run.checks.map((check) => ({
290
+ checkId: check.id,
291
+ status: check.status,
292
+ ...(check.verificationStatus ? { verificationStatus: check.verificationStatus } : {}),
293
+ evidenceHash: hash({ id: check.id, status: check.status, stdout: check.stdout, stderr: check.stderr, verification: check.verification }),
294
+ }))
295
+ const metrics = Object.fromEntries(run.checks.flatMap((check) => Object.entries(check.verification?.metrics ?? {})))
296
+ return { ...run, evidenceReferences, metrics, outputHash: hash({ checks: run.checks, outcomes: run.outcomes, evidenceReferences, metrics }) }
297
+ }
156
298
 
157
299
  const runVerification = async (root, config, runId) => {
158
300
  const source = sourceRevision(root)
@@ -173,12 +315,16 @@ const runVerification = async (root, config, runId) => {
173
315
  runId: id,
174
316
  project: config.project,
175
317
  profile: config.profile,
318
+ profilePolicy: config.profilePolicy,
176
319
  sourceRevision: source,
320
+ contractHash: hash(config.contract),
177
321
  inputHash,
178
322
  state: 'PLANNED',
179
323
  configPath: relative(root, config.configPath),
180
- checks: config.checks.map(({ id: checkId, category, command, required, timeoutMs }) => ({ id: checkId, category, command, required, timeoutMs, status: 'pending' })),
324
+ contract: config.contract,
325
+ checks: config.checks.map(({ id: checkId, category, command, required, timeoutMs, capabilities }) => ({ id: checkId, category, command, required, timeoutMs, ...(capabilities ? { capabilities } : {}), status: 'pending' })),
181
326
  surfaces: config.surfaces,
327
+ applicability: config.surfaces,
182
328
  tracking: config.tracking,
183
329
  exemptions: config.exemptions ?? [],
184
330
  transitions: [{ from: null, to: 'PLANNED', at: now() }],
@@ -191,8 +337,29 @@ const runVerification = async (root, config, runId) => {
191
337
  run = { ...run, checks: run.checks.map((item, itemIndex) => itemIndex === index ? { ...item, ...result } : item) }
192
338
  saveRun(root, run)
193
339
  }
340
+ run = validateUiEvidence(root, run)
341
+ run = {
342
+ ...run,
343
+ outcomes: run.contract.outcomes.map((outcome) => {
344
+ const checks = run.checks.filter((check) => outcome.checks.includes(check.id))
345
+ const status = checks.some((check) => check.status === 'failed') ? 'failed' : checks.some((check) => check.status === 'awaiting-human-approval') ? 'awaiting-human-approval' : 'passed'
346
+ return { ...outcome, status }
347
+ }),
348
+ }
349
+ run = validateMeasurementResult(run, config.measurement)
350
+ run = {
351
+ ...run,
352
+ outcomes: run.contract.outcomes.map((outcome) => {
353
+ const checks = run.checks.filter((check) => outcome.checks.includes(check.id))
354
+ const status = checks.some((check) => check.status === 'failed') ? 'failed' : checks.some((check) => check.status === 'awaiting-human-approval') ? 'awaiting-human-approval' : 'passed'
355
+ return { ...outcome, status }
356
+ }),
357
+ }
358
+ run = attachEvidence(run)
359
+ const failedOutcomes = run.outcomes.filter((outcome) => outcome.status === 'failed')
194
360
  const failed = run.checks.filter((check) => check.required && !['passed', 'awaiting-human-approval'].includes(check.status))
195
361
  if (failed.length) run = transition(run, 'BLOCKED', `Required checks failed: ${failed.map((check) => check.id).join(', ')}`)
362
+ else if (failedOutcomes.length) run = transition(run, 'BLOCKED', `Contract outcomes failed: ${failedOutcomes.map((outcome) => outcome.id).join(', ')}`)
196
363
  else if (config.surfaces.ui.required) run = transition(run, 'AWAITING_HUMAN_APPROVAL', 'Visual UI approval is required.')
197
364
  else if (config.tracking.required) run = transition(run, 'AWAITING_AUTHORIZATION', `Tracking authorization is required for ${config.tracking.target}.`)
198
365
  else run = transition(run, 'COMPLETE', 'All configured verification gates passed.')
@@ -211,13 +378,43 @@ const updateApproval = (root, run, type, rawIntent, by) => {
211
378
  if (run.state !== expectedState) fail(`Cannot record ${type} while run is ${run.state}.`)
212
379
  const approvedIntent = intent(rawIntent)
213
380
  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() })
381
+ writeAtomic(join(dir, `${type}.json`), {
382
+ type,
383
+ runId: run.runId,
384
+ runInputHash: run.inputHash,
385
+ runSourceRevision: run.sourceRevision,
386
+ runContractHash: run.contractHash,
387
+ runOutputHash: run.outputHash,
388
+ intent: approvedIntent,
389
+ by: by ?? 'human',
390
+ at: now(),
391
+ })
392
+ if (type === 'human-approval') run = { ...run, outcomes: run.outcomes.map((outcome) => outcome.status === 'awaiting-human-approval' ? { ...outcome, status: 'passed' } : outcome) }
215
393
  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
394
  if (type === 'tracking-authorization' && run.state === 'AWAITING_AUTHORIZATION') run = transition(run, 'COMPLETE', `Tracking authorization recorded for ${run.tracking.target}.`)
217
395
  saveRun(root, run)
218
396
  return run
219
397
  }
220
398
 
399
+ const replaceBaseline = (root, config, sourcePath, rawIntent, by) => {
400
+ if (!config.measurement.required) fail('Baseline replacement requires measurement.required to be true.')
401
+ if (!config.measurement.baseline) fail('Baseline replacement requires measurement.baseline.')
402
+ if (!by) fail('Baseline replacement requires --by.')
403
+ const approvedIntent = intent(rawIntent)
404
+ const target = resolve(root, config.measurement.baseline)
405
+ const source = resolve(root, sourcePath ?? '')
406
+ if (!inside(root, target) || !inside(root, source)) fail('Baseline source and target must be inside the project root.')
407
+ if (!existsSync(source)) fail(`Baseline source not found: ${sourcePath}`)
408
+ if (source === target) fail('Baseline source must differ from the configured baseline target.')
409
+ const value = readJson(source)
410
+ writeAtomic(target, value)
411
+ const auditPath = join(stateDirFor(root), 'baseline-audit.jsonl')
412
+ mkdirSync(dirname(auditPath), { recursive: true })
413
+ const entry = { action: 'replace-baseline', source: relative(root, source), target: relative(root, target), baselineHash: hash(value), intent: approvedIntent, by, at: now() }
414
+ appendFileSync(auditPath, `${JSON.stringify(entry)}\n`, 'utf8')
415
+ return { status: 'baseline-replaced', ...entry }
416
+ }
417
+
221
418
  const clean = (root, config, periodic) => {
222
419
  const manifestPath = join(stateDirFor(root), 'owned-artifacts.json')
223
420
  if (!existsSync(manifestPath)) return { removed: [], skipped: [], periodic, message: 'No task-owned artifacts are registered.' }
@@ -240,13 +437,19 @@ const main = async (argv) => {
240
437
  const { flags, values, positional } = parseArgs(argv)
241
438
  const command = positional[0] ?? 'help'
242
439
  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')
440
+ 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
441
  return 0
245
442
  }
246
443
  const configPath = resolve(values.get('config') ?? DEFAULT_CONFIG)
247
444
  if (!existsSync(configPath)) fail(`Verification contract not found: ${configPath}`)
248
445
  const root = projectRoot(configPath, readJson(configPath))
249
446
  const config = validateConfig(readJson(configPath), configPath)
447
+ if (command === 'baseline') {
448
+ if (positional[1] !== 'replace') fail('Use: baseline replace <source> <intent> --by <actor>.')
449
+ const result = replaceBaseline(root, config, positional[2], positional[3], values.get('by'))
450
+ output(result, flags.has('json'))
451
+ return 0
452
+ }
250
453
  if (command === 'run') {
251
454
  const run = await runVerification(root, config, values.get('run-id'))
252
455
  output(run, flags.has('json'))
@@ -277,4 +480,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
277
480
  catch (error) { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 2 }
278
481
  }
279
482
 
280
- export { INTENTS, main, validateConfig }
483
+ 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.3'
6
+ const VERSION = '1.7.44'
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
  }
@@ -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,23 @@ 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?.includeOrphanedDocuments === undefined ? {} : { includeOrphanedDocuments: config.reconciliation.includeOrphanedDocuments }),
504
+ })
505
+ return runWorkflow(workflowOptions(root, config, snapshot.sourceRevision, 'reconcile', { reconcile: () => report }, { pipelineVersion: snapshot.pipelineVersion, analyzerVersions: snapshot.analyzerVersions }))
494
506
  }
495
507
 
496
508
  const checkWorkflow = (root: string, config: DocBridgeConfigV1): WorkflowExecutionResult => {
497
509
  const reconciled = reconcileWorkflow(root, config)
498
510
  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 }))
511
+ const versions = { pipelineVersion: report.pipelineVersion, analyzerVersions: report.analyzerVersions }
512
+ runWorkflow(workflowOptions(root, config, report.sourceRevision, 'evaluate', { evaluate: () => evaluateRules(report, { ...(config.rules ? { config: config.rules } : {}) }) }, versions))
513
+ return runWorkflow(workflowOptions(root, config, report.sourceRevision, 'report', { report: ({ input }) => input }, versions))
501
514
  }
502
515
 
503
516
  const workflowOutput = (result: WorkflowExecutionResult): Record<string, unknown> => {
@@ -593,7 +606,7 @@ const runWorkflowCommand = (
593
606
  const thresholdValue = optionValues(argv, '--report-threshold')[0]
594
607
  const thresholdBytes = thresholdValue === undefined ? undefined : Number(thresholdValue)
595
608
  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 }) })
609
+ const artifact = renderOfflineReportArtifact({ snapshot, report }, { ...(config.report?.privacy ? { privacy: config.report.privacy } : {}), ...(thresholdBytes === undefined ? {} : { thresholdBytes }) })
597
610
  output.htmlPath = writeReportArtifact(htmlPath, artifact)
598
611
  output.htmlMode = artifact.mode
599
612
  }
@@ -947,6 +960,26 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
947
960
  }
948
961
  }
949
962
 
963
+ if (command === 'benchmark') {
964
+ const fixturePath = positional[1]
965
+ const observationPath = positional[2]
966
+ if (!fixturePath || !observationPath) {
967
+ process.stderr.write('Usage: ak-docs benchmark <fixture.json> <observation.json> [--text|--json]\n')
968
+ return 1
969
+ }
970
+ try {
971
+ const fixture = benchmarkFixture(JSON.parse(readFileSync(resolve(fixturePath), 'utf8')) as unknown)
972
+ const observation = JSON.parse(readFileSync(resolve(observationPath), 'utf8')) as Parameters<typeof measureBenchmark>[0]
973
+ const result = measureBenchmark(observation, fixture)
974
+ if (flags.has('--text')) writeLines([formatBenchmarkText(result)])
975
+ else writeJson(result)
976
+ return result.regressions.length ? 1 : 0
977
+ } catch (error) {
978
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
979
+ return 2
980
+ }
981
+ }
982
+
950
983
  if (command === 'scan' || command === 'reconcile' || command === 'check' || command === 'map') {
951
984
  return runWorkflowCommand(command, flags, configPath, argv)
952
985
  }
@@ -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'