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
@@ -3,22 +3,101 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
6
+ import { canonicalizeWorkflowName } from "../../core/asset/asset-spec.js";
6
7
  import { loadConfig } from "../../core/config/config.js";
7
8
  import { NotFoundError, UsageError } from "../../core/errors.js";
8
9
  import { appendEvent } from "../../core/events.js";
10
+ import { warn } from "../../core/warn.js";
9
11
  import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
10
12
  import { getCurrentWorkflowScopeKey } from "../authoring/scope-key.js";
13
+ import { frozenSummaryJudge } from "../exec/frozen-judge.js";
14
+ import { detectSecretShapedParams } from "../exec/param-secrets.js";
15
+ import { collectProgramWarnings } from "../ir/compile.js";
16
+ import { compileResolveFreezeWorkflow } from "../ir/freeze.js";
17
+ import { validateWorkflowParams } from "../ir/params.js";
18
+ import { canonicalPlanJson, computePlanHash } from "../ir/plan-hash.js";
19
+ import { decodeWorkflowPlanV3, WORKFLOW_IR_VERSION } from "../ir/schema.js";
11
20
  import { validateStepSummary } from "../validate-summary.js";
12
21
  import { resolveAgentIdentity } from "./agent-identity.js";
13
22
  import { evaluateCheckin } from "./checkin.js";
23
+ import { assertWorkflowSpineMatchesPlan, classifyWorkflowRunPlan, frozenStepRows, requireAbandonableWorkflowPlan, requireExecutableWorkflowPlan, } from "./plan-classifier.js";
24
+ import { evaluateStaleUnits } from "./unit-checkin.js";
14
25
  import { loadWorkflowAsset, resolveWorkflowEntryId } from "./workflow-asset-loader.js";
