@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.
Files changed (213) hide show
  1. package/CHANGELOG.md +488 -0
  2. package/CONTRIBUTING.md +23 -0
  3. package/README.md +87 -40
  4. package/SECURITY.md +7 -0
  5. package/action.yml +1 -1
  6. package/bin/ak-docs.js +2 -2
  7. package/bin/ak-verify.js +13 -7
  8. package/dist/cli/program.d.ts +3 -1
  9. package/dist/cli/program.js +15871 -6218
  10. package/dist/cli/program.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.js +95 -6
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/{index-BUL0q7s8.d.ts → index-7wYGbllW.d.ts} +134 -1
  15. package/dist/index.d.ts +9880 -1062
  16. package/dist/index.js +16143 -6127
  17. package/dist/index.js.map +1 -1
  18. package/docs/MARKETPLACE.md +1 -1
  19. package/docs/PRD-documentation-efficiency-study.md +406 -0
  20. package/docs/PRD-knowledge-retrieval-and-enrichment.md +466 -0
  21. package/docs/adr/0002-documentation-audit-boundary.md +22 -0
  22. package/docs/adr/0003-study-protocol-and-historical-evidence.md +40 -0
  23. package/docs/adr/0004-controlled-study-runner.md +25 -0
  24. package/docs/adr/0005-documentation-quality-and-criticality.md +20 -0
  25. package/docs/adr/0006-registry-semantic-grounding.md +20 -0
  26. package/docs/adr/0007-longitudinal-study-metrics.md +21 -0
  27. package/docs/adr/0008-study-verification-boundary.md +21 -0
  28. package/docs/adr/0009-study-provider-cli-adapter.md +25 -0
  29. package/docs/agent-corpus/INDEX.md +12 -1
  30. package/docs/agent-corpus/OVERVIEW.md +25 -0
  31. package/docs/agent-corpus/chat.md +5 -1
  32. package/docs/agent-corpus/cli.md +16 -0
  33. package/docs/agent-corpus/conformance.md +12 -0
  34. package/docs/agent-corpus/doc-bridge.md +47 -0
  35. package/docs/agent-corpus/doctor.md +8 -0
  36. package/docs/agent-corpus/gates.md +4 -0
  37. package/docs/agent-corpus/mcp.md +13 -0
  38. package/docs/agent-corpus/memory.md +4 -0
  39. package/docs/agent-corpus/query.md +33 -0
  40. package/docs/bench/README.md +122 -0
  41. package/docs/bench/retrieval-baseline-v1.json +28 -0
  42. package/docs/bench/retrieval-suite-v1.json +1033 -0
  43. package/docs/chat-and-rag.md +3 -2
  44. package/docs/for-agents.md +9 -1
  45. package/docs/getting-started.md +4 -11
  46. package/docs/guides/gate-ci.md +11 -1
  47. package/docs/guides/install-and-run.md +9 -65
  48. package/docs/index.md +22 -1
  49. package/docs/knowledge-engine-runbook.md +38 -3
  50. package/docs/landing/assets/context-payload-reduction.svg +21 -0
  51. package/docs/landing/assets/controlled-ab-comparison.svg +30 -0
  52. package/docs/landing/index.html +119 -5
  53. package/docs/loop-workflow.md +117 -0
  54. package/docs/mcp.md +6 -1
  55. package/docs/parity/public-claims-v1.json +145 -0
  56. package/docs/playbook/doc-bridge-pattern.md +1 -1
  57. package/docs/query.md +90 -2
  58. package/docs/recipes/index-pipeline.md +1 -1
  59. package/docs/schemas/agent-handoff-v1.md +15 -0
  60. package/docs/schemas/doc-bridge-index-v1.md +65 -0
  61. package/docs/spec/benchmark-v1.md +33 -1
  62. package/docs/spec/cli.md +30 -10
  63. package/docs/spec/config-v1.md +157 -8
  64. package/docs/spec/documentation-audit-v1.md +61 -0
  65. package/docs/spec/enrichment-overlay-v1.md +241 -0
  66. package/docs/spec/graph-signals-v1.md +92 -0
  67. package/docs/spec/incremental-scan-v1.md +102 -0
  68. package/docs/spec/markdown-analyzer-v1.md +73 -0
  69. package/docs/spec/mcp-knowledge-tools-v1.md +147 -0
  70. package/docs/spec/measured-enrichment-v1.md +229 -0
  71. package/docs/spec/public-parity-v1.md +119 -0
  72. package/docs/spec/registry-agents.md +6 -0
  73. package/docs/spec/render-v1.md +122 -0
  74. package/docs/spec/retrieval-index-v1.md +164 -0
  75. package/docs/spec/study-metrics-v1.md +58 -0
  76. package/docs/spec/study-protocol-v1.md +46 -0
  77. package/docs/spec/study-provider-cli-v1.md +116 -0
  78. package/docs/spec/study-runner-v1.md +35 -0
  79. package/docs/spec/study-task-suite-v1.md +41 -0
  80. package/docs/spec/study-verification-v1.md +40 -0
  81. package/docs/study/README.md +84 -0
  82. package/docs/study/ab-adjudicated-cost-analysis-v1.md +29 -0
  83. package/docs/study/ab-adjudicated-cost-plan-v1.json +33 -0
  84. package/docs/study/ab-adjudicated-cost-plan-v2-v1.json +33 -0
  85. package/docs/study/ab-adjudicated-cost-result-v1.json +80 -0
  86. package/docs/study/ab-baseline-analysis-v1.md +21 -0
  87. package/docs/study/ab-baseline-plan-v1.json +33 -0
  88. package/docs/study/ab-baseline-recovery-plan-v1.json +33 -0
  89. package/docs/study/ab-baseline-result-v1.json +79 -0
  90. package/docs/study/documentation-audit-round-2026-08-31.json +183 -0
  91. package/docs/study/historical-evidence-v1.json +252 -0
  92. package/docs/study/observation-ledger-v1.json +30632 -0
  93. package/docs/study/phase3-task-coverage-v1.json +34 -0
  94. package/docs/study/phase4-public-pilot-ledger-v1.json +1344 -0
  95. package/docs/study/phase4-public-pilot-result-v1.json +52 -0
  96. package/docs/study/phase4-public-pilot-run-plan-v1.json +26 -0
  97. package/docs/study/phase4-public-pilot-task-suite-v1.json +71 -0
  98. package/docs/study/pilot-round-2026-08-31.json +46 -0
  99. package/docs/study/protocol-v1.json +90 -0
  100. package/docs/study/publication-gate-v1.md +45 -0
  101. package/docs/study/quality-scorecard-cycle-plan.md +545 -0
  102. package/docs/study/quality-scorecard-v1.json +38 -0
  103. package/docs/study/round-1-adjudicated-smoke-v1.json +30642 -0
  104. package/docs/study/round-1-instrumentation-plan-v1.md +39 -0
  105. package/docs/study/round-2-expanded-adjudication-v1.json +91 -0
  106. package/docs/study/round-2-expanded-validation-v1.md +58 -0
  107. package/docs/study/round-3-evidence-contract-v1.json +75 -0
  108. package/docs/study/round-3-evidence-contract-v1.md +57 -0
  109. package/docs/study/round-4-confirmation-v1.json +75 -0
  110. package/docs/study/round-4-confirmation-v1.md +55 -0
  111. package/docs/study/run-plan-v1.json +33 -0
  112. package/docs/study/semantic-adjudication-cycle-8.md +20 -0
  113. package/docs/study/task-suite-v1.json +96 -0
  114. package/docs/study/token-efficiency-plan-v1.md +337 -0
  115. package/docs/study/token-efficiency-protocol-v2.json +62 -0
  116. package/docs/study/verification-binding-v1.json +27 -0
  117. package/docs/validation-cycle-plan.md +14 -0
  118. package/docs/verification-harness.md +11 -6
  119. package/ecosystem-claims.json +2 -2
  120. package/ecosystem-upstream.json +2 -2
  121. package/ecosystem.json +4 -4
  122. package/mcpb/manifest.json +9 -1
  123. package/package.json +34 -15
  124. package/scripts/check-ecosystem-upstream.mjs +34 -6
  125. package/skills/doc-bridge-handoff/fixtures/synthetic-repo/docs/for-agents/packages/payments.md +7 -0
  126. package/skills/doc-bridge-handoff/scripts/resolve-handoff.mjs +1 -1
  127. package/src/agents/registry-adapter.ts +192 -24
  128. package/src/audit/documentation.ts +513 -0
  129. package/src/bench/baseline.ts +198 -0
  130. package/src/bench/overlay-delta.ts +139 -0
  131. package/src/bench/retrieval.ts +319 -0
  132. package/src/budget/compile.ts +91 -0
  133. package/src/budget/sections.ts +70 -0
  134. package/src/cli/program.ts +684 -74
  135. package/src/cli/usage.ts +71 -0
  136. package/src/config/defaults.ts +1 -0
  137. package/src/config/index.ts +4 -0
  138. package/src/config/schema.ts +126 -2
  139. package/src/conformance/documentation-standard-v1.ts +8 -6
  140. package/src/discovery/areas.ts +197 -0
  141. package/src/discovery/documentation.ts +211 -5
  142. package/src/discovery/identity.ts +24 -0
  143. package/src/discovery/incremental.ts +314 -0
  144. package/src/discovery/inputs.ts +110 -0
  145. package/src/discovery/markdown.ts +481 -0
  146. package/src/discovery/repository.ts +485 -101
  147. package/src/doctor/run-doctor.ts +244 -12
  148. package/src/enrich/approvals.ts +190 -0
  149. package/src/enrich/cache.ts +93 -0
  150. package/src/enrich/context-pack.ts +272 -0
  151. package/src/enrich/overlay.ts +255 -0
  152. package/src/enrich/review.ts +106 -0
  153. package/src/enrich/stage.ts +374 -0
  154. package/src/enrich/stats.ts +100 -0
  155. package/src/enrich/validate.ts +410 -0
  156. package/src/federation/llms.ts +2 -4
  157. package/src/findings/report.ts +103 -0
  158. package/src/graph/build.ts +356 -0
  159. package/src/graph/memory.ts +208 -0
  160. package/src/index-builder/build-handoffs.ts +22 -11
  161. package/src/index-builder/build-index.ts +132 -3
  162. package/src/index-builder/llms-txt.ts +48 -8
  163. package/src/index-builder/project-corpus.ts +111 -0
  164. package/src/index.ts +630 -2
  165. package/src/lib/fuzzy-match.ts +235 -0
  166. package/src/mcp/knowledge.ts +554 -0
  167. package/src/mcp/server.ts +113 -18
  168. package/src/metrics/benchmark.ts +21 -0
  169. package/src/parity/check.ts +309 -0
  170. package/src/parity/claims.ts +259 -0
  171. package/src/parity/resolve.ts +160 -0
  172. package/src/query/handoff.ts +326 -0
  173. package/src/query/load-index.ts +53 -1
  174. package/src/query/query.ts +92 -59
  175. package/src/query/search.ts +289 -92
  176. package/src/query/text.ts +155 -0
  177. package/src/reconciliation/reconcile.ts +101 -10
  178. package/src/render/data.ts +356 -0
  179. package/src/render/engine.ts +398 -0
  180. package/src/render/generated.ts +77 -0
  181. package/src/render/render.ts +209 -0
  182. package/src/render/template-source.ts +52 -0
  183. package/src/render/templates.ts +289 -0
  184. package/src/report/html.ts +2 -2
  185. package/src/retrieval/bm25.ts +161 -0
  186. package/src/retrieval/project.ts +495 -0
  187. package/src/retrieval/rank.ts +383 -0
  188. package/src/retrieval/weights.ts +39 -0
  189. package/src/retriever/doc-bridge-retriever.ts +100 -15
  190. package/src/rules/engine.ts +30 -10
  191. package/src/schemas/agent-handoff.ts +56 -0
  192. package/src/schemas/budget.ts +37 -0
  193. package/src/schemas/doc-bridge-index.ts +53 -2
  194. package/src/schemas/enrichment.ts +369 -0
  195. package/src/schemas/json-schemas.ts +39 -2
  196. package/src/schemas/knowledge.ts +14 -1
  197. package/src/schemas/retrieval-index.ts +152 -0
  198. package/src/shims/graphology.d.ts +91 -0
  199. package/src/study/adjudication.ts +196 -0
  200. package/src/study/execution.ts +350 -0
  201. package/src/study/expectations.ts +219 -0
  202. package/src/study/metrics.ts +467 -0
  203. package/src/study/protocol.ts +271 -0
  204. package/src/study/provider-cli.ts +115 -0
  205. package/src/study/provider-telemetry.ts +47 -0
  206. package/src/study/quality-scorecard.ts +164 -0
  207. package/src/study/runner.ts +461 -0
  208. package/src/study/task-suite.ts +321 -0
  209. package/src/study/verification.ts +134 -0
  210. package/src/validate.ts +1 -4
  211. package/src/version.ts +1 -1
  212. package/src/workflow/engine.ts +36 -11
  213. 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
- const normalized = AgentHandoffV1Schema.safeParse(normalizeAgentHandoff(legacy.data))
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.7.45'
1
+ export const PACKAGE_VERSION = '1.10.0'
@@ -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
- export const WORKFLOW_STAGES = ['collect', 'normalize', 'reconcile', 'evaluate', 'report'] as const
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
- proposed: ['proposed', 'validating', 'delivered', 'failed', 'cancelled', 'stale'],
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
- const previousStageIndex = firstSelectedStage ? WORKFLOW_STAGES.indexOf(firstSelectedStage) - 1 : -1
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
- const artifactPath = existing?.artifactRefs?.[0] ? resolve(stateDir, existing.artifactRefs[0]) : stageArtifactPath(stateDir, stage, inputHash)
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
- if (selectedStages(options.stage).every((stage) => run!.steps.find((step) => step.name === stage)?.status === 'completed')) {
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]!)