akm-cli 0.9.0-rc.0 → 0.9.0-rc.2

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 (262) hide show
  1. package/CHANGELOG.md +339 -2
  2. package/SECURITY.md +23 -24
  3. package/dist/assets/help/help-improve.md +10 -10
  4. package/dist/assets/hints/cli-hints-full.md +44 -10
  5. package/dist/assets/hints/cli-hints-short.md +6 -2
  6. package/dist/assets/{profiles → improve-strategies}/default.json +1 -0
  7. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +1 -1
  8. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +2 -3
  9. package/dist/assets/{profiles → improve-strategies}/reflect-distill.json +3 -4
  10. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  11. package/dist/assets/stash-skeleton/README.md +28 -0
  12. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +6 -0
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +6 -0
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +12 -1
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +11 -1
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +6 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +9 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +7 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +7 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +6 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +98 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/domains.md +63 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/organization.md +127 -0
  24. package/dist/assets/tasks/core/backup.yml +1 -0
  25. package/dist/assets/tasks/core/extract.yml +1 -0
  26. package/dist/assets/tasks/core/improve.yml +1 -0
  27. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  28. package/dist/assets/tasks/core/sync.yml +1 -0
  29. package/dist/assets/tasks/core/version-check.yml +1 -0
  30. package/dist/assets/tasks/graph-refresh-weekly.yml +4 -4
  31. package/dist/assets/templates/html/health.html +5 -1
  32. package/dist/cli/config-migrate.js +31 -138
  33. package/dist/cli/config-validate.js +10 -8
  34. package/dist/cli.js +48 -14
  35. package/dist/commands/agent/agent-dispatch.js +17 -16
  36. package/dist/commands/agent/agent-support.js +0 -24
  37. package/dist/commands/agent/contribute-cli.js +5 -15
  38. package/dist/commands/backup-cli.js +54 -0
  39. package/dist/commands/config-cli.js +45 -159
  40. package/dist/commands/env/env-binding.js +95 -0
  41. package/dist/commands/env/env-cli.js +8 -65
  42. package/dist/commands/env/secret.js +8 -5
  43. package/dist/commands/health/checks.js +130 -83
  44. package/dist/commands/health/html-report.js +4 -0
  45. package/dist/commands/health/improve-metrics.js +30 -32
  46. package/dist/commands/health/llm-usage.js +19 -19
  47. package/dist/commands/health/md-report.js +4 -0
  48. package/dist/commands/health/metrics.js +2 -1
  49. package/dist/commands/health/surfaces.js +5 -4
  50. package/dist/commands/health.js +1 -1
  51. package/dist/commands/improve/consolidate/chunking.js +2 -2
  52. package/dist/commands/improve/consolidate.js +28 -25
  53. package/dist/commands/improve/distill/promote-memory.js +5 -12
  54. package/dist/commands/improve/distill/quality-gate.js +5 -7
  55. package/dist/commands/improve/distill.js +16 -5
  56. package/dist/commands/improve/eligibility.js +22 -12
  57. package/dist/commands/improve/extract-cli.js +47 -19
  58. package/dist/commands/improve/extract.js +110 -62
  59. package/dist/commands/improve/improve-cli.js +38 -16
  60. package/dist/commands/improve/improve-result-file.js +30 -24
  61. package/dist/commands/improve/improve-strategies.js +137 -0
  62. package/dist/commands/improve/improve.js +60 -30
  63. package/dist/commands/improve/locks.js +66 -45
  64. package/dist/commands/improve/loop-stages.js +75 -33
  65. package/dist/commands/improve/memory/memory-belief.js +79 -7
  66. package/dist/commands/improve/memory/memory-contradiction-detect.js +12 -4
  67. package/dist/commands/improve/preparation.js +71 -73
  68. package/dist/commands/improve/procedural.js +3 -2
  69. package/dist/commands/improve/recombine.js +2 -1
  70. package/dist/commands/improve/reflect.js +119 -214
  71. package/dist/commands/improve/shared.js +11 -5
  72. package/dist/commands/lint/base-linter.js +152 -42
  73. package/dist/commands/mv-cli.js +809 -0
  74. package/dist/commands/proposal/proposal-cli.js +18 -8
  75. package/dist/commands/proposal/propose.js +64 -69
  76. package/dist/commands/read/knowledge.js +436 -4
  77. package/dist/commands/read/remember-cli.js +39 -2
  78. package/dist/commands/read/search-cli.js +6 -1
  79. package/dist/commands/registry-cli.js +29 -14
  80. package/dist/commands/remember.js +2 -0
  81. package/dist/commands/sources/init.js +13 -14
  82. package/dist/commands/sources/migration-help.js +7 -4
  83. package/dist/commands/sources/schema-repair.js +2 -4
  84. package/dist/commands/sources/source-add.js +62 -73
  85. package/dist/commands/sources/source-manage.js +50 -46
  86. package/dist/commands/sources/stash-cli.js +41 -4
  87. package/dist/commands/tasks/default-tasks.js +12 -12
  88. package/dist/commands/tasks/tasks-cli.js +7 -3
  89. package/dist/commands/tasks/tasks.js +113 -18
  90. package/dist/commands/wiki-cli.js +9 -10
  91. package/dist/commands/workflow-cli.js +276 -12
  92. package/dist/core/asset/asset-spec.js +58 -1
  93. package/dist/core/asset/frontmatter.js +12 -2
  94. package/dist/core/common.js +5 -3
  95. package/dist/core/config/config-io.js +28 -17
  96. package/dist/core/config/config-schema.js +379 -66
  97. package/dist/core/config/config-types.js +3 -3
  98. package/dist/core/config/config-version.js +29 -0
  99. package/dist/core/config/config-walker.js +98 -27
  100. package/dist/core/config/config.js +132 -266
  101. package/dist/core/config/deep-merge.js +41 -0
  102. package/dist/core/config/engine-semantics.js +32 -0
  103. package/dist/core/errors.js +2 -2
  104. package/dist/core/extra-params.js +61 -0
  105. package/dist/core/file-lock.js +201 -56
  106. package/dist/core/improve-result.js +178 -0
  107. package/dist/core/json-schema.js +142 -0
  108. package/dist/core/maintenance-barrier.js +119 -0
  109. package/dist/core/migration-backup.js +416 -0
  110. package/dist/core/paths.js +3 -0
  111. package/dist/core/redaction.js +358 -0
  112. package/dist/core/state/migrations.js +17 -2
  113. package/dist/core/state-db.js +44 -1
  114. package/dist/indexer/db/db.js +118 -2
  115. package/dist/indexer/graph/graph-extraction.js +28 -16
  116. package/dist/indexer/index-writer-lock.js +31 -24
  117. package/dist/indexer/index-written-assets.js +15 -6
  118. package/dist/indexer/indexer.js +47 -2
  119. package/dist/indexer/passes/memory-inference.js +10 -6
  120. package/dist/indexer/passes/metadata.js +250 -0
  121. package/dist/indexer/search/db-search.js +111 -44
  122. package/dist/indexer/search/fts-query.js +41 -0
  123. package/dist/indexer/search/ranking-contributors.js +48 -0
  124. package/dist/indexer/search/ranking.js +36 -23
  125. package/dist/indexer/search/search-fields.js +11 -1
  126. package/dist/indexer/walk/matchers.js +39 -0
  127. package/dist/integrations/agent/builder-shared.js +7 -0
  128. package/dist/integrations/agent/builders.js +5 -50
  129. package/dist/integrations/agent/config.js +3 -143
  130. package/dist/integrations/agent/detect.js +17 -2
  131. package/dist/integrations/agent/engine-resolution.js +202 -0
  132. package/dist/integrations/agent/index.js +1 -2
  133. package/dist/integrations/agent/model-aliases.js +16 -2
  134. package/dist/integrations/agent/profiles.js +36 -62
  135. package/dist/integrations/agent/runner-dispatch.js +91 -4
  136. package/dist/integrations/agent/runner.js +76 -207
  137. package/dist/integrations/agent/spawn.js +141 -20
  138. package/dist/integrations/harnesses/aider/agent-builder.js +112 -0
  139. package/dist/integrations/harnesses/aider/index.js +57 -0
  140. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  141. package/dist/integrations/harnesses/amazonq/agent-builder.js +152 -0
  142. package/dist/integrations/harnesses/amazonq/index.js +58 -0
  143. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  144. package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
  145. package/dist/integrations/harnesses/claude/index.js +25 -25
  146. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  147. package/dist/integrations/harnesses/codex/agent-builder.js +136 -0
  148. package/dist/integrations/harnesses/codex/index.js +62 -0
  149. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  150. package/dist/integrations/harnesses/copilot/agent-builder.js +121 -0
  151. package/dist/integrations/harnesses/copilot/index.js +59 -0
  152. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  153. package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
  154. package/dist/integrations/harnesses/gemini/index.js +59 -0
  155. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  156. package/dist/integrations/harnesses/index.js +27 -28
  157. package/dist/integrations/harnesses/opencode/agent-builder.js +2 -3
  158. package/dist/integrations/harnesses/opencode/index.js +15 -22
  159. package/dist/integrations/harnesses/opencode-sdk/harness.js +60 -0
  160. package/dist/integrations/harnesses/opencode-sdk/index.js +8 -32
  161. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
  162. package/dist/integrations/harnesses/openhands/agent-builder.js +125 -0
  163. package/dist/integrations/harnesses/openhands/index.js +57 -0
  164. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  165. package/dist/integrations/harnesses/pi/agent-builder.js +103 -0
  166. package/dist/integrations/harnesses/pi/index.js +57 -0
  167. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  168. package/dist/integrations/harnesses/types.js +8 -32
  169. package/dist/integrations/lockfile.js +32 -21
  170. package/dist/integrations/session-logs/index.js +24 -11
  171. package/dist/llm/client.js +48 -14
  172. package/dist/llm/feature-gate.js +15 -47
  173. package/dist/llm/graph-extract.js +1 -1
  174. package/dist/llm/index-passes.js +8 -42
  175. package/dist/llm/memory-infer-impl.js +1 -1
  176. package/dist/llm/usage-persist.js +4 -0
  177. package/dist/llm/usage-telemetry.js +35 -5
  178. package/dist/output/renderers.js +3 -2
  179. package/dist/output/shapes/helpers.js +2 -1
  180. package/dist/output/shapes/passthrough.js +6 -0
  181. package/dist/output/text/helpers.js +215 -2
  182. package/dist/output/text/workflow.js +3 -1
  183. package/dist/schemas/akm-config.json +16638 -0
  184. package/dist/schemas/akm-task.json +87 -0
  185. package/dist/schemas/akm-workflow.json +372 -0
  186. package/dist/scripts/migrate-storage.js +10944 -8801
  187. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +9247 -350
  188. package/dist/setup/detected-engines.js +142 -0
  189. package/dist/setup/engine-config.js +89 -0
  190. package/dist/setup/setup.js +236 -132
  191. package/dist/setup/steps/connection.js +61 -32
  192. package/dist/setup/steps/platforms.js +4 -4
  193. package/dist/setup/steps.js +3 -2
  194. package/dist/storage/database.js +13 -1
  195. package/dist/storage/engines/sqlite-migrations.js +1 -0
  196. package/dist/storage/repositories/improve-runs-repository.js +5 -5
  197. package/dist/storage/repositories/task-history-repository.js +78 -0
  198. package/dist/storage/repositories/workflow-runs-repository.js +190 -1
  199. package/dist/tasks/parser.js +138 -52
  200. package/dist/tasks/runner.js +71 -75
  201. package/dist/tasks/schema.js +1 -1
  202. package/dist/tasks/validator.js +11 -6
  203. package/dist/text-import-hook.mjs +1 -1
  204. package/dist/wiki/wiki.js +9 -8
  205. package/dist/workflows/authoring/authoring.js +123 -10
  206. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  207. package/dist/workflows/cli.js +4 -0
  208. package/dist/workflows/concurrency-policy.js +15 -0
  209. package/dist/workflows/db.js +200 -13
  210. package/dist/workflows/exec/brief.js +478 -0
  211. package/dist/workflows/exec/frozen-judge.js +47 -0
  212. package/dist/workflows/exec/native-executor.js +1034 -0
  213. package/dist/workflows/exec/param-secrets.js +115 -0
  214. package/dist/workflows/exec/report.js +1355 -0
  215. package/dist/workflows/exec/run-workflow.js +609 -0
  216. package/dist/workflows/exec/scheduler.js +71 -0
  217. package/dist/workflows/exec/step-work.js +1212 -0
  218. package/dist/workflows/exec/unit-writer.js +23 -0
  219. package/dist/workflows/exec/watch.js +116 -0
  220. package/dist/workflows/exec/worktree.js +171 -0
  221. package/dist/workflows/ir/compile.js +375 -0
  222. package/dist/workflows/ir/freeze.js +243 -0
  223. package/dist/workflows/ir/params.js +54 -0
  224. package/dist/workflows/ir/plan-hash.js +68 -0
  225. package/dist/workflows/ir/schema.js +545 -0
  226. package/dist/workflows/parser.js +10 -1
  227. package/dist/workflows/program/expressions.js +369 -0
  228. package/dist/workflows/program/parser.js +869 -0
  229. package/dist/workflows/program/project.js +104 -0
  230. package/dist/workflows/program/schema.js +54 -0
  231. package/dist/workflows/renderer.js +82 -5
  232. package/dist/workflows/resource-limits.js +20 -0
  233. package/dist/workflows/runtime/agent-identity.js +59 -14
  234. package/dist/workflows/runtime/plan-classifier.js +187 -0
  235. package/dist/workflows/runtime/runs.js +246 -69
  236. package/dist/workflows/runtime/unit-checkin.js +45 -0
  237. package/dist/workflows/runtime/workflow-asset-loader.js +42 -1
  238. package/dist/workflows/validate-summary.js +24 -3
  239. package/dist/workflows/validator.js +26 -1
  240. package/docs/data-and-telemetry.md +4 -3
  241. package/docs/migration/release-notes/0.6.0.md +1 -1
  242. package/docs/migration/release-notes/0.7.0.md +5 -4
  243. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  244. package/docs/migration/v0.8-to-v0.9.md +401 -0
  245. package/package.json +4 -2
  246. package/schemas/akm-config.json +16638 -0
  247. package/schemas/akm-task.json +87 -0
  248. package/schemas/akm-workflow.json +372 -0
  249. package/dist/commands/improve/improve-profiles.js +0 -168
  250. package/dist/core/config/config-migration.js +0 -602
  251. package/dist/core/deep-merge.js +0 -38
  252. package/dist/llm/call-ai.js +0 -62
  253. package/dist/setup/legacy-config.js +0 -106
  254. package/docs/README.md +0 -104
  255. /package/dist/assets/{profiles → improve-strategies}/catchup.json +0 -0
  256. /package/dist/assets/{profiles → improve-strategies}/consolidate.json +0 -0
  257. /package/dist/assets/{profiles → improve-strategies}/frequent.json +0 -0
  258. /package/dist/assets/{profiles → improve-strategies}/memory-focus.json +0 -0
  259. /package/dist/assets/{profiles → improve-strategies}/quick.json +0 -0
  260. /package/dist/assets/{profiles → improve-strategies}/recombine-only.json +0 -0
  261. /package/dist/assets/{profiles → improve-strategies}/synthesize.json +0 -0
  262. /package/dist/assets/{profiles → improve-strategies}/thorough.json +0 -0
