@agentskit/doc-bridge 1.7.45 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +488 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +87 -40
- package/SECURITY.md +7 -0
- package/action.yml +1 -1
- package/bin/ak-docs.js +2 -2
- package/bin/ak-verify.js +13 -7
- package/dist/cli/program.d.ts +3 -1
- package/dist/cli/program.js +15871 -6218
- package/dist/cli/program.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +95 -6
- package/dist/config/index.js.map +1 -1
- package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
- package/dist/index.d.ts +9880 -1062
- package/dist/index.js +16143 -6127
- package/dist/index.js.map +1 -1
- package/docs/MARKETPLACE.md +1 -1
- package/docs/PRD-documentation-efficiency-study.md +406 -0
- package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
- package/docs/adr/0002-documentation-audit-boundary.md +22 -0
- package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
- package/docs/adr/0004-controlled-study-runner.md +25 -0
- package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
- package/docs/adr/0006-registry-semantic-grounding.md +20 -0
- package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
- package/docs/adr/0008-study-verification-boundary.md +21 -0
- package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
- package/docs/agent-corpus/INDEX.md +12 -1
- package/docs/agent-corpus/OVERVIEW.md +25 -0
- package/docs/agent-corpus/chat.md +5 -1
- package/docs/agent-corpus/cli.md +16 -0
- package/docs/agent-corpus/conformance.md +12 -0
- package/docs/agent-corpus/doc-bridge.md +47 -0
- package/docs/agent-corpus/doctor.md +8 -0
- package/docs/agent-corpus/gates.md +4 -0
- package/docs/agent-corpus/mcp.md +13 -0
- package/docs/agent-corpus/memory.md +4 -0
- package/docs/agent-corpus/query.md +33 -0
- package/docs/bench/README.md +122 -0
- package/docs/bench/retrieval-baseline-v1.json +28 -0
- package/docs/bench/retrieval-suite-v1.json +1033 -0
- package/docs/chat-and-rag.md +3 -2
- package/docs/for-agents.md +9 -1
- package/docs/getting-started.md +4 -11
- package/docs/guides/gate-ci.md +11 -1
- package/docs/guides/install-and-run.md +9 -65
- package/docs/index.md +22 -1
- package/docs/knowledge-engine-runbook.md +38 -3
- package/docs/landing/assets/context-payload-reduction.svg +21 -0
- package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
- package/docs/landing/index.html +119 -5
- package/docs/loop-workflow.md +117 -0
- package/docs/mcp.md +6 -1
- package/docs/parity/public-claims-v1.json +145 -0
- package/docs/playbook/doc-bridge-pattern.md +1 -1
- package/docs/query.md +90 -2
- package/docs/recipes/index-pipeline.md +1 -1
- package/docs/schemas/agent-handoff-v1.md +15 -0
- package/docs/schemas/doc-bridge-index-v1.md +65 -0
- package/docs/spec/benchmark-v1.md +33 -1
- package/docs/spec/cli.md +30 -10
- package/docs/spec/config-v1.md +157 -8
- package/docs/spec/documentation-audit-v1.md +61 -0
- package/docs/spec/enrichment-overlay-v1.md +241 -0
- package/docs/spec/graph-signals-v1.md +92 -0
- package/docs/spec/incremental-scan-v1.md +102 -0
- package/docs/spec/markdown-analyzer-v1.md +73 -0
- package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
- package/docs/spec/measured-enrichment-v1.md +229 -0
- package/docs/spec/public-parity-v1.md +119 -0
- package/docs/spec/registry-agents.md +6 -0
- package/docs/spec/render-v1.md +122 -0
- package/docs/spec/retrieval-index-v1.md +164 -0
- package/docs/spec/study-metrics-v1.md +58 -0
- package/docs/spec/study-protocol-v1.md +46 -0
- package/docs/spec/study-provider-cli-v1.md +116 -0
- package/docs/spec/study-runner-v1.md +35 -0
- package/docs/spec/study-task-suite-v1.md +41 -0
- package/docs/spec/study-verification-v1.md +40 -0
- package/docs/study/README.md +84 -0
- package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
- package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
- package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
- package/docs/study/ab-baseline-analysis-v1.md +21 -0
- package/docs/study/ab-baseline-plan-v1.json +33 -0
- package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
- package/docs/study/ab-baseline-result-v1.json +79 -0
- package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
- package/docs/study/historical-evidence-v1.json +252 -0
- package/docs/study/observation-ledger-v1.json +30632 -0
- package/docs/study/phase3-task-coverage-v1.json +34 -0
- package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
- package/docs/study/phase4-public-pilot-result-v1.json +52 -0
- package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
- package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
- package/docs/study/pilot-round-2026-08-31.json +46 -0
- package/docs/study/protocol-v1.json +90 -0
- package/docs/study/publication-gate-v1.md +45 -0
- package/docs/study/quality-scorecard-cycle-plan.md +545 -0
- package/docs/study/quality-scorecard-v1.json +38 -0
- package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
- package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
- package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
- package/docs/study/round-2-expanded-validation-v1.md +58 -0
- package/docs/study/round-3-evidence-contract-v1.json +75 -0
- package/docs/study/round-3-evidence-contract-v1.md +57 -0
- package/docs/study/round-4-confirmation-v1.json +75 -0
- package/docs/study/round-4-confirmation-v1.md +55 -0
- package/docs/study/run-plan-v1.json +33 -0
- package/docs/study/semantic-adjudication-cycle-8.md +20 -0
- package/docs/study/task-suite-v1.json +96 -0
- package/docs/study/token-efficiency-plan-v1.md +337 -0
- package/docs/study/token-efficiency-protocol-v2.json +62 -0
- package/docs/study/verification-binding-v1.json +27 -0
- package/docs/validation-cycle-plan.md +14 -0
- package/docs/verification-harness.md +11 -6
- package/ecosystem-claims.json +2 -2
- package/ecosystem-upstream.json +2 -2
- package/ecosystem.json +4 -4
- package/mcpb/manifest.json +9 -1
- package/package.json +34 -15
- package/scripts/check-ecosystem-upstream.mjs +34 -6
- package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
- package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
- package/src/agents/registry-adapter.ts +192 -24
- package/src/audit/documentation.ts +513 -0
- package/src/bench/baseline.ts +198 -0
- package/src/bench/overlay-delta.ts +139 -0
- package/src/bench/retrieval.ts +319 -0
- package/src/budget/compile.ts +91 -0
- package/src/budget/sections.ts +70 -0
- package/src/cli/program.ts +684 -74
- package/src/cli/usage.ts +71 -0
- package/src/config/defaults.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/config/schema.ts +126 -2
- package/src/conformance/documentation-standard-v1.ts +8 -6
- package/src/discovery/areas.ts +197 -0
- package/src/discovery/documentation.ts +211 -5
- package/src/discovery/identity.ts +24 -0
- package/src/discovery/incremental.ts +314 -0
- package/src/discovery/inputs.ts +110 -0
- package/src/discovery/markdown.ts +481 -0
- package/src/discovery/repository.ts +485 -101
- package/src/doctor/run-doctor.ts +244 -12
- package/src/enrich/approvals.ts +190 -0
- package/src/enrich/cache.ts +93 -0
- package/src/enrich/context-pack.ts +272 -0
- package/src/enrich/overlay.ts +255 -0
- package/src/enrich/review.ts +106 -0
- package/src/enrich/stage.ts +374 -0
- package/src/enrich/stats.ts +100 -0
- package/src/enrich/validate.ts +410 -0
- package/src/federation/llms.ts +2 -4
- package/src/findings/report.ts +103 -0
- package/src/graph/build.ts +356 -0
- package/src/graph/memory.ts +208 -0
- package/src/index-builder/build-handoffs.ts +22 -11
- package/src/index-builder/build-index.ts +132 -3
- package/src/index-builder/llms-txt.ts +48 -8
- package/src/index-builder/project-corpus.ts +111 -0
- package/src/index.ts +630 -2
- package/src/lib/fuzzy-match.ts +235 -0
- package/src/mcp/knowledge.ts +554 -0
- package/src/mcp/server.ts +113 -18
- package/src/metrics/benchmark.ts +21 -0
- package/src/parity/check.ts +309 -0
- package/src/parity/claims.ts +259 -0
- package/src/parity/resolve.ts +160 -0
- package/src/query/handoff.ts +326 -0
- package/src/query/load-index.ts +53 -1
- package/src/query/query.ts +92 -59
- package/src/query/search.ts +289 -92
- package/src/query/text.ts +155 -0
- package/src/reconciliation/reconcile.ts +101 -10
- package/src/render/data.ts +356 -0
- package/src/render/engine.ts +398 -0
- package/src/render/generated.ts +77 -0
- package/src/render/render.ts +209 -0
- package/src/render/template-source.ts +52 -0
- package/src/render/templates.ts +289 -0
- package/src/report/html.ts +2 -2
- package/src/retrieval/bm25.ts +161 -0
- package/src/retrieval/project.ts +495 -0
- package/src/retrieval/rank.ts +383 -0
- package/src/retrieval/weights.ts +39 -0
- package/src/retriever/doc-bridge-retriever.ts +100 -15
- package/src/rules/engine.ts +30 -10
- package/src/schemas/agent-handoff.ts +56 -0
- package/src/schemas/budget.ts +37 -0
- package/src/schemas/doc-bridge-index.ts +53 -2
- package/src/schemas/enrichment.ts +369 -0
- package/src/schemas/json-schemas.ts +39 -2
- package/src/schemas/knowledge.ts +14 -1
- package/src/schemas/retrieval-index.ts +152 -0
- package/src/shims/graphology.d.ts +91 -0
- package/src/study/adjudication.ts +196 -0
- package/src/study/execution.ts +350 -0
- package/src/study/expectations.ts +219 -0
- package/src/study/metrics.ts +467 -0
- package/src/study/protocol.ts +271 -0
- package/src/study/provider-cli.ts +115 -0
- package/src/study/provider-telemetry.ts +47 -0
- package/src/study/quality-scorecard.ts +164 -0
- package/src/study/runner.ts +461 -0
- package/src/study/task-suite.ts +321 -0
- package/src/study/verification.ts +134 -0
- package/src/validate.ts +1 -4
- package/src/version.ts +1 -1
- package/src/workflow/engine.ts +36 -11
- package/scripts/verification-harness.mjs +0 -482
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
4
|
+
import { isSafeStudyText } from './protocol.js'
|
|
5
|
+
|
|
6
|
+
export const STUDY_TASK_SUITE_SCHEMA_VERSION = 1 as const
|
|
7
|
+
export const STUDY_TASK_SUITE_CONTENT_HASH_ALGO = 'sha256-normalized-v1' as const
|
|
8
|
+
|
|
9
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/)
|
|
10
|
+
const identifier = z.string().regex(/^[a-z][a-z0-9-]{0,127}$/)
|
|
11
|
+
const reference = z.string().regex(/^[a-z0-9][a-z0-9._:-]{0,255}$/)
|
|
12
|
+
const safeText = z.string().min(1).max(2_048).refine(
|
|
13
|
+
isSafeStudyText,
|
|
14
|
+
'Public study text cannot contain paths, URLs, credentials, or secret material',
|
|
15
|
+
)
|
|
16
|
+
const safeTextList = z.array(safeText).min(1).max(32)
|
|
17
|
+
|
|
18
|
+
const surfaces = ['logic', 'endpoint', 'database', 'cli', 'mcp', 'ui', 'docs'] as const
|
|
19
|
+
const categories = ['discovery', 'architecture', 'documentation', 'implementation'] as const
|
|
20
|
+
const outcomes = ['success', 'partial', 'incorrect', 'incomplete', 'blocked'] as const
|
|
21
|
+
|
|
22
|
+
const SurfaceSchema = z.object({
|
|
23
|
+
required: z.boolean(),
|
|
24
|
+
reason: safeText.optional(),
|
|
25
|
+
}).strict().superRefine((value, context) => {
|
|
26
|
+
if (!value.required && !value.reason) context.addIssue({ code: z.ZodIssueCode.custom, path: ['reason'], message: 'Non-applicable surfaces require a reason.' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const CheckSchema = z.object({
|
|
30
|
+
id: identifier,
|
|
31
|
+
command: safeText,
|
|
32
|
+
expected: safeText,
|
|
33
|
+
}).strict()
|
|
34
|
+
|
|
35
|
+
const EvidenceSchema = z.object({
|
|
36
|
+
id: identifier,
|
|
37
|
+
description: safeText,
|
|
38
|
+
source: z.enum(['runner', 'artifact', 'human', 'agent']),
|
|
39
|
+
}).strict()
|
|
40
|
+
|
|
41
|
+
const VariantSchema = z.object({
|
|
42
|
+
id: identifier,
|
|
43
|
+
label: safeText,
|
|
44
|
+
context: safeText,
|
|
45
|
+
}).strict()
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What mechanically checks a rubric item, when anything does.
|
|
49
|
+
*
|
|
50
|
+
* `acceptance-checks` is the task's own commands and their exit status; `evidence-coverage` is
|
|
51
|
+
* whether each required evidence id was cited; `retrieval-expectations` is the entities and
|
|
52
|
+
* documents the task expects retrieval to return, measured by `ak-docs bench retrieval`. An item
|
|
53
|
+
* that names one of these is decided by the runner and never shown to the model adjudicator.
|
|
54
|
+
*/
|
|
55
|
+
export const RUBRIC_MECHANICAL_CHECKS = ['acceptance-checks', 'evidence-coverage', 'retrieval-expectations'] as const
|
|
56
|
+
export type RubricMechanicalCheck = (typeof RUBRIC_MECHANICAL_CHECKS)[number]
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A rubric item: prose, or prose with the mechanical check that decides it.
|
|
60
|
+
*
|
|
61
|
+
* The plain string is kept because most of a rubric is a judgement — "the answer identifies a
|
|
62
|
+
* wrong entrypoint" is not something a checker can settle — and a suite written before this
|
|
63
|
+
* existed stays valid, with every item going to the adjudicator exactly as before.
|
|
64
|
+
*/
|
|
65
|
+
const RubricItemSchema = z.union([
|
|
66
|
+
safeText,
|
|
67
|
+
z.object({ text: safeText, check: z.enum(RUBRIC_MECHANICAL_CHECKS) }).strict(),
|
|
68
|
+
])
|
|
69
|
+
const RubricItemListSchema = z.array(RubricItemSchema).min(1).max(32)
|
|
70
|
+
|
|
71
|
+
export type RubricItem = z.infer<typeof RubricItemSchema>
|
|
72
|
+
|
|
73
|
+
export const rubricItemText = (item: RubricItem): string => (typeof item === 'string' ? item : item.text)
|
|
74
|
+
export const rubricItemCheck = (item: RubricItem): RubricMechanicalCheck | undefined => (typeof item === 'string' ? undefined : item.check)
|
|
75
|
+
|
|
76
|
+
const RubricSchema = z.object({
|
|
77
|
+
success: RubricItemListSchema,
|
|
78
|
+
partial: RubricItemListSchema,
|
|
79
|
+
incorrect: RubricItemListSchema,
|
|
80
|
+
incomplete: RubricItemListSchema,
|
|
81
|
+
blocked: RubricItemListSchema,
|
|
82
|
+
}).strict()
|
|
83
|
+
|
|
84
|
+
const BudgetSchema = z.object({
|
|
85
|
+
maxTokens: z.number().int().positive(),
|
|
86
|
+
maxRuntimeMs: z.number().int().positive(),
|
|
87
|
+
}).strict()
|
|
88
|
+
|
|
89
|
+
const TaskSchema = z.object({
|
|
90
|
+
id: identifier,
|
|
91
|
+
repositoryId: identifier,
|
|
92
|
+
category: z.enum(categories),
|
|
93
|
+
difficulty: z.enum(['easy', 'medium', 'hard']),
|
|
94
|
+
objective: safeText,
|
|
95
|
+
initialContext: safeTextList,
|
|
96
|
+
allowedTools: safeTextList,
|
|
97
|
+
forbiddenActions: safeTextList,
|
|
98
|
+
expectedOutcome: safeText,
|
|
99
|
+
evidenceRequirements: z.array(EvidenceSchema).min(1).max(16),
|
|
100
|
+
acceptanceChecks: z.array(CheckSchema).min(1).max(16),
|
|
101
|
+
budget: BudgetSchema,
|
|
102
|
+
surfaces: z.object(Object.fromEntries(surfaces.map((surface) => [surface, SurfaceSchema])) as Record<typeof surfaces[number], typeof SurfaceSchema>).strict(),
|
|
103
|
+
rubric: RubricSchema,
|
|
104
|
+
variantGroup: identifier,
|
|
105
|
+
variants: z.array(VariantSchema).length(2),
|
|
106
|
+
/*
|
|
107
|
+
* What retrieval is expected to return for this task, as opaque references.
|
|
108
|
+
*
|
|
109
|
+
* The last study round recorded zero semantic successes because success was only ever a model's
|
|
110
|
+
* opinion, and an opinion produces no signal. These are the mechanical half: the references are
|
|
111
|
+
* resolved to concrete entities and documents by a local expectations file — never here, since
|
|
112
|
+
* this suite is publication-bound and a repository path in it is a privacy failure — and the
|
|
113
|
+
* resolved targets are checked by `ak-docs bench retrieval`.
|
|
114
|
+
*/
|
|
115
|
+
expectedEntities: z.array(reference).max(16).optional(),
|
|
116
|
+
expectedDocuments: z.array(reference).max(16).optional(),
|
|
117
|
+
/** What to ask retrieval. Defaults to the task's objective, which is already stated above. */
|
|
118
|
+
retrievalQueries: z.array(safeText).min(1).max(8).optional(),
|
|
119
|
+
}).strict().superRefine((value, context) => {
|
|
120
|
+
if (value.expectedEntities?.length === 0) context.addIssue({ code: z.ZodIssueCode.custom, path: ['expectedEntities'], message: 'Declare at least one expected entity or omit the field.' })
|
|
121
|
+
if (value.expectedDocuments?.length === 0) context.addIssue({ code: z.ZodIssueCode.custom, path: ['expectedDocuments'], message: 'Declare at least one expected document or omit the field.' })
|
|
122
|
+
if (value.retrievalQueries && !value.expectedEntities && !value.expectedDocuments) {
|
|
123
|
+
context.addIssue({ code: z.ZodIssueCode.custom, path: ['retrievalQueries'], message: 'A retrieval query needs expected entities or documents; a query with nothing expected checks nothing.' })
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const TaskSuitePayloadSchema = z.object({
|
|
128
|
+
type: z.literal('study-task-suite'),
|
|
129
|
+
schemaVersion: z.literal(STUDY_TASK_SUITE_SCHEMA_VERSION),
|
|
130
|
+
suiteVersion: reference,
|
|
131
|
+
protocolVersion: reference,
|
|
132
|
+
title: safeText,
|
|
133
|
+
population: z.array(identifier).min(1).max(64),
|
|
134
|
+
modelIds: z.array(identifier).length(2),
|
|
135
|
+
scenarioIds: z.array(identifier).length(3),
|
|
136
|
+
maxTokensPerTask: z.number().int().positive(),
|
|
137
|
+
maxRuntimeMsPerTask: z.number().int().positive(),
|
|
138
|
+
maxRuns: z.number().int().positive(),
|
|
139
|
+
replicatesPerTask: z.number().int().positive(),
|
|
140
|
+
ordering: z.object({
|
|
141
|
+
strategy: z.literal('balanced-counter-order'),
|
|
142
|
+
seed: reference,
|
|
143
|
+
}).strict(),
|
|
144
|
+
tasks: z.array(TaskSchema).max(128),
|
|
145
|
+
}).strict()
|
|
146
|
+
|
|
147
|
+
export const StudyTaskSuiteV1Schema = TaskSuitePayloadSchema.extend({
|
|
148
|
+
contentHash: hash,
|
|
149
|
+
contentHashAlgo: z.literal(STUDY_TASK_SUITE_CONTENT_HASH_ALGO),
|
|
150
|
+
}).strict()
|
|
151
|
+
|
|
152
|
+
export type StudyTaskSuiteV1 = z.infer<typeof StudyTaskSuiteV1Schema>
|
|
153
|
+
export type StudyTaskV1 = StudyTaskSuiteV1['tasks'][number]
|
|
154
|
+
export type TaskOutcomeStatus = typeof outcomes[number]
|
|
155
|
+
|
|
156
|
+
/** Whether a task can be checked without asking a model anything. */
|
|
157
|
+
export const hasRetrievalExpectations = (task: StudyTaskV1): boolean =>
|
|
158
|
+
(task.expectedEntities?.length ?? 0) > 0 || (task.expectedDocuments?.length ?? 0) > 0
|
|
159
|
+
|
|
160
|
+
/** The queries a task is checked with: its own, or its objective. */
|
|
161
|
+
export const taskRetrievalQueries = (task: StudyTaskV1): readonly string[] => task.retrievalQueries ?? [task.objective]
|
|
162
|
+
|
|
163
|
+
/** The rubric items a checker decides, by outcome. */
|
|
164
|
+
export const mechanicalRubricItems = (task: StudyTaskV1): readonly { readonly outcome: TaskOutcomeStatus; readonly text: string; readonly check: RubricMechanicalCheck }[] =>
|
|
165
|
+
outcomes.flatMap((outcome) =>
|
|
166
|
+
task.rubric[outcome].flatMap((item) => {
|
|
167
|
+
const check = rubricItemCheck(item)
|
|
168
|
+
return check === undefined ? [] : [{ outcome, text: rubricItemText(item), check }]
|
|
169
|
+
}),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The rubric items no checker can decide — the only ones a model adjudicator should ever see.
|
|
174
|
+
*
|
|
175
|
+
* Handing a model an item the runner already settled invites it to disagree with a measurement,
|
|
176
|
+
* which is how a study ends up with an opinion where it had a number.
|
|
177
|
+
*/
|
|
178
|
+
export const modelRubricItems = (task: StudyTaskV1): Readonly<Record<TaskOutcomeStatus, readonly string[]>> => {
|
|
179
|
+
const judged = (outcome: TaskOutcomeStatus): readonly string[] =>
|
|
180
|
+
task.rubric[outcome].filter((item) => rubricItemCheck(item) === undefined).map(rubricItemText)
|
|
181
|
+
return { success: judged('success'), partial: judged('partial'), incorrect: judged('incorrect'), incomplete: judged('incomplete'), blocked: judged('blocked') }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const uniqueIds = (values: readonly string[], label: string): void => {
|
|
185
|
+
if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label} id.`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export type ValidateStudyTaskSuiteOptions = {
|
|
189
|
+
/**
|
|
190
|
+
* Require every task to declare retrieval expectations.
|
|
191
|
+
*
|
|
192
|
+
* Off by default, because a suite written before expectations existed is still a valid suite.
|
|
193
|
+
* A study that wants a mechanical answer turns it on and finds out which tasks cannot give one.
|
|
194
|
+
*/
|
|
195
|
+
readonly requireExpectations?: boolean
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const validateStudyTaskSuite = (suite: StudyTaskSuiteV1, options: ValidateStudyTaskSuiteOptions = {}): void => {
|
|
199
|
+
uniqueIds(suite.population, 'population')
|
|
200
|
+
uniqueIds(suite.modelIds, 'model')
|
|
201
|
+
uniqueIds(suite.scenarioIds, 'scenario')
|
|
202
|
+
uniqueIds(suite.tasks.map((task) => task.id), 'task')
|
|
203
|
+
const expectedTaskCount = suite.population.length * categories.length
|
|
204
|
+
if (suite.tasks.length !== expectedTaskCount) throw new Error(`The controlled task suite must contain exactly ${expectedTaskCount} tasks; received ${suite.tasks.length}.`)
|
|
205
|
+
const expectedCategories = new Set(categories)
|
|
206
|
+
for (const repositoryId of suite.population) {
|
|
207
|
+
const repositoryTasks = suite.tasks.filter((task) => task.repositoryId === repositoryId)
|
|
208
|
+
if (repositoryTasks.length !== categories.length) throw new Error(`Repository ${repositoryId} must contain exactly four tasks.`)
|
|
209
|
+
const repositoryCategories = new Set(repositoryTasks.map((task) => task.category))
|
|
210
|
+
if (repositoryCategories.size !== categories.length || [...expectedCategories].some((category) => !repositoryCategories.has(category))) {
|
|
211
|
+
throw new Error(`Repository ${repositoryId} must cover discovery, architecture, documentation, and implementation.`)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const task of suite.tasks) {
|
|
215
|
+
if (!suite.population.includes(task.repositoryId)) throw new Error(`Task ${task.id} references unknown repository ${task.repositoryId}.`)
|
|
216
|
+
if (task.budget.maxTokens > suite.maxTokensPerTask || task.budget.maxRuntimeMs > suite.maxRuntimeMsPerTask) throw new Error(`Task ${task.id} exceeds the suite budget.`)
|
|
217
|
+
if (task.variantGroup !== task.id) throw new Error(`Task ${task.id} must own its variant group.`)
|
|
218
|
+
uniqueIds(task.acceptanceChecks.map((check) => check.id), `acceptance check for ${task.id}`)
|
|
219
|
+
uniqueIds(task.evidenceRequirements.map((evidence) => evidence.id), `evidence requirement for ${task.id}`)
|
|
220
|
+
uniqueIds(task.variants.map((variant) => variant.id), `variant for ${task.id}`)
|
|
221
|
+
if (task.variants[0]?.label === task.variants[1]?.label) throw new Error(`Task ${task.id} variants must be distinguishable.`)
|
|
222
|
+
}
|
|
223
|
+
const plannedRuns = suite.tasks.length * suite.modelIds.length * suite.scenarioIds.length * suite.replicatesPerTask
|
|
224
|
+
if (plannedRuns > suite.maxRuns) throw new Error(`Planned runs ${plannedRuns} exceed maxRuns ${suite.maxRuns}.`)
|
|
225
|
+
if (options.requireExpectations) {
|
|
226
|
+
const without = suite.tasks.filter((task) => !hasRetrievalExpectations(task)).map((task) => task.id)
|
|
227
|
+
if (without.length) throw new Error(`These tasks declare no expected entities or documents, so nothing about them can be checked mechanically: ${without.join(', ')}.`)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const createStudyTaskSuite = (input: unknown): StudyTaskSuiteV1 => {
|
|
232
|
+
const payload = TaskSuitePayloadSchema.parse(input)
|
|
233
|
+
const hashable = { ...payload, contentHashAlgo: STUDY_TASK_SUITE_CONTENT_HASH_ALGO }
|
|
234
|
+
const suite = StudyTaskSuiteV1Schema.parse({ ...hashable, contentHash: sha256NormalizedV1(hashable) })
|
|
235
|
+
validateStudyTaskSuite(suite)
|
|
236
|
+
return suite
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export const parseStudyTaskSuite = (input: unknown): StudyTaskSuiteV1 => {
|
|
240
|
+
const suite = StudyTaskSuiteV1Schema.parse(input)
|
|
241
|
+
if (contentHashForArtifactV1(suite) !== suite.contentHash) throw new Error('Invalid task-suite content hash.')
|
|
242
|
+
validateStudyTaskSuite(suite)
|
|
243
|
+
return suite
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type TaskExecution = {
|
|
247
|
+
readonly taskId: string
|
|
248
|
+
readonly repositoryId: string
|
|
249
|
+
readonly category: typeof categories[number]
|
|
250
|
+
readonly scenarioId: string
|
|
251
|
+
readonly modelId: string
|
|
252
|
+
readonly replicate: number
|
|
253
|
+
readonly variantId: string
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export const selectTaskExecutions = (suite: StudyTaskSuiteV1, sampleSize?: number, sampling?: { readonly strategy?: 'balanced-task-strata' | 'pairwise-task-strata'; readonly scenarioIds?: readonly string[] }): readonly TaskExecution[] => {
|
|
257
|
+
const selectedScenarioIds = sampling?.strategy === 'pairwise-task-strata' ? sampling.scenarioIds ?? [] : suite.scenarioIds
|
|
258
|
+
if (sampling?.strategy === 'pairwise-task-strata' && selectedScenarioIds.length !== 2) throw new Error('Pairwise sampling requires exactly two scenarios.')
|
|
259
|
+
const executions: TaskExecution[] = []
|
|
260
|
+
for (const task of suite.tasks) {
|
|
261
|
+
const taskExecutions: Omit<TaskExecution, 'variantId'>[] = []
|
|
262
|
+
for (const scenarioId of selectedScenarioIds) for (const modelId of suite.modelIds) for (let replicate = 0; replicate < suite.replicatesPerTask; replicate += 1) {
|
|
263
|
+
taskExecutions.push({ taskId: task.id, repositoryId: task.repositoryId, category: task.category, scenarioId, modelId, replicate })
|
|
264
|
+
}
|
|
265
|
+
taskExecutions.sort((a, b) => sha256NormalizedV1({ seed: suite.ordering.seed, ...a }).localeCompare(sha256NormalizedV1({ seed: suite.ordering.seed, ...b })))
|
|
266
|
+
taskExecutions.forEach((execution, index) => executions.push({ ...execution, variantId: task.variants[index % task.variants.length]?.id ?? '' }))
|
|
267
|
+
}
|
|
268
|
+
const ordered = executions.sort((a, b) => sha256NormalizedV1({ seed: suite.ordering.seed, ...a }).localeCompare(sha256NormalizedV1({ seed: suite.ordering.seed, ...b })))
|
|
269
|
+
if (sampleSize === undefined || sampleSize === ordered.length) return ordered
|
|
270
|
+
const stratumCount = suite.modelIds.length * selectedScenarioIds.length
|
|
271
|
+
const maxSampleSize = suite.tasks.length * stratumCount
|
|
272
|
+
if (!Number.isInteger(sampleSize) || sampleSize <= 0 || sampleSize > maxSampleSize) throw new Error(`Sample size must be an integer between 1 and ${maxSampleSize}.`)
|
|
273
|
+
if (sampleSize % stratumCount !== 0) throw new Error(`Sample size must be divisible by the ${stratumCount} model/scenario strata.`)
|
|
274
|
+
if (sampling?.strategy === 'pairwise-task-strata') {
|
|
275
|
+
const taskCount = sampleSize / stratumCount
|
|
276
|
+
const taskOrder = [...suite.tasks].sort((a, b) => sha256NormalizedV1({ seed: suite.ordering.seed, taskId: a.id }).localeCompare(sha256NormalizedV1({ seed: suite.ordering.seed, taskId: b.id })))
|
|
277
|
+
const selectedTasks = new Set(taskOrder.slice(0, taskCount).map((task) => task.id))
|
|
278
|
+
return ordered.filter((execution) => selectedTasks.has(execution.taskId) && execution.replicate === 0).slice(0, sampleSize)
|
|
279
|
+
}
|
|
280
|
+
const selected: TaskExecution[] = []
|
|
281
|
+
for (let index = 0; index < sampleSize; index += 1) {
|
|
282
|
+
const task = suite.tasks[index]
|
|
283
|
+
const scenarioId = suite.scenarioIds[Math.floor(index / suite.modelIds.length) % suite.scenarioIds.length]
|
|
284
|
+
const modelId = suite.modelIds[index % suite.modelIds.length]
|
|
285
|
+
const execution = ordered.find((candidate) => candidate.taskId === task?.id && candidate.scenarioId === scenarioId && candidate.modelId === modelId && candidate.replicate === 0)
|
|
286
|
+
if (!execution) throw new Error(`Unable to select a balanced execution for task ${task?.id ?? index}.`)
|
|
287
|
+
selected.push(execution)
|
|
288
|
+
}
|
|
289
|
+
return selected
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export type TaskEvaluationInput = {
|
|
293
|
+
readonly acceptanceChecksPassed: number
|
|
294
|
+
readonly evidenceItemsPresent: number
|
|
295
|
+
readonly blocked?: boolean
|
|
296
|
+
readonly incorrect?: boolean
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export type TaskEvaluation = TaskEvaluationInput & {
|
|
300
|
+
readonly status: TaskOutcomeStatus
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export const evaluateStudyTask = (task: StudyTaskV1, result: TaskEvaluationInput): TaskEvaluation => {
|
|
304
|
+
const acceptanceTotal = task.acceptanceChecks.length
|
|
305
|
+
const evidenceTotal = task.evidenceRequirements.length
|
|
306
|
+
if (result.blocked) return { ...result, status: 'blocked' }
|
|
307
|
+
if (result.incorrect) return { ...result, status: 'incorrect' }
|
|
308
|
+
if (result.acceptanceChecksPassed === acceptanceTotal && result.evidenceItemsPresent === evidenceTotal) return { ...result, status: 'success' }
|
|
309
|
+
if (result.acceptanceChecksPassed === 0 && result.evidenceItemsPresent === 0) return { ...result, status: 'incomplete' }
|
|
310
|
+
return { ...result, status: 'partial' }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export const formatStudyTaskSuiteText = (suite: StudyTaskSuiteV1): readonly string[] => [
|
|
314
|
+
`Task suite: ${suite.suiteVersion}`,
|
|
315
|
+
`Mechanically checkable tasks: ${suite.tasks.filter(hasRetrievalExpectations).length}/${suite.tasks.length}`,
|
|
316
|
+
`Tasks: ${suite.tasks.length} (${suite.population.length} repositories × ${categories.length} categories)`,
|
|
317
|
+
`Executions planned: ${suite.tasks.length * suite.modelIds.length * suite.scenarioIds.length * suite.replicatesPerTask}`,
|
|
318
|
+
`Ordering: ${suite.ordering.strategy} (${suite.ordering.seed})`,
|
|
319
|
+
`Budget: ${suite.maxTokensPerTask} tokens/task, ${suite.maxRuntimeMsPerTask} ms/task, ${suite.maxRuns} runs`,
|
|
320
|
+
`Content hash: ${suite.contentHash}`,
|
|
321
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
4
|
+
|
|
5
|
+
export const STUDY_VERIFICATION_SCHEMA_VERSION = 1 as const
|
|
6
|
+
export const STUDY_VERIFICATION_CONTENT_HASH_ALGO = 'sha256-normalized-v1' as const
|
|
7
|
+
|
|
8
|
+
const hash = z.string().regex(/^[a-f0-9]{64}$/)
|
|
9
|
+
const reference = z.string().regex(/^[a-z0-9][a-z0-9._:-]{0,255}$/)
|
|
10
|
+
const artifactId = z.string().regex(/^[a-z][a-z0-9-]{0,127}$/)
|
|
11
|
+
|
|
12
|
+
const PrivacyBindingSchema = z.object({
|
|
13
|
+
mode: z.literal('anonymized'),
|
|
14
|
+
checkedArtifactCount: z.number().int().positive(),
|
|
15
|
+
forbiddenMatchCount: z.literal(0),
|
|
16
|
+
publicationReview: z.enum(['pending', 'approved']),
|
|
17
|
+
}).strict()
|
|
18
|
+
|
|
19
|
+
const BudgetBindingSchema = z.object({
|
|
20
|
+
maxTokens: z.number().int().positive(),
|
|
21
|
+
usedTokens: z.number().int().nonnegative(),
|
|
22
|
+
maxRuntimeMs: z.number().int().positive(),
|
|
23
|
+
usedRuntimeMs: z.number().int().nonnegative(),
|
|
24
|
+
}).strict().superRefine((value, context) => {
|
|
25
|
+
if (value.usedTokens > value.maxTokens) context.addIssue({ code: z.ZodIssueCode.custom, path: ['usedTokens'], message: 'Study token budget was exceeded.' })
|
|
26
|
+
if (value.usedRuntimeMs > value.maxRuntimeMs) context.addIssue({ code: z.ZodIssueCode.custom, path: ['usedRuntimeMs'], message: 'Study runtime budget was exceeded.' })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const BindingPayloadSchema = z.object({
|
|
30
|
+
type: z.literal('controlled-study-verification'),
|
|
31
|
+
schemaVersion: z.literal(STUDY_VERIFICATION_SCHEMA_VERSION),
|
|
32
|
+
verificationRunId: reference,
|
|
33
|
+
sourceRevisionHash: hash,
|
|
34
|
+
protocolHash: hash,
|
|
35
|
+
configurationHash: hash,
|
|
36
|
+
baselineHash: hash,
|
|
37
|
+
artifactHashes: z.record(artifactId, hash).superRefine((value, context) => {
|
|
38
|
+
if (!Object.keys(value).length) context.addIssue({ code: z.ZodIssueCode.custom, message: 'At least one study artifact hash is required.' })
|
|
39
|
+
if (Object.keys(value).length > 128) context.addIssue({ code: z.ZodIssueCode.custom, message: 'Study artifact hash count exceeds the limit.' })
|
|
40
|
+
}),
|
|
41
|
+
privacy: PrivacyBindingSchema,
|
|
42
|
+
budget: BudgetBindingSchema,
|
|
43
|
+
}).strict()
|
|
44
|
+
|
|
45
|
+
export const StudyVerificationBindingV1Schema = BindingPayloadSchema.extend({
|
|
46
|
+
contentHash: hash,
|
|
47
|
+
contentHashAlgo: z.literal(STUDY_VERIFICATION_CONTENT_HASH_ALGO),
|
|
48
|
+
}).strict()
|
|
49
|
+
|
|
50
|
+
export type StudyVerificationBindingV1 = z.infer<typeof StudyVerificationBindingV1Schema>
|
|
51
|
+
|
|
52
|
+
const VerifiedResultPayloadSchema = z.object({
|
|
53
|
+
type: z.literal('controlled-study-verified-result'),
|
|
54
|
+
schemaVersion: z.literal(STUDY_VERIFICATION_SCHEMA_VERSION),
|
|
55
|
+
verification: StudyVerificationBindingV1Schema,
|
|
56
|
+
result: z.unknown(),
|
|
57
|
+
}).strict()
|
|
58
|
+
|
|
59
|
+
export const ControlledStudyVerifiedResultV1Schema = VerifiedResultPayloadSchema.extend({
|
|
60
|
+
contentHash: hash,
|
|
61
|
+
contentHashAlgo: z.literal(STUDY_VERIFICATION_CONTENT_HASH_ALGO),
|
|
62
|
+
}).strict()
|
|
63
|
+
|
|
64
|
+
export type ControlledStudyVerifiedResultV1 = z.infer<typeof ControlledStudyVerifiedResultV1Schema>
|
|
65
|
+
|
|
66
|
+
export const createStudyVerificationBinding = (input: unknown): StudyVerificationBindingV1 => {
|
|
67
|
+
const payload = BindingPayloadSchema.parse(input)
|
|
68
|
+
const hashable = { ...payload, contentHashAlgo: STUDY_VERIFICATION_CONTENT_HASH_ALGO }
|
|
69
|
+
return StudyVerificationBindingV1Schema.parse({ ...hashable, contentHash: sha256NormalizedV1(hashable) })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const parseStudyVerificationBinding = (input: unknown): StudyVerificationBindingV1 => {
|
|
73
|
+
const binding = StudyVerificationBindingV1Schema.parse(input)
|
|
74
|
+
if (contentHashForArtifactV1(binding) !== binding.contentHash) throw new Error('Invalid study verification binding content hash.')
|
|
75
|
+
return binding
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const createControlledStudyVerifiedResult = (result: unknown, binding: StudyVerificationBindingV1): ControlledStudyVerifiedResultV1 => {
|
|
79
|
+
const verification = parseStudyVerificationBinding(binding)
|
|
80
|
+
const payload = VerifiedResultPayloadSchema.parse({ type: 'controlled-study-verified-result', schemaVersion: STUDY_VERIFICATION_SCHEMA_VERSION, verification, result })
|
|
81
|
+
const hashable = { ...payload, contentHashAlgo: STUDY_VERIFICATION_CONTENT_HASH_ALGO }
|
|
82
|
+
return ControlledStudyVerifiedResultV1Schema.parse({ ...hashable, contentHash: sha256NormalizedV1(hashable) })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const parseControlledStudyVerifiedResult = (input: unknown): ControlledStudyVerifiedResultV1 => {
|
|
86
|
+
const result = ControlledStudyVerifiedResultV1Schema.parse(input)
|
|
87
|
+
if (contentHashForArtifactV1(result) !== result.contentHash) throw new Error('Invalid controlled study result content hash.')
|
|
88
|
+
parseStudyVerificationBinding(result.verification)
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const unsafeKey = /^(?:prompt|raw(?:[-_ ]agent)?[-_ ]?(?:response|output)|agent[-_ ]?(?:response|output)|credential|secret|password|repository[-_ ]?(?:content|path)|private[-_ ]identifier|snippet|stdout|stderr)$/i
|
|
93
|
+
const unsafeValue = /(?:^|[\\/])(?:Users|private|tmp|home)(?:[\\/])|(?:api[_-]?key|token|password|secret)\s*[:=]|-----BEGIN|https?:\/\//i
|
|
94
|
+
|
|
95
|
+
export type StudyPrivacyScan = {
|
|
96
|
+
readonly ok: boolean
|
|
97
|
+
readonly checkedStrings: number
|
|
98
|
+
readonly forbiddenMatches: readonly string[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const scanStudyPublicationArtifact = (input: unknown): StudyPrivacyScan => {
|
|
102
|
+
const forbiddenMatches: string[] = []
|
|
103
|
+
let checkedStrings = 0
|
|
104
|
+
const visit = (value: unknown, path: string): void => {
|
|
105
|
+
if (typeof value === 'string') {
|
|
106
|
+
checkedStrings += 1
|
|
107
|
+
if (unsafeValue.test(value)) forbiddenMatches.push(path)
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
value.forEach((item, index) => visit(item, `${path}[${index}]`))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (value && typeof value === 'object') {
|
|
115
|
+
for (const [key, child] of Object.entries(value)) {
|
|
116
|
+
const childPath = path ? `${path}.${key}` : key
|
|
117
|
+
if (unsafeKey.test(key)) forbiddenMatches.push(childPath)
|
|
118
|
+
visit(child, childPath)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
visit(input, '$')
|
|
123
|
+
return { ok: forbiddenMatches.length === 0, checkedStrings, forbiddenMatches }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const formatStudyVerificationText = (binding: StudyVerificationBindingV1): readonly string[] => [
|
|
127
|
+
`Study verification: ${binding.verificationRunId}`,
|
|
128
|
+
`Source: ${binding.sourceRevisionHash}`,
|
|
129
|
+
`Protocol: ${binding.protocolHash} | Configuration: ${binding.configurationHash}`,
|
|
130
|
+
`Baseline: ${binding.baselineHash} | Artifacts: ${Object.keys(binding.artifactHashes).length}`,
|
|
131
|
+
`Privacy: ${binding.privacy.mode}, matches=${binding.privacy.forbiddenMatchCount}, review=${binding.privacy.publicationReview}`,
|
|
132
|
+
`Budget: ${binding.budget.usedTokens}/${binding.budget.maxTokens} tokens, ${binding.budget.usedRuntimeMs}/${binding.budget.maxRuntimeMs} ms`,
|
|
133
|
+
`Content hash: ${binding.contentHash}`,
|
|
134
|
+
]
|
package/src/validate.ts
CHANGED
|
@@ -3,7 +3,6 @@ import { ZodError } from 'zod'
|
|
|
3
3
|
import { DocBridgeConfigV1Schema, type DocBridgeConfigV1 } from './config/schema.js'
|
|
4
4
|
import {
|
|
5
5
|
AgentHandoffLegacySchema,
|
|
6
|
-
AgentHandoffV1Schema,
|
|
7
6
|
AgentSearchV1Schema,
|
|
8
7
|
normalizeAgentHandoff,
|
|
9
8
|
type AgentHandoffV1,
|
|
@@ -51,9 +50,7 @@ const zodIssues = (error: ZodError): readonly ParseIssue[] =>
|
|
|
51
50
|
export const safeParseAgentHandoff = (input: unknown): ParseResult<AgentHandoffV1> => {
|
|
52
51
|
const legacy = AgentHandoffLegacySchema.safeParse(input)
|
|
53
52
|
if (!legacy.success) return { ok: false, issues: zodIssues(legacy.error) }
|
|
54
|
-
|
|
55
|
-
if (!normalized.success) return { ok: false, issues: zodIssues(normalized.error) }
|
|
56
|
-
return { ok: true, value: normalized.data }
|
|
53
|
+
return { ok: true, value: normalizeAgentHandoff(legacy.data) }
|
|
57
54
|
}
|
|
58
55
|
|
|
59
56
|
export const parseAgentHandoff = (input: unknown): AgentHandoffV1 => {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const PACKAGE_VERSION = '1.
|
|
1
|
+
export const PACKAGE_VERSION = '1.10.0'
|
package/src/workflow/engine.ts
CHANGED
|
@@ -2,11 +2,24 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync
|
|
|
2
2
|
import { join, relative, resolve } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { contentHashForArtifactV1, sha256NormalizedV1 } from '../index-builder/content-hash.js'
|
|
5
|
-
import { WorkflowRunV1Schema, type WorkflowRunV1, type WorkflowState, type WorkflowStep } from '../schemas/knowledge.js'
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import { WorkflowRunV1Schema, type CorrelationContextV1, type WorkflowRunV1, type WorkflowState, type WorkflowStep } from '../schemas/knowledge.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `enrich` sits between `reconcile` and `evaluate` and is run only by `ak-docs enrich` or
|
|
9
|
+
* `check --enrich`: a plain `check` leaves its step pending, and `evaluate` reads a null
|
|
10
|
+
* previous output rather than an overlay. A missing, failed or stale enrich step never changes
|
|
11
|
+
* what `evaluate` or `report` produce.
|
|
12
|
+
*/
|
|
13
|
+
export const WORKFLOW_STAGES = ['collect', 'normalize', 'reconcile', 'enrich', 'evaluate', 'report'] as const
|
|
8
14
|
export type WorkflowStage = (typeof WORKFLOW_STAGES)[number]
|
|
9
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Stages a run may complete without. Given no handler, an optional stage is skipped rather than
|
|
18
|
+
* demanded, and the stage after it reads the output of the last stage that did run — so a caller
|
|
19
|
+
* that never heard of enrichment drives the workflow exactly as before.
|
|
20
|
+
*/
|
|
21
|
+
export const OPTIONAL_WORKFLOW_STAGES: readonly WorkflowStage[] = ['enrich']
|
|
22
|
+
|
|
10
23
|
export type WorkflowStageContext = {
|
|
11
24
|
readonly root: string
|
|
12
25
|
readonly stage: WorkflowStage
|
|
@@ -25,6 +38,7 @@ export type WorkflowOptions = {
|
|
|
25
38
|
readonly pipelineVersion?: string
|
|
26
39
|
readonly analyzerVersions?: Readonly<Record<string, string>>
|
|
27
40
|
readonly runId?: string
|
|
41
|
+
readonly correlation?: CorrelationContextV1
|
|
28
42
|
readonly stage?: WorkflowStage | 'all'
|
|
29
43
|
readonly inputs?: Partial<Record<WorkflowStage, unknown>>
|
|
30
44
|
readonly handlers: Partial<Record<WorkflowStage, WorkflowStageHandler>>
|
|
@@ -49,6 +63,7 @@ const stageState: Record<WorkflowStage, WorkflowState> = {
|
|
|
49
63
|
collect: 'discovering',
|
|
50
64
|
normalize: 'analyzed',
|
|
51
65
|
reconcile: 'compared',
|
|
66
|
+
enrich: 'awaiting-agent',
|
|
52
67
|
evaluate: 'proposed',
|
|
53
68
|
report: 'delivered',
|
|
54
69
|
}
|
|
@@ -74,14 +89,15 @@ const transition = (run: WorkflowRunV1, to: WorkflowState, reason?: string): Wor
|
|
|
74
89
|
created: ['created', 'discovering', 'failed', 'cancelled'],
|
|
75
90
|
discovering: ['discovering', 'analyzed', 'failed', 'cancelled', 'stale'],
|
|
76
91
|
analyzed: ['analyzed', 'compared', 'failed', 'cancelled', 'stale'],
|
|
77
|
-
compared: ['compared', 'proposed', 'failed', 'cancelled', 'stale'],
|
|
92
|
+
compared: ['compared', 'awaiting-agent', 'proposed', 'failed', 'cancelled', 'stale'],
|
|
78
93
|
'awaiting-agent': ['awaiting-agent', 'proposed', 'failed', 'cancelled', 'stale'],
|
|
79
|
-
|
|
94
|
+
// A delivered run can still be enriched; the stages after the overlay then re-run on their new input.
|
|
95
|
+
proposed: ['proposed', 'awaiting-agent', 'validating', 'delivered', 'failed', 'cancelled', 'stale'],
|
|
80
96
|
'awaiting-approval': ['awaiting-approval', 'validating', 'failed', 'cancelled', 'stale'],
|
|
81
97
|
validating: ['validating', 'delivered', 'failed', 'cancelled', 'stale'],
|
|
82
|
-
delivered: ['delivered', 'stale', 'failed', 'cancelled'],
|
|
83
|
-
failed: ['failed', 'discovering', 'analyzed', 'compared', 'proposed', 'validating', 'delivered', 'cancelled'],
|
|
84
|
-
cancelled: ['cancelled', 'discovering', 'analyzed', 'compared', 'proposed', 'validating', 'delivered'],
|
|
98
|
+
delivered: ['delivered', 'awaiting-agent', 'stale', 'failed', 'cancelled'],
|
|
99
|
+
failed: ['failed', 'discovering', 'analyzed', 'compared', 'awaiting-agent', 'proposed', 'validating', 'delivered', 'cancelled', 'stale'],
|
|
100
|
+
cancelled: ['cancelled', 'discovering', 'analyzed', 'compared', 'awaiting-agent', 'proposed', 'validating', 'delivered', 'stale'],
|
|
85
101
|
stale: [],
|
|
86
102
|
superseded: [],
|
|
87
103
|
}
|
|
@@ -164,6 +180,7 @@ const baseRun = (options: WorkflowOptions, stateDir: string, supersedes?: string
|
|
|
164
180
|
pipelineVersion: options.pipelineVersion ?? '1.0.0',
|
|
165
181
|
analyzerVersions: { ...(options.analyzerVersions ?? {}), workflow: options.toolVersion ?? '1.0.0' },
|
|
166
182
|
runId: options.runId ?? runId(),
|
|
183
|
+
...(options.correlation === undefined ? {} : { correlation: options.correlation }),
|
|
167
184
|
state: 'created',
|
|
168
185
|
steps: WORKFLOW_STAGES.map((name) => ({ name, status: 'pending', inputHash })) as WorkflowStep[],
|
|
169
186
|
transitions: [{ from: null, to: 'created', at: new Date().toISOString() }],
|
|
@@ -205,7 +222,11 @@ export const runWorkflow = (options: WorkflowOptions): WorkflowExecutionResult =
|
|
|
205
222
|
|
|
206
223
|
if (!run) throw new Error('Workflow manifest was not initialized.')
|
|
207
224
|
const firstSelectedStage = selectedStages(options.stage)[0]
|
|
208
|
-
|
|
225
|
+
let previousStageIndex = firstSelectedStage ? WORKFLOW_STAGES.indexOf(firstSelectedStage) - 1 : -1
|
|
226
|
+
// An optional stage that never ran is transparent: the input comes from the stage before it.
|
|
227
|
+
while (previousStageIndex >= 0 && OPTIONAL_WORKFLOW_STAGES.includes(WORKFLOW_STAGES[previousStageIndex]!) && run.steps.find((step) => step.name === WORKFLOW_STAGES[previousStageIndex])?.status !== 'completed') {
|
|
228
|
+
previousStageIndex -= 1
|
|
229
|
+
}
|
|
209
230
|
let previousOutput: unknown = null
|
|
210
231
|
if (previousStageIndex >= 0) {
|
|
211
232
|
try {
|
|
@@ -222,7 +243,8 @@ export const runWorkflow = (options: WorkflowOptions): WorkflowExecutionResult =
|
|
|
222
243
|
const input = options.inputs?.[stage] ?? previousOutput
|
|
223
244
|
const inputHash = stageInputHash(options, stage, input)
|
|
224
245
|
const existing = run.steps.find((step) => step.name === stage)
|
|
225
|
-
|
|
246
|
+
// A step whose input moved is a new step: its artifact is keyed on the new input, not on the old ref.
|
|
247
|
+
const artifactPath = existing?.artifactRefs?.[0] && existing.inputHash === inputHash ? resolve(stateDir, existing.artifactRefs[0]) : stageArtifactPath(stateDir, stage, inputHash)
|
|
226
248
|
if (existing?.status === 'completed' && existing.inputHash === inputHash && existing.outputHash && existsSync(artifactPath)) {
|
|
227
249
|
try {
|
|
228
250
|
previousOutput = readVerifiedArtifact(artifactPath, stage, existing).value
|
|
@@ -244,6 +266,7 @@ export const runWorkflow = (options: WorkflowOptions): WorkflowExecutionResult =
|
|
|
244
266
|
}
|
|
245
267
|
|
|
246
268
|
const handler = options.handlers[stage]
|
|
269
|
+
if (!handler && OPTIONAL_WORKFLOW_STAGES.includes(stage)) continue
|
|
247
270
|
if (!handler) throw new Error(`No handler configured for workflow stage "${stage}".`)
|
|
248
271
|
run = withHash(transition(run, stageState[stage]))
|
|
249
272
|
appendTransition(stateDir, run.transitions[run.transitions.length - 1]!)
|
|
@@ -274,7 +297,9 @@ export const runWorkflow = (options: WorkflowOptions): WorkflowExecutionResult =
|
|
|
274
297
|
}
|
|
275
298
|
}
|
|
276
299
|
|
|
277
|
-
|
|
300
|
+
const satisfied = (stage: WorkflowStage): boolean =>
|
|
301
|
+
run!.steps.find((step) => step.name === stage)?.status === 'completed' || (OPTIONAL_WORKFLOW_STAGES.includes(stage) && !options.handlers[stage])
|
|
302
|
+
if (selectedStages(options.stage).every(satisfied)) {
|
|
278
303
|
const complete = selectedStages(options.stage).includes('report') && run.state !== 'delivered' ? withHash(transition(run, 'delivered')) : run
|
|
279
304
|
if (complete !== run) {
|
|
280
305
|
appendTransition(stateDir, complete.transitions[complete.transitions.length - 1]!)
|