26
+ /** Clip bound for a unit's `result_json` on the `--units` diagnostic surface. */
27
+ const UNIT_DIAGNOSTIC_CLIP = 2000;
28
+ function toUnitDiagnostic(row, stale, plannedEngine) {
29
+ let diagnostic = null;
30
+ if (row.result_json !== null) {
31
+ // `result_json` is a JSON-encoded value: a bare JSON string for a free-text
32
+ // unit, an object/array for a schema unit. Render the decoded string as-is
33
+ // (no surrounding quotes) and other shapes as compact JSON, then clip so a
34
+ // large artifact can't flood the diagnostic surface.
35
+ let text = row.result_json;
36
+ try {
37
+ const parsed = JSON.parse(row.result_json);
38
+ text = typeof parsed === "string" ? parsed : JSON.stringify(parsed);
39
+ }
40
+ catch {
41
+ /* leave the raw journaled text */
42
+ }
43
+ diagnostic = text.length > UNIT_DIAGNOSTIC_CLIP ? `${text.slice(0, UNIT_DIAGNOSTIC_CLIP)}…` : text;
44
+ }
45
+ return {
46
+ unitId: row.unit_id,
47
+ nodeId: row.node_id,
48
+ stepId: row.step_id,
49
+ phase: row.phase,
50
+ status: row.status,
51
+ attempts: row.attempts,
52
+ tokens: row.tokens,
53
+ failureReason: row.failure_reason,
54
+ sessionId: row.session_id,
55
+ diagnostic,
56
+ startedAt: row.started_at,
57
+ finishedAt: row.finished_at,
58
+ stale: stale !== undefined,
59
+ staleIdleMs: stale ? (Number.isFinite(stale.idleMs) ? stale.idleMs : null) : null,
60
+ claimHolder: row.claim_holder,
61
+ claimExpiresAt: row.claim_expires_at,
62
+ engine: row.engine ?? null,
63
+ runtimeKind: row.engine && (row.runner === "llm" || row.runner === "agent" || row.runner === "sdk") ? row.runner : null,
64
+ platform: plannedEngine?.kind === "agent" ? plannedEngine.platform : null,
65
+ ...(!row.engine && row.runner ? { legacyRunnerSelector: row.runner } : {}),
66
+ };
67
+ }
15
68
  export async function startWorkflowRun(ref, params = {}, options) {
16
69
  const asset = await loadWorkflowAsset(ref);
70
+ // Frozen plan (redesign addendum, R1): compile the plan ONCE at start and
71
+ // persist it on the run row in the same transaction as the insert. Every
72
+ // later invocation executes this snapshot — the asset file is never re-read
73
+ // for an in-flight run; re-planning is an explicit new run.
74
+ const plan = decodeWorkflowPlanV3(compileResolveFreezeWorkflow(asset, loadConfig()).plan);
75
+ // Non-fatal WARNINGS (redesign addendum): a YAML program's untyped-step and
76
+ // undeclared-param advisories surface as `warn()` lines at start (stderr,
77
+ // consistent with the repo's other author-facing warnings) without blocking
78
+ // the run. Markdown workflows carry no `program` and warn about nothing.
79
+ if (asset.program) {
80
+ for (const w of collectProgramWarnings(asset.program)) {
81
+ warn(`workflow start: ${asset.path}:${w.line} — ${w.message}`);
82
+ }
83
+ }
84
+ // Reviewer #12: validate supplied `--params` against the frozen param
85
+ // schemas BEFORE creating the run, so a type-mismatched param (e.g. a string
86
+ // for a `{ type: array }` param) is rejected with actionable errors instead
87
+ // of flowing silently into a unit prompt. Programs without declared param
88
+ // schemas (and every Markdown workflow) validate trivially.
89
+ const paramErrors = validateWorkflowParams(plan, params);
90
+ if (paramErrors.length > 0) {
91
+ throw new UsageError(`Cannot start ${asset.ref}: the supplied --params do not satisfy the workflow's declared parameter schemas:\n` +
92
+ paramErrors.map((e) => ` - ${e}`).join("\n"), "INVALID_JSON_ARGUMENT");
93
+ }
94
+ const planJson = canonicalPlanJson(plan);
95
+ const planHash = computePlanHash(plan);
17
96
  return withWorkflowRunsRepo(async (repo) => {
18
97
  const now = new Date().toISOString();
19
98
  const runId = randomUUID();
20
99
  const scopeKey = getCurrentWorkflowScopeKey();
21
- const currentStepId = asset.steps[0]?.id ?? null;
100
+ const currentStepId = plan.steps[0]?.stepId ?? null;
22
101
  const workflowEntryId = resolveWorkflowEntryId(asset.sourcePath, asset.ref);
23
102
  // Capture the agent harness + session driving this run. Explicit options
24
103
  // win; otherwise fall back to best-effort environment detection. This is
@@ -57,16 +136,27 @@ export async function startWorkflowRun(ref, params = {}, options) {
57
136
  agentSessionId,
58
137
  checkinArmedAt: now,
59
138
  });
60
- repo.insertSteps(asset.steps.map((step) => ({
139
+ repo.insertSteps(frozenStepRows(plan).map((step) => ({
61
140
  runId,
62
- stepId: step.id,
63
- stepTitle: step.title,
141
+ stepId: step.stepId,
142
+ stepTitle: step.stepTitle,
64
143
  instructions: step.instructions,
65
- completionJson: step.completionCriteria ? JSON.stringify(step.completionCriteria) : null,
66
- sequenceIndex: step.sequenceIndex ?? 0,
144
+ completionJson: step.completionJson,
145
+ sequenceIndex: step.sequenceIndex,
67
146
  })));
147
+ // Same transaction as the insert: a run row never exists without its
148
+ // frozen plan (rows with NULL plan_json are pre-006 legacy runs).
149
+ repo.setRunPlan(runId, planJson, planHash, WORKFLOW_IR_VERSION);
68
150
  });
69
151
  const result = await getWorkflowStatus(runId);
152
+ // #13: params are declared non-secret (they are copied verbatim into every
153
+ // unit prompt and hashed into the unit identity, so they cannot be redacted
154
+ // without breaking the driver protocol). Surface a loud, best-effort warning
155
+ // when a param LOOKS like a credential so the author moves it to an env
156
+ // binding. Advisory only — never blocks the start.
157
+ const secretWarnings = detectSecretShapedParams(params);
158
+ if (secretWarnings.length > 0)
159
+ result.warnings = [...(result.warnings ?? []), ...secretWarnings];
70
160
  // 07 P1-B: emit only the run id + status — NOT the raw workflowTitle (which
71
161
  // comes verbatim from the workflow asset's frontmatter and is therefore
72
162
  // attacker-influenceable). Keeping raw titles out of the events stream
@@ -80,11 +170,25 @@ export async function startWorkflowRun(ref, params = {}, options) {
80
170
  return result;
81
171
  });
82
172
  }
83
- export async function getWorkflowStatus(runId) {
173
+ export async function getWorkflowStatus(runId, opts) {
84
174
  return withWorkflowRunsRepo((repo) => {
85
175
  const run = readWorkflowRun(repo, runId);
86
176
  const steps = readWorkflowRunSteps(repo, run.id);
87
- return buildWorkflowRunDetail(run, steps);
177
+ const detail = buildWorkflowRunDetail(run, steps);
178
+ if (opts?.includeUnits) {
179
+ // The honest diagnostic surface (#22): read the unit journal straight and
180
+ // project each row, INCLUDING failures whose diagnostic text the
181
+ // deterministic evidence graph drops. Read-only; never mutates the run.
182
+ const rows = repo.getUnitsForRun(run.id);
183
+ // Codex round-3 finding B: run the SAME pure stale-claim evaluator `brief`
184
+ // uses (`now` injected for deterministic tests) so a dead driver's claimed
185
+ // `running` unit surfaces as stale here too, not just as raw `running`.
186
+ const staleById = new Map(evaluateStaleUnits(rows, opts.now ?? Date.now()).map((u) => [u.unitId, u]));
187
+ const classified = classifyWorkflowRunPlan(run);
188
+ const engines = classified.support === "supported" ? classified.plan.execution?.engines : undefined;
189
+ detail.units = rows.map((row) => toUnitDiagnostic(row, staleById.get(row.unit_id), row.engine ? engines?.[row.engine] : undefined));
190
+ }
191
+ return detail;
88
192
  });
89
193
  }
90
194
  export async function hasWorkflowRun(runId) {
@@ -99,7 +203,7 @@ export async function listWorkflowRuns(input) {
99
203
  if (parsed.type !== "workflow") {
100
204
  throw new UsageError(`Expected a workflow ref (workflow:<name>), got "${input.workflowRef}".`);
101
205
  }
102
- workflowRef = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${parsed.name}`;
206
+ workflowRef = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${canonicalizeWorkflowName(parsed.name)}`;
103
207
  }
104
208
  const rows = repo.listRuns({
105
209
  scopeKey,
@@ -113,39 +217,72 @@ export async function getNextWorkflowStep(specifier, params) {
113
217
  return withWorkflowRunsRepo(async (repo) => {
114
218
  const { run, autoStarted } = await resolveRunSpecifier(repo, specifier, params);
115
219
  const steps = readWorkflowRunSteps(repo, run.id);
116
- const currentStep = resolveCurrentStep(run, steps);
117
- const done = run.status === "completed" ? true : undefined;
118
- // #506: surface a check-in directive through the normal command output when
119
- // the run looks stalled. Pure timestamp evaluation — no background thread.
120
- const checkin = evaluateCheckin({
121
- status: run.status,
122
- updatedAt: run.updated_at,
123
- checkinArmedAt: run.checkin_armed_at,
124
- agentHarness: run.agent_harness,
125
- agentSessionId: run.agent_session_id,
126
- }) ?? undefined;
127
- return {
128
- run: toWorkflowRunSummary(run),
129
- workflow: {
130
- ref: run.workflow_ref,
131
- title: run.workflow_title,
132
- steps: steps.map(toWorkflowRunStepState),
133
- },
134
- step: currentStep ? toWorkflowRunStepState(currentStep) : null,
135
- ...(done ? { done } : {}),
136
- ...(autoStarted ? { autoStarted } : {}),
137
- ...(checkin ? { checkin } : {}),
138
- };
220
+ const plan = requireExecutableWorkflowPlan(run);
221
+ assertWorkflowSpineMatchesPlan(plan, run, steps);
222
+ return { ...projectNextResult(run, steps), ...(autoStarted ? { autoStarted: true } : {}) };
139
223
  });
140
224
  }
225
+ /**
226
+ * Project a run row + its step rows into a {@link WorkflowNextResult}. The pure
227
+ * read-shaping half of {@link getNextWorkflowStep}, extracted so the driver
228
+ * snapshot below reproduces the exact same projection without re-running the
229
+ * auto-start-capable {@link resolveRunSpecifier}.
230
+ */
231
+ function projectNextResult(run, steps) {
232
+ const currentStep = resolveCurrentStep(run, steps);
233
+ const done = run.status === "completed" ? true : undefined;
234
+ // #506: surface a check-in directive through the normal command output when
235
+ // the run looks stalled. Pure timestamp evaluation — no background thread.
236
+ const checkin = evaluateCheckin({
237
+ status: run.status,
238
+ updatedAt: run.updated_at,
239
+ checkinArmedAt: run.checkin_armed_at,
240
+ agentHarness: run.agent_harness,
241
+ agentSessionId: run.agent_session_id,
242
+ }) ?? undefined;
243
+ return {
244
+ run: toWorkflowRunSummary(run),
245
+ workflow: {
246
+ ref: run.workflow_ref,
247
+ title: run.workflow_title,
248
+ steps: steps.map(toWorkflowRunStepState),
249
+ },
250
+ step: currentStep ? toWorkflowRunStepState(currentStep) : null,
251
+ ...(done ? { done } : {}),
252
+ ...(checkin ? { checkin } : {}),
253
+ };
254
+ }
255
+ /**
256
+ * A consistent point-in-time snapshot of a run for the harness-neutral driver
257
+ * protocol (PR #714 review round 2, #14). `brief`/`report` previously read the
258
+ * spine (`getNextWorkflowStep`) and then, in a SEPARATE connection, the run row
259
+ * + unit journal — so a concurrent `report`/`run`/manual completion could change
260
+ * the active step BETWEEN the two reads, leaving the described work-list
261
+ * inconsistent with the run row it was stamped against. This reads the run row,
262
+ * its steps, AND its unit rows inside ONE transaction (one connection, one
263
+ * snapshot) so all three agree. It never auto-starts — `runId` must already
264
+ * resolve to a concrete run — because the driver protocol never mutates on read.
265
+ */
266
+ export async function snapshotRunForDriver(runId) {
267
+ return withWorkflowRunsRepo((repo) => repo.transaction(() => {
268
+ const run = readWorkflowRun(repo, runId);
269
+ const steps = readWorkflowRunSteps(repo, run.id);
270
+ const plan = requireExecutableWorkflowPlan(run);
271
+ assertWorkflowSpineMatchesPlan(plan, run, steps);
272
+ const units = repo.getUnitsForRun(run.id);
273
+ return { next: projectNextResult(run, steps), run, units };
274
+ }));
275
+ }
141
276
  export async function resumeWorkflowRun(runId) {
142
277
  return withWorkflowRunsRepo((repo) => {
143
278
  const run = readWorkflowRun(repo, runId);
279
+ const plan = requireExecutableWorkflowPlan(run);
280
+ const steps = readWorkflowRunSteps(repo, run.id);
281
+ assertWorkflowSpineMatchesPlan(plan, run, steps);
144
282
  if (run.status === "completed") {
145
283
  throw new UsageError(`Workflow run ${run.id} is already completed and cannot be resumed.`);
146
284
  }
147
285
  if (run.status === "active") {
148
- const steps = readWorkflowRunSteps(repo, run.id);
149
286
  return buildWorkflowRunDetail(run, steps);
150
287
  }
151
288
  // blocked or failed → flip back to active and re-open the current step so
@@ -158,8 +295,8 @@ export async function resumeWorkflowRun(runId) {
158
295
  repo.markRunActive(run.id, now);
159
296
  });
160
297
  const updated = { ...run, status: "active", updated_at: now };
161
- const steps = readWorkflowRunSteps(repo, run.id);
162
- return buildWorkflowRunDetail(updated, steps);
298
+ const refreshedSteps = readWorkflowRunSteps(repo, run.id);
299
+ return buildWorkflowRunDetail(updated, refreshedSteps);
163
300
  });
164
301
  }
165
302
  /**
@@ -171,6 +308,11 @@ export async function resumeWorkflowRun(runId) {
171
308
  export async function abandonWorkflowRun(runId) {
172
309
  return withWorkflowRunsRepo((repo) => {
173
310
  const run = readWorkflowRun(repo, runId);
311
+ requireAbandonableWorkflowPlan(run);
312
+ const classified = classifyWorkflowRunPlan(run);
313
+ const existingSteps = readWorkflowRunSteps(repo, run.id);
314
+ if (classified.support === "supported")
315
+ assertWorkflowSpineMatchesPlan(classified.plan, run, existingSteps);
174
316
  if (run.status === "completed" || run.status === "failed") {
175
317
  throw new UsageError(`Workflow run ${run.id} is already ${run.status}.`);
176
318
  }
@@ -203,9 +345,13 @@ export async function completeWorkflowStep(input) {
203
345
  // the write transaction — a slow/hung LLM must never hold a db write lock.
204
346
  const preflight = await withWorkflowRunsRepo((repo) => {
205
347
  const run = readWorkflowRun(repo, input.runId);
348
+ const plan = requireExecutableWorkflowPlan(run);
349
+ const steps = readWorkflowRunSteps(repo, run.id);
350
+ assertWorkflowSpineMatchesPlan(plan, run, steps);
206
351
  if (run.status !== "active") {
207
352
  throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`);
208
353
  }
354
+ assertLeaseAllowsSpineAdvance(run, input.leaseHolder);
209
355
  const existing = repo.getStep(run.id, input.stepId);
210
356
  if (!existing) {
211
357
  throw new NotFoundError(`Step "${input.stepId}" was not found in workflow run ${run.id}.`);
@@ -216,7 +362,10 @@ export async function completeWorkflowStep(input) {
216
362
  if (run.current_step_id !== existing.step_id) {
217
363
  throw new UsageError(`Step "${input.stepId}" is not the current step for workflow run ${run.id}. Complete "${run.current_step_id}" first.`);
218
364
  }
219
- return { existing };
365
+ const stepPlan = plan.steps.find((step) => step.stepId === input.stepId);
366
+ if (!stepPlan)
367
+ throw new NotFoundError(`Step "${input.stepId}" was not found in workflow run ${run.id}.`);
368
+ return { existing, plan, stepPlan };
220
369
  });
221
370
  const summary = input.summary?.trim();
222
371
  // #506: completing a step requires a summary of the work done.
@@ -227,9 +376,11 @@ export async function completeWorkflowStep(input) {
227
376
  // completionCriteria via the configured LLM. Fail-open when no criteria or no
228
377
  // judge. Only a well-formed `complete: false` blocks completion.
229
378
  if (input.status === "completed" && summary) {
230
- const criteria = parseJsonArray(preflight.existing.completion_json) ?? [];
231
- const judge = input.summaryJudge === undefined ? buildDefaultSummaryJudge() : input.summaryJudge;
232
- const verdict = await validateStepSummary({ stepTitle: preflight.existing.step_title, completionCriteria: criteria, summary }, judge ?? undefined);
379
+ const criteria = preflight.stepPlan.gate.criteria;
380
+ const judge = input.summaryJudge === undefined
381
+ ? frozenSummaryJudge(preflight.plan, preflight.stepPlan.gate.judge)
382
+ : input.summaryJudge;
383
+ const verdict = await validateStepSummary({ stepTitle: preflight.stepPlan.title, completionCriteria: criteria, summary }, judge ?? undefined, { required: input.requireGate === true });
233
384
  if (!verdict.complete) {
234
385
  // Re-arm the check-in so a subsequent stall is still nudged, but leave the
235
386
  // step pending and return corrective feedback instead of completing.
@@ -242,6 +393,9 @@ export async function completeWorkflowStep(input) {
242
393
  stepId: input.stepId,
243
394
  missing: verdict.missing,
244
395
  feedback: verdict.feedback ?? "The summary does not satisfy the step's completion criteria.",
396
+ // A REQUIRED gate that could not be judged (finding A): the caller BLOCKS
397
+ // rather than treating this as a normal, retryable gate rejection.
398
+ ...(verdict.errored ? { errored: true } : {}),
245
399
  };
246
400
  }
247
401
  }
@@ -250,9 +404,16 @@ export async function completeWorkflowStep(input) {
250
404
  let refreshedSteps = [];
251
405
  repo.transaction(() => {
252
406
  const run = readWorkflowRun(repo, input.runId);
407
+ const plan = requireExecutableWorkflowPlan(run);
408
+ const spine = readWorkflowRunSteps(repo, run.id);
409
+ assertWorkflowSpineMatchesPlan(plan, run, spine);
253
410
  if (run.status !== "active") {
254
411
  throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`);
255
412
  }
413
+ // Re-checked inside the write transaction (like every other preflight
414
+ // condition): an engine may have claimed the run while the summary gate
415
+ // above was awaiting its LLM judge.
416
+ assertLeaseAllowsSpineAdvance(run, input.leaseHolder);
256
417
  const existing = repo.getStep(run.id, input.stepId);
257
418
  if (!existing) {
258
419
  throw new NotFoundError(`Step "${input.stepId}" was not found in workflow run ${run.id}.`);
@@ -295,10 +456,16 @@ export async function completeWorkflowStep(input) {
295
456
  };
296
457
  });
297
458
  const detail = buildWorkflowRunDetail(updatedRun, refreshedSteps);
459
+ // #11: emit `workflow_step_completed` ONLY for a genuine `completed`
460
+ // transition; every other non-pending status (failed/skipped/blocked)
461
+ // carries the honest `workflow_step_updated` name. The status is ALWAYS
462
+ // in metadata so consumers never infer it from the event name. Raw `notes`
463
+ // are workflow/model-authored content — an event-stream prompt-injection
464
+ // surface — and never enter the events log; they live on the step row only.
298
465
  appendEvent({
299
- eventType: "workflow_step_completed",
466
+ eventType: input.status === "completed" ? "workflow_step_completed" : "workflow_step_updated",
300
467
  ref: detail.run.workflowRef,
301
- metadata: { runId: input.runId, stepId: input.stepId, notes: input.notes },
468
+ metadata: { runId: input.runId, stepId: input.stepId, status: input.status },
302
469
  });
303
470
  if (detail.run.status === "completed") {
304
471
  appendEvent({ eventType: "workflow_finished", ref: detail.run.workflowRef, metadata: { runId: input.runId } });
@@ -321,7 +488,7 @@ async function resolveRunSpecifier(repo, specifier, params) {
321
488
  if (parsed.type !== "workflow") {
322
489
  throw new UsageError(`Expected a workflow ref or workflow run id, got "${specifier}".`);
323
490
  }
324
- const ref = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${parsed.name}`;
491
+ const ref = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${canonicalizeWorkflowName(parsed.name)}`;
325
492
  const scopeKey = getCurrentWorkflowScopeKey();
326
493
  const active = repo.getActiveRunRowForScope(ref, scopeKey);
327
494
  if (active) {
@@ -344,6 +511,16 @@ function readWorkflowRunSteps(repo, runId) {
344
511
  return repo.getStepsForRun(runId);
345
512
  }
346
513
  function buildWorkflowRunDetail(run, steps) {
514
+ // Review M1: `workflow status` (and every other detail-shaped response) now
515
+ // evaluates the check-in, not just `workflow next`. Pure timestamp check —
516
+ // no background thread (see checkin.ts).
517
+ const checkin = evaluateCheckin({
518
+ status: run.status,
519
+ updatedAt: run.updated_at,
520
+ checkinArmedAt: run.checkin_armed_at,
521
+ agentHarness: run.agent_harness,
522
+ agentSessionId: run.agent_session_id,
523
+ });
347
524
  return {
348
525
  run: toWorkflowRunSummary(run),
349
526
  workflow: {
@@ -351,9 +528,11 @@ function buildWorkflowRunDetail(run, steps) {
351
528
  title: run.workflow_title,
352
529
  steps: steps.map(toWorkflowRunStepState),
353
530
  },
531
+ ...(checkin ? { checkin } : {}),
354
532
  };
355
533
  }
356
534
  function toWorkflowRunSummary(run) {
535
+ const plan = classifyWorkflowRunPlan(run);
357
536
  return {
358
537
  id: run.id,
359
538
  workflowRef: run.workflow_ref,
@@ -368,8 +547,33 @@ function toWorkflowRunSummary(run) {
368
547
  params: parseJsonObject(run.params_json),
369
548
  agentHarness: run.agent_harness ?? null,
370
549
  agentSessionId: run.agent_session_id ?? null,
550
+ planIrVersion: plan.irVersion,
551
+ executionSupport: plan.support,
552
+ // Surface the engine lease (holder id + expiry — never workflow-authored
553
+ // content) so `workflow next`/`status` show who is driving the run.
554
+ ...(run.engine_lease_holder && run.engine_lease_until
555
+ ? { engineLease: { holder: run.engine_lease_holder, until: run.engine_lease_until } }
556
+ : {}),
371
557
  };
372
558
  }
559
+ /**
560
+ * Single-driver enforcement (R2 run lease): while a LIVE (unexpired) engine
561
+ * lease is held, only the holding engine may advance the gate spine. Manual
562
+ * `akm workflow complete` (no `leaseHolder`) — or a stale engine invocation
563
+ * whose lease was claimed by another — is refused with the holder + expiry.
564
+ * An EXPIRED lease never blocks: the engine that held it is presumed dead.
565
+ */
566
+ function assertLeaseAllowsSpineAdvance(run, leaseHolder) {
567
+ if (!run.engine_lease_holder || !run.engine_lease_until)
568
+ return;
569
+ if (leaseHolder === run.engine_lease_holder)
570
+ return;
571
+ if (run.engine_lease_until < new Date().toISOString())
572
+ return; // expired ⇒ claimable, not live
573
+ throw new UsageError(`Workflow run ${run.id} is being driven by engine ${run.engine_lease_holder} ` +
574
+ `(run lease expires ${run.engine_lease_until}). The engine owns the step spine while it runs — ` +
575
+ `wait for it to finish or for the lease to expire before advancing steps manually.`);
576
+ }
373
577
  function toWorkflowRunStepState(step) {
374
578
  return {
375
579
  id: step.step_id,
@@ -410,33 +614,6 @@ function deriveRunState(steps) {
410
614
  .at(-1);
411
615
  return { status: "completed", currentStepId: null, completedAt: completedAt ?? null };
412
616
  }
413
- /**
414
- /**
415
- * Build the default summary-validation judge from the configured LLM, or return
416
- * `null` when no LLM is configured (gate is then skipped — fail-open). Lazily
417
- * imports the client/config so the workflow engine has no hard LLM dependency.
418
- */
419
- function buildDefaultSummaryJudge() {
420
- let llm;
421
- try {
422
- const config = loadConfig();
423
- const { getDefaultLlmConfig } = require("../../core/config/config");
424
- llm = getDefaultLlmConfig(config);
425
- }
426
- catch {
427
- return null;
428
- }
429
- if (!llm)
430
- return null;
431
- const resolved = llm;
432
- return async ({ system, user }) => {
433
- const { chatCompletion } = require("../../llm/client");
434
- return chatCompletion(resolved, [
435
- { role: "system", content: system },
436
- { role: "user", content: user },
437
- ]);
438
- };
439
- }
440
617
  function parseJsonObject(value) {
441
618
  if (!value)
442
619
  return undefined;
@@ -0,0 +1,45 @@
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
+ import { GATE_EVALUATION_PHASE } from "../exec/step-work.js";
5
+ /**
6
+ * Default staleness window. A unit claimed `running` whose last heartbeat (or,
7
+ * absent any heartbeat, its first claim) is older than this is surfaced as
8
+ * stale. Matches the run-level {@link ../runtime/checkin.CHECKIN_STALL_MS}.
9
+ */
10
+ export const UNIT_STALE_MS = 90_000;
11
+ function parseIso(value) {
12
+ if (!value)
13
+ return null;
14
+ const ms = Date.parse(value);
15
+ return Number.isNaN(ms) ? null : ms;
16
+ }
17
+ /**
18
+ * Pure stale-unit evaluator. Returns every `running` DISPATCH unit whose last
19
+ * heartbeat (`last_checkin_at`, else the first-claim `started_at`) is older than
20
+ * `staleMs`. Gate-evaluation rows (`phase = "gate"`) are excluded — they are
21
+ * synchronous engine judge calls, never driver-claimed work. A running row with
22
+ * no usable timestamp at all is treated as stale (a claim we can no longer age).
23
+ * Deterministic in `now` — free of timer flakiness.
24
+ */
25
+ export function evaluateStaleUnits(rows, now = Date.now(), staleMs = UNIT_STALE_MS) {
26
+ const stale = [];
27
+ for (const row of rows) {
28
+ if (row.status !== "running")
29
+ continue;
30
+ if (row.phase === GATE_EVALUATION_PHASE)
31
+ continue;
32
+ const lastSeenAt = row.last_checkin_at ?? row.started_at;
33
+ const lastSeen = parseIso(lastSeenAt);
34
+ const idleMs = lastSeen === null ? Number.POSITIVE_INFINITY : now - lastSeen;
35
+ if (idleMs < staleMs)
36
+ continue;
37
+ stale.push({
38
+ unitId: row.unit_id,
39
+ stepId: row.step_id,
40
+ idleMs,
41
+ lastSeenAt: lastSeenAt ?? null,
42
+ });
43
+ }
44
+ return stale;
45
+ }
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
5
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
6
+ import { canonicalizeWorkflowName } from "../../core/asset/asset-spec.js";
6
7
  import { loadConfig } from "../../core/config/config.js";
7
8
  import { NotFoundError, UsageError } from "../../core/errors.js";
8
9
  import { getDbPath } from "../../core/paths.js";
@@ -12,6 +13,8 @@ import { resolveAssetPath } from "../../sources/resolve.js";
12
13
  import { withIndexDb } from "../../storage/repositories/index-db.js";
13
14
  import { formatWorkflowErrors } from "../authoring/authoring.js";
14
15
  import { parseWorkflow } from "../parser.js";
16
+ import { parseWorkflowProgram } from "../program/parser.js";
17
+ import { isWorkflowProgramPath, projectProgramParameters, projectProgramStepDefinitions } from "../program/project.js";
15
18
  /**
16
19
  * Resolve a `workflow:<name>` ref to a fully-projected {@link WorkflowAsset}.
17
20
  *
@@ -44,7 +47,19 @@ export async function loadWorkflowAsset(ref) {
44
47
  throw new NotFoundError(`Workflow not found for ref: workflow:${parsed.name}`);
45
48
  }
46
49
  const resolvedSourcePath = sourcePath ?? config.stashDir ?? assetPath;
47
- const fullRef = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${parsed.name}`;
50
+ // Canonicalize the stored ref: `workflow:foo.yaml` and `workflow:foo`
51
+ // resolve to the same file, so they MUST share one run identity. The raw
52
+ // `parsed.name` (with any extension) is what drives file resolution above;
53
+ // only the persisted/queried ref is collapsed (matches the index entry key,
54
+ // which is keyed by the extension-stripped canonical name).
55
+ const canonicalName = canonicalizeWorkflowName(parsed.name);
56
+ const fullRef = `${parsed.origin ? `${parsed.origin}//` : ""}workflow:${canonicalName}`;
57
+ // Format detection by extension: `.yaml`/`.yml` is a YAML workflow program
58
+ // (redesign addendum, R1); everything else is the markdown document format.
59
+ if (isWorkflowProgramPath(assetPath)) {
60
+ const program = loadWorkflowProgramFromDisk(assetPath);
61
+ return projectProgramAsset(program, fullRef, assetPath, resolvedSourcePath);
62
+ }
48
63
  const cached = readWorkflowDocumentFromIndex(resolvedSourcePath, fullRef);
49
64
  const document = cached ?? loadWorkflowDocumentFromDisk(assetPath);
50
65
  return projectAsset(document, fullRef, assetPath, resolvedSourcePath);
@@ -69,6 +84,14 @@ export function resolveWorkflowEntryId(sourcePath, ref) {
69
84
  return row?.id ?? null;
70
85
  });
71
86
  }
87
+ function loadWorkflowProgramFromDisk(assetPath) {
88
+ const content = fs.readFileSync(assetPath, "utf8");
89
+ const result = parseWorkflowProgram(content, { path: assetPath });
90
+ if (!result.ok) {
91
+ throw new UsageError(formatWorkflowErrors(assetPath, result.errors));
92
+ }
93
+ return result.program;
94
+ }
72
95
  function loadWorkflowDocumentFromDisk(assetPath) {
73
96
  const content = fs.readFileSync(assetPath, "utf8");
74
97
  const result = parseWorkflow(content, { path: assetPath });
@@ -121,5 +144,23 @@ function projectAsset(doc, ref, assetPath, sourcePath) {
121
144
  ...(s.completionCriteria ? { completionCriteria: s.completionCriteria.map((c) => c.text) } : {}),
122
145
  sequenceIndex: s.sequenceIndex,
123
146
  })),
147
+ document: doc,
148
+ };
149
+ }
150
+ /**
151
+ * Project a parsed YAML program into the run-repository asset shape. Step
152
+ * instructions carry the RAW `${{ … }}` templates — resolution happens in
153
+ * the engine against the frozen plan, never here.
154
+ */
155
+ function projectProgramAsset(program, ref, assetPath, sourcePath) {
156
+ const parameters = projectProgramParameters(program);
157
+ return {
158
+ ref,
159
+ path: assetPath,
160
+ sourcePath,
161
+ title: program.name,
162
+ ...(parameters ? { parameters } : {}),
163
+ steps: projectProgramStepDefinitions(program),
164
+ program,
124
165
  };
125
166
  }
@@ -18,6 +18,9 @@
18
18
  */
19
19
  import validateSummaryJudgePrompt from "../assets/prompts/validate-summary-judge.md" with { type: "text" };
20
20
  import { parseJsonResponse } from "../core/parse.js";
21
+ /** Feedback surfaced when a REQUIRED gate's judge could not be evaluated. */
22
+ const REQUIRED_GATE_JUDGE_UNAVAILABLE_FEEDBACK = "The required completion gate could not be judged — the LLM threw, was unreachable, or returned an " +
23
+ "unparseable verdict. A required gate must be judged; refusing to pass it. Restore the judge and re-evaluate.";
21
24
  const JUDGE_SYSTEM = validateSummaryJudgePrompt;
22
25
  function buildUserPrompt(input) {
23
26
  const criteria = input.completionCriteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
@@ -36,14 +39,22 @@ function buildUserPrompt(input) {
36
39
  /**
37
40
  * Run the summary-validation gate.
38
41
  *
39
- * Fail-open contract:
42
+ * Fail-open contract (NON-required gates):
40
43
  * - no criteria → `{ complete: true, skipped: true }`
41
44
  * - no judge → `{ complete: true, skipped: true }`
42
45
  * - judge throws / returns unparseable → `{ complete: true, skipped: true }`
43
46
  *
47
+ * REQUIRED gates (`opts.required`) do NOT fail open on an un-evaluable judge
48
+ * (Codex round-3 finding A): a judge that throws / is unreachable, or returns an
49
+ * unparseable / malformed verdict, yields `{ complete: false, errored: true }`
50
+ * so the caller BLOCKS the step rather than silently passing an unjudged gate.
51
+ * (A required gate with NO judge at all is blocked upstream in
52
+ * `finalizeExecutedStep` before this gate runs, so that branch stays fail-open.)
53
+ *
44
54
  * Only a well-formed `complete: false` verdict blocks completion.
45
55
  */
46
- export async function validateStepSummary(input, judge) {
56
+ export async function validateStepSummary(input, judge, opts) {
57
+ const required = opts?.required === true;
47
58
  const criteria = input.completionCriteria.filter((c) => c.trim().length > 0);
48
59
  if (criteria.length === 0) {
49
60
  return { complete: true, missing: [], skipped: true };
@@ -56,11 +67,21 @@ export async function validateStepSummary(input, judge) {
56
67
  raw = await judge({ system: JUDGE_SYSTEM, user: buildUserPrompt({ ...input, completionCriteria: criteria }) });
57
68
  }
58
69
  catch {
59
- // LLM unreachable / errored fail open so offline use keeps working.
70
+ // LLM unreachable / errored. A REQUIRED gate must be judged block rather
71
+ // than pass an unjudged gate; a non-required gate fails open so offline use
72
+ // keeps working.
73
+ if (required) {
74
+ return { complete: false, missing: [], errored: true, feedback: REQUIRED_GATE_JUDGE_UNAVAILABLE_FEEDBACK };
75
+ }
60
76
  return { complete: true, missing: [], skipped: true };
61
77
  }
62
78
  const parsed = parseJsonResponse(raw);
63
79
  if (!parsed || typeof parsed.complete !== "boolean") {
80
+ // An unparseable / malformed verdict is a judge that did not actually judge:
81
+ // a required gate blocks; a non-required gate fails open.
82
+ if (required) {
83
+ return { complete: false, missing: [], errored: true, feedback: REQUIRED_GATE_JUDGE_UNAVAILABLE_FEEDBACK };
84
+ }
64
85
  return { complete: true, missing: [], skipped: true };
65
86
  }
66
87
  if (parsed.complete) {