@@ -0,0 +1,1212 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Shared step semantics — the ONE implementation of a step's orchestration
6
+ * decisions, consumed by BOTH the engine loop (`run-workflow.ts` +
7
+ * `native-executor.ts`) and, from R3 on, the harness-neutral driver protocol
8
+ * (`workflow brief` / `workflow report`). The cardinal rule of the driver
9
+ * protocol (redesign addendum R3) is *no duplicated semantics*: work-list
10
+ * computation, prompt assembly, reducer/artifact promotion, output-schema
11
+ * validation, artifact-judged gate summaries, gate-feedback recovery, and
12
+ * route evaluation live here so an engine-driven run and a brief/report-driven
13
+ * run of the same frozen plan produce byte-identical unit graphs.
14
+ *
15
+ * ## What is PURE here
16
+ *
17
+ * {@link computeStepWorkList} — given the frozen step plan and a
18
+ * {@link WorkListInput} (params, prior step outputs, gate-loop number + its
19
+ * recovered feedback) — is a pure function: same inputs ⇒ same unit ids, input
20
+ * hashes, and fully-resolved prompts. It takes NO clock, NO IO, and NO journal
21
+ * (journal-derived state, i.e. the recovered gate feedback, is passed in). This
22
+ * is the load-bearing guarantee that `brief` can predict exactly the units the
23
+ * engine would dispatch. So are the reducer/artifact helpers
24
+ * ({@link buildEvidence}, {@link projectStepOutput}, {@link validateStepArtifact},
25
+ * {@link buildArtifactSummary}), the gate-feedback recovery
26
+ * ({@link recoverGateFeedback} / {@link activeGateLoop}), and route evaluation
27
+ * ({@link evaluateRoute} and its bookkeeping).
28
+ *
29
+ * ## What does IO here
30
+ *
31
+ * The gate-evaluation journaling ({@link journalGateEvaluationStart} /
32
+ * {@link journalGateEvaluationFinish}) writes `workflow_run_units` rows through
33
+ * the serialized writer queue — an engine-driven judge call is an LLM call and
34
+ * is journaled like a unit. It lives here (not in the engine loop) so the
35
+ * report path journals gate evaluations through the identical writer.
36
+ *
37
+ * This module NEVER dispatches a unit and NEVER writes step rows: dispatch is
38
+ * the executor's job (`native-executor.ts`), advancing the gated spine is the
39
+ * engine loop's job (`run-workflow.ts` via `completeWorkflowStep`).
40
+ */
41
+ import { createHash } from "node:crypto";
42
+ import unitPreambleTemplate from "../../assets/prompts/workflow-unit-preamble.md" with { type: "text" };
43
+ import { UsageError } from "../../core/errors.js";
44
+ import { appendEvent } from "../../core/events.js";
45
+ import { validateJsonSchemaSubset } from "../../core/json-schema.js";
46
+ import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
47
+ import { canonicalJson as canonicalJsonString, decodeCanonicalPlan } from "../ir/plan-hash.js";
48
+ import { parseTemplate, resolveTemplate, resolveWholeValue, } from "../program/expressions.js";
49
+ import { WORKFLOW_MAX_MAP_EXPANSION } from "../resource-limits.js";
50
+ import { requireExecutableWorkflowPlan } from "../runtime/plan-classifier.js";
51
+ import { completeWorkflowStep } from "../runtime/runs.js";
52
+ import { enqueueUnitWrite } from "./unit-writer.js";
53
+ /**
54
+ * Default per-unit timeout. Deliberately NOT the 60 s agent default
55
+ * (`DEFAULT_AGENT_TIMEOUT_MS`) — workflow units routinely run real coding
56
+ * tasks on slow local models; 10 minutes matches the LLM-path default
57
+ * (`tryLlmFeature`). A unit's `timeout` declaration overrides this; `none`
58
+ * disables.
59
+ */
60
+ export const DEFAULT_UNIT_TIMEOUT_MS = 600_000;
61
+ /** How much raw unit output is retained in step evidence (full text lives on the unit row). */
62
+ const EVIDENCE_TEXT_CLIP = 2_000;
63
+ /** How much artifact JSON the completion-criteria judge receives (addendum R2, artifact-judging gates). */
64
+ const GATE_ARTIFACT_CLIP = 4_000;
65
+ /**
66
+ * Compute a step's expected work-list PURELY from the frozen plan and its
67
+ * inputs: resolve the fan-out list, derive content-derived unit ids, assemble
68
+ * each unit's prompt (preamble + interpolated instructions + gate feedback +
69
+ * schema directive), and hash the resolved input. Same inputs ⇒ byte-identical
70
+ * ids/hashes/prompts — the invariant `brief` relies on to predict the engine.
71
+ *
72
+ * Whole-list failures (missing subgraph, template parse error, unresolvable /
73
+ * non-array `over`, duplicate fan-out items) return `{ ok: false }`; a per-unit
74
+ * expression-resolution failure is carried on that unit's `resolved` field so
75
+ * the caller fails just that unit (mirroring the engine's `expression_error`
76
+ * outcome), never the whole step.
77
+ */
78
+ export function computeStepWorkList(plan, input) {
79
+ const root = plan.root;
80
+ // Route-only steps (YAML `route:`) carry no execution subgraph.
81
+ if (!root) {
82
+ return {
83
+ ok: false,
84
+ error: `Step "${plan.stepId}" has no execution subgraph (a route-only step); the native executor cannot dispatch it.`,
85
+ };
86
+ }
87
+ const template = root.kind === "map" ? root.template : root;
88
+ const reducer = root.kind === "map" ? root.reducer : "collect";
89
+ const scope = { params: input.params, stepOutputs: input.stepOutputs };
90
+ // Parse the instruction template ONCE (deterministic; resolution is a single
91
+ // pass per unit — substituted content is never re-scanned). Only nodes the
92
+ // frontend marked `templating: "expressions"` carry the `${{ … }}` grammar;
93
+ // classic linear markdown is opaque verbatim text.
94
+ let instructionSegments;
95
+ if (template.templating === "expressions") {
96
+ const parsedInstructions = parseTemplate(template.instructions);
97
+ if (!parsedInstructions.ok) {
98
+ return {
99
+ ok: false,
100
+ error: `Step "${plan.stepId}" instructions template failed to parse: ` +
101
+ parsedInstructions.errors.map((e) => e.message).join(" "),
102
+ };
103
+ }
104
+ instructionSegments = parsedInstructions.segments;
105
+ }
106
+ else {
107
+ instructionSegments = [{ kind: "literal", text: template.instructions }];
108
+ }
109
+ // Resolve fan-out items: `over` is a single whole-value `${{ … }}` reference
110
+ // naming its producer explicitly — no ambient key search.
111
+ let items;
112
+ if (root.kind === "map") {
113
+ const source = resolveWholeValue(root.over, scope);
114
+ if (!source.ok) {
115
+ return {
116
+ ok: false,
117
+ error: `Step "${plan.stepId}" fan-out "over" (${root.over}) failed to resolve: ${source.error.message}`,
118
+ };
119
+ }
120
+ if (!Array.isArray(source.value)) {
121
+ return {
122
+ ok: false,
123
+ error: `Step "${plan.stepId}" fan-out "over" (${root.over}) resolved to ${typeof source.value}, not an array.`,
124
+ };
125
+ }
126
+ items = source.value;
127
+ }
128
+ else {
129
+ items = [undefined];
130
+ }
131
+ const isFanOut = root.kind === "map";
132
+ if (isFanOut && items.length > WORKFLOW_MAX_MAP_EXPANSION) {
133
+ return {
134
+ ok: false,
135
+ error: `Step "${plan.stepId}" fan-out expands to ${items.length} units, exceeding the ${WORKFLOW_MAX_MAP_EXPANSION}-unit resource limit.`,
136
+ };
137
+ }
138
+ // Content-derived unit identity: compute every id up front. Duplicate items
139
+ // collide on identity — an authoring error caught HERE, deterministically.
140
+ const unitIds = items.map((item) => unitIdFor(template.id, item, isFanOut));
141
+ if (isFanOut) {
142
+ const firstIndexByCanonical = new Map();
143
+ for (let i = 0; i < items.length; i++) {
144
+ const canonical = canonicalJson(items[i]) ?? "null";
145
+ const firstIndex = firstIndexByCanonical.get(canonical);
146
+ if (firstIndex !== undefined) {
147
+ return {
148
+ ok: false,
149
+ error: `Step "${plan.stepId}" fan-out list contains duplicate items (indices ${firstIndex} and ${i}: ` +
150
+ `${clip(canonical, 200)}). Content-derived unit identity requires distinct items — ` +
151
+ `deduplicate the list this workflow fans out over.`,
152
+ };
153
+ }
154
+ firstIndexByCanonical.set(canonical, i);
155
+ }
156
+ }
157
+ const gateLoop = input.gateLoop ?? 1;
158
+ const frozenInvocation = template.invocation;
159
+ if (!frozenInvocation)
160
+ return { ok: false, error: `Step "${plan.stepId}" has no frozen invocation.` };
161
+ const frozenEngine = input.engines?.[frozenInvocation.engine];
162
+ if (!frozenEngine) {
163
+ return { ok: false, error: `Step "${plan.stepId}" references missing frozen engine "${frozenInvocation.engine}".` };
164
+ }
165
+ const runner = frozenEngine.kind === "llm" ? "llm" : frozenEngine.runnerKind;
166
+ const timeoutMs = frozenInvocation.timeoutMs;
167
+ const units = items.map((item, index) => {
168
+ const unitId = unitIds[index];
169
+ // Gate loops (>= 2) journal under `<unitId>~l<loop>` so loop 1's rows are
170
+ // never clobbered; the content-derived identity (and the prompt's
171
+ // {{UNIT_ID}}) stays the base id.
172
+ const journalBaseId = gateLoop > 1 ? `${unitId}~l${gateLoop}` : unitId;
173
+ // Single-pass resolution of the pre-parsed template against this unit's
174
+ // scope. A resolution failure is deterministic authoring/data breakage.
175
+ const unitScope = isFanOut ? { ...scope, item, itemIndex: index } : scope;
176
+ const resolvedInstr = resolveTemplate(instructionSegments, unitScope);
177
+ let resolved;
178
+ if (!resolvedInstr.ok) {
179
+ resolved = {
180
+ ok: false,
181
+ error: `instructions failed to resolve: ${resolvedInstr.errors.map((e) => e.message).join(" ")}`,
182
+ };
183
+ }
184
+ else {
185
+ const prompt = buildUnitPrompt({
186
+ runId: input.runId,
187
+ stepId: plan.stepId,
188
+ unitId,
189
+ params: input.params,
190
+ ...(input.gateFeedback ? { gateFeedback: input.gateFeedback } : {}),
191
+ ...(template.schema ? { schema: template.schema } : {}),
192
+ instructions: resolvedInstr.text,
193
+ });
194
+ // Canonical dispatch-input envelope (reviewer finding #1). Every field
195
+ // here is a PLAN-FROZEN input that changes what the backend is actually
196
+ // asked to do, so a completed unit is reused ONLY when all of them match;
197
+ // a change to any of them re-dispatches. Key order is FIXED — it is the
198
+ // hash preimage (JSON.stringify preserves insertion order) — and shared
199
+ // by ALL surfaces, since this is the ONE place a unit's inputHash is
200
+ // computed (engine, brief, and report all call computeStepWorkList), so
201
+ // the byte-identical hash across surfaces is structural, not coincidental.
202
+ //
203
+ // Included beyond the R4 baseline (prompt/runner/model/schema): resolved
204
+ // timeoutMs, the env asset ref NAMES, and isolation — each
205
+ // reaches dispatch (native-executor's UnitDispatchRequest) and a changed
206
+ // one yields a materially different call. `env` carries NAMES ONLY, never
207
+ // resolved values: hashing a resolved secret would leak it into a
208
+ // durable hash oracle and would spuriously re-dispatch on every secret
209
+ // rotation. `retry`/`onError` are DELIBERATELY excluded — they govern
210
+ // failed-unit re-dispatch and step-level failure reduction, not a
211
+ // COMPLETED unit's inputs/output, so a completed row stays valid across
212
+ // policy changes.
213
+ //
214
+ // Ambient config is DELIBERATELY excluded — the model-alias table, the
215
+ // resolved backend/connection, and the working directory (`ctx.workDir` /
216
+ // process.cwd()) are NOT plan-frozen. The frozen plan is the identity
217
+ // boundary (redesign addendum determinism bar #2): config drift under an
218
+ // in-flight run is out of scope by design.
219
+ const dispatch = transitiveDispatchSnapshot(frozenEngine, input.engines ?? {});
220
+ const inputHash = createHash("sha256")
221
+ .update(canonicalJsonString({
222
+ hashVersion: 3,
223
+ prompt,
224
+ dispatch,
225
+ invocation: frozenInvocation,
226
+ schema: template.schema ?? null,
227
+ env: template.env ?? null,
228
+ isolation: template.isolation ?? "none",
229
+ }))
230
+ .digest("hex");
231
+ resolved = { ok: true, prompt, inputHash };
232
+ }
233
+ return {
234
+ unitId,
235
+ nodeId: template.id,
236
+ index,
237
+ item,
238
+ isFanOut,
239
+ journalBaseId,
240
+ runner,
241
+ engine: frozenEngine,
242
+ ...(frozenEngine?.kind === "agent" &&
243
+ frozenEngine.fallbackLlmEngine &&
244
+ input.engines?.[frozenEngine.fallbackLlmEngine]?.kind === "llm"
245
+ ? {
246
+ fallbackEngine: input.engines[frozenEngine.fallbackLlmEngine],
247
+ }
248
+ : {}),
249
+ invocation: frozenInvocation,
250
+ ...(frozenInvocation.model ? { model: frozenInvocation.model } : {}),
251
+ timeoutMs,
252
+ ...(template.schema ? { schema: template.schema } : {}),
253
+ ...(template.env ? { env: template.env } : {}),
254
+ ...(template.retry ? { retry: template.retry } : {}),
255
+ onError: template.onError,
256
+ ...(template.isolation ? { isolation: template.isolation } : {}),
257
+ resolved,
258
+ };
259
+ });
260
+ const concurrency = root.kind === "map" ? root.concurrency : 1;
261
+ return {
262
+ ok: true,
263
+ list: { template, reducer, isFanOut, ...(concurrency !== undefined ? { concurrency } : {}), items, units },
264
+ };
265
+ }
266
+ /**
267
+ * Assemble the final prompt: engine preamble + resolved instructions
268
+ * (+ gate feedback on loop re-executions, + schema directive). Workflow-
269
+ * authored interpolation happened upstream via the expression module; only
270
+ * the ENGINE's own preamble placeholders are substituted here.
271
+ */
272
+ export function buildUnitPrompt(input) {
273
+ const { runId, stepId, unitId, params, gateFeedback, schema, instructions } = input;
274
+ // Function replacements throughout: a string replacement would interpret
275
+ // GetSubstitution patterns ($&, $$, $', $`) inside VALUES and silently
276
+ // corrupt the prompt (e.g. a param value containing "$&").
277
+ const preamble = unitPreambleTemplate
278
+ .replaceAll("{{RUN_ID}}", () => runId)
279
+ .replaceAll("{{STEP_ID}}", () => stepId)
280
+ .replaceAll("{{UNIT_ID}}", () => unitId)
281
+ .replaceAll("{{PARAMS_JSON}}", () => safeJson(params));
282
+ // Gate-loop feedback (R2 max_loops): the judge's rejection is appended so
283
+ // the re-executed unit can address it — and so the input hash changes,
284
+ // making the loop's re-dispatch natural instead of a durable-row reuse.
285
+ const gateBlock = gateFeedback
286
+ ? `\n\n## Completion-gate feedback (previous attempt rejected)\n` +
287
+ `A completion-criteria judge rejected this step's previous results. Address this feedback:\n` +
288
+ gateFeedback.feedback +
289
+ (gateFeedback.missing.length > 0
290
+ ? `\nUnmet criteria:\n${gateFeedback.missing.map((m) => `- ${m}`).join("\n")}`
291
+ : "")
292
+ : "";
293
+ const schemaDirective = schema
294
+ ? `\n\nRespond with ONLY a JSON value matching this JSON Schema (no prose, no code fences):\n${safeJson(schema)}`
295
+ : "";
296
+ return `${preamble}\n${instructions}${gateBlock}${schemaDirective}`;
297
+ }
298
+ /**
299
+ * Content-derived unit identity (module doc): `<node_id>:<hash12>` for a
300
+ * fan-out item, `<node_id>:solo` otherwise. The hash is over the item's
301
+ * canonical JSON (sorted keys — same canonicalization the vote reducer
302
+ * counts with), so identity survives list reordering/regeneration and is
303
+ * independent of item position. Retry attempts stack `~r<n>` on top.
304
+ */
305
+ export function unitIdFor(nodeId, item, isFanOut) {
306
+ if (!isFanOut)
307
+ return `${nodeId}:solo`;
308
+ const canonical = canonicalJson(item) ?? "null";
309
+ return `${nodeId}:${createHash("sha256").update(canonical).digest("hex").slice(0, 12)}`;
310
+ }
311
+ /** Include an SDK fallback in v3 call identity without copying catalog entries onto nodes. */
312
+ function transitiveDispatchSnapshot(engine, engines) {
313
+ if (engine.kind !== "agent" || !engine.fallbackLlmEngine)
314
+ return engine;
315
+ const fallback = engines[engine.fallbackLlmEngine];
316
+ if (!fallback || fallback.kind !== "llm") {
317
+ throw new UsageError(`Frozen agent engine "${engine.name}" has no valid LLM fallback snapshot.`);
318
+ }
319
+ return { engine, fallback };
320
+ }
321
+ // ── Step outputs + reducers + typed artifacts ────────────────────────────────
322
+ /**
323
+ * The value `${{ steps.<id>.output }}` resolves to for ONE step, given that
324
+ * step's journaled evidence: an engine-executed step carries a promoted
325
+ * ARTIFACT under `evidence.output` (solo unit result/text, collect array, or
326
+ * vote winner); evidence without an `output` key (manually-completed steps) is
327
+ * exposed as-is.
328
+ */
329
+ export function projectStepOutput(evidence) {
330
+ return Object.hasOwn(evidence, "output") ? evidence.output : evidence;
331
+ }
332
+ /** Project the engine's evidence map into the expression scope's `stepOutputs`. */
333
+ export function stepOutputsFromEvidence(evidence) {
334
+ const outputs = {};
335
+ for (const [stepId, stepEvidence] of Object.entries(evidence)) {
336
+ if (stepEvidence !== undefined)
337
+ outputs[stepId] = projectStepOutput(stepEvidence);
338
+ }
339
+ return outputs;
340
+ }
341
+ /**
342
+ * Typed artifacts (addendum, R2): validate the promoted step artifact against
343
+ * `IrStepPlan.outputSchema`. Returns the step-failure summary (validation
344
+ * errors included) on mismatch, undefined when valid or when no schema is
345
+ * declared.
346
+ */
347
+ export function validateStepArtifact(plan, evidence) {
348
+ if (!plan.outputSchema)
349
+ return undefined;
350
+ const errors = validateJsonSchemaSubset(projectStepOutput(evidence), plan.outputSchema);
351
+ if (errors.length === 0)
352
+ return undefined;
353
+ return (`Step "${plan.stepId}" artifact failed validation against the step's declared output schema: ` +
354
+ `${errors.join("; ")}.`);
355
+ }
356
+ /**
357
+ * Build the summary the completion-criteria gate judges for a step (addendum
358
+ * R2, "typed artifacts, honest gates"): a one-line unit count followed by the
359
+ * promoted step artifact as canonical JSON, clipped at {@link GATE_ARTIFACT_CLIP}
360
+ * chars. This replaces machine-prose so the gate evaluates real results.
361
+ */
362
+ export function buildArtifactSummary(stepId, units, evidence) {
363
+ const failedCount = units.filter((u) => !u.ok).length;
364
+ const json = canonicalJson(projectStepOutput(evidence)) ?? "null";
365
+ return (`Step "${stepId}" executed ${units.length} unit(s) (${units.length - failedCount} succeeded, ${failedCount} failed). ` +
366
+ `Step artifact (canonical JSON${json.length > GATE_ARTIFACT_CLIP ? `, clipped at ${GATE_ARTIFACT_CLIP} chars` : ""}):\n` +
367
+ clip(json, GATE_ARTIFACT_CLIP));
368
+ }
369
+ /** A unit's contribution to the step artifact: structured result, else text, else null (failures). */
370
+ function unitOutputValue(unit) {
371
+ if (!unit.ok)
372
+ return null;
373
+ if (unit.result !== undefined)
374
+ return unit.result;
375
+ return unit.text ?? null;
376
+ }
377
+ export function buildEvidence(units, reducer, isFanOut) {
378
+ // Per-unit evidence is the DURABLE, surface-independent projection the two
379
+ // driver surfaces (engine + brief/report) must agree on byte-for-byte (R4
380
+ // conformance, "identical unit graph"). It therefore carries ONLY fields both
381
+ // surfaces can reproduce from the journal:
382
+ // - a SUCCESS keeps its promoted contribution (structured `result` or clipped
383
+ // `text`) — the report path rehydrates exactly these from the unit row;
384
+ // - a FAILURE keeps only its `failureReason` (the durable, journaled failure
385
+ // vocabulary). The engine's in-memory dispatch diagnostic (`error`) and any
386
+ // residual `text` on a failed unit are NOT persisted here: a driver-reported
387
+ // failure carries neither, so persisting them on the engine surface alone
388
+ // would diverge the durable graph. The full raw text/reason still lives on
389
+ // the unit row for engine-side diagnostics; this is the shared graph.
390
+ const collected = units.map((u) => u.ok
391
+ ? {
392
+ unitId: u.unitId,
393
+ ok: true,
394
+ ...(u.result !== undefined ? { result: u.result } : {}),
395
+ ...(u.text !== undefined ? { text: clip(u.text, EVIDENCE_TEXT_CLIP) } : {}),
396
+ }
397
+ : {
398
+ unitId: u.unitId,
399
+ ok: false,
400
+ ...(u.failureReason ? { failureReason: u.failureReason } : {}),
401
+ });
402
+ const evidence = { units: collected, itemCount: units.length };
403
+ // Promoted step artifact (`evidence.output`) — what `${{ steps.<id>.output }}`
404
+ // resolves to (see projectStepOutput). Values are UNCLIPPED.
405
+ if (reducer === "vote") {
406
+ evidence.output = null;
407
+ }
408
+ else {
409
+ evidence.output = isFanOut ? units.map(unitOutputValue) : unitOutputValue(units[0]);
410
+ }
411
+ if (reducer === "vote") {
412
+ const counts = new Map();
413
+ for (const unit of units) {
414
+ if (!unit.ok)
415
+ continue;
416
+ const value = unit.result !== undefined ? unit.result : unit.text;
417
+ const key = canonicalJson(value);
418
+ const entry = counts.get(key);
419
+ if (entry)
420
+ entry.count++;
421
+ else
422
+ counts.set(key, { value, count: 1 });
423
+ }
424
+ const ranked = [...counts.values()].sort((a, b) => b.count - a.count);
425
+ if (ranked.length === 0) {
426
+ evidence.voteError = "Vote reducer had no successful unit results to count.";
427
+ }
428
+ else if (ranked.length > 1 && ranked[0].count === ranked[1].count) {
429
+ evidence.voteError = `Vote reducer tied at ${ranked[0].count} vote(s) — no majority.`;
430
+ }
431
+ else {
432
+ evidence.vote = { winner: ranked[0].value, votes: ranked[0].count, total: units.length };
433
+ evidence.output = ranked[0].value;
434
+ }
435
+ }
436
+ return evidence;
437
+ }
438
+ /**
439
+ * Reduce a step's terminal unit outcomes into the promoted artifact + step
440
+ * verdict — the shared semantics between native dispatch and the report path.
441
+ * Applies the `on_error` policy (`fail` vs `continue`), the reducer (via
442
+ * {@link buildEvidence}), the vote-tie failure, and the typed-artifact schema
443
+ * validation (fail-fast, errors in the summary, `artifactSchemaFailure` marker).
444
+ * Callers own dispatch-specific concerns (replay-divergence, budget) BEFORE
445
+ * calling this; those never occur on the report path (units are journaled).
446
+ */
447
+ export function reduceStepOutcomes(plan, reducer, isFanOut, onError, units) {
448
+ const failed = units.filter((u) => !u.ok);
449
+ const evidence = buildEvidence(units, reducer, isFanOut);
450
+ const reducerNote = typeof evidence.voteError === "string" ? ` ${evidence.voteError}` : "";
451
+ const tolerateFailures = onError === "continue";
452
+ let ok = (tolerateFailures || failed.length === 0) && !evidence.voteError;
453
+ let summary = `Executed ${units.length} unit(s) for step "${plan.stepId}" via workflow orchestration: ` +
454
+ `${units.length - failed.length} succeeded, ${failed.length} failed.` +
455
+ (failed.length > 0
456
+ ? ` Failures${tolerateFailures ? " (recorded, on_error: continue)" : ""}: ${failed
457
+ .map((u) => `${u.unitId} (${u.failureReason ?? "error"})`)
458
+ .join(", ")}.`
459
+ : "") +
460
+ reducerNote;
461
+ let artifactSchemaFailure = false;
462
+ if (ok) {
463
+ const schemaFailure = validateStepArtifact(plan, evidence);
464
+ if (schemaFailure !== undefined) {
465
+ ok = false;
466
+ summary = schemaFailure;
467
+ artifactSchemaFailure = true;
468
+ }
469
+ }
470
+ return { ok, units, evidence, summary, ...(artifactSchemaFailure ? { artifactSchemaFailure: true } : {}) };
471
+ }
472
+ /**
473
+ * The reduced outcome of a step whose fan-out list resolved to EMPTY (`over: []`
474
+ * or a producer that yielded `[]`): no units are dispatched, so the promoted
475
+ * artifact is the degenerate empty value — the empty array for a `collect`
476
+ * reducer, `null` for `vote` (references into a missing winner fail loudly at
477
+ * resolution rather than silently reading the envelope). Even the degenerate
478
+ * artifact must honor the step's declared `outputSchema` before it can complete.
479
+ *
480
+ * Shared by native dispatch (`executeStepPlan`'s `items.length === 0` branch)
481
+ * and the R3 driver protocol (`report` auto-completes an empty step the spine
482
+ * reaches, since no `report --unit` can ever advance a zero-unit step) so both
483
+ * surfaces promote the SAME artifact and apply the SAME schema verdict — the
484
+ * anti-drift guarantee. Deliberately does NOT run the reducer/vote-tie logic:
485
+ * an empty step has no successful results to count, and a vote-tie "failure"
486
+ * would diverge from the engine's long-standing empty-list semantics.
487
+ */
488
+ export function reduceEmptyStep(plan, reducer) {
489
+ const evidence = { units: [], itemCount: 0, output: reducer === "collect" ? [] : null };
490
+ const schemaFailure = validateStepArtifact(plan, evidence);
491
+ return {
492
+ ok: schemaFailure === undefined,
493
+ units: [],
494
+ evidence,
495
+ summary: schemaFailure ?? `Step "${plan.stepId}" fan-out list was empty — no units dispatched.`,
496
+ ...(schemaFailure !== undefined ? { artifactSchemaFailure: true } : {}),
497
+ };
498
+ }
499
+ /**
500
+ * Rehydrate a journaled unit row into a {@link UnitOutcome}. Shared by the
501
+ * executor's durable-row reuse (`native-executor.ts`, completed rows only) and
502
+ * the R3 report path (which reduces completed AND failed rows replayed from the
503
+ * journal). A completed row's text unit journals its output as a JSON string; a
504
+ * schema unit journals the validated structure. A failed row carries its
505
+ * `failure_reason`; any journaled text is surfaced too.
506
+ */
507
+ export function unitOutcomeFromRow(unitId, row, hasSchema) {
508
+ let parsed;
509
+ try {
510
+ parsed = row.result_json === null ? undefined : JSON.parse(row.result_json);
511
+ }
512
+ catch {
513
+ parsed = undefined;
514
+ }
515
+ if (row.status === "completed") {
516
+ return {
517
+ unitId,
518
+ ok: true,
519
+ ...(hasSchema
520
+ ? { result: parsed }
521
+ : typeof parsed === "string"
522
+ ? { text: parsed }
523
+ : parsed !== undefined
524
+ ? { result: parsed }
525
+ : {}),
526
+ ...(row.tokens !== null ? { tokens: row.tokens } : {}),
527
+ ...(row.session_id !== null && row.session_id !== undefined ? { sessionId: row.session_id } : {}),
528
+ };
529
+ }
530
+ return {
531
+ unitId,
532
+ ok: false,
533
+ failureReason: row.failure_reason ?? "reported_failure",
534
+ ...(typeof parsed === "string" ? { text: parsed } : {}),
535
+ ...(row.tokens !== null ? { tokens: row.tokens } : {}),
536
+ };
537
+ }
538
+ /**
539
+ * Select the journaled attempt row that determines a unit's TERMINAL outcome on
540
+ * a REPLAY surface — the engine's durable-row reuse AND the harness-neutral
541
+ * brief/report driver protocol — given the run's dispatch rows indexed by
542
+ * unit_id. This is the ONE place all surfaces resolve "which journaled row IS
543
+ * this unit's outcome," so they cannot drift from each other or from the engine.
544
+ *
545
+ * It mirrors the executor's {@link classifyUnitReuse} attempt scan
546
+ * (native-executor.ts): among the base attempt and its `~r<n>` retries — all
547
+ * stacked on `journalBaseId`, which already carries the active `~l<loop>` gate
548
+ * suffix — the FIRST completed attempt is the effective result. So a unit whose
549
+ * base attempt FAILED but whose later retry COMPLETED reduces as COMPLETED,
550
+ * exactly like an engine resume reusing the `~r1` row (Codex round-3 finding C);
551
+ * reading only the base row would reduce it as failed and diverge the two
552
+ * surfaces. With no completed attempt the HIGHEST journaled attempt stands (a
553
+ * terminal failure, or a still-running row); no attempt row at all ⇒ `undefined`
554
+ * (the unit is still outstanding).
555
+ */
556
+ export function selectUnitAttemptRow(workUnit, dispatchRows) {
557
+ const base = workUnit.journalBaseId;
558
+ const maxAttempts = 1 + Math.max(0, workUnit.retry?.max ?? 0);
559
+ let fallback;
560
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
561
+ const row = dispatchRows.get(attempt === 0 ? base : `${base}~r${attempt}`);
562
+ if (!row)
563
+ continue;
564
+ if (row.status === "completed")
565
+ return row;
566
+ fallback = row; // remember the highest journaled (non-completed) attempt
567
+ }
568
+ return fallback;
569
+ }
570
+ /**
571
+ * Is a FAILED unit still RETRY-ELIGIBLE — i.e. NOT terminal, because a driver
572
+ * could still re-run it via the `--rerun` form (the engine's automatic
573
+ * `<baseId>~r<n>` retry)? A unit whose declared `retry.on` matches the recorded
574
+ * failure reason AND whose attempt budget (`1 + retry.max`) is not yet spent can
575
+ * still be re-run. No `retry`, an off-list reason, or an exhausted attempt budget
576
+ * ⇒ the failure IS terminal. Shared by the report fail-fast decision, the
577
+ * `--settle` refusal, and `brief`'s fully-terminal detection so all three agree
578
+ * on when a failed unit is genuinely done vs. still re-runnable. The normalized
579
+ * failure reason is compared against `retry.on` directly (a canonical taxonomy
580
+ * reason is stored verbatim; an `external:*` reason is by construction outside
581
+ * the taxonomy `retry.on` lists).
582
+ */
583
+ export function isRetryEligibleFailure(workUnit, row, failureReason) {
584
+ const retry = workUnit.retry;
585
+ if (!retry || failureReason === null || !retry.on.includes(failureReason))
586
+ return false;
587
+ const attempts = row?.attempts ?? 1;
588
+ return attempts < 1 + Math.max(0, retry.max);
589
+ }
590
+ /**
591
+ * Does a resolvable unit still need a driver to execute + report it (or re-run
592
+ * it)? True for a unit with no terminal row (pending), a still-`running` row (a
593
+ * live/stale claim another driver holds), or a FAILED row that is still
594
+ * retry-eligible. False for a COMPLETED row, a terminal non-retry-eligible
595
+ * FAILURE, or an UNRESOLVABLE unit (the engine's immediate `expression_error` —
596
+ * never reportable). The best terminal attempt (base + `~r<n>` retries) is the
597
+ * one consulted, the SAME reuse the engine and reducer apply.
598
+ */
599
+ export function unitStillNeedsReport(workUnit, dispatchRows) {
600
+ if (!workUnit.resolved.ok)
601
+ return false;
602
+ const row = selectUnitAttemptRow(workUnit, dispatchRows);
603
+ if (!row)
604
+ return true; // no journal row → pending
605
+ if (row.status === "running")
606
+ return true; // a live/stale claim is still in flight
607
+ if (row.status === "failed")
608
+ return isRetryEligibleFailure(workUnit, row, row.failure_reason);
609
+ return false; // completed (or a non-retry-eligible failure) → terminal
610
+ }
611
+ /**
612
+ * Is the active step's work-list FULLY TERMINAL — every resolvable unit run to a
613
+ * terminal (done, or non-retry-eligible failed) state with nothing left to
614
+ * execute or per-unit report — yet still needing finalization? This is the
615
+ * driver-recovery state after a required-gate block is resumed, or a crash
616
+ * between the last unit write and the step's completion (owner manual-validation
617
+ * finding 3): the work-list is done but the step never advanced. `brief`
618
+ * surfaces it with a single `report --settle` command and `--settle` runs the
619
+ * shared completion path for it. A list with ANY outstanding unit (pending,
620
+ * in-flight, or retry-eligible failed) is NOT fully terminal — the driver
621
+ * `report --unit`s those. A route-only / empty / all-unresolvable list (no
622
+ * resolvable units) is a DIFFERENT non-dispatching state, handled separately.
623
+ */
624
+ export function isWorkListFullyTerminal(workList, dispatchRows) {
625
+ if (!workList.units.some((u) => u.resolved.ok))
626
+ return false;
627
+ return workList.units.every((u) => !unitStillNeedsReport(u, dispatchRows));
628
+ }
629
+ /** Stable stringify (sorted object keys, recursively) so equal values vote together. */
630
+ export function canonicalJson(value) {
631
+ return JSON.stringify(sortKeys(value));
632
+ }
633
+ function sortKeys(value) {
634
+ if (Array.isArray(value))
635
+ return value.map(sortKeys);
636
+ if (value && typeof value === "object") {
637
+ return Object.fromEntries(Object.entries(value)
638
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
639
+ .map(([k, v]) => [k, sortKeys(v)]));
640
+ }
641
+ return value;
642
+ }
643
+ // ── Gate-feedback recovery (PURE) ────────────────────────────────────────────
644
+ //
645
+ // A gate rejection is journaled as `<stepId>.gate:l<loop>` with result_json
646
+ // `{ complete: false, missing, feedback }` (see journalGateEvaluationFinish).
647
+ // The feedback stored there is BYTE-IDENTICAL to what the engine threads into
648
+ // the next loop's prompts — both are the same `rejection.feedback`/`.missing`.
649
+ // `brief` recovers it from the journal so its loop-N work-list matches the
650
+ // engine's (redesign addendum R3, task item 2). `native-executor.test.ts`
651
+ // asserts the round-trip identity.
652
+ /**
653
+ * `phase` marker stamped on gate-evaluation unit rows. Step ids cannot contain
654
+ * dots (`PROGRAM_STEP_ID_PATTERN`), so a step can never be NAMED `x.gate` and
655
+ * the synthetic `<stepId>.gate` node id is collision-free against user step
656
+ * ids. The phase column is nonetheless the discriminator we key on — an
657
+ * explicit marker, not a `node_id` suffix match, so recovery stays robust even
658
+ * if the id scheme evolves. Dispatch rows always journal `phase: null`.
659
+ */
660
+ export const GATE_EVALUATION_PHASE = "gate";
661
+ /** The unit id of a step's gate-evaluation row for a given 1-based loop. */
662
+ export function gateUnitId(stepId, loop) {
663
+ return `${stepId}.gate:l${loop}`;
664
+ }
665
+ /**
666
+ * The gate loop the engine is about to (re-)run for an ACTIVE step, derived
667
+ * purely from the journal: one past the highest journaled loop that REJECTED
668
+ * (`complete: false`). No rejected gate rows ⇒ loop 1 (the first execution).
669
+ * A passed gate would have advanced the spine, so an active step never has a
670
+ * `complete: true` row as its latest gate evaluation.
671
+ *
672
+ * Reviewer #17: a gate row that EXISTS but cannot be parsed (or carries an
673
+ * invalid verdict shape) is CORRUPTION — {@link parseGateVerdict} throws loudly
674
+ * rather than letting `gateRowRejected` swallow the parse error, which would
675
+ * silently drop the loop back to 1 and re-dispatch work whose gate outcome is
676
+ * unknown.
677
+ */
678
+ export function activeGateLoop(rows, stepId) {
679
+ let maxRejectedLoop = 0;
680
+ for (const row of rows) {
681
+ if (row.phase !== GATE_EVALUATION_PHASE || row.step_id !== stepId)
682
+ continue;
683
+ const loop = gateLoopOf(row.unit_id, stepId);
684
+ if (loop === undefined)
685
+ continue;
686
+ // Throws loudly on a corrupt/malformed gate row — never treated as absent.
687
+ if (parseGateVerdict(row).kind === "rejected" && loop > maxRejectedLoop)
688
+ maxRejectedLoop = loop;
689
+ }
690
+ return maxRejectedLoop + 1;
691
+ }
692
+ /**
693
+ * Recover the gate feedback the engine threads into `loop`'s unit prompts: the
694
+ * `{ feedback, missing }` journaled by the previous loop's rejection
695
+ * (`<stepId>.gate:l<loop-1>`). Loop 1 (or a missing/passed/errored previous row)
696
+ * has no feedback. Pure — the journal rows are passed in.
697
+ *
698
+ * Reviewer #17: a PRESENT previous gate row that cannot be parsed fails LOUDLY
699
+ * (via {@link parseGateVerdict}) instead of returning undefined — a corrupt row
700
+ * must not make an in-loop step look like loop 1 with no recovered feedback.
701
+ */
702
+ export function recoverGateFeedback(rows, stepId, loop) {
703
+ if (loop <= 1)
704
+ return undefined;
705
+ const prevId = gateUnitId(stepId, loop - 1);
706
+ const prev = rows.find((r) => r.unit_id === prevId && r.phase === GATE_EVALUATION_PHASE);
707
+ if (!prev)
708
+ return undefined;
709
+ const verdict = parseGateVerdict(prev);
710
+ return verdict.kind === "rejected" ? { feedback: verdict.feedback, missing: verdict.missing } : undefined;
711
+ }
712
+ /** The 1-based loop encoded in a `<stepId>.gate:l<n>` unit id, if well-formed. */
713
+ function gateLoopOf(unitId, stepId) {
714
+ const prefix = `${stepId}.gate:l`;
715
+ if (!unitId.startsWith(prefix))
716
+ return undefined;
717
+ const n = Number.parseInt(unitId.slice(prefix.length), 10);
718
+ return Number.isInteger(n) && n >= 1 ? n : undefined;
719
+ }
720
+ /**
721
+ * Classify a gate-evaluation row's journaled verdict, failing LOUDLY on a
722
+ * corrupt one (reviewer #17). A NULL `result_json` is the LEGITIMATE
723
+ * errored-judge / in-flight shape (`journalGateEvaluationFinish` writes null for
724
+ * an errored judge, and a `running` row has no verdict yet) and classifies as
725
+ * `empty`. But a PRESENT `result_json` that does not parse as JSON, or parses to
726
+ * anything other than an object with a boolean `complete` field, is corruption —
727
+ * a truncated or hand-edited row — and MUST NOT be silently treated as absent
728
+ * (which would reset an active step's gate loop to 1 and re-dispatch work whose
729
+ * completion outcome is unknown). We refuse to guess.
730
+ */
731
+ function parseGateVerdict(row) {
732
+ if (row.result_json === null)
733
+ return { kind: "empty" };
734
+ let verdict;
735
+ try {
736
+ verdict = JSON.parse(row.result_json);
737
+ }
738
+ catch {
739
+ throw new UsageError(gateCorruptionMessage(row, "its result_json is not valid JSON"));
740
+ }
741
+ if (typeof verdict !== "object" || verdict === null || Array.isArray(verdict)) {
742
+ throw new UsageError(gateCorruptionMessage(row, "its result_json is not a JSON object"));
743
+ }
744
+ const v = verdict;
745
+ if (typeof v.complete !== "boolean") {
746
+ throw new UsageError(gateCorruptionMessage(row, 'its verdict has no boolean "complete" field'));
747
+ }
748
+ if (v.complete === false) {
749
+ const feedback = typeof v.feedback === "string" ? v.feedback : "";
750
+ const missing = Array.isArray(v.missing) ? v.missing.filter((m) => typeof m === "string") : [];
751
+ return { kind: "rejected", missing, feedback };
752
+ }
753
+ return { kind: "passed" };
754
+ }
755
+ function gateCorruptionMessage(row, why) {
756
+ return (`Workflow run ${row.run_id} has a corrupt gate-evaluation row "${row.unit_id}" for step "${row.step_id}" — ${why}. ` +
757
+ `A gate verdict must be {"complete": true|false, …}; refusing to treat a malformed gate row as absent, which would ` +
758
+ `silently restart the step's gate loop and re-dispatch work whose completion outcome is unknown. Fix or remove the ` +
759
+ `journaled row, then resume the run.`);
760
+ }
761
+ /** Insert the gate-evaluation unit row (running) just before the judge runs. */
762
+ export async function journalGateEvaluationStart(gate) {
763
+ const unitId = gateUnitId(gate.stepId, gate.loop);
764
+ await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.insertUnit({
765
+ runId: gate.runId,
766
+ unitId,
767
+ stepId: gate.stepId,
768
+ nodeId: `${gate.stepId}.gate`,
769
+ parentUnitId: null,
770
+ // Marks the row as a judge call, NOT a dispatch: the budget/lifetime
771
+ // seed in `driveRun` skips these so resume accounting matches live.
772
+ phase: GATE_EVALUATION_PHASE,
773
+ runner: "llm",
774
+ engine: gate.invocation.engine,
775
+ model: gate.invocation.model,
776
+ inputHash: gate.inputHash,
777
+ startedAt: new Date().toISOString(),
778
+ })));
779
+ appendEvent({
780
+ eventType: "workflow_unit_started",
781
+ ref: gate.workflowRef,
782
+ metadata: { runId: gate.runId, stepId: gate.stepId, unitId },
783
+ });
784
+ }
785
+ /**
786
+ * Finish the gate-evaluation unit row with the verdict as observed from the
787
+ * completion outcome: a rejection journals `{ complete: false, missing,
788
+ * feedback }`; a pass journals `{ complete: true, missing: [] }`; a judge that
789
+ * threw (or, on a required gate, returned an unparseable verdict) journals a
790
+ * failed row with a NULL verdict. A NON-required errored gate then fails OPEN
791
+ * inside `validateStepSummary`; a REQUIRED errored gate BLOCKS the step
792
+ * (`finalizeExecutedStep`, Codex round-3 finding A).
793
+ */
794
+ export async function journalGateEvaluationFinish(gate, errored, rejection) {
795
+ const unitId = gateUnitId(gate.stepId, gate.loop);
796
+ const verdict = errored
797
+ ? null
798
+ : rejection
799
+ ? { complete: false, missing: rejection.missing, feedback: rejection.feedback }
800
+ : { complete: true, missing: [] };
801
+ const status = errored ? "failed" : "completed";
802
+ await enqueueUnitWrite(() => withWorkflowRunsRepo((repo) => repo.finishUnit({
803
+ runId: gate.runId,
804
+ unitId,
805
+ status,
806
+ resultJson: verdict ? JSON.stringify(verdict) : null,
807
+ tokens: null,
808
+ failureReason: errored ? "dispatch_error" : null,
809
+ finishedAt: new Date().toISOString(),
810
+ })));
811
+ appendEvent({
812
+ eventType: "workflow_unit_finished",
813
+ ref: gate.workflowRef,
814
+ metadata: { runId: gate.runId, stepId: gate.stepId, unitId, status },
815
+ });
816
+ }
817
+ /**
818
+ * Resolve a route's input (a single whole-value `${{ … }}` reference) and pick
819
+ * the branch. No ambient key search. Only primitive values route; the
820
+ * comparison is exact string equality against the declared `when:` matches.
821
+ */
822
+ export function evaluateRoute(route, scope) {
823
+ const resolved = resolveWholeValue(route.input, scope);
824
+ if (!resolved.ok) {
825
+ return { ok: false, error: `route input ${route.input} failed to resolve: ${resolved.error.message}` };
826
+ }
827
+ const value = resolved.value;
828
+ if (typeof value === "object" && value !== null) {
829
+ return {
830
+ ok: false,
831
+ error: `route input ${route.input} resolved to a non-primitive value; branches match on strings/numbers/booleans.`,
832
+ };
833
+ }
834
+ const valueString = typeof value === "string" ? value : String(value);
835
+ // Own-property check: `when` is author-controlled, and a value such as
836
+ // "constructor" must not resolve through Object.prototype.
837
+ const selected = Object.hasOwn(route.when, valueString) ? route.when[valueString] : route.defaultStepId;
838
+ if (!selected) {
839
+ return {
840
+ ok: false,
841
+ error: `value "${valueString}" matched no "when:" branch and the route declares no default.`,
842
+ };
843
+ }
844
+ return { ok: true, value: valueString, selected };
845
+ }
846
+ /**
847
+ * Cascade a SKIPPED router: it never evaluated its route, so every declared
848
+ * target (branches + default) is marked skip-on-reach unless an earlier router
849
+ * already claimed it. Shared by the live skip path and the journal replay.
850
+ */
851
+ export function cascadeSkippedRouter(route, routerId, routeUnselected) {
852
+ const targets = [...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])];
853
+ for (const target of targets) {
854
+ if (!routeUnselected.has(target)) {
855
+ routeUnselected.set(target, { router: routerId, selected: null });
856
+ }
857
+ }
858
+ }
859
+ /**
860
+ * Record one router's decision in the skip bookkeeping: the selected target is
861
+ * protected, every other declared target (branches + default) is marked
862
+ * skip-on-reach unless an earlier router already claimed it. Shared by the live
863
+ * evaluation path and the journal replay.
864
+ */
865
+ export function applyRouteDecision(route, routerId, selected, routeSelected, routeUnselected) {
866
+ routeSelected.add(selected);
867
+ const targets = [...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])];
868
+ for (const target of targets) {
869
+ if (target !== selected && !routeUnselected.has(target)) {
870
+ routeUnselected.set(target, { router: routerId, selected });
871
+ }
872
+ }
873
+ }
874
+ /**
875
+ * The `stepOutputs` scope a route resolves against: every prior step's recorded
876
+ * evidence plus the just-finished step's fresh evidence — each projected
877
+ * through {@link projectStepOutput}. Same projection as unit templates, so the
878
+ * two scopes cannot drift.
879
+ */
880
+ export function routeStepOutputs(evidence, currentStepId, currentEvidence) {
881
+ const outputs = {};
882
+ for (const [stepId, stepEvidence] of Object.entries(evidence)) {
883
+ if (stepEvidence !== undefined)
884
+ outputs[stepId] = projectStepOutput(stepEvidence);
885
+ }
886
+ outputs[currentStepId] = projectStepOutput(currentEvidence);
887
+ return outputs;
888
+ }
889
+ /** The `selected` target journaled on a route step's evidence, if well-formed. */
890
+ function journaledRouteSelection(evidence) {
891
+ const route = evidence?.route;
892
+ if (typeof route !== "object" || route === null || Array.isArray(route))
893
+ return undefined;
894
+ const selected = route.selected;
895
+ return typeof selected === "string" && selected !== "" ? selected : undefined;
896
+ }
897
+ /** The set of steps a route may legally select: its `when` branches + default. */
898
+ function routeTargets(route) {
899
+ return new Set([...Object.values(route.when), ...(route.defaultStepId ? [route.defaultStepId] : [])]);
900
+ }
901
+ /**
902
+ * Reviewer #7: a journaled route decision must name a target the route actually
903
+ * DECLARES (`when` branch or `default`). Corrupted or hand-edited evidence can
904
+ * otherwise mark a non-existent step as `selected` — which unselects and skips
905
+ * every REAL branch target, silently steering the run down a phantom branch.
906
+ * `evaluateRoute` can only ever produce a declared target, so a stored value
907
+ * outside that set is provably tampered evidence: fail loudly rather than seed a
908
+ * bogus skip set.
909
+ */
910
+ function assertRouteTargetDeclared(route, stepId, selected, runId) {
911
+ const targets = routeTargets(route);
912
+ if (!targets.has(selected)) {
913
+ throw new UsageError(`Workflow run ${runId} has a completed route step "${stepId}" whose journaled route decision selected ` +
914
+ `"${selected}", which is not a declared branch or default target of the route (valid targets: ` +
915
+ `${[...targets].join(", ") || "(none)"}). The route evidence was corrupted or manually edited — refusing to ` +
916
+ `apply a bogus route decision that would skip the real branch targets. Start a new run.`);
917
+ }
918
+ }
919
+ /**
920
+ * Validate every COMPLETED route step's journaled selection against its declared
921
+ * targets (reviewer #7). Read-only: it throws on a PRESENT-but-invalid selection
922
+ * and is silent on an absent one, so it never false-positives on a healthy run —
923
+ * making it safe to call from the read-only `brief` surface as well as the
924
+ * resume/report surfaces that already re-apply the decisions.
925
+ */
926
+ export function assertJournaledRouteSelectionsValid(plan, state) {
927
+ for (const stepPlan of plan.steps) {
928
+ if (!stepPlan.route)
929
+ continue;
930
+ const stepState = state.workflow.steps.find((s) => s.id === stepPlan.stepId);
931
+ if (!stepState || stepState.status !== "completed")
932
+ continue;
933
+ const selected = journaledRouteSelection(stepState.evidence);
934
+ if (selected !== undefined) {
935
+ assertRouteTargetDeclared(stepPlan.route, stepPlan.stepId, selected, state.run.id);
936
+ }
937
+ }
938
+ }
939
+ /**
940
+ * Replay journaled route decisions into the skip bookkeeping (resume path).
941
+ * For every COMPLETED route step of the frozen plan, in spine order: the
942
+ * journaled decision wins; else a re-derivation from the frozen plan +
943
+ * journaled evidence; else fail loudly. A SKIPPED route step cascades its
944
+ * targets into the skip set exactly as on the live path.
945
+ */
946
+ export function seedJournaledRouteDecisions(plan, state, routeSelected, routeUnselected) {
947
+ const evidence = {};
948
+ for (const s of state.workflow.steps)
949
+ evidence[s.id] = s.evidence;
950
+ for (const stepPlan of plan.steps) {
951
+ if (!stepPlan.route)
952
+ continue;
953
+ const stepState = state.workflow.steps.find((s) => s.id === stepPlan.stepId);
954
+ if (!stepState)
955
+ continue;
956
+ if (stepState.status === "skipped") {
957
+ cascadeSkippedRouter(stepPlan.route, stepPlan.stepId, routeUnselected);
958
+ continue;
959
+ }
960
+ if (stepState.status !== "completed")
961
+ continue;
962
+ let selected = journaledRouteSelection(stepState.evidence);
963
+ if (selected !== undefined) {
964
+ // Reviewer #7: a stored decision must name a declared target — a bogus one
965
+ // (tampered/hand-edited evidence) fails loudly rather than seeding a skip
966
+ // set that buries the real branches.
967
+ assertRouteTargetDeclared(stepPlan.route, stepPlan.stepId, selected, state.run.id);
968
+ }
969
+ if (selected === undefined) {
970
+ const scope = {
971
+ params: state.run.params ?? {},
972
+ stepOutputs: routeStepOutputs(evidence, stepPlan.stepId, stepState.evidence ?? {}),
973
+ };
974
+ const decision = evaluateRoute(stepPlan.route, scope);
975
+ if (decision.ok)
976
+ selected = decision.selected;
977
+ }
978
+ if (selected === undefined) {
979
+ throw new UsageError(`Workflow run ${state.run.id} has a completed route step "${stepPlan.stepId}" with no journaled route ` +
980
+ `decision, and the decision cannot be re-derived from the journaled evidence. Refusing to guess which ` +
981
+ `branch was selected — advance the remaining steps manually with \`akm workflow complete\`.`);
982
+ }
983
+ applyRouteDecision(stepPlan.route, stepPlan.stepId, selected, routeSelected, routeUnselected);
984
+ }
985
+ }
986
+ /**
987
+ * Perform ONE completion attempt for an executed step:
988
+ *
989
+ * - a hard unit failure completes the step `failed` (a retryable typed-artifact
990
+ * mismatch with loops remaining returns `retry` WITHOUT journaling a gate row
991
+ * — no judge ran, exactly like the engine);
992
+ * - a route decision is evaluated against params + prior/fresh step outputs; an
993
+ * unroutable value fails the step; a valid decision is journaled on the
994
+ * step evidence and applied to the skip bookkeeping;
995
+ * - the completion gate judges a summary BUILT FROM the promoted artifact (when
996
+ * the step declares criteria), journaled as a `<stepId>.gate:l<loop>` unit
997
+ * row; a rejection with loops remaining returns `retry` (feedback threaded
998
+ * into the next loop), a rejection with none returns `gate-exhausted`, a pass
999
+ * returns `advanced`.
1000
+ *
1001
+ * Every DB advance goes through {@link completeWorkflowStep} — the gate spine is
1002
+ * never bypassed. Behavior is byte-identical to the engine's former inline loop
1003
+ * body (its tests prove it).
1004
+ */
1005
+ export async function finalizeExecutedStep(input) {
1006
+ const { runId, workflowRef, stepId, stepPlan, completionCriteria, gateLoop, loopsRemaining, result } = input;
1007
+ const lease = input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {};
1008
+ if (!result.ok) {
1009
+ // Typed-artifact mismatch with loop budget left: regenerate-with-errors
1010
+ // (the validation errors become the next loop's feedback). No judge ran, so
1011
+ // no gate row is journaled for this attempt.
1012
+ if (result.artifactSchemaFailure && loopsRemaining) {
1013
+ return { kind: "retry", gateFeedback: { feedback: result.summary, missing: [] } };
1014
+ }
1015
+ await completeWorkflowStep({
1016
+ runId,
1017
+ stepId,
1018
+ status: "failed",
1019
+ notes: result.summary,
1020
+ evidence: result.evidence,
1021
+ ...lease,
1022
+ });
1023
+ return { kind: "failed", summary: result.summary };
1024
+ }
1025
+ // Resolve the completion-criteria judge ONCE (reused by the gate below). A
1026
+ // A frozen plan either supplies its judge at the dispatch boundary or has no
1027
+ // judge. Re-selecting defaults here would let config drift change a run.
1028
+ const innerJudge = input.summaryJudge ?? null;
1029
+ // Reviewer #18: a REQUIRED completion gate must actually be judged. When the
1030
+ // gate carries criteria but no judge is available, `validateStepSummary` would
1031
+ // fail OPEN and silently pass the gate — exactly the offline/misconfigured
1032
+ // bypass a required gate exists to prevent. BLOCK the step instead (a human
1033
+ // resolves it via the documented manual path), rather than advance the spine
1034
+ // on an unjudged gate. `gate.required` rides the frozen plan (both surfaces);
1035
+ // `requireGates` is the engine's run-wide `--require-gates` override. Checked
1036
+ // BEFORE route evaluation so a blocked step journals no route decision.
1037
+ const gateRequired = stepPlan.gate.required === true || input.requireGates === true;
1038
+ if (gateRequired && completionCriteria.length > 0 && innerJudge === null) {
1039
+ const notes = `Step "${stepId}" has a REQUIRED completion gate but no summary-validation judge is available ` +
1040
+ `(no LLM is configured, or default LLM resolution failed). A required gate must be judged — refusing to fail ` +
1041
+ `open and silently pass it. The step is BLOCKED: configure an LLM, then \`akm workflow resume ${runId}\` to ` +
1042
+ `re-evaluate the gate, or advance the step manually with \`akm workflow complete\`.`;
1043
+ await completeWorkflowStep({ runId, stepId, status: "blocked", notes, evidence: result.evidence, ...lease });
1044
+ return { kind: "blocked", summary: notes };
1045
+ }
1046
+ // Route evaluation BEFORE completion: an unroutable value is an
1047
+ // authoring/config failure that must fail the step deterministically.
1048
+ let summaryOverride;
1049
+ if (stepPlan.route) {
1050
+ const scope = {
1051
+ params: input.params,
1052
+ stepOutputs: routeStepOutputs(input.priorEvidence, stepId, result.evidence),
1053
+ };
1054
+ const decision = evaluateRoute(stepPlan.route, scope);
1055
+ if (!decision.ok) {
1056
+ const notes = `Step "${stepId}" route failed: ${decision.error}`;
1057
+ await completeWorkflowStep({ runId, stepId, status: "failed", notes, evidence: result.evidence, ...lease });
1058
+ return { kind: "failed", summary: notes, routeFailure: true };
1059
+ }
1060
+ applyRouteDecision(stepPlan.route, stepId, decision.selected, input.routeSelected, input.routeUnselected);
1061
+ // Journal the decision on the evidence: resume replays it via
1062
+ // seedJournaledRouteDecisions, so the skip set survives re-invocation.
1063
+ result.evidence.route = { input: stepPlan.route.input, value: decision.value, selected: decision.selected };
1064
+ if (!stepPlan.root) {
1065
+ summaryOverride = `Step "${stepId}" routed on ${stepPlan.route.input}: value "${decision.value}" selected step "${decision.selected}".`;
1066
+ }
1067
+ }
1068
+ // Artifact-judging gate: a criteria-bearing executing step is judged on a
1069
+ // summary BUILT FROM the promoted artifact; everything else keeps the machine
1070
+ // summary (a route-only step's summary IS its decision).
1071
+ const summary = stepPlan.root && completionCriteria.length > 0
1072
+ ? buildArtifactSummary(stepId, result.units, result.evidence)
1073
+ : (summaryOverride ?? result.summary);
1074
+ // Journal engine-driven judge calls as unit rows (they are LLM calls). The
1075
+ // wrapper's `invoked` stays false when the gate is fail-open (no criteria / no
1076
+ // judge) — nothing is journaled, and human approvals are never cached.
1077
+ const frozenGate = innerJudge
1078
+ ? await withWorkflowRunsRepo((repo) => {
1079
+ const row = repo.getRunById(runId);
1080
+ if (!row)
1081
+ throw new UsageError(`Workflow run ${runId} was not found.`);
1082
+ const plan = requireExecutableWorkflowPlan(row);
1083
+ const invocation = plan.steps.find((step) => step.stepId === stepId)?.gate.judge ?? null;
1084
+ return invocation ? { invocation, engine: plan.execution?.engines[invocation.engine] ?? null } : null;
1085
+ })
1086
+ : null;
1087
+ const gateInvocation = frozenGate?.invocation ?? null;
1088
+ let gateUnit;
1089
+ const judgeState = { invoked: false, errored: false };
1090
+ const summaryJudge = innerJudge
1091
+ ? async (prompt) => {
1092
+ judgeState.invoked = true;
1093
+ if (gateInvocation) {
1094
+ gateUnit = {
1095
+ runId,
1096
+ workflowRef,
1097
+ stepId,
1098
+ loop: gateLoop,
1099
+ invocation: gateInvocation,
1100
+ inputHash: createHash("sha256")
1101
+ .update(canonicalJsonString({
1102
+ hashVersion: 3,
1103
+ dispatch: frozenGate?.engine ?? null,
1104
+ invocation: gateInvocation,
1105
+ prompt,
1106
+ }))
1107
+ .digest("hex"),
1108
+ };
1109
+ await journalGateEvaluationStart(gateUnit);
1110
+ }
1111
+ try {
1112
+ return await innerJudge(prompt);
1113
+ }
1114
+ catch (err) {
1115
+ judgeState.errored = true;
1116
+ throw err;
1117
+ }
1118
+ }
1119
+ : null;
1120
+ // Reviewer #6: once the judge is invoked, its gate row is journaled `running`
1121
+ // (journalGateEvaluationStart) and MUST be finished on every exit. The
1122
+ // already-fixed window is the judge itself throwing (caught inside
1123
+ // validateStepSummary — `judgeState.errored` records it; a non-required gate
1124
+ // fails open, a required gate blocks below). The remaining
1125
+ // window is `completeWorkflowStep` throwing AFTER the judge ran — a stolen
1126
+ // lease, a concurrent state change, a DB error — which would otherwise skip the
1127
+ // finish and strand the gate row in `running`. Finish it as an errored row (the
1128
+ // observed outcome: the completion did not succeed), then re-propagate.
1129
+ let completion;
1130
+ try {
1131
+ completion = await completeWorkflowStep({
1132
+ runId,
1133
+ stepId,
1134
+ status: "completed",
1135
+ summary,
1136
+ evidence: result.evidence,
1137
+ summaryJudge,
1138
+ // Codex round-3 finding A: mark this completion's gate REQUIRED so
1139
+ // `validateStepSummary` does NOT fail open when the judge throws / is
1140
+ // unreachable / returns garbage — it flags `errored` and we block below.
1141
+ ...(gateRequired ? { requireGate: true } : {}),
1142
+ ...lease,
1143
+ });
1144
+ }
1145
+ catch (err) {
1146
+ if (gateUnit)
1147
+ await journalGateEvaluationFinish(gateUnit, true, undefined);
1148
+ throw err;
1149
+ }
1150
+ const rejection = "ok" in completion && completion.ok === false ? completion : undefined;
1151
+ // A required gate whose judge could not be evaluated is an errored gate, not a
1152
+ // real rejection: journal the gate row as errored (verdict null) so the
1153
+ // observed outcome is honest, driven by EITHER the wrapper catching a throw OR
1154
+ // validateStepSummary flagging an unparseable verdict.
1155
+ const gateErrored = judgeState.errored || rejection?.errored === true;
1156
+ if (gateUnit) {
1157
+ await journalGateEvaluationFinish(gateUnit, gateErrored, rejection);
1158
+ }
1159
+ // Codex round-3 finding A: a REQUIRED gate that could not be judged (the judge
1160
+ // threw, was unreachable, or returned an unparseable verdict) must NOT fail
1161
+ // open and advance. The gate row is journaled errored above; BLOCK the step (a
1162
+ // human resolves it) instead of silently passing an unjudged required gate.
1163
+ if (rejection?.errored) {
1164
+ const notes = `Step "${stepId}" has a REQUIRED completion gate but its summary-validation judge failed to return a verdict ` +
1165
+ `(the LLM threw, was unreachable, or returned an unparseable response). A required gate must be judged — refusing ` +
1166
+ `to fail open and silently pass it. The step is BLOCKED: fix the LLM/connection, then \`akm workflow resume ${runId}\` ` +
1167
+ `to re-evaluate the gate, or advance the step manually with \`akm workflow complete\`.`;
1168
+ await completeWorkflowStep({ runId, stepId, status: "blocked", notes, evidence: result.evidence, ...lease });
1169
+ return { kind: "blocked", summary: notes };
1170
+ }
1171
+ if (!rejection) {
1172
+ return { kind: "advanced", ...(summaryOverride !== undefined ? { summaryOverride } : {}) };
1173
+ }
1174
+ if (loopsRemaining) {
1175
+ return { kind: "retry", gateFeedback: { feedback: rejection.feedback, missing: rejection.missing } };
1176
+ }
1177
+ return {
1178
+ kind: "gate-exhausted",
1179
+ gateRejection: { stepId, missing: rejection.missing, feedback: rejection.feedback },
1180
+ };
1181
+ }
1182
+ // ── Frozen plan parse + integrity check (shared) ─────────────────────────────
1183
+ /**
1184
+ * Parse and integrity-check a run's frozen plan JSON (migration 006). Shared by
1185
+ * the engine loop's plan loader (`run-workflow.ts`) and the R3 brief/report
1186
+ * surfaces so all three apply the SAME corruption + hash checks — the frozen
1187
+ * plan the engine executes is the exact plan brief describes and report
1188
+ * validates against. A NULL `plan_json` is the CALLER's decision (the engine
1189
+ * warns and compiles from the asset; brief/report error), so this helper only
1190
+ * handles a PRESENT plan string.
1191
+ */
1192
+ export function parseFrozenPlan(runId, planJson, planHash) {
1193
+ try {
1194
+ return decodeCanonicalPlan(runId, planJson, planHash);
1195
+ }
1196
+ catch (cause) {
1197
+ throw new UsageError(`Workflow run ${runId} has a corrupt frozen plan: ${cause instanceof Error ? cause.message : String(cause)}. ` +
1198
+ `The journaled plan cannot be executed — abandon it and start a new run.`);
1199
+ }
1200
+ }
1201
+ // ── Small helpers ────────────────────────────────────────────────────────────
1202
+ function safeJson(value) {
1203
+ try {
1204
+ return JSON.stringify(value) ?? "null";
1205
+ }
1206
+ catch {
1207
+ return "null";
1208
+ }
1209
+ }
1210
+ function clip(text, max) {
1211
+ return text.length > max ? `${text.slice(0, max)}…` : text;
1212
+ }