akm-cli 0.9.0-beta.9 → 0.9.0-rc.1

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 (381) hide show
  1. package/CHANGELOG.md +715 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/help/help-improve.md +9 -6
  6. package/dist/assets/hints/cli-hints-full.md +6 -5
  7. package/dist/assets/profiles/default.json +9 -4
  8. package/dist/assets/profiles/frequent.json +1 -1
  9. package/dist/assets/profiles/memory-focus.json +1 -1
  10. package/dist/assets/profiles/proactive-maintenance.json +25 -0
  11. package/dist/assets/profiles/quick.json +1 -1
  12. package/dist/assets/profiles/recombine-only.json +21 -0
  13. package/dist/assets/profiles/reflect-distill.json +30 -0
  14. package/dist/assets/profiles/synthesize.json +15 -0
  15. package/dist/assets/profiles/thorough.json +1 -1
  16. package/dist/assets/prompts/consolidate-system.md +23 -0
  17. package/dist/assets/prompts/contradiction-judge.md +33 -0
  18. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  19. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  20. package/dist/assets/prompts/extract-session.md +11 -3
  21. package/dist/assets/prompts/graph-extract-system.md +1 -0
  22. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  23. package/dist/assets/prompts/memory-infer-system.md +1 -0
  24. package/dist/assets/prompts/memory-infer-user.md +5 -0
  25. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  26. package/dist/assets/prompts/procedural-system.md +44 -0
  27. package/dist/assets/prompts/recombine-system.md +40 -0
  28. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  29. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  30. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  39. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  40. package/dist/assets/templates/html/health.html +281 -111
  41. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  42. package/dist/assets/wiki/schema-template.md +4 -4
  43. package/dist/cli/clack.js +56 -0
  44. package/dist/cli/config-migrate.js +7 -1
  45. package/dist/cli/confirm.js +1 -1
  46. package/dist/cli/parse-args.js +46 -1
  47. package/dist/cli/shared.js +28 -0
  48. package/dist/cli.js +25 -21
  49. package/dist/commands/agent/agent-dispatch.js +3 -2
  50. package/dist/commands/agent/agent-support.js +0 -7
  51. package/dist/commands/agent/contribute-cli.js +26 -7
  52. package/dist/commands/config-cli.js +26 -13
  53. package/dist/commands/env/child-env.js +47 -0
  54. package/dist/commands/env/env-binding.js +95 -0
  55. package/dist/commands/env/env-cli.js +228 -292
  56. package/dist/commands/env/env.js +14 -67
  57. package/dist/commands/env/secret-cli.js +140 -138
  58. package/dist/commands/feedback-cli.js +156 -155
  59. package/dist/commands/graph/graph-cli.js +5 -13
  60. package/dist/commands/graph/graph.js +3 -3
  61. package/dist/commands/health/advisories.js +151 -0
  62. package/dist/commands/health/checks.js +103 -16
  63. package/dist/commands/health/html-report.js +447 -81
  64. package/dist/commands/health/improve-metrics.js +771 -0
  65. package/dist/commands/health/llm-usage.js +65 -0
  66. package/dist/commands/health/md-report.js +103 -0
  67. package/dist/commands/health/metrics.js +278 -0
  68. package/dist/commands/health/stash-exposure.js +46 -0
  69. package/dist/commands/health/surfaces.js +216 -0
  70. package/dist/commands/health/task-runs.js +135 -0
  71. package/dist/commands/health/types.js +26 -0
  72. package/dist/commands/health/windows.js +195 -0
  73. package/dist/commands/health.js +91 -1091
  74. package/dist/commands/improve/anti-collapse.js +170 -0
  75. package/dist/commands/improve/calibration.js +161 -0
  76. package/dist/commands/improve/collapse-detector.js +421 -0
  77. package/dist/commands/improve/consolidate/chunking.js +141 -0
  78. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  79. package/dist/commands/improve/consolidate/merge.js +145 -0
  80. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  81. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  82. package/dist/commands/improve/consolidate.js +1295 -1277
  83. package/dist/commands/improve/dedup.js +482 -0
  84. package/dist/commands/improve/distill/content-repair.js +202 -0
  85. package/dist/commands/improve/distill/promote-memory.js +229 -0
  86. package/dist/commands/improve/distill/quality-gate.js +236 -0
  87. package/dist/commands/improve/distill-guards.js +127 -0
  88. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  89. package/dist/commands/improve/distill.js +228 -605
  90. package/dist/commands/improve/eligibility.js +434 -0
  91. package/dist/commands/improve/encoding-salience.js +205 -0
  92. package/dist/commands/improve/extract-cli.js +179 -59
  93. package/dist/commands/improve/extract-prompt.js +54 -3
  94. package/dist/commands/improve/extract-watch.js +140 -0
  95. package/dist/commands/improve/extract.js +409 -43
  96. package/dist/commands/improve/feedback-valence.js +54 -0
  97. package/dist/commands/improve/hot-probation.js +45 -0
  98. package/dist/commands/improve/improve-auto-accept.js +157 -10
  99. package/dist/commands/improve/improve-cli.js +115 -73
  100. package/dist/commands/improve/improve-profiles.js +28 -8
  101. package/dist/commands/improve/improve-result-file.js +15 -25
  102. package/dist/commands/improve/improve-session.js +58 -0
  103. package/dist/commands/improve/improve.js +485 -2764
  104. package/dist/commands/improve/locks.js +154 -0
  105. package/dist/commands/improve/loop-stages.js +1100 -0
  106. package/dist/commands/improve/memory/memory-belief.js +14 -15
  107. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  108. package/dist/commands/improve/memory/memory-improve.js +27 -27
  109. package/dist/commands/improve/outcome-loop.js +270 -0
  110. package/dist/commands/improve/preparation.js +2002 -0
  111. package/dist/commands/improve/proactive-maintenance.js +37 -35
  112. package/dist/commands/improve/procedural.js +398 -0
  113. package/dist/commands/improve/recombine.js +818 -0
  114. package/dist/commands/improve/reflect-noise.js +0 -0
  115. package/dist/commands/improve/reflect.js +206 -45
  116. package/dist/commands/improve/salience.js +455 -0
  117. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  118. package/dist/commands/improve/shared.js +51 -0
  119. package/dist/commands/improve/triage.js +93 -0
  120. package/dist/commands/lint/agent-linter.js +19 -24
  121. package/dist/commands/lint/base-linter.js +173 -60
  122. package/dist/commands/lint/command-linter.js +19 -24
  123. package/dist/commands/lint/env-key-rules.js +38 -1
  124. package/dist/commands/lint/fact-linter.js +39 -0
  125. package/dist/commands/lint/index.js +31 -13
  126. package/dist/commands/lint/memory-linter.js +1 -1
  127. package/dist/commands/lint/registry.js +7 -2
  128. package/dist/commands/lint/task-linter.js +3 -3
  129. package/dist/commands/lint/workflow-linter.js +26 -1
  130. package/dist/commands/observability-cli.js +4 -4
  131. package/dist/commands/proposal/drain-policies.js +13 -4
  132. package/dist/commands/proposal/drain.js +45 -51
  133. package/dist/commands/proposal/legacy-import.js +115 -0
  134. package/dist/commands/proposal/proposal-cli.js +24 -34
  135. package/dist/commands/proposal/proposal.js +2 -1
  136. package/dist/commands/proposal/propose.js +8 -3
  137. package/dist/commands/proposal/repository.js +829 -0
  138. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  139. package/dist/commands/proposal/validators/proposals.js +93 -895
  140. package/dist/commands/read/curate.js +410 -111
  141. package/dist/commands/read/knowledge.js +10 -3
  142. package/dist/commands/read/remember-cli.js +133 -138
  143. package/dist/commands/read/search-cli.js +15 -8
  144. package/dist/commands/read/search.js +22 -11
  145. package/dist/commands/read/show.js +106 -14
  146. package/dist/commands/registry-cli.js +76 -87
  147. package/dist/commands/remember.js +11 -12
  148. package/dist/commands/sources/add-cli.js +91 -95
  149. package/dist/commands/sources/history.js +1 -1
  150. package/dist/commands/sources/init.js +66 -18
  151. package/dist/commands/sources/installed-stashes.js +11 -3
  152. package/dist/commands/sources/migration-help.js +7 -4
  153. package/dist/commands/sources/schema-repair.js +44 -46
  154. package/dist/commands/sources/self-update.js +2 -2
  155. package/dist/commands/sources/source-add.js +7 -3
  156. package/dist/commands/sources/sources-cli.js +3 -3
  157. package/dist/commands/sources/stash-cli.js +19 -39
  158. package/dist/commands/sources/stash-skeleton.js +57 -8
  159. package/dist/commands/tasks/default-tasks.js +15 -2
  160. package/dist/commands/tasks/tasks-cli.js +20 -29
  161. package/dist/commands/tasks/tasks.js +39 -11
  162. package/dist/commands/wiki-cli.js +23 -38
  163. package/dist/commands/workflow-cli.js +291 -13
  164. package/dist/core/asset/asset-registry.js +3 -1
  165. package/dist/core/asset/asset-spec.js +79 -5
  166. package/dist/core/asset/frontmatter.js +188 -167
  167. package/dist/core/asset/markdown.js +8 -0
  168. package/dist/core/authoring-rules.js +92 -0
  169. package/dist/core/common.js +4 -23
  170. package/dist/core/concurrent.js +10 -1
  171. package/dist/core/config/config-io.js +10 -1
  172. package/dist/core/config/config-migration.js +18 -40
  173. package/dist/core/config/config-schema.js +403 -62
  174. package/dist/core/config/config-types.js +3 -3
  175. package/dist/core/config/config.js +67 -22
  176. package/dist/core/deep-merge.js +38 -0
  177. package/dist/core/errors.js +1 -0
  178. package/dist/core/eval/rank-metrics.js +113 -0
  179. package/dist/core/events.js +4 -7
  180. package/dist/core/improve-types.js +47 -8
  181. package/dist/core/json-schema.js +142 -0
  182. package/dist/core/logs-db.js +14 -75
  183. package/dist/core/parse.js +36 -16
  184. package/dist/core/paths.js +18 -18
  185. package/dist/core/standards/resolve-standards-context.js +87 -0
  186. package/dist/core/standards/resolve-stash-standards.js +99 -0
  187. package/dist/core/standards/resolve-type-conventions.js +66 -0
  188. package/dist/core/state/migrations.js +770 -0
  189. package/dist/core/state-db.js +132 -1126
  190. package/dist/core/structured.js +69 -0
  191. package/dist/core/time.js +53 -0
  192. package/dist/core/warn.js +21 -0
  193. package/dist/core/write-source.js +37 -0
  194. package/dist/indexer/db/db.js +261 -770
  195. package/dist/indexer/db/entry-mapper.js +41 -0
  196. package/dist/indexer/db/graph-db.js +129 -86
  197. package/dist/indexer/db/llm-cache.js +2 -2
  198. package/dist/indexer/db/schema.js +516 -0
  199. package/dist/indexer/ensure-index.js +36 -92
  200. package/dist/indexer/feedback/utility-policy.js +75 -0
  201. package/dist/indexer/graph/graph-boost.js +51 -41
  202. package/dist/indexer/graph/graph-extraction.js +207 -4
  203. package/dist/indexer/index-writer-lock.js +18 -11
  204. package/dist/indexer/index-written-assets.js +105 -0
  205. package/dist/indexer/indexer.js +182 -204
  206. package/dist/indexer/passes/dir-staleness.js +114 -0
  207. package/dist/indexer/passes/memory-inference.js +13 -5
  208. package/dist/indexer/passes/metadata.js +20 -0
  209. package/dist/indexer/read-preflight.js +23 -0
  210. package/dist/indexer/search/db-search.js +89 -13
  211. package/dist/indexer/search/fts-query.js +51 -0
  212. package/dist/indexer/search/ranking-contributors.js +95 -9
  213. package/dist/indexer/search/ranking.js +79 -3
  214. package/dist/indexer/search/search-fields.js +6 -0
  215. package/dist/indexer/search/search-source.js +32 -21
  216. package/dist/indexer/search/semantic-status.js +4 -0
  217. package/dist/indexer/walk/matchers.js +48 -0
  218. package/dist/indexer/walk/walker.js +21 -13
  219. package/dist/integrations/agent/builders.js +41 -13
  220. package/dist/integrations/agent/config.js +20 -59
  221. package/dist/integrations/agent/detect.js +9 -0
  222. package/dist/integrations/agent/index.js +3 -19
  223. package/dist/integrations/agent/model-aliases.js +16 -2
  224. package/dist/integrations/agent/profiles.js +79 -6
  225. package/dist/integrations/agent/prompts.js +75 -9
  226. package/dist/integrations/agent/runner-dispatch.js +83 -0
  227. package/dist/integrations/agent/runner.js +13 -9
  228. package/dist/integrations/agent/spawn.js +206 -81
  229. package/dist/integrations/harnesses/aider/agent-builder.js +113 -0
  230. package/dist/integrations/harnesses/aider/index.js +58 -0
  231. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  232. package/dist/integrations/harnesses/amazonq/agent-builder.js +153 -0
  233. package/dist/integrations/harnesses/amazonq/index.js +59 -0
  234. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  235. package/dist/integrations/harnesses/claude/agent-builder.js +46 -7
  236. package/dist/integrations/harnesses/claude/index.js +27 -23
  237. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  238. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  239. package/dist/integrations/harnesses/codex/agent-builder.js +137 -0
  240. package/dist/integrations/harnesses/codex/index.js +63 -0
  241. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  242. package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
  243. package/dist/integrations/harnesses/copilot/index.js +60 -0
  244. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  245. package/dist/integrations/harnesses/gemini/agent-builder.js +121 -0
  246. package/dist/integrations/harnesses/gemini/index.js +60 -0
  247. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  248. package/dist/integrations/harnesses/index.js +28 -7
  249. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  250. package/dist/integrations/harnesses/opencode/index.js +17 -16
  251. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  252. package/dist/integrations/harnesses/opencode-sdk/harness.js +65 -0
  253. package/dist/integrations/harnesses/opencode-sdk/index.js +10 -34
  254. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +642 -71
  255. package/dist/integrations/harnesses/openhands/agent-builder.js +126 -0
  256. package/dist/integrations/harnesses/openhands/index.js +58 -0
  257. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  258. package/dist/integrations/harnesses/pi/agent-builder.js +104 -0
  259. package/dist/integrations/harnesses/pi/index.js +58 -0
  260. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  261. package/dist/integrations/harnesses/types.js +8 -0
  262. package/dist/integrations/session-logs/index.js +40 -11
  263. package/dist/llm/call-ai.js +2 -2
  264. package/dist/llm/client.js +34 -11
  265. package/dist/llm/embedder.js +67 -4
  266. package/dist/llm/embedders/cache.js +3 -1
  267. package/dist/llm/embedders/deterministic.js +66 -0
  268. package/dist/llm/embedders/local.js +73 -3
  269. package/dist/llm/feature-gate.js +16 -15
  270. package/dist/llm/graph-extract.js +67 -44
  271. package/dist/llm/memory-infer-impl.js +138 -0
  272. package/dist/llm/memory-infer.js +1 -127
  273. package/dist/llm/metadata-enhance.js +44 -31
  274. package/dist/llm/structured-call.js +49 -0
  275. package/dist/migrate-storage-node.mjs +8 -0
  276. package/dist/output/context.js +5 -5
  277. package/dist/output/renderers.js +87 -15
  278. package/dist/output/shapes/curate.js +14 -2
  279. package/dist/output/shapes/helpers.js +0 -3
  280. package/dist/output/shapes/passthrough.js +6 -1
  281. package/dist/output/text/helpers.js +241 -2
  282. package/dist/output/text/workflow.js +4 -1
  283. package/dist/registry/providers/skills-sh.js +21 -147
  284. package/dist/registry/providers/static-index.js +15 -157
  285. package/dist/registry/resolve.js +27 -9
  286. package/dist/runtime.js +25 -1
  287. package/dist/schemas/akm-config.json +14225 -0
  288. package/dist/schemas/akm-workflow.json +328 -0
  289. package/dist/scripts/migrate-storage.js +2743 -8390
  290. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +1652 -607
  291. package/dist/setup/detect.js +9 -0
  292. package/dist/setup/legacy-config.js +106 -0
  293. package/dist/setup/prompt.js +57 -0
  294. package/dist/setup/providers.js +14 -0
  295. package/dist/setup/registry-stash-loader.js +12 -0
  296. package/dist/setup/semantic-assets.js +124 -0
  297. package/dist/setup/setup.js +52 -1614
  298. package/dist/setup/steps/connection.js +734 -0
  299. package/dist/setup/steps/output.js +31 -0
  300. package/dist/setup/steps/platforms.js +124 -0
  301. package/dist/setup/steps/semantic.js +27 -0
  302. package/dist/setup/steps/sources.js +222 -0
  303. package/dist/setup/steps/stashdir.js +42 -0
  304. package/dist/setup/steps/tasks.js +152 -0
  305. package/dist/sources/include.js +6 -2
  306. package/dist/sources/providers/filesystem.js +0 -1
  307. package/dist/sources/providers/git-install.js +210 -0
  308. package/dist/sources/providers/git-provider.js +234 -0
  309. package/dist/sources/providers/git-stash.js +248 -0
  310. package/dist/sources/providers/git.js +10 -661
  311. package/dist/sources/providers/npm.js +2 -6
  312. package/dist/sources/providers/provider-utils.js +13 -7
  313. package/dist/sources/providers/sync-from-ref.js +9 -1
  314. package/dist/sources/providers/website.js +9 -5
  315. package/dist/sources/website-ingest.js +187 -29
  316. package/dist/sources/wiki-fetchers/registry.js +53 -0
  317. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  318. package/dist/storage/database.js +45 -10
  319. package/dist/storage/managed-db.js +82 -0
  320. package/dist/storage/repositories/canaries-repository.js +107 -0
  321. package/dist/storage/repositories/consolidation-repository.js +38 -0
  322. package/dist/storage/repositories/embeddings-repository.js +72 -0
  323. package/dist/storage/repositories/events-repository.js +187 -0
  324. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  325. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  326. package/dist/storage/repositories/index-db.js +14 -8
  327. package/dist/storage/repositories/proposals-repository.js +220 -0
  328. package/dist/storage/repositories/recombine-repository.js +213 -0
  329. package/dist/storage/repositories/registry-cache.js +93 -0
  330. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  331. package/dist/storage/repositories/task-history-repository.js +93 -0
  332. package/dist/storage/repositories/workflow-runs-repository.js +189 -1
  333. package/dist/storage/sqlite-pragmas.js +146 -0
  334. package/dist/tasks/backends/cron.js +1 -1
  335. package/dist/tasks/backends/index.js +9 -0
  336. package/dist/tasks/backends/launchd.js +1 -1
  337. package/dist/tasks/backends/schtasks.js +1 -1
  338. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  339. package/dist/tasks/runner.js +15 -13
  340. package/dist/text-import-hook.mjs +1 -1
  341. package/dist/wiki/wiki.js +52 -11
  342. package/dist/workflows/authoring/authoring.js +123 -10
  343. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  344. package/dist/workflows/cli.js +5 -0
  345. package/dist/workflows/db.js +138 -4
  346. package/dist/workflows/exec/brief.js +484 -0
  347. package/dist/workflows/exec/native-executor.js +975 -0
  348. package/dist/workflows/exec/param-secrets.js +115 -0
  349. package/dist/workflows/exec/report.js +1295 -0
  350. package/dist/workflows/exec/run-workflow.js +596 -0
  351. package/dist/workflows/exec/scheduler.js +100 -0
  352. package/dist/workflows/exec/step-work.js +1156 -0
  353. package/dist/workflows/exec/unit-writer.js +23 -0
  354. package/dist/workflows/exec/watch.js +116 -0
  355. package/dist/workflows/exec/worktree.js +171 -0
  356. package/dist/workflows/ir/compile.js +388 -0
  357. package/dist/workflows/ir/params.js +54 -0
  358. package/dist/workflows/ir/plan-hash.js +33 -0
  359. package/dist/workflows/ir/schema.js +4 -0
  360. package/dist/workflows/parser.js +3 -1
  361. package/dist/workflows/program/expressions.js +369 -0
  362. package/dist/workflows/program/parser.js +760 -0
  363. package/dist/workflows/program/project.js +105 -0
  364. package/dist/workflows/program/schema.js +54 -0
  365. package/dist/workflows/renderer.js +82 -5
  366. package/dist/workflows/runtime/agent-identity.js +59 -14
  367. package/dist/workflows/runtime/runs.js +248 -153
  368. package/dist/workflows/runtime/unit-checkin.js +45 -0
  369. package/dist/workflows/runtime/workflow-asset-loader.js +188 -0
  370. package/dist/workflows/validate-summary.js +26 -10
  371. package/dist/workflows/validator.js +1 -1
  372. package/docs/README.md +69 -18
  373. package/docs/data-and-telemetry.md +7 -5
  374. package/docs/migration/release-notes/0.7.0.md +1 -1
  375. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  376. package/docs/migration/release-notes/0.9.0.md +39 -0
  377. package/package.json +10 -10
  378. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  379. package/dist/commands/db-cli.js +0 -23
  380. package/dist/indexer/db/db-backup.js +0 -376
  381. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -0,0 +1,975 @@
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
+ * Native executor — executes ONE step's IR v2 subgraph (`IrStepPlan.root`) on
6
+ * the local machine: fan-out through the scheduler, schema-validated
7
+ * structured output through `runStructured` (core/structured.ts), per-unit
8
+ * persistence through the serialized writer queue, and `workflow_unit_*`
9
+ * events for observability.
10
+ *
11
+ * Data flow (redesign addendum, R1): workflow-authored templates go through
12
+ * the deterministic `${{ … }}` expression language (`program/expressions.ts`)
13
+ * — but ONLY for nodes the frontend marked `templating: "expressions"` (YAML
14
+ * program units). Classic linear markdown instructions are `"verbatim"`:
15
+ * opaque data handed to the agent byte-exact (the stable CLI contract — a
16
+ * literal `${{` there is content, never grammar). Expression templates are
17
+ * parsed ONCE per step and resolved per unit against `{ params, stepOutputs,
18
+ * item, item_index }`; `map.over` resolves as a single whole-value reference.
19
+ * Substituted content is data, never re-scanned — the P1 `{{item}}` re-scan
20
+ * injection class is structurally impossible. There is NO ambient key search:
21
+ * a `steps.<id>.output.<path>` reference addresses INTO that step's recorded
22
+ * output explicitly.
23
+ *
24
+ * Step outputs (`${{ steps.<id>.output… }}`): every engine-executed step
25
+ * journals a promoted ARTIFACT under `evidence.output` — the solo unit's
26
+ * result/text, the collect reducer's per-item array, or the vote reducer's
27
+ * winner — and that artifact is what the expression scope exposes
28
+ * ({@link projectStepOutput}). The documented addressing
29
+ * (`steps.discover.output.files`) therefore resolves against real step
30
+ * results, never the raw evidence envelope (peer review R1). Steps completed
31
+ * manually (no `output` key in their evidence) expose their recorded evidence
32
+ * object as-is.
33
+ *
34
+ * Empty free-text outputs (peer review): a SUCCESSFUL schemaless unit that
35
+ * returns the empty string is normalized to "no output" — {@link dispatchUnit}
36
+ * drops the falsy `text`, `finishUnit` journals `result_json = NULL`, and both
37
+ * durable-row reuse and the R3 report surface rehydrate the same absence
38
+ * (`unitOutcomeFromRow`). This is the ONLY empty-output resolution: `''` never
39
+ * survives on any surface, so the live artifact cannot diverge from the
40
+ * resume/report artifact (the cross-surface parity cardinal rule; the
41
+ * `EMPTY_OUTPUT` driver-parity golden pins it). Consequences that follow from
42
+ * "empty == absent", not special-cased anywhere:
43
+ * - a SOLO empty step promotes `output = null` (the unit's absent text ??
44
+ * null); a `collect` fan-out promotes `null` in that item's slot.
45
+ * - A downstream `${{ steps.x.output }}` of an empty solo step therefore
46
+ * resolves against `null` and fails LOUDLY at expression resolution
47
+ * (`… resolved to null`) — a deterministic `expression_error` on BOTH
48
+ * surfaces, never a silent empty string.
49
+ * - A SCHEMA unit is unaffected by this normalization: an empty response is
50
+ * not parseable JSON, so `runStructured` fails it (`parse_error`) — an
51
+ * empty output can never satisfy a declared schema as a silent `null`.
52
+ *
53
+ * Typed artifacts (addendum, R2): when the step declares an `output` schema
54
+ * (`IrStepPlan.outputSchema`), the promoted artifact is validated with the
55
+ * JSON-schema-subset validator BEFORE the step can complete. A mismatch fails
56
+ * the step (fail-fast) with the validation errors in the summary — a
57
+ * downstream consumer must never receive an artifact the author's contract
58
+ * says cannot exist. The failure is flagged (`artifactSchemaFailure` on the
59
+ * result) so the engine's bounded gate loop can re-run the step with the
60
+ * validation errors as feedback ("gate loops can re-run it") — a step with
61
+ * loop budget left regenerates instead of killing the run.
62
+ *
63
+ * Unit identity (addendum, R2): CONTENT-DERIVED, never positional. A fan-out
64
+ * unit's id is `<node_id>:<sha256(canonicalJson(item))[:12]>`; a solo unit's
65
+ * is `<node_id>:solo`. Identity therefore survives item-list regeneration and
66
+ * reordering — resuming a run whose producer re-emitted the same items in a
67
+ * different order reuses every journaled result. Consequences:
68
+ * - DUPLICATE items in one fan-out list collide on identity. That is an
69
+ * authoring error (the same work dispatched twice under one id): the step
70
+ * fails deterministically after resolving the item list, naming the
71
+ * duplicate, before anything dispatches.
72
+ * - REPLAY DIVERGENCE: a journaled COMPLETED row whose unit_id matches but
73
+ * whose `input_hash` differs is a hard step failure ("replay divergence"),
74
+ * never a silent re-dispatch — under a frozen plan the same identity must
75
+ * reproduce the same inputs, so a mismatch means the journal (or params
76
+ * row) was tampered with. Failed/running/missing rows dispatch live.
77
+ * - Pre-release R1 journals used positional ids (`node.unit[3]`). There is
78
+ * no back-compat shim: those rows simply never match a content-derived id
79
+ * and are ignored (the step re-runs cleanly on top of them).
80
+ *
81
+ * Gate loops (addendum, R2 `gate.max_loops`): when the engine re-executes a
82
+ * step subgraph after a gate rejection, it threads the judge's feedback in as
83
+ * `ctx.gateFeedback` (appended to every unit prompt — the input hash changes,
84
+ * so re-dispatch is natural) and marks the attempt with `ctx.gateLoop` (>= 2).
85
+ * Loop attempts journal under `<unitId>~l<loop>` — like `~r<n>` retries, pure
86
+ * journal bookkeeping on top of the content-derived identity, so loop 1's
87
+ * rows are never clobbered. Because gate feedback is JUDGE-authored (a fresh
88
+ * LLM output per invocation, not a pure function of the frozen plan), a
89
+ * journaled loop row whose hash no longer matches re-dispatches live instead
90
+ * of raising replay divergence — the divergence guarantee applies to loop-1
91
+ * rows, whose inputs ARE pure functions of (plan, params, journaled results).
92
+ *
93
+ * Failure policy (addendum, "explicit surface, fail-fast default"):
94
+ * - `onError: "fail"` (default) fails the step on any unit failure;
95
+ * `"continue"` records failures in the evidence and lets the gate decide.
96
+ * - `retry: { max, on }` re-dispatches a failed unit up to `max` extra
97
+ * times when its `failureReason` is in `on`. Every retry journals its OWN
98
+ * row under `<unitId>~r<attempt>` so no attempt's record is clobbered.
99
+ *
100
+ * Worktree isolation (addendum, R2 `isolation: worktree`): each journaled
101
+ * attempt of an isolated agent/sdk unit runs in a FRESH detached git worktree
102
+ * of the engine's working directory (`ctx.workDir`, default `process.cwd()`),
103
+ * minted under a run-scoped tmp dir (`worktree.ts`) and passed to dispatch as
104
+ * the child's cwd. The path is journaled on the unit row (`worktree_path`);
105
+ * after the unit finishes, a clean worktree is removed and a dirty one is
106
+ * retained + logged (uncollected work is never destroyed). "Clean" is
107
+ * `git status --porcelain` WITHOUT `--ignored`, so a worktree whose only
108
+ * residue is `.gitignore`-matched files (build outputs, `node_modules`) counts
109
+ * as clean and IS removed — those files are disposable by the repo's own
110
+ * declaration, and retaining a worktree per build would blow up disk
111
+ * (`worktree.ts` contract). A non-git base directory fails the step cleanly
112
+ * before any dispatch, and llm units reject isolation loudly — there is no
113
+ * child process to isolate.
114
+ *
115
+ * Budget ceilings (addendum, R2): a frozen plan's `budget` block
116
+ * (`max_units` / `max_tokens`) is enforced per RUN. The engine seeds
117
+ * `ctx.unitsDispatched` (journal row count) and `ctx.tokensUsed` (journaled
118
+ * token sum) and threads the running totals across steps; this executor
119
+ * consumes both per ACTUAL dispatch. Hitting a ceiling aborts pending and
120
+ * in-flight dispatches through an AbortController chained onto `ctx.signal`
121
+ * and fails the step with a "budget exceeded (<which> ceiling)" summary —
122
+ * hard, regardless of `on_error`, exactly like the lifetime cap.
123
+ *
124
+ * Layering (see the plan's *Reconciliation* section):
125
+ * - Dispatch goes through ONE injected {@link UnitDispatcher} seam. The
126
+ * default dispatcher composes the EXISTING substrate — `executeRunner`
127
+ * (agent/sdk) and `chatCompletion` (llm, lazily imported so the engine
128
+ * stays offline-capable until a workflow actually declares an llm unit).
129
+ * - This module NEVER writes step rows: advancing the gated spine is the
130
+ * engine loop's job (`run-workflow.ts`) via `completeWorkflowStep`.
131
+ */
132
+ import { ConfigError } from "../../core/errors.js";
133
+ import { appendEvent } from "../../core/events.js";
134
+ import { validateJsonSchemaSubset } from "../../core/json-schema.js";
135
+ import { runStructured } from "../../core/structured.js";
136
+ import { warn } from "../../core/warn.js";
137
+ import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
138
+ import { LIFETIME_UNIT_CAP, scheduleUnits, UnitCapExceededError } from "./scheduler.js";
139
+ // Shared step semantics — the ONE implementation consumed by both the engine
140
+ // (this module + run-workflow.ts) and, from R3, the brief/report driver
141
+ // protocol. This module dispatches; step-work.ts owns the pure decisions.
142
+ import { buildArtifactSummary, computeStepWorkList, DEFAULT_UNIT_TIMEOUT_MS, projectStepOutput, reduceEmptyStep, reduceStepOutcomes, stepOutputsFromEvidence, unitOutcomeFromRow, } from "./step-work.js";
143
+ import { enqueueUnitWrite } from "./unit-writer.js";
144
+ import { assertGitWorkTree, cleanupUnitWorktree, createUnitWorktree } from "./worktree.js";
145
+ // Re-exported for existing consumers (run-workflow.ts, tests) that import these
146
+ // from native-executor; they now live in the shared step-work module.
147
+ export { buildArtifactSummary, DEFAULT_UNIT_TIMEOUT_MS, projectStepOutput };
148
+ /**
149
+ * Mutable per-step dispatch budget: the lifetime unit cap PLUS the declared
150
+ * run-level budget ceilings (`budget.max_units` / `budget.max_tokens`,
151
+ * addendum R2). Consumed once per journaled dispatch attempt (including
152
+ * retries); durable-row reuses never touch it — the peer-review fix that
153
+ * keeps large partially-completed fan-outs resumable instead of tripping the
154
+ * cap on `journaled + items`. Token usage accumulates per actual dispatch on
155
+ * top of the journal-seeded run total (reused rows' tokens are already in the
156
+ * seed). Check-and-increment is synchronous, so concurrent units cannot race
157
+ * it; crossing a declared ceiling fires `onExceeded` ONCE (the executor's
158
+ * chained AbortController), aborting pending and in-flight dispatches.
159
+ */
160
+ class DispatchBudget {
161
+ used;
162
+ /** Run-total tokens: journal-seeded input + this step's dispatch usage. */
163
+ tokens;
164
+ /** Set (once) when a dispatch was refused; the step fails with this message. */
165
+ capMessage;
166
+ /** Set (once) when a declared budget ceiling was hit; the step fails hard with it. */
167
+ budgetMessage;
168
+ maxUnits;
169
+ maxTokens;
170
+ onExceeded;
171
+ constructor(alreadyDispatched, opts) {
172
+ this.used = alreadyDispatched;
173
+ this.tokens = opts?.tokensUsed ?? 0;
174
+ this.maxUnits = opts?.budget?.maxUnits;
175
+ this.maxTokens = opts?.budget?.maxTokens;
176
+ this.onExceeded = opts?.onExceeded;
177
+ }
178
+ /** Consume one dispatch slot; false (and a sticky message) when a ceiling or the cap is hit. */
179
+ tryConsume() {
180
+ if (this.budgetMessage !== undefined)
181
+ return false;
182
+ if (this.maxUnits !== undefined && this.used >= this.maxUnits) {
183
+ this.exceed(`budget exceeded (max_units ceiling): ${this.used} unit(s) already dispatched for this run ` +
184
+ `against the workflow's declared budget.max_units of ${this.maxUnits} — refusing further dispatch.`);
185
+ return false;
186
+ }
187
+ if (this.maxTokens !== undefined && this.tokens >= this.maxTokens) {
188
+ this.exceed(`budget exceeded (max_tokens ceiling): ${this.tokens} token(s) already spent for this run ` +
189
+ `against the workflow's declared budget.max_tokens of ${this.maxTokens} — refusing further dispatch.`);
190
+ return false;
191
+ }
192
+ if (this.used >= LIFETIME_UNIT_CAP) {
193
+ this.capMessage ??= new UnitCapExceededError(LIFETIME_UNIT_CAP).message;
194
+ return false;
195
+ }
196
+ this.used++;
197
+ return true;
198
+ }
199
+ /** Record one dispatch's reported usage; crossing `maxTokens` trips the ceiling. */
200
+ addTokens(tokens) {
201
+ this.tokens += tokens;
202
+ if (this.budgetMessage === undefined && this.maxTokens !== undefined && this.tokens >= this.maxTokens) {
203
+ this.exceed(`budget exceeded (max_tokens ceiling): ${this.tokens} token(s) spent for this run, ` +
204
+ `reaching the workflow's declared budget.max_tokens of ${this.maxTokens} — aborting pending dispatches.`);
205
+ }
206
+ }
207
+ exceed(message) {
208
+ this.budgetMessage = message;
209
+ this.onExceeded?.();
210
+ }
211
+ }
212
+ function classifyUnitReuse(workUnit, existingUnits, gateLoop) {
213
+ if (!workUnit.resolved.ok)
214
+ return { kind: "dispatch" };
215
+ const inputHash = workUnit.resolved.inputHash;
216
+ const maxAttempts = 1 + Math.max(0, workUnit.retry?.max ?? 0);
217
+ const base = workUnit.journalBaseId;
218
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
219
+ const attemptId = attempt === 0 ? base : `${base}~r${attempt}`;
220
+ const prior = existingUnits?.get(attemptId);
221
+ if (!prior || prior.status !== "completed")
222
+ continue;
223
+ if (prior.input_hash === inputHash)
224
+ return { kind: "reuse", row: prior };
225
+ // Gate-loop rows are NOT replay-deterministic (the prompt embeds a fresh
226
+ // judge output): a stale loop-N row with a different hash re-dispatches
227
+ // live. Divergence only guards loop-1 rows, whose inputs ARE a pure
228
+ // function of (frozen plan, params, journaled results).
229
+ if (gateLoop > 1)
230
+ return { kind: "dispatch" };
231
+ return { kind: "diverge", attemptId };
232
+ }
233
+ return { kind: "dispatch" };
234
+ }
235
+ /**
236
+ * Does the step have at least one unit that will ACTUALLY dispatch? Env
237
+ * resolution and worktree preflight are dispatch prerequisites, so a step whose
238
+ * units are all reused / unresolved / diverged must skip them (reviewer finding
239
+ * #2). Mirrors runUnit's reuse decision exactly (shared {@link classifyUnitReuse}).
240
+ */
241
+ function stepWillDispatch(workUnits, existingUnits, gateLoop) {
242
+ return workUnits.some((u) => u.resolved.ok && classifyUnitReuse(u, existingUnits, gateLoop).kind === "dispatch");
243
+ }
244
+ /** Execute one step plan natively. Never throws for unit-level failures. */
245
+ export async function executeStepPlan(plan, ctx) {
246
+ const dispatched = ctx.unitsDispatched ?? 0;
247
+ // Work-list computation is the SHARED, PURE decision (step-work.ts): resolve
248
+ // the fan-out list, derive content-derived unit ids, assemble each unit's
249
+ // prompt, and hash its resolved input. `brief` (R3) computes the identical
250
+ // list — that shared implementation is the anti-drift guarantee. This module
251
+ // owns only the impure remainder: env/worktree preflight, durable-row reuse,
252
+ // dispatch, journaling, budget.
253
+ const workList = computeStepWorkList(plan, {
254
+ runId: ctx.runId,
255
+ params: ctx.params,
256
+ stepOutputs: stepOutputsFromEvidence(ctx.evidence),
257
+ ...(ctx.gateLoop !== undefined ? { gateLoop: ctx.gateLoop } : {}),
258
+ ...(ctx.gateFeedback ? { gateFeedback: ctx.gateFeedback } : {}),
259
+ });
260
+ if (!workList.ok) {
261
+ return failedStep(dispatched, workList.error);
262
+ }
263
+ const { template, reducer, isFanOut, items, units: workUnits } = workList.list;
264
+ if (items.length === 0) {
265
+ // Empty fan-out: the promoted artifact is the degenerate empty value, honored
266
+ // against the step's declared output schema. `reduceEmptyStep` is the SHARED
267
+ // decision (step-work.ts) the R3 report surface also uses to auto-complete an
268
+ // empty step the spine reaches, so both surfaces promote the identical
269
+ // artifact + schema verdict.
270
+ return { ...reduceEmptyStep(plan, reducer), unitsDispatched: dispatched };
271
+ }
272
+ const dispatcher = ctx.dispatcher ?? defaultUnitDispatcher;
273
+ // Durable-row resume: load the step's journaled unit rows FIRST — before
274
+ // resolving env or preflighting worktrees. A unit whose previous attempt
275
+ // completed with the SAME input hash (the canonical envelope in step-work.ts)
276
+ // is reused, not re-dispatched — a crash-resume must never double-issue
277
+ // side-effecting work. Loading the rows up front is what lets us skip the
278
+ // dispatch prerequisites below when nothing will actually dispatch.
279
+ const existingUnits = new Map();
280
+ for (const row of await withWorkflowRunsRepo((repo) => repo.getUnitsForStep(ctx.runId, plan.stepId))) {
281
+ existingUnits.set(row.unit_id, row);
282
+ }
283
+ // Reviewer finding #2: env resolution and worktree preflight are DISPATCH
284
+ // prerequisites, so they must run only when a unit will actually dispatch. A
285
+ // fully-journaled step whose units all reuse completed rows must resume to
286
+ // completion even if an env asset was deleted, a secret is unavailable, the
287
+ // cwd is no longer a git worktree, or git is missing — none of that is needed
288
+ // to hand back a cached result. The predicate mirrors runUnit's reuse
289
+ // decision exactly (shared classifyUnitReuse).
290
+ const gateLoop = ctx.gateLoop ?? 1;
291
+ const willDispatch = stepWillDispatch(workUnits, existingUnits, gateLoop);
292
+ // Env bindings resolve once per step, before any dispatch; a binding error
293
+ // fails the whole step cleanly rather than N units racing into it. Skipped
294
+ // entirely when nothing will dispatch.
295
+ let env;
296
+ if (willDispatch && template.env && template.env.length > 0) {
297
+ const resolveEnv = ctx.resolveEnv ?? resolveEnvBindings;
298
+ try {
299
+ env = await resolveEnv(template.env);
300
+ }
301
+ catch (err) {
302
+ return failedStep(dispatched, `Step "${plan.stepId}" env binding failed: ${message(err)}`);
303
+ }
304
+ }
305
+ // Worktree isolation preflight (addendum R2), once per step, before any
306
+ // dispatch — and ONLY when a unit will dispatch: llm units have no working
307
+ // directory to isolate (fail loudly), and a non-git base directory (or a
308
+ // missing git binary) fails the step cleanly instead of N units racing into
309
+ // identical git errors. The actual worktrees are minted per journaled
310
+ // attempt in dispatchJournaledAttempt.
311
+ let worktreeBase;
312
+ if (willDispatch && template.isolation === "worktree") {
313
+ if (template.runner === "llm") {
314
+ return failedStep(dispatched, `Step "${plan.stepId}" declares isolation: worktree on an llm unit — the llm runner has no ` +
315
+ `working directory to isolate. Use the agent or sdk runner for worktree-isolated units.`);
316
+ }
317
+ const base = ctx.workDir ?? process.cwd();
318
+ const preflightWorktree = ctx.preflightWorktree ?? assertGitWorkTree;
319
+ const gitError = preflightWorktree(base);
320
+ if (gitError !== undefined) {
321
+ return failedStep(dispatched, `Step "${plan.stepId}" cannot use isolation: worktree: ${gitError}`);
322
+ }
323
+ worktreeBase = base;
324
+ }
325
+ // Budget ceilings (addendum R2): when the frozen plan declares a budget,
326
+ // dispatch runs under an AbortController CHAINED onto ctx.signal — hitting
327
+ // a ceiling aborts pending and in-flight dispatches, and the step fails
328
+ // hard below. Without a budget the context signal passes through untouched
329
+ // (the no-budget path is byte-identical to pre-R2 behavior).
330
+ const declaredBudget = ctx.budget && (ctx.budget.maxUnits !== undefined || ctx.budget.maxTokens !== undefined) ? ctx.budget : undefined;
331
+ let signal = ctx.signal;
332
+ let onExceeded;
333
+ let unchainSignal;
334
+ if (declaredBudget) {
335
+ const controller = new AbortController();
336
+ const upstream = ctx.signal;
337
+ if (upstream) {
338
+ if (upstream.aborted) {
339
+ controller.abort();
340
+ }
341
+ else {
342
+ const onUpstreamAbort = () => controller.abort();
343
+ upstream.addEventListener("abort", onUpstreamAbort, { once: true });
344
+ unchainSignal = () => upstream.removeEventListener("abort", onUpstreamAbort);
345
+ }
346
+ }
347
+ signal = controller.signal;
348
+ onExceeded = () => controller.abort();
349
+ }
350
+ // Lifetime-cap + declared-budget accounting: seeded with the run's
351
+ // journaled dispatch count and token total, consumed per ACTUAL dispatch
352
+ // inside runUnit — never for durable-row reuses, so resuming a large
353
+ // partially-completed fan-out works.
354
+ const budget = new DispatchBudget(dispatched, {
355
+ tokensUsed: ctx.tokensUsed ?? 0,
356
+ ...(declaredBudget ? { budget: declaredBudget } : {}),
357
+ ...(onExceeded ? { onExceeded } : {}),
358
+ });
359
+ let outcomes;
360
+ try {
361
+ outcomes = await scheduleUnits(workUnits, (workUnit) => runUnit({
362
+ plan,
363
+ workUnit,
364
+ env,
365
+ ...(worktreeBase !== undefined ? { worktreeBase } : {}),
366
+ ctx,
367
+ signal,
368
+ dispatcher,
369
+ existingUnits,
370
+ budget,
371
+ }), {
372
+ concurrency: workList.list.concurrency,
373
+ signal,
374
+ maxConcurrency: ctx.maxConcurrency,
375
+ });
376
+ }
377
+ finally {
378
+ unchainSignal?.();
379
+ }
380
+ // Declared budget ceilings and the lifetime cap are hard backstops: a step
381
+ // that hit one FAILS regardless of on_error policy (a capped run must never
382
+ // quietly pass its gate). The budget message names WHICH ceiling tripped.
383
+ if (budget.budgetMessage) {
384
+ return { ...failedStep(budget.used, budget.budgetMessage), tokensUsed: budget.tokens };
385
+ }
386
+ if (budget.capMessage) {
387
+ return { ...failedStep(budget.used, budget.capMessage), tokensUsed: budget.tokens };
388
+ }
389
+ const units = outcomes.map((outcome, index) => outcome ?? {
390
+ unitId: workUnits[index].unitId,
391
+ ok: false,
392
+ failureReason: "aborted",
393
+ error: "unit was not dispatched (aborted or scheduler failure)",
394
+ });
395
+ // Replay divergence is a HARD failure regardless of on_error: a journal
396
+ // whose completed row disagrees with the frozen plan's inputs must stop the
397
+ // run loudly (module doc), never be tolerated as "just a failed unit".
398
+ const diverged = units.filter((u) => u.failureReason === "replay_divergence");
399
+ if (diverged.length > 0) {
400
+ return failedStep(budget.used, diverged
401
+ .map((u) => u.error ?? `replay divergence: unit "${u.unitId}" was journaled with different inputs`)
402
+ .join(" "));
403
+ }
404
+ // Failure policy + reducer + typed-artifact validation are the SHARED
405
+ // post-dispatch decision (`reduceStepOutcomes`): `onError: "fail"` (default)
406
+ // fails the step on any unit failure, `"continue"` records failures and lets
407
+ // the gate decide, a vote reducer with no majority fails under either policy,
408
+ // and the promoted artifact is validated against the step's declared output
409
+ // schema (fail-fast; the `artifactSchemaFailure` marker lets the bounded gate
410
+ // loop retry that ONE failure class with the errors as feedback). The report
411
+ // path (R3) reduces journal-replayed outcomes through the same function, so a
412
+ // step promotes the SAME artifact whichever surface drove it.
413
+ const reduced = reduceStepOutcomes(plan, reducer, isFanOut, template.onError, units);
414
+ return {
415
+ ...reduced,
416
+ unitsDispatched: budget.used,
417
+ tokensUsed: budget.tokens,
418
+ };
419
+ }
420
+ async function runUnit(input) {
421
+ const { plan, workUnit, env, ctx, dispatcher } = input;
422
+ const unitId = workUnit.unitId;
423
+ // A per-unit expression resolution failure (missing param, bad `item.<path>`)
424
+ // is deterministic authoring/data breakage computed by the shared work-list:
425
+ // the unit fails WITHOUT dispatching — and without journaling a row, since no
426
+ // resolved input exists to hash.
427
+ if (!workUnit.resolved.ok) {
428
+ return { unitId, ok: false, failureReason: "expression_error", error: workUnit.resolved.error };
429
+ }
430
+ // The prompt (and therefore the input hash) was built once with the BASE
431
+ // unit id by computeStepWorkList: a retry re-dispatches the SAME input, the
432
+ // `~r<n>` suffix is journal bookkeeping only.
433
+ const { prompt, inputHash } = workUnit.resolved;
434
+ const request = {
435
+ runId: ctx.runId,
436
+ stepId: plan.stepId,
437
+ unitId,
438
+ nodeId: workUnit.nodeId,
439
+ prompt,
440
+ runner: workUnit.runner,
441
+ ...(workUnit.profile ? { profile: workUnit.profile } : {}),
442
+ ...(workUnit.model ? { model: workUnit.model } : {}),
443
+ timeoutMs: workUnit.timeoutMs,
444
+ ...(workUnit.schema ? { schema: workUnit.schema } : {}),
445
+ ...(env ? { env } : {}),
446
+ ...(input.signal ? { signal: input.signal } : {}),
447
+ };
448
+ // Bounded retry (IR v2 failure policy): attempt 0 journals under the base
449
+ // journal id (`<unitId>`, or `<unitId>~l<loop>` in a gate loop — computed by
450
+ // the shared work-list), retry attempt N under `<baseId>~r<N>`. Every attempt
451
+ // keeps its own row. Retries only fire when the failure reason is in
452
+ // `retry.on`.
453
+ const retry = workUnit.retry;
454
+ const maxAttempts = 1 + Math.max(0, retry?.max ?? 0);
455
+ const gateLoop = ctx.gateLoop ?? 1;
456
+ const journalBaseId = workUnit.journalBaseId;
457
+ const attemptIdFor = (attempt) => (attempt === 0 ? journalBaseId : `${journalBaseId}~r${attempt}`);
458
+ // Durable-row reuse (shared classifyUnitReuse — the SAME decision
459
+ // executeStepPlan's preflight gate uses, so the gate can never disagree with
460
+ // what happens here). A completed row with the matching input hash IS the
461
+ // result: return it without touching rows, dispatching, or re-emitting events
462
+ // (a crash-resume must never double-issue work). A completed loop-1 row with
463
+ // a DIFFERENT hash is replay divergence (under a frozen plan the same
464
+ // content-derived identity must reproduce the same inputs — the journal was
465
+ // tampered with; executeStepPlan promotes this to a hard step failure
466
+ // regardless of on_error). Stale gate-loop rows, failed/running/missing rows,
467
+ // and pre-release R1 positional ids all fall through and dispatch live.
468
+ const reuse = classifyUnitReuse(workUnit, input.existingUnits, gateLoop);
469
+ if (reuse.kind === "reuse") {
470
+ // Identity in the durable step evidence is the CONTENT-derived base id, not
471
+ // the `~r<n>` attempt row it was reused from — the report surface reduces
472
+ // from the base id too, so both surfaces' evidence.units[].unitId agree.
473
+ return reuseCompletedUnit(unitId, reuse.row, workUnit.schema !== undefined);
474
+ }
475
+ if (reuse.kind === "diverge") {
476
+ return {
477
+ unitId,
478
+ ok: false,
479
+ failureReason: "replay_divergence",
480
+ error: `replay divergence: unit "${reuse.attemptId}" was journaled with different inputs ` +
481
+ `(journaled input_hash does not match this invocation's) — refusing to re-dispatch.`,
482
+ };
483
+ }
484
+ let outcome;
485
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
486
+ const attemptId = attemptIdFor(attempt);
487
+ // Lifetime cap + declared budget ceilings, consumed per ACTUAL dispatch
488
+ // (reuses above returned before reaching here). Refusal fails this unit
489
+ // without journaling a row — nothing was dispatched — and the sticky
490
+ // capMessage/budgetMessage fails the step.
491
+ if (!input.budget.tryConsume()) {
492
+ const budgetHit = input.budget.budgetMessage !== undefined;
493
+ return (outcome ?? {
494
+ unitId,
495
+ ok: false,
496
+ failureReason: budgetHit ? "budget_exceeded" : "unit_cap_exceeded",
497
+ error: input.budget.budgetMessage ?? input.budget.capMessage ?? "lifetime unit cap exceeded",
498
+ });
499
+ }
500
+ outcome = await dispatchJournaledAttempt({
501
+ plan,
502
+ workUnit,
503
+ ctx,
504
+ dispatcher,
505
+ request: { ...request, unitId: attemptId },
506
+ attemptId,
507
+ inputHash,
508
+ ...(input.worktreeBase !== undefined ? { worktreeBase: input.worktreeBase } : {}),
509
+ });
510
+ // The journal ROW keeps the `~r<n>`/`~l<loop>` attempt id (dispatchJournaledAttempt
511
+ // wrote it), but the returned outcome's identity in the DURABLE step evidence is
512
+ // the content-derived BASE id — the suffix is journal bookkeeping the report
513
+ // surface never sees, so leaking it into evidence.units would diverge the two
514
+ // surfaces (R4 parity, exposed once the conformance graph compares evidence.units).
515
+ outcome.unitId = unitId;
516
+ // Budget token accounting (addendum R2): every actual dispatch's reported
517
+ // usage counts against the run's max_tokens ceiling; crossing it aborts
518
+ // pending dispatches via the chained controller. Reuses never reach here
519
+ // (their tokens are already in the journal-seeded total).
520
+ if (outcome.tokens !== undefined)
521
+ input.budget.addTokens(outcome.tokens);
522
+ if (outcome.ok)
523
+ return outcome;
524
+ const reason = outcome.failureReason;
525
+ if (!retry || reason === undefined || !retry.on.includes(reason))
526
+ return outcome;
527
+ }
528
+ // maxAttempts >= 1, so outcome is always set by the loop above.
529
+ return outcome;
530
+ }
531
+ /** Journal one dispatch attempt: insert row, events, dispatch, finish row. */
532
+ async function dispatchJournaledAttempt(input) {
533
+ const { plan, workUnit, ctx, dispatcher, attemptId, inputHash } = input;
534
+ let request = input.request;
535
+ // Worktree isolation (addendum R2): a FRESH detached worktree per journaled
536
+ // attempt, minted before the row is inserted so worktree_path is journaled
537
+ // with the dispatch. A creation failure fails the unit WITHOUT journaling a
538
+ // row — nothing was dispatched (same contract as an expression failure).
539
+ let worktreePath;
540
+ if (input.worktreeBase !== undefined) {
541
+ const created = createUnitWorktree(input.worktreeBase, ctx.runId, attemptId);
542
+ if (!created.ok) {
543
+ return { unitId: request.unitId, ok: false, failureReason: "worktree_failed", error: created.error };
544
+ }
545
+ if (created.preservedLeftover !== undefined) {
546
+ // Never destroy a dirty (or unverifiable) leftover from a prior
547
+ // invocation of the same attempt — it was moved aside instead.
548
+ warn(`Workflow unit ${attemptId}: a previous attempt left uncollected work in its isolation worktree; ` +
549
+ `preserved at ${created.preservedLeftover}`);
550
+ }
551
+ worktreePath = created.path;
552
+ request = { ...request, cwd: worktreePath };
553
+ }
554
+ await enqueueUnitWrite(async () => {
555
+ await withWorkflowRunsRepo((repo) => repo.insertUnit({
556
+ runId: ctx.runId,
557
+ unitId: attemptId,
558
+ stepId: plan.stepId,
559
+ nodeId: workUnit.nodeId,
560
+ parentUnitId: workUnit.isFanOut ? `${plan.stepId}.map` : null,
561
+ phase: null,
562
+ runner: workUnit.runner,
563
+ model: workUnit.model ?? null,
564
+ inputHash,
565
+ worktreePath: worktreePath ?? null,
566
+ startedAt: new Date().toISOString(),
567
+ }));
568
+ });
569
+ // Ids/status only — instructions and results are workflow-authored content
570
+ // and stay out of the events stream (07 P1-B).
571
+ appendEvent({
572
+ eventType: "workflow_unit_started",
573
+ ref: ctx.workflowRef,
574
+ metadata: { runId: ctx.runId, stepId: plan.stepId, unitId: attemptId },
575
+ });
576
+ const outcome = await dispatchUnit(request, dispatcher);
577
+ await enqueueUnitWrite(async () => {
578
+ await withWorkflowRunsRepo((repo) => repo.finishUnit({
579
+ runId: ctx.runId,
580
+ unitId: attemptId,
581
+ status: outcome.ok ? "completed" : "failed",
582
+ resultJson: outcome.result !== undefined
583
+ ? JSON.stringify(outcome.result)
584
+ : outcome.text
585
+ ? JSON.stringify(outcome.text)
586
+ : null,
587
+ tokens: outcome.tokens ?? null,
588
+ failureReason: outcome.failureReason ?? null,
589
+ // Harness-native session id (P2): journaled so resume can replay the
590
+ // harness's own context cache (e.g. `codex exec resume <id>`).
591
+ sessionId: outcome.sessionId ?? null,
592
+ finishedAt: new Date().toISOString(),
593
+ }));
594
+ });
595
+ appendEvent({
596
+ eventType: "workflow_unit_finished",
597
+ ref: ctx.workflowRef,
598
+ metadata: {
599
+ runId: ctx.runId,
600
+ stepId: plan.stepId,
601
+ unitId: attemptId,
602
+ status: outcome.ok ? "completed" : "failed",
603
+ ...(outcome.failureReason ? { failureReason: outcome.failureReason } : {}),
604
+ ...(outcome.tokens !== undefined ? { tokens: outcome.tokens } : {}),
605
+ },
606
+ });
607
+ // Worktree lifecycle epilogue: a CLEAN worktree (`git status --porcelain`
608
+ // empty) is removed; a DIRTY one is retained and logged — the unit left
609
+ // uncollected work, and its journaled worktree_path says where. Cleanup is
610
+ // best-effort observability, never a unit failure.
611
+ if (worktreePath !== undefined && input.worktreeBase !== undefined) {
612
+ const cleanup = cleanupUnitWorktree(input.worktreeBase, worktreePath);
613
+ if (cleanup.dirty) {
614
+ warn(`Workflow unit ${attemptId} left uncommitted changes in its isolation worktree; retained at ${worktreePath}`);
615
+ }
616
+ else if (!cleanup.removed) {
617
+ warn(`Workflow unit ${attemptId}: could not clean up isolation worktree ${worktreePath}: ${cleanup.error}`);
618
+ }
619
+ }
620
+ return outcome;
621
+ }
622
+ /** Transport failures surface as this sentinel so runStructured doesn't retry them. */
623
+ class UnitTransportError extends Error {
624
+ result;
625
+ constructor(result) {
626
+ super(result.error ?? "unit dispatch failed");
627
+ this.result = result;
628
+ this.name = "UnitTransportError";
629
+ }
630
+ }
631
+ async function dispatchUnit(request, dispatcher) {
632
+ let tokens = 0;
633
+ let sawUsage = false;
634
+ // Harness-native session id revealed by dispatch (P2). Captured across
635
+ // structured-output retries (last one wins) so it survives into the
636
+ // UnitOutcome and gets journaled on the unit row by finishUnit — the seam's
637
+ // contract ("stored opportunistically on the unit row for resume").
638
+ let sessionId;
639
+ const dispatchOnce = async (feedback) => {
640
+ const result = await dispatcher(request, feedback);
641
+ if (result.usage) {
642
+ sawUsage = true;
643
+ tokens +=
644
+ (result.usage.inputTokens ?? 0) + (result.usage.outputTokens ?? 0) + (result.usage.reasoningTokens ?? 0);
645
+ }
646
+ // Capture before the ok-check: a failed attempt can still have configured
647
+ // a session (e.g. codex `session_configured` then a tool crash).
648
+ if (result.sessionId !== undefined)
649
+ sessionId = result.sessionId;
650
+ if (!result.ok)
651
+ throw new UnitTransportError(result);
652
+ return result.text;
653
+ };
654
+ const captured = () => ({
655
+ ...(sawUsage ? { tokens } : {}),
656
+ ...(sessionId !== undefined ? { sessionId } : {}),
657
+ });
658
+ try {
659
+ if (request.schema) {
660
+ const schema = request.schema;
661
+ const structured = await runStructured({
662
+ dispatch: dispatchOnce,
663
+ validate: (candidate) => {
664
+ const errors = validateJsonSchemaSubset(candidate, schema);
665
+ return errors.length === 0 ? { ok: true, value: candidate } : { ok: false, errors };
666
+ },
667
+ });
668
+ if (structured.ok) {
669
+ return { unitId: request.unitId, ok: true, result: structured.value, ...captured() };
670
+ }
671
+ return {
672
+ unitId: request.unitId,
673
+ ok: false,
674
+ failureReason: structured.reason,
675
+ error: structured.errors.join("; "),
676
+ text: structured.raw,
677
+ ...captured(),
678
+ };
679
+ }
680
+ const text = await dispatchOnce();
681
+ // Normalize an EMPTY successful output to "no text". `finishUnit` journals
682
+ // result_json = NULL for a falsy text, so durable-reuse and the R3 report
683
+ // surface both rehydrate NO text from the row (unitOutcomeFromRow). Preserving
684
+ // `text: ""` only in this live outcome would make the LIVE step artifact ("")
685
+ // diverge from the resume/report artifact (null) — the exact byte-identical-
686
+ // graph violation the cardinal rule forbids. Treating empty as absent keeps
687
+ // the live engine, engine resume, and report surfaces identical.
688
+ return { unitId: request.unitId, ok: true, ...(text ? { text } : {}), ...captured() };
689
+ }
690
+ catch (err) {
691
+ if (err instanceof UnitTransportError) {
692
+ return {
693
+ unitId: request.unitId,
694
+ ok: false,
695
+ failureReason: err.result.failureReason ?? "dispatch_error",
696
+ error: err.result.error ?? "unit dispatch failed",
697
+ text: err.result.text,
698
+ ...captured(),
699
+ };
700
+ }
701
+ return {
702
+ unitId: request.unitId,
703
+ ok: false,
704
+ failureReason: "dispatch_error",
705
+ error: message(err),
706
+ ...captured(),
707
+ };
708
+ }
709
+ }
710
+ // ── Env bindings ─────────────────────────────────────────────────────────────
711
+ /**
712
+ * Resolve every unit `env` ref through the extracted `akm env run` core
713
+ * (loadEnv + secret tokens + dangerous-key policy + keys-only audit event).
714
+ * Lazily imported so the engine has no env/secret dependency until a
715
+ * workflow actually declares bindings.
716
+ */
717
+ async function resolveEnvBindings(refs) {
718
+ const { resolveEnvBinding } = await import("../../commands/env/env-binding.js");
719
+ const merged = {};
720
+ for (const ref of refs) {
721
+ Object.assign(merged, resolveEnvBinding(ref).values);
722
+ }
723
+ return merged;
724
+ }
725
+ // ── Default dispatcher (production substrate) ───────────────────────────────
726
+ /**
727
+ * Dispatch through akm's existing execution substrate:
728
+ * llm → `chatCompletion` on the profile/default LLM connection
729
+ * agent → `executeRunner` → `runAgent` (per-harness AgentCommandBuilder)
730
+ * sdk → `executeRunner` → `runOpencodeSdk`
731
+ *
732
+ * `inherit` resolves against config: the node/step profile, else
733
+ * `defaults.agent` (sdk when the profile is opencode-sdk), else `defaults.llm`.
734
+ */
735
+ /**
736
+ * Build the platform-agnostic {@link import("../../integrations/agent/builder-shared.js").AgentDispatchRequest}
737
+ * for an agent (CLI) unit from the resolved dispatch request and its final
738
+ * (feedback-augmented) prompt.
739
+ *
740
+ * Threading the unit's output `schema` here is what activates each harness's
741
+ * native structured-output path (plan §"Structured-output normalization"):
742
+ * - Codex (native-schema tier) writes it to a temp file and passes
743
+ * `--output-schema <file>`.
744
+ * - Copilot / Gemini switch stdout to their documented JSON envelope
745
+ * (`--output-format json`) and append their schema-aware prompt directive.
746
+ * - Pi switches to its JSONL event stream (`--mode json`) and appends its
747
+ * directive.
748
+ * Without the schema the argv is byte-identical to the pre-fix plain-prompt
749
+ * shape. The engine's post-hoc `runStructured` validation runs regardless — the
750
+ * harness path constrains/hints, the engine still verifies (constrained output
751
+ * is trusted but verified). The `model` is passed raw so the builder resolves
752
+ * aliases per-harness. Only `prompt` (with any gate feedback already folded in),
753
+ * `model`, and `schema` are engine-derived; `systemPrompt`/`tools`/`cwd` come
754
+ * from the profile/asset, not the workflow unit.
755
+ */
756
+ export function buildAgentDispatchRequest(request, prompt) {
757
+ return {
758
+ prompt,
759
+ ...(request.model ? { model: request.model } : {}),
760
+ ...(request.schema ? { schema: request.schema } : {}),
761
+ };
762
+ }
763
+ export const defaultUnitDispatcher = async (request, feedback) => {
764
+ const { loadConfig } = await import("../../core/config/config.js");
765
+ const config = loadConfig();
766
+ const prompt = feedback ? `${request.prompt}\n\n${feedback}` : request.prompt;
767
+ const resolved = await resolveUnitRunner(request, config);
768
+ // `env` bindings can only reach a child process. The agent (CLI) runner
769
+ // spawns one per call, and the sdk runner now injects them for real via the
770
+ // env-keyed opencode server registry (sdk-runner.ts module doc, open seam
771
+ // decision 1 resolved in R2) — but the llm runner has no child at all, so
772
+ // it still fails loudly: an audit event claiming an injection that never
773
+ // reached the unit would be a lie.
774
+ if (request.env && Object.keys(request.env).length > 0 && resolved.kind === "llm") {
775
+ return {
776
+ ok: false,
777
+ text: "",
778
+ failureReason: "env_unsupported",
779
+ error: `unit "${request.unitId}" declares env bindings, which require a child process (agent or sdk runner) — ` +
780
+ `the "llm" runner cannot inject a per-unit child environment.`,
781
+ };
782
+ }
783
+ // Same shape for worktree isolation resolved onto llm through `inherit`:
784
+ // the executor already rejects an EXPLICIT llm+isolation pairing before
785
+ // dispatch, but an inherit unit only reveals its runner here.
786
+ if (request.cwd && resolved.kind === "llm") {
787
+ return {
788
+ ok: false,
789
+ text: "",
790
+ failureReason: "isolation_unsupported",
791
+ error: `unit "${request.unitId}" declares isolation: worktree but resolved to the "llm" runner, ` +
792
+ `which has no working directory to isolate. Use the agent or sdk runner for isolated units.`,
793
+ };
794
+ }
795
+ if (resolved.kind === "llm") {
796
+ const { chatCompletion, LlmCallError } = await import("../../llm/client.js");
797
+ const { resolveModel } = await import("../../integrations/agent/model-aliases.js");
798
+ const connection = request.model
799
+ ? { ...resolved.connection, model: resolveModel(request.model, "llm", undefined, config.modelAliases) }
800
+ : resolved.connection;
801
+ try {
802
+ const text = await chatCompletion(connection, [{ role: "user", content: prompt }], {
803
+ // null = author declared `timeout: none` — cap at the max signed
804
+ // 32-bit delay (setTimeout's ceiling, ~24.8 days ≈ unbounded here).
805
+ timeoutMs: request.timeoutMs === null ? 2 ** 31 - 1 : request.timeoutMs,
806
+ ...(request.signal ? { signal: request.signal } : {}),
807
+ // Native structured output where the connection supports it; the
808
+ // executor's subset validator still runs downstream either way.
809
+ ...(request.schema ? { responseSchema: request.schema } : {}),
810
+ });
811
+ return { ok: true, text };
812
+ }
813
+ catch (err) {
814
+ // Map typed LlmCallError codes into the persisted AgentFailureReason
815
+ // taxonomy — the vocabulary `retry.on` is validated against (program
816
+ // schema PROGRAM_RETRY_REASONS) and the journal's failure_reason column
817
+ // speaks. A collapsed out-of-taxonomy value ("llm_error") made the
818
+ // declared failure policy dead for the entire llm runner.
819
+ const failureReason = err instanceof LlmCallError ? llmFailureReasonFor(err.code) : "dispatch_error";
820
+ return { ok: false, text: "", failureReason, error: message(err) };
821
+ }
822
+ }
823
+ const { executeRunner } = await import("../../integrations/agent/runner-dispatch.js");
824
+ const profile = request.model ? { ...resolved.profile, model: request.model } : resolved.profile;
825
+ const result = await executeRunner({ kind: resolved.kind, profile }, prompt, {
826
+ stdio: "captured",
827
+ parseOutput: "text",
828
+ timeoutMs: request.timeoutMs,
829
+ ...(request.env ? { env: request.env } : {}),
830
+ // Worktree isolation: the unit's fresh checkout is the child's cwd —
831
+ // runAgent spawns there; the sdk runner scopes the session to it.
832
+ ...(request.cwd ? { cwd: request.cwd } : {}),
833
+ ...(request.signal ? { signal: request.signal } : {}),
834
+ // Route CLI dispatch through the platform AgentCommandBuilder so model
835
+ // aliases resolve per-harness (P0.5 model routing) AND the unit's output
836
+ // schema reaches the harness's structured-output path (see
837
+ // buildAgentDispatchRequest).
838
+ ...(resolved.kind === "agent" ? { dispatch: buildAgentDispatchRequest(request, prompt) } : {}),
839
+ });
840
+ // Harness result extraction (P2, plan §"The adapter contract" step 3):
841
+ // when the profile's harness declares a `resultExtractor`, normalize the
842
+ // raw stdout into the final answer (+ opportunistic session id) BEFORE the
843
+ // engine's schema validation / retry loop sees it. Only successful agent
844
+ // (CLI) runs are normalized — failures keep the raw stdout for diagnostics,
845
+ // and the default path is byte-identical when no extractor is registered.
846
+ let text = result.stdout;
847
+ let sessionId = result.sessionId;
848
+ if (resolved.kind === "agent" && result.ok) {
849
+ const extractor = await resolveHarnessExtractor(resolved.profile);
850
+ if (extractor) {
851
+ const extraction = extractor(result);
852
+ text = extraction.text;
853
+ if (extraction.sessionId !== undefined)
854
+ sessionId = extraction.sessionId;
855
+ }
856
+ }
857
+ return {
858
+ ok: result.ok,
859
+ text,
860
+ ...(sessionId !== undefined ? { sessionId } : {}),
861
+ ...(result.reason ? { failureReason: result.reason } : {}),
862
+ ...(result.error ? { error: result.error } : {}),
863
+ ...(result.usage ? { usage: result.usage } : {}),
864
+ };
865
+ };
866
+ /**
867
+ * Map a typed {@link import("../../llm/client.js").LlmCallErrorCode} into the
868
+ * persisted `AgentFailureReason` taxonomy (agent/spawn.ts) — the ONLY
869
+ * vocabulary `retry.on` accepts and the journal's `failure_reason` column
870
+ * carries. Exhaustive over the code union (typecheck fails on drift):
871
+ *
872
+ * - `timeout` → `timeout` (wall-clock expiry; also covers
873
+ * signal aborts, which chatCompletion
874
+ * folds into its timeout code)
875
+ * - `rate_limited` → `llm_rate_limit` (HTTP 429 — the canonical transient)
876
+ * - `parse_error` / `provider_html_error`
877
+ * → `parse_error` (a response arrived but was not the
878
+ * promised JSON)
879
+ * - `network_error` / `provider_error`
880
+ * → `spawn_failed` (the backend could not be reached or
881
+ * could not do the work — the LLM
882
+ * analog of failing to start the
883
+ * child; retryable as a transient)
884
+ */
885
+ export function llmFailureReasonFor(code) {
886
+ switch (code) {
887
+ case "timeout":
888
+ return "timeout";
889
+ case "rate_limited":
890
+ return "llm_rate_limit";
891
+ case "parse_error":
892
+ case "provider_html_error":
893
+ return "parse_error";
894
+ case "network_error":
895
+ case "provider_error":
896
+ return "spawn_failed";
897
+ }
898
+ }
899
+ /**
900
+ * Resolve the harness `resultExtractor` for an agent profile, mirroring the
901
+ * platform routing of `getCommandBuilder`: the profile's explicit
902
+ * `commandBuilder` wins, else its name (with the `-headless` builtin-variant
903
+ * suffix stripped, same derivation as BUILTIN_BUILDERS). Unknown/custom
904
+ * platforms resolve to no extractor — raw stdout passes through unchanged.
905
+ */
906
+ async function resolveHarnessExtractor(profile) {
907
+ const { getHarness } = await import("../../integrations/harnesses/index.js");
908
+ const platform = profile.commandBuilder ?? profile.name;
909
+ const harness = getHarness(platform) ?? getHarness(platform.replace(/-headless$/, ""));
910
+ return harness?.resultExtractor;
911
+ }
912
+ async function resolveUnitRunner(request, config) {
913
+ const requested = request.runner;
914
+ if (requested === "llm") {
915
+ const connection = request.profile
916
+ ? config.profiles?.llm?.[request.profile]
917
+ : await requireDefaultLlm(config, request);
918
+ if (!connection) {
919
+ throw new ConfigError(`Workflow unit "${request.unitId}" wants llm profile "${request.profile}", which is not in profiles.llm.`, "LLM_NOT_CONFIGURED");
920
+ }
921
+ return { kind: "llm", connection };
922
+ }
923
+ const profileName = request.profile ?? config.defaults?.agent;
924
+ if (profileName) {
925
+ const { resolveProfileFromConfig } = await import("../../integrations/agent/config.js");
926
+ const profile = resolveProfileFromConfig(profileName, config);
927
+ if (!profile) {
928
+ throw new ConfigError(`Workflow unit "${request.unitId}" wants agent profile "${profileName}", which cannot be resolved. ` +
929
+ `Define profiles.agent."${profileName}" or set defaults.agent.`, "INVALID_CONFIG_FILE");
930
+ }
931
+ const inferred = profile.sdkMode ? "sdk" : "agent";
932
+ if (requested !== "inherit" && requested !== inferred) {
933
+ throw new ConfigError(`Workflow unit "${request.unitId}" declares runner "${requested}" but profile "${profileName}" is ${profile.sdkMode ? "an opencode-sdk (sdk) profile" : "a CLI (agent) profile"}.`, "INVALID_CONFIG_FILE");
934
+ }
935
+ return { kind: inferred, profile };
936
+ }
937
+ if (requested === "inherit") {
938
+ const connection = await requireDefaultLlm(config, request, /* soft */ true);
939
+ if (connection)
940
+ return { kind: "llm", connection };
941
+ }
942
+ throw new ConfigError(`Workflow unit "${request.unitId}" has no runnable backend: set defaults.agent or defaults.llm in config.json, ` +
943
+ `or declare a runner/profile on the unit.`, "INVALID_CONFIG_FILE");
944
+ }
945
+ async function requireDefaultLlm(config, request, soft = false) {
946
+ const { getDefaultLlmConfig } = await import("../../core/config/config.js");
947
+ const connection = getDefaultLlmConfig(config);
948
+ if (!connection && !soft) {
949
+ throw new ConfigError(`Workflow unit "${request.unitId}" declares runner "llm" but no default LLM is configured (defaults.llm).`, "LLM_NOT_CONFIGURED");
950
+ }
951
+ return connection ?? undefined;
952
+ }
953
+ // ── Small helpers ────────────────────────────────────────────────────────────
954
+ /**
955
+ * Rehydrate a journaled completed unit row into a UnitOutcome (durable-row
956
+ * reuse). Delegates to the shared {@link unitOutcomeFromRow} — the reuse path
957
+ * only reaches here for completed rows (the caller guards `status ===
958
+ * "completed"`), so the mapping is identical to what the R3 report path applies
959
+ * when it replays the same journal.
960
+ */
961
+ function reuseCompletedUnit(unitId, row, hasSchema) {
962
+ return unitOutcomeFromRow(unitId, row, hasSchema);
963
+ }
964
+ function failedStep(dispatched, reason) {
965
+ return {
966
+ ok: false,
967
+ units: [],
968
+ evidence: { error: reason },
969
+ summary: reason,
970
+ unitsDispatched: dispatched,
971
+ };
972
+ }
973
+ function message(err) {
974
+ return err instanceof Error ? err.message : String(err);
975
+ }