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
@@ -9,7 +9,8 @@ import * as p from "../../cli/clack.js";
9
9
  import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../../integrations/agent/index.js";
10
10
  import { probeLlmCapabilities } from "../../llm/client.js";
11
11
  import { detectLMStudio, detectOllama } from "../detect.js";
12
- import { cloneLlmConfig, getCurrentAgentBlock, getCurrentLlm } from "../legacy-config.js";
12
+ import { verifyOpenAiCompatibleEndpoint } from "../detected-engines.js";
13
+ import { cloneLlmConnection, readAgentEngineSelection, readCurrentLlmEngine, } from "../engine-config.js";
13
14
  import { prompt, promptOrBack } from "../prompt.js";
14
15
  export async function stepOllama(current) {
15
16
  const spin = p.spinner();
@@ -154,7 +155,7 @@ export async function stepLlm(current, ollamaEndpoint, ollamaChatModels, lmStudi
154
155
  options.push({ value: "lmstudio", label: "LM Studio / local server", hint: lmStudioHint });
155
156
  options.push({ value: "custom", label: "Custom OpenAI-compatible endpoint" });
156
157
  options.push({ value: "none", label: "Skip LLM", hint: "no metadata enhancement during indexing" });
157
- const currentLlm = getCurrentLlm(current);
158
+ const currentLlm = readCurrentLlmEngine(current);
158
159
  if (currentLlm) {
159
160
  options.push({
160
161
  value: "keep",
@@ -169,7 +170,7 @@ export async function stepLlm(current, ollamaEndpoint, ollamaChatModels, lmStudi
169
170
  initialValue,
170
171
  }));
171
172
  if (choice === "keep")
172
- return cloneLlmConfig(currentLlm);
173
+ return cloneLlmConnection(currentLlm);
173
174
  if (choice === "none")
174
175
  return undefined;
175
176
  // Handle "Import from <Harness>" choices
@@ -180,16 +181,28 @@ export async function stepLlm(current, ollamaEndpoint, ollamaChatModels, lmStudi
180
181
  // Show a summary before accepting
181
182
  p.log.info(`Importing LLM config from ${harness.harnessName}: ` +
182
183
  [harness.provider, harness.model, harness.baseUrl].filter(Boolean).join(", "));
184
+ if (!harness.baseUrl || !harness.model) {
185
+ p.log.warn(`Skipping ${harness.harnessName}: no complete endpoint/model was detected.`);
186
+ return undefined;
187
+ }
188
+ const verified = await verifyOpenAiCompatibleEndpoint({
189
+ endpoint: harness.baseUrl,
190
+ model: harness.model,
191
+ apiKeyEnvVar: harness.apiKeyEnvVar,
192
+ });
193
+ if (!verified.ok) {
194
+ p.log.warn(`Skipping ${harness.harnessName}: ${verified.reason}. Fix its endpoint/credential and retry setup.`);
195
+ return undefined;
196
+ }
183
197
  const llmConfig = {
184
- endpoint: harness.baseUrl ?? "",
198
+ endpoint: verified.endpoint,
185
199
  model: harness.model ?? "",
186
200
  temperature: 0.3,
187
201
  maxTokens: 1024,
202
+ ...(harness.apiKeyEnvVar ? { apiKey: `\${${harness.apiKeyEnvVar}}` } : {}),
188
203
  };
189
204
  if (harness.provider)
190
205
  llmConfig.provider = harness.provider;
191
- if (harness.baseUrl)
192
- llmConfig.endpoint = harness.baseUrl;
193
206
  return llmConfig;
194
207
  }
195
208
  let llm;
@@ -376,7 +389,7 @@ export async function stepSmallModelConnection(current) {
376
389
  ? `${lmStudio.models.length} model${lmStudio.models.length === 1 ? "" : "s"} detected`
377
390
  : "http://localhost:1234";
378
391
  providerOptions.push({ value: "openai", label: "OpenAI", hint: "requires AKM_LLM_API_KEY" }, { value: "lmstudio", label: "LM Studio / local server", hint: lmStudioHint }, { value: "custom", label: "Custom OpenAI-compatible endpoint" }, { value: "skip", label: "Skip — disable enrichment features" });
379
- const currentLlmSmall = getCurrentLlm(current);
392
+ const currentLlmSmall = readCurrentLlmEngine(current);
380
393
  if (currentLlmSmall) {
381
394
  providerOptions.push({
382
395
  value: "keep",
@@ -391,7 +404,7 @@ export async function stepSmallModelConnection(current) {
391
404
  initialValue,
392
405
  }));
393
406
  if (providerChoice === "keep") {
394
- return { llm: cloneLlmConfig(currentLlmSmall), skipped: false, ollamaEndpoint };
407
+ return { llm: cloneLlmConnection(currentLlmSmall), skipped: false, ollamaEndpoint };
395
408
  }
396
409
  if (providerChoice === "skip") {
397
410
  p.note([
@@ -539,8 +552,11 @@ export async function stepSmallModelConnection(current) {
539
552
  validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
540
553
  }));
541
554
  const apiKeyInput = await promptOrBack(() => p.text({
542
- message: "API key (optional press Enter to skip):",
543
- placeholder: "",
555
+ message: "API key environment variable name (optional):",
556
+ placeholder: "CUSTOM_LLM_API_KEY",
557
+ validate: (value) => value && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)
558
+ ? "Use an environment variable name, not a key value"
559
+ : undefined,
544
560
  }));
545
561
  llm = {
546
562
  provider: "custom",
@@ -548,7 +564,7 @@ export async function stepSmallModelConnection(current) {
548
564
  model: model.trim(),
549
565
  temperature: 0.3,
550
566
  maxTokens: 1024,
551
- ...(apiKeyInput?.trim() ? { apiKey: apiKeyInput.trim() } : {}),
567
+ ...(apiKeyInput?.trim() ? { apiKey: `\${${apiKeyInput.trim()}}` } : {}),
552
568
  };
553
569
  }
554
570
  // Best-effort probe — never blocks setup.
@@ -584,7 +600,7 @@ export async function stepAgentConnection(current, smallModel) {
584
600
  ].join("\n"));
585
601
  // Detect available CLI agents.
586
602
  const detections = detectAgentCliProfiles(current);
587
- const currentAgentBlock = getCurrentAgentBlock(current);
603
+ const currentAgentBlock = readAgentEngineSelection(current);
588
604
  const availableClis = detections.filter((d) => d.available);
589
605
  const agentOptions = [];
590
606
  if (!smallModel.skipped && smallModel.llm) {
@@ -606,8 +622,8 @@ export async function stepAgentConnection(current, smallModel) {
606
622
  if (currentAgentBlock) {
607
623
  const currentDesc = currentAgentBlock.default
608
624
  ? `CLI: ${currentAgentBlock.default}`
609
- : currentAgentBlock.profiles?.default?.model
610
- ? `SDK: ${currentAgentBlock.profiles.default.model}`
625
+ : currentAgentBlock.engines?.default?.model
626
+ ? `SDK: ${currentAgentBlock.engines.default.model}`
611
627
  : "configured";
612
628
  agentOptions.push({ value: "keep", label: `Keep current: ${currentDesc}` });
613
629
  }
@@ -647,9 +663,9 @@ export async function stepAgentConnection(current, smallModel) {
647
663
  else {
648
664
  const baseEndpoint = smallModel.llm.endpoint.replace("/v1/chat/completions", "");
649
665
  p.log.info(`Endpoint: ${baseEndpoint} (from Step 1)`);
650
- const profileName = smallModel.llm.provider ?? "default";
666
+ const profileName = `${smallModel.llm.provider ?? "default"}-agent`;
651
667
  // Pre-populate from existing agent profile for this provider, if any.
652
- const existingAgentModel = currentAgentBlock?.profiles?.[profileName]?.model ?? smallModel.llm.model ?? undefined;
668
+ const existingAgentModel = currentAgentBlock?.engines?.[profileName]?.model ?? smallModel.llm.model ?? undefined;
653
669
  const agentModel = await prompt(() => p.text({
654
670
  message: "Model to use for agent tasks (same model is fine, larger models work better):",
655
671
  placeholder: existingAgentModel ?? "qwen2.5-coder:32b",
@@ -658,13 +674,14 @@ export async function stepAgentConnection(current, smallModel) {
658
674
  }));
659
675
  return {
660
676
  ...(currentAgentBlock ?? {}),
661
- profiles: {
662
- ...(currentAgentBlock?.profiles ?? {}),
677
+ engines: {
678
+ ...(currentAgentBlock?.engines ?? {}),
663
679
  [profileName]: {
664
- ...(currentAgentBlock?.profiles?.[profileName] ?? {}),
665
- sdkMode: true,
680
+ ...(currentAgentBlock?.engines?.[profileName] ?? {}),
681
+ kind: "agent",
682
+ platform: "opencode-sdk",
666
683
  model: agentModel.trim(),
667
- endpoint: smallModel.llm.endpoint,
684
+ ...(current.defaults?.llmEngine ? { llmEngine: current.defaults.llmEngine } : {}),
668
685
  },
669
686
  },
670
687
  default: profileName,
@@ -693,8 +710,8 @@ export async function stepAgentConnection(current, smallModel) {
693
710
  }
694
711
  // "new-connection" (also fall-through from "same-provider" when Step 1 was skipped)
695
712
  // Pre-populate from current "custom" agent profile if available.
696
- const currentCustomAgentProfile = currentAgentBlock?.profiles?.custom;
697
- const currentNewEndpoint = currentCustomAgentProfile?.endpoint ?? undefined;
713
+ const currentCustomAgentProfile = currentAgentBlock?.engines?.["custom-agent"];
714
+ const currentNewEndpoint = undefined;
698
715
  const currentNewModel = currentCustomAgentProfile?.model ?? undefined;
699
716
  const newEndpoint = await prompt(() => p.text({
700
717
  message: "OpenAI-compatible chat completions endpoint:",
@@ -708,8 +725,11 @@ export async function stepAgentConnection(current, smallModel) {
708
725
  },
709
726
  }));
710
727
  const newApiKeyInput = await promptOrBack(() => p.text({
711
- message: "API key (optional press Enter to skip):",
712
- placeholder: "",
728
+ message: "API key environment variable name (optional):",
729
+ placeholder: "CUSTOM_LLM_API_KEY",
730
+ validate: (value) => value && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)
731
+ ? "Use an environment variable name, not a key value"
732
+ : undefined,
713
733
  }));
714
734
  const newModel = await prompt(() => p.text({
715
735
  message: "Model name (larger is better, e.g. gpt-4o):",
@@ -717,18 +737,27 @@ export async function stepAgentConnection(current, smallModel) {
717
737
  ...(currentNewModel ? { defaultValue: currentNewModel } : {}),
718
738
  validate: (v) => (!v?.trim() ? "Model name cannot be empty" : undefined),
719
739
  }));
740
+ const llmEngineName = "custom-llm";
741
+ const agentEngineName = "custom-agent";
720
742
  const customProfile = {
721
- sdkMode: true,
722
- endpoint: newEndpoint.trim(),
743
+ kind: "agent",
744
+ platform: "opencode-sdk",
745
+ llmEngine: llmEngineName,
723
746
  model: newModel.trim(),
724
- ...(newApiKeyInput?.trim() ? { apiKey: newApiKeyInput.trim() } : {}),
725
747
  };
726
748
  return {
727
749
  ...(currentAgentBlock ?? {}),
728
- profiles: {
729
- ...(currentAgentBlock?.profiles ?? {}),
730
- custom: customProfile,
750
+ engines: {
751
+ ...(currentAgentBlock?.engines ?? {}),
752
+ [llmEngineName]: {
753
+ kind: "llm",
754
+ provider: "custom",
755
+ endpoint: newEndpoint.trim(),
756
+ model: newModel.trim(),
757
+ ...(newApiKeyInput?.trim() ? { apiKey: `\${${newApiKeyInput.trim()}}` } : {}),
758
+ },
759
+ [agentEngineName]: customProfile,
731
760
  },
732
- default: "custom",
761
+ default: agentEngineName,
733
762
  };
734
763
  }
@@ -8,7 +8,7 @@
8
8
  import * as p from "../../cli/clack.js";
9
9
  import { detectAgentCliProfiles, pickDefaultAgentProfile } from "../../integrations/agent/index.js";
10
10
  import { detectAgentPlatforms } from "../detect.js";
11
- import { getCurrentAgentBlock } from "../legacy-config.js";
11
+ import { readAgentEngineSelection } from "../engine-config.js";
12
12
  import { prompt } from "../prompt.js";
13
13
  export async function stepAgentPlatforms(current) {
14
14
  const platforms = detectAgentPlatforms();
@@ -66,7 +66,7 @@ export function printCapabilitySummary(smallModelSkipped, agentConfigured) {
66
66
  p.note(lines.join("\n"), "Feature Summary");
67
67
  }
68
68
  export async function stepAgentSelection(current, detections) {
69
- const currentAgentBlock = getCurrentAgentBlock(current);
69
+ const currentAgentBlock = readAgentEngineSelection(current);
70
70
  const available = detections.filter((d) => d.available);
71
71
  if (available.length === 0) {
72
72
  return currentAgentBlock;
@@ -85,7 +85,7 @@ export async function stepAgentSelection(current, detections) {
85
85
  initialValue,
86
86
  }));
87
87
  if (selectedDefault === "disabled") {
88
- if (!currentAgentBlock?.profiles && !currentAgentBlock?.timeoutMs) {
88
+ if (!currentAgentBlock?.engines) {
89
89
  return undefined;
90
90
  }
91
91
  return {
@@ -110,7 +110,7 @@ export async function stepAgentSelection(current, detections) {
110
110
  */
111
111
  export function stepAgentCliDetection(current, detectFn = detectAgentCliProfiles) {
112
112
  const detections = detectFn(current);
113
- const currentAgentBlock = getCurrentAgentBlock(current);
113
+ const currentAgentBlock = readAgentEngineSelection(current);
114
114
  const defaultName = pickDefaultAgentProfile(detections, currentAgentBlock?.default);
115
115
  // No installed agents found and no existing config → leave block absent.
116
116
  if (!defaultName && !currentAgentBlock) {
@@ -1,20 +1,21 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ import { deepMergeConfig } from "../core/config/deep-merge.js";
4
5
  /**
5
6
  * Build a fresh `SetupContext` over a starting config. The returned context
6
7
  * applies deltas in-place onto an internal accumulator and exposes the
7
8
  * latest snapshot via `ctx.config`.
8
9
  */
9
10
  export function createSetupContext(initial, options) {
10
- let acc = { ...initial };
11
+ let acc = deepMergeConfig({}, initial);
11
12
  return {
12
13
  get config() {
13
14
  return acc;
14
15
  },
15
16
  nonInteractive: options.nonInteractive,
16
17
  apply(delta) {
17
- acc = { ...acc, ...delta };
18
+ acc = deepMergeConfig(acc, delta);
18
19
  },
19
20
  };
20
21
  }
@@ -133,5 +133,17 @@ function openNodeDatabase(path, opts) {
133
133
  if (opts?.create === false)
134
134
  options.fileMustExist = true;
135
135
  const db = opts ? new BetterSqlite3(path, options) : new BetterSqlite3(path);
136
- return db;
136
+ return {
137
+ prepare: db.prepare.bind(db),
138
+ exec: db.exec.bind(db),
139
+ // better-sqlite3 exposes mutations on prepared statements, while
140
+ // bun:sqlite also provides db.run(). Normalize the latter at the provider
141
+ // boundary so callers and maintenance wrappers can rely on one contract.
142
+ run: (sql, ...params) => db.prepare(sql).run(...params),
143
+ transaction: db.transaction.bind(db),
144
+ get inTransaction() {
145
+ return db.inTransaction;
146
+ },
147
+ close: db.close.bind(db),
148
+ };
137
149
  }
@@ -34,6 +34,7 @@ export function runMigrations(db, migrations, opts) {
34
34
  for (const migration of migrations) {
35
35
  if (applied.has(migration.id))
36
36
  continue;
37
+ opts?.beforeMigration?.(migration);
37
38
  db.transaction(() => {
38
39
  db.exec(migration.up);
39
40
  db.prepare("INSERT INTO schema_migrations (id) VALUES (?)").run(migration.id);
@@ -103,10 +103,10 @@ export function recordImproveRun(db, input) {
103
103
  const metricsObj = input.metrics ?? computeImproveRunMetrics(input.result);
104
104
  db.prepare(`
105
105
  INSERT INTO improve_runs
106
- (id, started_at, completed_at, stash_dir, dry_run, profile,
106
+ (id, started_at, completed_at, stash_dir, dry_run, profile, strategy,
107
107
  scope_mode, scope_value, guidance, ok, result_json, metrics_json, metadata_json)
108
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
- `).run(input.id, input.startedAt, input.completedAt, input.stashDir, input.dryRun ? 1 : 0, input.profile, input.scopeMode, input.scopeValue, input.guidance, input.ok ? 1 : 0, JSON.stringify(input.result), JSON.stringify(metricsObj), JSON.stringify(input.metadata ?? {}));
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ `).run(input.id, input.startedAt, input.completedAt, input.stashDir, input.dryRun ? 1 : 0, input.legacyProfile ?? null, input.strategy ?? null, input.scopeMode, input.scopeValue, input.guidance, input.ok ? 1 : 0, JSON.stringify(input.result), JSON.stringify(metricsObj), JSON.stringify(input.metadata ?? {}));
110
110
  }
111
111
  /**
112
112
  * Read real (non-dry-run) improve_runs rows whose `started_at` falls in the
@@ -124,8 +124,8 @@ export function recordImproveRun(db, input) {
124
124
  */
125
125
  export function queryImproveRuns(db, since, until) {
126
126
  const sql = until
127
- ? "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, result_json FROM improve_runs WHERE started_at >= ? AND started_at < ? AND dry_run = 0 ORDER BY started_at DESC"
128
- : "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, result_json FROM improve_runs WHERE started_at >= ? AND dry_run = 0 ORDER BY started_at DESC";
127
+ ? "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, profile AS legacyProfile, strategy, result_json FROM improve_runs WHERE started_at >= ? AND started_at < ? AND dry_run = 0 ORDER BY started_at DESC"
128
+ : "SELECT id, started_at, completed_at, ok, scope_mode, scope_value, profile AS legacyProfile, strategy, result_json FROM improve_runs WHERE started_at >= ? AND dry_run = 0 ORDER BY started_at DESC";
129
129
  return (until ? db.prepare(sql).all(since, until) : db.prepare(sql).all(since));
130
130
  }
131
131
  /**
@@ -1,6 +1,84 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ function metadataError(message) {
5
+ throw new Error(`invalid task_history metadata_json: ${message}`);
6
+ }
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function validateDetail(value) {
11
+ if (value === undefined || value === null)
12
+ return;
13
+ if (!isRecord(value))
14
+ metadataError("detail must be an object or null");
15
+ const allowed = new Set(["runId", "reason", "error", "exitCode"]);
16
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key));
17
+ if (unknown.length > 0)
18
+ metadataError(`unknown detail fields: ${unknown.sort().join(", ")}`);
19
+ for (const field of ["runId", "reason", "error"]) {
20
+ if (value[field] !== undefined && typeof value[field] !== "string")
21
+ metadataError(`detail.${field} must be a string`);
22
+ }
23
+ if (value.exitCode !== undefined && value.exitCode !== null && typeof value.exitCode !== "number") {
24
+ metadataError("detail.exitCode must be a number or null");
25
+ }
26
+ }
27
+ /** Decode historical v1 and current v2 metadata without cross-version aliases. */
28
+ export function decodeTaskHistoryMetadata(input) {
29
+ let parsed = input;
30
+ if (typeof input === "string") {
31
+ try {
32
+ parsed = JSON.parse(input);
33
+ }
34
+ catch {
35
+ metadataError("not valid JSON");
36
+ }
37
+ }
38
+ if (!isRecord(parsed))
39
+ metadataError("root must be an object");
40
+ const version = parsed.metadataVersion ?? 1;
41
+ if (version === 1) {
42
+ const allowed = new Set(["metadataVersion", "durationMs", "detail", "profile"]);
43
+ const unknown = Object.keys(parsed).filter((key) => !allowed.has(key));
44
+ if (unknown.length > 0)
45
+ metadataError(`unknown v1 fields: ${unknown.sort().join(", ")}`);
46
+ if (parsed.metadataVersion !== undefined && parsed.metadataVersion !== 1)
47
+ metadataError("invalid v1 metadataVersion");
48
+ if (parsed.durationMs !== undefined && typeof parsed.durationMs !== "number")
49
+ metadataError("durationMs must be a number");
50
+ if (parsed.profile !== undefined && typeof parsed.profile !== "string")
51
+ metadataError("profile must be a string");
52
+ validateDetail(parsed.detail);
53
+ return {
54
+ metadataVersion: 1,
55
+ ...(typeof parsed.durationMs === "number" ? { durationMs: parsed.durationMs } : {}),
56
+ ...(parsed.detail !== undefined && parsed.detail !== null ? { detail: parsed.detail } : {}),
57
+ ...(typeof parsed.profile === "string" ? { legacyProfile: parsed.profile } : {}),
58
+ };
59
+ }
60
+ if (version === 2) {
61
+ const allowed = new Set(["metadataVersion", "durationMs", "detail", "engine"]);
62
+ const unknown = Object.keys(parsed).filter((key) => !allowed.has(key));
63
+ if (unknown.length > 0)
64
+ metadataError(`unknown v2 fields: ${unknown.sort().join(", ")}`);
65
+ if (typeof parsed.durationMs !== "number")
66
+ metadataError("durationMs must be a number");
67
+ if (!("detail" in parsed))
68
+ metadataError("detail is required in v2");
69
+ if (parsed.engine !== undefined && parsed.engine !== null && typeof parsed.engine !== "string") {
70
+ metadataError("engine must be a string or null");
71
+ }
72
+ validateDetail(parsed.detail);
73
+ return {
74
+ metadataVersion: 2,
75
+ durationMs: parsed.durationMs,
76
+ detail: parsed.detail ?? null,
77
+ ...(parsed.engine !== undefined ? { engine: parsed.engine } : {}),
78
+ };
79
+ }
80
+ metadataError(`unsupported metadataVersion: ${String(version)}`);
81
+ }
4
82
  /**
5
83
  * Upsert a task history row.
6
84
  */
@@ -56,7 +56,17 @@ export class WorkflowRunsRepository {
56
56
  params.push(filter.workflowRef);
57
57
  }
58
58
  if (filter.activeOnly) {
59
- filters.push("status IN ('active', 'blocked')");
59
+ // `activeOnly` means EXACTLY status='active' — a run currently
60
+ // executable. A `blocked` run is NOT active (it is parked awaiting a
61
+ // human `resume`), so it must never appear under `--active`, or a script
62
+ // treating `--active` output as executable work would pick up a blocked
63
+ // run (owner manual-validation finding 1). Blocked runs stay visible in
64
+ // plain `list` (all statuses) with their `blocked` status. The
65
+ // active-OR-blocked scope semantics some call sites want (the `akm show`
66
+ // scope guard, which surfaces a blocked run as the scope's occupant) live
67
+ // in the SEPARATE {@link findActiveOrBlockedRunForScope} — never folded
68
+ // into this shared list filter.
69
+ filters.push("status = 'active'");
60
70
  }
61
71
  const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "";
62
72
  return this.db
@@ -120,6 +130,185 @@ export class WorkflowRunsRepository {
120
130
  rearmCheckin(runId, checkinArmedAt) {
121
131
  this.db.prepare("UPDATE workflow_runs SET checkin_armed_at = ? WHERE id = ?").run(checkinArmedAt, runId);
122
132
  }
133
+ /**
134
+ * Freeze the compiled plan on the run row (migration 006, redesign addendum
135
+ * R1): `planJson` is the CANONICAL plan JSON (`ir/plan-hash.ts`), `planHash`
136
+ * its sha256. Called by `startWorkflowRun` inside the same transaction as
137
+ * `insertRun`, so a run row never exists without its frozen plan. Read back
138
+ * via {@link getRunById} (`plan_json` / `plan_hash` on the row).
139
+ */
140
+ setRunPlan(runId, planJson, planHash, planIrVersion = 3) {
141
+ this.db
142
+ .prepare("UPDATE workflow_runs SET plan_json = ?, plan_hash = ?, plan_ir_version = ? WHERE id = ?")
143
+ .run(planJson, planHash, planIrVersion, runId);
144
+ }
145
+ // ── engine run lease (migration 006 columns, R2 enforcement) ──────────────
146
+ //
147
+ // Single-driver invariant: at most one `akm workflow run` invocation drives
148
+ // a run at a time. The lease is (holder id, expiry); all timestamps are
149
+ // ISO-8601 UTC strings, which compare correctly with SQL `<` (lexicographic
150
+ // order matches chronological order for a fixed-format UTC ISO string).
151
+ /**
152
+ * Atomically claim the run lease: succeeds when the run is unleased OR the
153
+ * existing lease has expired (`engine_lease_until < now` — crash recovery).
154
+ * A live lease held by anyone (including a stale copy of the same holder)
155
+ * is NOT reclaimable through this method; the single UPDATE is the whole
156
+ * claim, so two racing invocations cannot both win.
157
+ */
158
+ acquireEngineLease(runId, holder, until, now) {
159
+ const result = this.db
160
+ .prepare(`UPDATE workflow_runs
161
+ SET engine_lease_holder = ?, engine_lease_until = ?
162
+ WHERE id = ? AND (engine_lease_holder IS NULL OR engine_lease_until IS NULL OR engine_lease_until < ?)`)
163
+ .run(holder, until, runId, now);
164
+ return Number(result.changes) > 0;
165
+ }
166
+ /**
167
+ * Extend the lease expiry — only while `holder` still owns it. Returns
168
+ * false when the lease was lost (expired and claimed by another engine),
169
+ * so the caller can stop driving instead of racing the new owner.
170
+ */
171
+ renewEngineLease(runId, holder, until) {
172
+ const result = this.db
173
+ .prepare("UPDATE workflow_runs SET engine_lease_until = ? WHERE id = ? AND engine_lease_holder = ?")
174
+ .run(until, runId, holder);
175
+ return Number(result.changes) > 0;
176
+ }
177
+ /**
178
+ * Clear the lease — only while `holder` still owns it, so a crashed-then-
179
+ * recovered invocation can never release a lease another engine has since
180
+ * claimed. Releasing an already-lost lease is a harmless no-op.
181
+ */
182
+ releaseEngineLease(runId, holder) {
183
+ this.db
184
+ .prepare("UPDATE workflow_runs SET engine_lease_holder = NULL, engine_lease_until = NULL WHERE id = ? AND engine_lease_holder = ?")
185
+ .run(runId, holder);
186
+ }
187
+ // ── unit rows (migration 004) ──────────────────────────────────────────────
188
+ //
189
+ // Writes to `workflow_run_units` should go through the serialized writer
190
+ // queue (`src/workflows/exec/unit-writer.ts`) when N units may complete
191
+ // concurrently — SQLite has a single writer and `withWorkflowRunsRepo`
192
+ // opens a fresh connection per call.
193
+ getUnitsForRun(runId) {
194
+ return this.db
195
+ .prepare("SELECT * FROM workflow_run_units WHERE run_id = ? ORDER BY started_at ASC, unit_id ASC")
196
+ .all(runId);
197
+ }
198
+ getUnitsForStep(runId, stepId) {
199
+ return this.db
200
+ .prepare("SELECT * FROM workflow_run_units WHERE run_id = ? AND step_id = ? ORDER BY started_at ASC, unit_id ASC")
201
+ .all(runId, stepId);
202
+ }
203
+ /** One unit row by primary key, or undefined. Used by the R3 report path's
204
+ * guarded read-then-write (idempotency / replay-divergence / claim checks). */
205
+ getUnit(runId, unitId) {
206
+ return this.db.prepare("SELECT * FROM workflow_run_units WHERE run_id = ? AND unit_id = ?").get(runId, unitId);
207
+ }
208
+ /**
209
+ * Stamp a unit-level check-in heartbeat (migration 007, R3 driver protocol):
210
+ * a driver executing a unit calls `report --status running` to claim (first
211
+ * call) or heartbeat (subsequent) it. Sets `status = 'running'` and
212
+ * `last_checkin_at`; NEVER touches `started_at` (the first-claim marker set by
213
+ * {@link insertUnit}) so the heartbeat window advances without resetting the
214
+ * claim time. The caller guards against re-claiming a terminal unit inside its
215
+ * transaction.
216
+ */
217
+ updateUnitCheckin(runId, unitId, lastCheckinAt) {
218
+ this.db
219
+ .prepare("UPDATE workflow_run_units SET status = 'running', last_checkin_at = ? WHERE run_id = ? AND unit_id = ?")
220
+ .run(lastCheckinAt, runId, unitId);
221
+ }
222
+ /**
223
+ * Insert a unit row in `running` state (a dispatch is starting now).
224
+ *
225
+ * Upsert on the (run_id, unit_id) primary key: durable-row resume
226
+ * re-dispatches units whose previous attempt never reached a terminal status
227
+ * (a crash mid-step leaves `running` rows). The fresh dispatch REPLACES the
228
+ * stale row — value columns (`result_json`/`tokens`/`failure_reason`/
229
+ * `session_id`/`finished_at`/`last_checkin_at`) are reset exactly as the old
230
+ * `INSERT OR REPLACE` reset them, and the dispatch metadata is overwritten —
231
+ * but `attempts` is INCREMENTED rather than reset (migration 008). A first
232
+ * insert lands `attempts = 1` (column default); each re-dispatch bumps it, so
233
+ * budget/lifetime seeds that sum `attempts` charge every crash-retried
234
+ * dispatch instead of collapsing them into one row. Using `ON CONFLICT DO
235
+ * UPDATE` (not `INSERT OR REPLACE`, which deletes+reinserts) is what lets the
236
+ * increment read the prior row's counter.
237
+ */
238
+ insertUnit(input) {
239
+ this.db
240
+ .prepare(`INSERT INTO workflow_run_units (
241
+ run_id, unit_id, step_id, node_id, parent_unit_id, phase, runner, engine, model,
242
+ status, input_hash, worktree_path, started_at, claim_holder, claim_expires_at
243
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?)
244
+ ON CONFLICT(run_id, unit_id) DO UPDATE SET
245
+ step_id = excluded.step_id,
246
+ node_id = excluded.node_id,
247
+ parent_unit_id = excluded.parent_unit_id,
248
+ phase = excluded.phase,
249
+ runner = excluded.runner,
250
+ engine = excluded.engine,
251
+ model = excluded.model,
252
+ status = excluded.status,
253
+ input_hash = excluded.input_hash,
254
+ worktree_path = excluded.worktree_path,
255
+ started_at = excluded.started_at,
256
+ claim_holder = excluded.claim_holder,
257
+ claim_expires_at = excluded.claim_expires_at,
258
+ result_json = NULL,
259
+ tokens = NULL,
260
+ failure_reason = NULL,
261
+ session_id = NULL,
262
+ finished_at = NULL,
263
+ last_checkin_at = NULL,
264
+ attempts = workflow_run_units.attempts + 1`)
265
+ .run(input.runId, input.unitId, input.stepId, input.nodeId, input.parentUnitId, input.phase, input.runner, input.engine ?? null, input.model, input.inputHash, input.worktreePath ?? null, input.startedAt, input.claimHolder ?? null, input.claimExpiresAt ?? null);
266
+ }
267
+ /**
268
+ * Stamp a `--status running` claim + heartbeat on a unit row (migration 009,
269
+ * PR #714 review round 2). Sets `status = 'running'`, refreshes the heartbeat
270
+ * (`last_checkin_at`), and (re)writes the claim owner + expiry. Called inside
271
+ * the report path's running-claim transaction AFTER it has validated that the
272
+ * claim is free / expired / already held by this holder, so the write is the
273
+ * final step of a checked reclaim. Never touches `started_at` (the first-claim
274
+ * marker set by {@link insertUnit}).
275
+ */
276
+ updateUnitClaim(runId, unitId, holder, expiresAt, lastCheckinAt) {
277
+ this.db
278
+ .prepare(`UPDATE workflow_run_units
279
+ SET status = 'running', last_checkin_at = ?, claim_holder = ?, claim_expires_at = ?
280
+ WHERE run_id = ? AND unit_id = ?`)
281
+ .run(lastCheckinAt, holder, expiresAt, runId, unitId);
282
+ }
283
+ /**
284
+ * Record a unit's terminal state (completed / failed / skipped).
285
+ *
286
+ * ## Loud contract (must update exactly one row)
287
+ *
288
+ * A finish ALWAYS targets a row a prior dispatch/claim inserted: every caller
289
+ * ({@link insertUnit} in the executor and the report path,
290
+ * {@link journalGateEvaluationStart} for gate rows) writes the `running` row
291
+ * before finishing it, inside the same writer-queue task or SQLite
292
+ * transaction. If the UPDATE matches NO `(run_id, unit_id)` row the journal is
293
+ * inconsistent — a finish against a missing or mismatched unit id — and the
294
+ * unit's terminal state would silently vanish (the row stays `running`, or no
295
+ * row exists at all). That is a journaling BUG, so we throw loudly at the
296
+ * source instead of no-oping: a stuck-`running` unit would wedge resume
297
+ * (durable-row reuse never matches it) or corrupt budget/gate accounting.
298
+ */
299
+ finishUnit(input) {
300
+ const result = this.db
301
+ .prepare(`UPDATE workflow_run_units
302
+ SET status = ?, result_json = ?, tokens = ?, failure_reason = ?, session_id = ?, finished_at = ?
303
+ WHERE run_id = ? AND unit_id = ?`)
304
+ .run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId);
305
+ if (Number(result.changes) === 0) {
306
+ throw new Error(`finishUnit updated no row: no unit "${input.unitId}" exists for run "${input.runId}". ` +
307
+ `A unit must be inserted (dispatched or claimed) before it can be finished — a finish that ` +
308
+ `matches no row indicates a journaling bug (mismatched unit id or a lost dispatch row), not a ` +
309
+ `recoverable state.`);
310
+ }
311
+ }
123
312
  }
124
313
  /**
125
314
  * Open the workflow database (bound to {@link StorageLocations.workflowDb}),