akm-cli 0.9.2-alpha.4 → 0.9.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 (143) hide show
  1. package/CHANGELOG.md +493 -0
  2. package/STABILITY.md +23 -5
  3. package/dist/assets/hints/cli-hints-full.md +12 -7
  4. package/dist/assets/tasks/core/extract.yml +3 -5
  5. package/dist/assets/tasks/core/improve.yml +3 -5
  6. package/dist/assets/tasks/core/index-refresh.yml +3 -5
  7. package/dist/assets/tasks/core/sync.yml +3 -5
  8. package/dist/assets/tasks/core/version-check.yml +3 -5
  9. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +3 -5
  10. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -6
  11. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +3 -5
  12. package/dist/assets/tasks/improve/akm-improve-frequent.yml +3 -5
  13. package/dist/assets/tasks/improve/akm-improve-nightly.yml +3 -5
  14. package/dist/cli/unknown-flags.js +12 -1
  15. package/dist/cli.js +8 -1
  16. package/dist/commands/command/command-execution.js +23 -2
  17. package/dist/commands/health/improve-metrics.js +38 -0
  18. package/dist/commands/health/windows.js +8 -4
  19. package/dist/commands/health.js +8 -4
  20. package/dist/commands/lint/index.js +1 -1
  21. package/dist/commands/migrate-cli.js +130 -24
  22. package/dist/commands/proposal/validators/proposal-validators.js +7 -2
  23. package/dist/commands/tasks/explain.js +304 -0
  24. package/dist/commands/tasks/tasks-cli.js +185 -3
  25. package/dist/commands/tasks/tasks.js +265 -52
  26. package/dist/commands/workflow/plan.js +159 -0
  27. package/dist/commands/workflow-cli.js +94 -2
  28. package/dist/core/activation-policy.js +2 -12
  29. package/dist/core/adapter/adapters/akm-lint.js +7 -4
  30. package/dist/core/adapter/adapters/akm-metadata.js +26 -14
  31. package/dist/core/adapter/adapters/akm-task-adapter.js +13 -10
  32. package/dist/core/errors.js +45 -0
  33. package/dist/core/json-schema.js +15 -5
  34. package/dist/core/state/migrations.js +57 -0
  35. package/dist/core/state-db.js +16 -14
  36. package/dist/core/subprocess.js +47 -13
  37. package/dist/execution/guarded-source.js +44 -0
  38. package/dist/execution/input-contract.js +250 -0
  39. package/dist/execution/target-ref.js +63 -0
  40. package/dist/indexer/usage/usage-events.js +14 -3
  41. package/dist/integrations/agent/execution-lowering.js +12 -1
  42. package/dist/output/shapes/passthrough.js +2 -0
  43. package/dist/output/text/helpers.js +1 -1
  44. package/dist/output/text/migrate.js +12 -3
  45. package/dist/output/text/workflow-format.js +192 -10
  46. package/dist/output/text/workflow.js +2 -1
  47. package/dist/runtime.js +1 -0
  48. package/dist/scripts/akm-migrate-node.js +11838 -10118
  49. package/dist/scripts/akm-migrate.js +11828 -10117
  50. package/dist/setup/steps/tasks.js +34 -17
  51. package/dist/storage/repositories/task-history-repository.js +5 -1
  52. package/dist/storage/repositories/workflow-runs-repository.js +144 -6
  53. package/dist/tasks/backends/launchd.js +31 -84
  54. package/dist/tasks/embedded.js +13 -7
  55. package/dist/tasks/model/invocation.js +4 -0
  56. package/dist/tasks/prepare/prepare-script-target.js +9 -0
  57. package/dist/tasks/prepare/prepare-support.js +154 -0
  58. package/dist/tasks/prepare/prepare.js +117 -0
  59. package/dist/tasks/prepare/prepared-execution.js +4 -0
  60. package/dist/tasks/prepare/script-capture.js +80 -0
  61. package/dist/tasks/run/attempt-lifecycle.js +165 -0
  62. package/dist/tasks/run/load-task.js +117 -0
  63. package/dist/tasks/run/provenance.js +20 -0
  64. package/dist/tasks/run/run-command-task.js +92 -0
  65. package/dist/tasks/run/run-native-task.js +222 -0
  66. package/dist/tasks/run/run-task.js +99 -0
  67. package/dist/tasks/run/run-workflow-task.js +222 -0
  68. package/dist/tasks/run/task-history.js +134 -0
  69. package/dist/tasks/run/task-log.js +179 -0
  70. package/dist/tasks/run/task-result.js +19 -0
  71. package/dist/tasks/scheduler-binding.js +66 -2
  72. package/dist/tasks/scheduler-invocation.js +63 -3
  73. package/dist/tasks/scheduler-sync.js +77 -14
  74. package/dist/tasks/source/bounded-document.js +455 -0
  75. package/dist/tasks/source/parse-task-source.js +59 -0
  76. package/dist/tasks/source/project-v4.js +62 -0
  77. package/dist/tasks/source/task-input-diagnostics.js +36 -0
  78. package/dist/tasks/source/task-source-v4.js +626 -0
  79. package/dist/tasks/source-v3.js +10 -733
  80. package/dist/tasks/task-run-reserved-flags.js +79 -0
  81. package/dist/workflows/authoring/authoring.js +17 -8
  82. package/dist/workflows/exec/child-invocation.js +34 -0
  83. package/dist/workflows/exec/child-workflow.js +370 -0
  84. package/dist/workflows/exec/exec-unit.js +50 -170
  85. package/dist/workflows/exec/frozen-judge.js +19 -2
  86. package/dist/workflows/exec/native-executor.js +49 -27
  87. package/dist/workflows/exec/param-secrets.js +12 -0
  88. package/dist/workflows/exec/run-workflow.js +48 -59
  89. package/dist/workflows/exec/step-work.js +222 -80
  90. package/dist/workflows/exec/unit-dispatch.js +72 -0
  91. package/dist/workflows/freeze/child-output-references.js +94 -0
  92. package/dist/workflows/freeze/environment.js +174 -0
  93. package/dist/workflows/freeze/identity.js +22 -0
  94. package/dist/workflows/freeze/resolve-steps.js +78 -0
  95. package/dist/workflows/freeze/source-freeze.js +57 -0
  96. package/dist/workflows/freeze/step-values.js +68 -0
  97. package/dist/workflows/freeze/targets/child-workflow.js +206 -0
  98. package/dist/workflows/freeze/targets/command.js +81 -0
  99. package/dist/workflows/freeze/targets/script.js +57 -0
  100. package/dist/workflows/freeze/targets/shell.js +31 -0
  101. package/dist/workflows/freeze/targets/task.js +179 -0
  102. package/dist/workflows/freeze/task-bindings.js +180 -0
  103. package/dist/workflows/ir/compile.js +59 -11
  104. package/dist/workflows/ir/environment-v4.js +3 -3
  105. package/dist/workflows/ir/freeze-v4.js +41 -7
  106. package/dist/workflows/ir/params.js +58 -131
  107. package/dist/workflows/ir/plan-hash.js +3 -3
  108. package/dist/workflows/ir/schema-v4.js +246 -17
  109. package/dist/workflows/parser.js +74 -2
  110. package/dist/workflows/program/schema.js +5 -2
  111. package/dist/workflows/resource-limits.js +20 -0
  112. package/dist/workflows/runtime/plan-classifier.js +24 -7
  113. package/dist/workflows/runtime/run-outputs.js +103 -0
  114. package/dist/workflows/runtime/runs.js +114 -9
  115. package/dist/workflows/runtime/workflow-asset-loader.js +14 -6
  116. package/dist/workflows/source-files.js +5 -5
  117. package/dist/workflows/source-ir/compare.js +17 -0
  118. package/dist/workflows/source-ir/compile.js +7 -3
  119. package/dist/workflows/source-ir/github-yaml.js +64 -17
  120. package/dist/workflows/source-ir/schema.js +69 -21
  121. package/dist/workflows/source-ir/semantics.js +7 -25
  122. package/dist/workflows/source-ir/triggers.js +79 -0
  123. package/dist/workflows/source-ir/uses.js +33 -7
  124. package/docs/migration/README.md +1 -1
  125. package/docs/migration/release-notes/0.9.2.md +87 -11
  126. package/docs/migration/release-notes/README.md +3 -2
  127. package/docs/migration/v0.8-to-v0.9.md +13 -11
  128. package/docs/migration/v0.9.0-troubleshooting.md +20 -13
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +598 -49
  130. package/docs/reference/README.md +1 -1
  131. package/docs/reference/cli.md +140 -46
  132. package/docs/reference/configuration.md +6 -5
  133. package/docs/reference/supported-formats.md +9 -5
  134. package/docs/reference/tasks.md +338 -75
  135. package/docs/reference/workflow-schema.md +290 -16
  136. package/docs/reference/workflows.md +57 -7
  137. package/package.json +1 -1
  138. package/schemas/akm-task.json +173 -118
  139. package/schemas/akm-workflow.json +28 -0
  140. package/dist/tasks/runner.js +0 -941
  141. package/dist/tasks/runtime-v3.js +0 -281
  142. package/dist/workflows/ir/source-freeze-v4.js +0 -506
  143. package/dist/workflows/source-ir/ordering.js +0 -38
@@ -4,44 +4,21 @@
4
4
  /**
5
5
  * Shared step semantics — the ONE implementation of a step's orchestration
6
6
  * decisions, consumed by the engine loop (`run-workflow.ts` +
7
- * `native-executor.ts`) on both the fresh-execution and the resume/replay path.
8
- * The cardinal rule here is *no duplicated semantics*: work-list computation,
9
- * prompt assembly, reducer/artifact promotion, output-schema validation,
10
- * artifact-judged gate summaries, gate-feedback recovery, and route evaluation
11
- * live here so a first run and a resumed run of the same frozen plan produce
12
- * byte-identical unit graphs.
7
+ * `native-executor.ts`) on both the fresh-execution and the resume/replay
8
+ * path, so a first run and a resumed run of the same frozen plan produce
9
+ * byte-identical unit graphs. `computeStepWorkList` and its reducer/gate/route
10
+ * helpers are PURE (no clock, no IO, no journal read); the gate-evaluation
11
+ * journaling functions are the one deliberate exception. This module never
12
+ * dispatches a unit and never writes step rows.
13
13
  *
14
- * ## What is PURE here
15
- *
16
- * {@link computeStepWorkList} — given the frozen step plan and a
17
- * {@link WorkListInput} (params, prior step outputs, gate-loop number + its
18
- * recovered feedback) — is a pure function: same inputs ⇒ same unit ids, input
19
- * hashes, and fully-resolved prompts. It takes NO clock, NO IO, and NO journal
20
- * (journal-derived state, i.e. the recovered gate feedback, is passed in). This
21
- * is the load-bearing guarantee that a resumed run recomputes exactly the units
22
- * the original run dispatched, so journaled rows can be reused instead of
23
- * re-executed. So are the reducer/artifact helpers
24
- * ({@link buildEvidence}, {@link projectStepOutput}, {@link validateStepArtifact},
25
- * {@link buildArtifactSummary}), the gate-feedback recovery
26
- * ({@link recoverGateFeedback} / {@link activeGateLoop}), and route evaluation
27
- * ({@link evaluateRoute} and its bookkeeping).
28
- *
29
- * ## What does IO here
30
- *
31
- * The gate-evaluation journaling ({@link journalGateEvaluationStart} /
32
- * {@link journalGateEvaluationFinish}) writes `workflow_run_units` rows through
33
- * the serialized writer queue — an engine-driven judge call is an LLM call and
34
- * is journaled like a unit. It lives here (not in the engine loop) so every
35
- * caller journals gate evaluations through the identical writer.
36
- *
37
- * This module NEVER dispatches a unit and NEVER writes step rows: dispatch is
38
- * the executor's job (`native-executor.ts`), advancing the gated spine is the
39
- * engine loop's job (`run-workflow.ts` via `completeWorkflowStep`).
14
+ * See docs/architecture/decisions/0002-unit-reuse-and-input-hash-scope.md for
15
+ * the full purity-contract design history.
40
16
  */
41
17
  import { createHash, randomUUID } from "node:crypto";
42
18
  import unitPreambleTemplate from "../../assets/prompts/workflow-unit-preamble.md" with { type: "text" };
43
19
  import { UsageError } from "../../core/errors.js";
44
20
  import { validateJsonSchemaSubset } from "../../core/json-schema.js";
21
+ import { canonicalInputJson, validateInputs } from "../../execution/input-contract.js";
45
22
  import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
46
23
  import { canonicalJson as canonicalJsonString } from "../ir/plan-hash.js";
47
24
  import { parseReference, resolveReferenceString, } from "../program/expressions.js";
@@ -157,6 +134,54 @@ function truncatedReferenceTarget(reference, scope, resolved) {
157
134
  }
158
135
  return isTruncatedEvidence(current) ? current : undefined;
159
136
  }
137
+ /**
138
+ * Pre-attempt resolution of a task-composing step's frozen `inputBindings`
139
+ * (spec docs/plans/specs/p2b-input-bindings.md §3.6, B-31..B-34): a
140
+ * `{kind:"literal"}` passes through unchanged — its schema was already
141
+ * checked at FREEZE (`freezeTaskInputBindings`,
142
+ * `src/workflows/freeze/task-bindings.ts`), so it is never re-validated. A
143
+ * `{kind:"reference"}` resolves via the SAME {@link resolveStepReference}
144
+ * every other whole-value position uses, then validates the resolved value
145
+ * against the binding's own frozen `schema` — a mismatch (or a reference that
146
+ * fails to resolve at all) fails the WHOLE step here, before
147
+ * `reserveUnitAttempt` is ever called by the native executor. Absent
148
+ * `bindings` (the overwhelmingly common case — no `with:` on this step's
149
+ * target) resolves trivially to `{}` with no scope access at all.
150
+ */
151
+ function resolveTaskInputBindings(bindings, stepId, scope) {
152
+ if (!bindings || bindings.length === 0)
153
+ return { ok: true, values: {} };
154
+ const values = {};
155
+ for (const binding of bindings) {
156
+ if (binding.kind === "literal") {
157
+ values[binding.name] = binding.value;
158
+ continue;
159
+ }
160
+ const resolved = resolveStepReference(binding.from, scope);
161
+ if (!resolved.ok) {
162
+ return {
163
+ ok: false,
164
+ error: `Step "${stepId}" input "${binding.name}" reference ${binding.from} failed to resolve: ` +
165
+ resolved.error.message,
166
+ };
167
+ }
168
+ const errors = validateInputs({ [binding.name]: { schema: binding.schema, required: false } }, { [binding.name]: resolved.value },
169
+ // Fixed neutral namespace (matches `checkScheduleEntryRunnable`'s
170
+ // `pathRoot: "inputs"` and the `contractViolation` diagnostics' own
171
+ // `$`-strip) — NOT `binding.name`, which would double the input name
172
+ // (`count.count: ...`) since the outer message below already names it.
173
+ { pathRoot: "inputs" });
174
+ if (errors.length > 0) {
175
+ return {
176
+ ok: false,
177
+ error: `Step "${stepId}" input "${binding.name}" reference ${binding.from} resolved to a value violating its ` +
178
+ `declared schema: ${errors.join("; ")}`,
179
+ };
180
+ }
181
+ values[binding.name] = resolved.value;
182
+ }
183
+ return { ok: true, values };
184
+ }
160
185
  export function computeStepWorkList(plan, input) {
161
186
  const root = plan.root;
162
187
  // Route-only steps (YAML `route:`) carry no execution subgraph.
@@ -187,6 +212,19 @@ export function computeStepWorkList(plan, input) {
187
212
  }
188
213
  resolvedInputs.push({ reference, value: resolved.value });
189
214
  }
215
+ // P2b Lane A2 — pre-attempt resolution of a task-composing step's frozen
216
+ // `inputBindings` (spec §3.6, B-31..B-34): a `{kind:"literal"}` passes
217
+ // through unchanged (its schema was already checked at freeze, B-34); a
218
+ // `{kind:"reference"}` resolves against this SAME scope, then its resolved
219
+ // value is validated against the binding's own frozen `schema` — a
220
+ // mismatch fails the WHOLE step here, before `reserveUnitAttempt` is ever
221
+ // reached (B-32). This runs for every target kind (command/shell/script);
222
+ // Lane B's delivery consumes the result via `StepWorkUnitContext.taskInputs`
223
+ // / `taskInputsJson` below.
224
+ const taskInputsResolution = resolveTaskInputBindings(template.frozenTarget.inputBindings, plan.stepId, scope);
225
+ if (!taskInputsResolution.ok)
226
+ return taskInputsResolution;
227
+ const hasTaskInputs = Object.keys(taskInputsResolution.values).length > 0;
190
228
  // Resolve fan-out items: `over` is a single whole-value reference naming
191
229
  // its producer explicitly — no ambient key search.
192
230
  let items;
@@ -235,7 +273,18 @@ export function computeStepWorkList(plan, input) {
235
273
  // An exec unit's budget is frozen on its exec spec (there is no engine to
236
274
  // inherit one from); `ir/freeze.ts` resolved it once from unit `timeout:` →
237
275
  // `defaults.timeout` → DEFAULT_EXEC_TIMEOUT_MS.
238
- const timeoutMs = target.kind === "command" ? (target.runner.timeoutMs ?? null) : target.exec.timeoutMs;
276
+ const timeoutMs = target.kind === "command"
277
+ ? (target.runner.timeoutMs ?? null)
278
+ : target.kind === "child-workflow"
279
+ ? // A child-workflow target carries no exec spec of its own (§3.5).
280
+ // computeStepWorkList still builds this unit's context
281
+ // unconditionally — the child executor (child-workflow.ts,
282
+ // reached from native-executor.ts's dispatch seam, P3b §3.2) is
283
+ // what actually drives a child-workflow unit, not this line, so
284
+ // `null` only needs to be a value this layer can carry, never one
285
+ // an engine acts on.
286
+ null
287
+ : target.exec.timeoutMs;
239
288
  // Step-constant exec context: `AKM_PARAMS` / `AKM_INPUTS` depend only on
240
289
  // step-level values, so they are serialized ONCE here and shared by every
241
290
  // unit. Building them inside the per-unit loop deep-cloned and re-stringified
@@ -245,6 +294,11 @@ export function computeStepWorkList(plan, input) {
245
294
  const execInputsJson = frozenExec && resolvedInputs.length > 0
246
295
  ? (canonicalJson(Object.fromEntries(resolvedInputs.map((entry) => [entry.reference, entry.value]))) ?? "{}")
247
296
  : undefined;
297
+ // P2b Lane A2 (§3.6): the resolved effective task-composition inputs,
298
+ // serialized ONCE here (mirrors execParamsJson/execInputsJson above) —
299
+ // Lane B's delivery (buildUnitPrompt's "## Task inputs" block,
300
+ // buildExecContextEnv's AKM_TASK_INPUTS) reads both back per unit.
301
+ const taskInputsJson = hasTaskInputs ? (canonicalJson(taskInputsResolution.values) ?? "{}") : undefined;
248
302
  const ctx = {
249
303
  plan,
250
304
  input,
@@ -258,6 +312,7 @@ export function computeStepWorkList(plan, input) {
258
312
  ...(frozenExec ? { frozenExec } : {}),
259
313
  ...(execParamsJson !== undefined ? { execParamsJson } : {}),
260
314
  ...(execInputsJson !== undefined ? { execInputsJson } : {}),
315
+ ...(hasTaskInputs ? { taskInputs: taskInputsResolution.values, taskInputsJson } : {}),
261
316
  };
262
317
  const units = items.map((item, index) => buildStepWorkUnit(ctx, unitIds[index], item, index));
263
318
  const concurrency = root.kind === "map" ? root.concurrency : 1;
@@ -277,7 +332,7 @@ export function computeStepWorkList(plan, input) {
277
332
  * repo's 220-line function bar.
278
333
  */
279
334
  function buildStepWorkUnit(ctx, unitId, item, index) {
280
- const { plan, input, template, isFanOut, resolvedInputs, target, frozenExec } = ctx;
335
+ const { plan, input, template, isFanOut, resolvedInputs, target, frozenExec, taskInputs } = ctx;
281
336
  // Gate loops (>= 2) journal under `<unitId>~l<loop>` so loop 1's rows are
282
337
  // never clobbered; the content-derived identity (and the prompt's
283
338
  // {{UNIT_ID}}) stays the base id.
@@ -302,6 +357,9 @@ function buildStepWorkUnit(ctx, unitId, item, index) {
302
357
  params: input.params,
303
358
  ...(isFanOut ? { item, itemIndex: index } : {}),
304
359
  ...(resolvedInputs.length > 0 ? { inputs: resolvedInputs } : {}),
360
+ // P2b Lane B (§4.2, B-38/B-39): the composed task's resolved
361
+ // `inputBindings`, when non-empty — see StepWorkUnitContext.taskInputs.
362
+ ...(taskInputs && Object.keys(taskInputs).length > 0 ? { taskInputs } : {}),
305
363
  ...(input.gateFeedback ? { gateFeedback: input.gateFeedback } : {}),
306
364
  ...(template.schema ? { schema: template.schema } : {}),
307
365
  instructions: template.instructions,
@@ -324,6 +382,10 @@ function buildStepWorkUnit(ctx, unitId, item, index) {
324
382
  ...(template.retry ? { retry: template.retry } : {}),
325
383
  onError: template.onError,
326
384
  ...(template.isolation ? { isolation: template.isolation } : {}),
385
+ // P3b §3.3 step 2: the SAME resolved `with:` bindings `taskInputs` already
386
+ // carries, exposed under the name `child-workflow.ts`'s drive contract
387
+ // reads. Absent (never `{}`) when the step binds nothing.
388
+ ...(taskInputs && Object.keys(taskInputs).length > 0 ? { childParams: taskInputs } : {}),
327
389
  prompt,
328
390
  inputHash,
329
391
  };
@@ -345,7 +407,8 @@ function buildStepWorkUnit(ctx, unitId, item, index) {
345
407
  *
346
408
  * SIZE is not bounded here, on purpose. A workflow artifact has no bound
347
409
  * comparable to an OS environment entry, so `AKM_INPUTS` (and `AKM_PARAMS` /
348
- * `AKM_ITEM`) can serialize past what `execve` accepts and make PROCESS CREATION
410
+ * `AKM_ITEM` / `AKM_TASK_INPUTS`) can serialize past what `execve` accepts and
411
+ * make PROCESS CREATION
349
412
  * fail with a bare `E2BIG`. The check belongs at the spawn boundary, where the
350
413
  * failure can be journaled as a unit outcome with an actionable message naming
351
414
  * the variable: `checkExecContextSize` in `exec/exec-unit.ts`, against
@@ -369,54 +432,42 @@ function buildExecContextEnv(args) {
369
432
  }
370
433
  if (ctx.execInputsJson !== undefined)
371
434
  env.AKM_INPUTS = ctx.execInputsJson;
435
+ // P2b Lane B (spec §4.1, B-35/B-36/B-39, B-N1): ONE variable carrying the
436
+ // composed task's effective `inputBindings` as canonical JSON — never one
437
+ // var per input. Absent when the frozen target carries no `inputBindings`
438
+ // or every resolved value is empty. Sizing is enforced by the SAME generic
439
+ // `checkExecContextSize` loop as every other `AKM_*` entry (exec-unit.ts) —
440
+ // no change there, the roster is just longer by one name (B-37).
441
+ if (ctx.taskInputsJson !== undefined)
442
+ env.AKM_TASK_INPUTS = ctx.taskInputsJson;
372
443
  return env;
373
444
  }
374
445
  /**
375
- * The canonical dispatch-input envelope (reviewer finding #1). Every field here
376
- * is a PLAN-FROZEN input that changes what the backend is actually asked to do,
377
- * so a completed unit is reused ONLY when all of them match; a change to any of
378
- * them re-dispatches. `canonicalJsonString` sorts keys recursively, so the
379
- * preimage is order-independent, and this is the ONE place a unit's inputHash
380
- * is computed (every caller goes through {@link computeStepWorkList}) — a hash
381
- * that is byte-identical across a fresh run and a resume is structural, not
382
- * coincidental.
383
- *
384
- * Unit identity (workflow-format-unification, spec §2.3/§4) hashes the FROZEN
385
- * TEMPLATE BYTES (`template.instructions`, byte-exact, never an
386
- * instantiated/interpolated string) + the canonical item JSON + the
387
- * declared-input artifacts + the params snapshot instead of a
388
- * resolved/spliced prompt string, since there is no more splicing.
446
+ * The canonical dispatch-input envelope: every field here is an input that
447
+ * changes what the backend is actually asked to do, so a completed unit is
448
+ * reused ONLY when all of them match. `env` carries names only, never
449
+ * resolved secret values. `retry`/`onError` are deliberately excluded they
450
+ * govern failed-unit re-dispatch, not a completed unit's inputs/output.
451
+ * `gateFeedback` is included conditionally (a gate retry is a materially
452
+ * different ask). `taskInputs` is likewise included conditionally (R-R15,
453
+ * `hashVersion` 7): a reference binding's RESOLVED value reaches the unit's
454
+ * prompt / `AKM_TASK_INPUTS` / `childParams`, so a changed upstream value is a
455
+ * materially different ask even though the binding's authored shape inside
456
+ * `frozenTarget` is unchanged hashing it makes a resume whose journaled
457
+ * upstream output was altered fail loudly as replay divergence instead of
458
+ * silently reusing the stale row. The key is absent for a unit whose target
459
+ * carries no `inputBindings`, so a binding-free unit's preimage keeps the same
460
+ * shape it had (only the version fields moved 6 → 7). This is the ONE place a
461
+ * unit's inputHash is computed.
389
462
  *
390
- * Included beyond the template/runner/model/schema baseline: resolved
391
- * timeoutMs, named environment bindings, and isolation
392
- * each reaches dispatch and a changed one yields a materially different call.
393
- * `env` carries NAMES ONLY, never resolved values: hashing a resolved secret
394
- * would leak it into a durable hash oracle and would spuriously re-dispatch on
395
- * every secret rotation. `retry`/`onError` are DELIBERATELY excluded — they
396
- * govern failed-unit re-dispatch and step-level failure reduction, not a
397
- * COMPLETED unit's inputs/output, so a completed row stays valid across policy
398
- * changes.
399
- *
400
- * `gateFeedback` IS included (conditionally, so a no-feedback unit's preimage
401
- * is byte-identical to before): it is appended to the prompt by
402
- * `buildUnitPrompt`, so a gate loop's retry is materially a different ask than
403
- * the rejected attempt. Replay-safe: feedback is re-derived from the journaled
404
- * gate decision, so a resumed retry re-hashes identically.
405
- *
406
- * Command targets carry their frozen argv/script/cwd/timeout identity in the
407
- * same target slot used by agent, SDK, and direct-LLM work. The complete
408
- * current target is hashed once, so a completed unit is reused only for the
409
- * exact durable request that originally produced it.
410
- *
411
- * Ambient config is deliberately excluded because it is not consulted during
412
- * execution. The frozen target and named environment bindings are the runtime
413
- * identity boundary; only the values behind those names remain live.
463
+ * See docs/architecture/decisions/0002-unit-reuse-and-input-hash-scope.md for
464
+ * the full field-by-field inclusion/exclusion rationale (reviewer finding #1).
414
465
  */
415
466
  function computeUnitInputHash(ctx, item) {
416
467
  return createHash("sha256")
417
- .update("akm.workflow.unit\0v5\0")
468
+ .update("akm.workflow.unit\0v7\0")
418
469
  .update(canonicalJsonString({
419
- hashVersion: 5,
470
+ hashVersion: 7,
420
471
  role: "unit",
421
472
  stepId: ctx.plan.stepId,
422
473
  nodeId: ctx.template.id,
@@ -428,6 +479,7 @@ function computeUnitInputHash(ctx, item) {
428
479
  environment: ctx.template.environment,
429
480
  schema: ctx.template.schema ?? null,
430
481
  isolation: ctx.template.isolation ?? "none",
482
+ ...(ctx.taskInputs !== undefined ? { taskInputs: ctx.taskInputs } : {}),
431
483
  ...(ctx.input.gateFeedback ? { gateFeedback: ctx.input.gateFeedback } : {}),
432
484
  }))
433
485
  .digest("hex");
@@ -442,7 +494,7 @@ function computeUnitInputHash(ctx, item) {
442
494
  * here.
443
495
  */
444
496
  export function buildUnitPrompt(input) {
445
- const { runId, stepId, unitId, params, itemIndex, item, inputs, gateFeedback, schema, instructions } = input;
497
+ const { runId, stepId, unitId, params, itemIndex, item, inputs, taskInputs, gateFeedback, schema, instructions } = input;
446
498
  // Function replacements throughout: a string replacement would interpret
447
499
  // GetSubstitution patterns ($&, $$, $', $`) inside VALUES and silently
448
500
  // corrupt the prompt (e.g. a param value containing "$&").
@@ -460,6 +512,15 @@ export function buildUnitPrompt(input) {
460
512
  const inputsBlock = inputs && inputs.length > 0
461
513
  ? `\n\n## Declared inputs\n${inputs.map((i) => `### ${i.reference}\n${safeJson(i.value)}`).join("\n\n")}`
462
514
  : "";
515
+ // P2b Lane B (spec §4.2, B-38/B-39, B-N2): the composed task's resolved
516
+ // `inputBindings`, as a structured fenced JSON block — the same "attached
517
+ // context, never a splice" mechanism as itemBlock/inputsBlock above.
518
+ // `canonicalInputJson` (sorted keys) matches the AKM_TASK_INPUTS env var's
519
+ // own serialization, so the effective-inputs value reads identically on
520
+ // every delivery surface. Absent (or empty) appends nothing (B-39).
521
+ const taskInputsBlock = taskInputs && Object.keys(taskInputs).length > 0
522
+ ? `\n\n## Task inputs\nThe composed task's declared inputs resolved to:\n\`\`\`json\n${canonicalInputJson(taskInputs)}\n\`\`\``
523
+ : "";
463
524
  // Gate-loop feedback (R2 max_loops): the judge's rejection is appended so
464
525
  // the re-executed unit can address it — and so the input hash changes,
465
526
  // making the loop's re-dispatch natural instead of a durable-row reuse.
@@ -474,7 +535,7 @@ export function buildUnitPrompt(input) {
474
535
  const schemaDirective = schema
475
536
  ? `\n\nRespond with ONLY a JSON value matching this JSON Schema (no prose, no code fences):\n${safeJson(schema)}`
476
537
  : "";
477
- return `${preamble}\n${instructions}${itemBlock}${inputsBlock}${gateBlock}${schemaDirective}`;
538
+ return `${preamble}\n${instructions}${itemBlock}${inputsBlock}${taskInputsBlock}${gateBlock}${schemaDirective}`;
478
539
  }
479
540
  /**
480
541
  * Content-derived unit identity (module doc): `<node_id>:<sha256>` for a
@@ -706,7 +767,28 @@ export function reduceStepOutcomes(plan, reducer, isFanOut, onError, units) {
706
767
  artifactSchemaFailure = true;
707
768
  }
708
769
  }
709
- return { ok, units, evidence, summary, ...(artifactSchemaFailure ? { artifactSchemaFailure: true } : {}) };
770
+ // P3b §3.4: a composed child workflow that blocked is carried on the
771
+ // failed unit's LIVE-ONLY `childRun` field (child-workflow.ts's
772
+ // driveChildWorkflowUnit). Surfaced here, unconditionally on the unit
773
+ // list, so `finalizeExecutedStep` can check it before deciding whether
774
+ // this step's failure is retryable — an `onError: "continue"` step that
775
+ // tolerates the failure (`ok` stays true) never reaches that check at all.
776
+ const blockedChildUnit = failed.find((u) => u.failureReason === "child_workflow_blocked" && u.childRun !== undefined);
777
+ const childBlocked = blockedChildUnit?.childRun
778
+ ? {
779
+ childRunId: blockedChildUnit.childRun.runId,
780
+ childRef: blockedChildUnit.childRun.ref,
781
+ childStepId: blockedChildUnit.childRun.currentStepId,
782
+ }
783
+ : undefined;
784
+ return {
785
+ ok,
786
+ units,
787
+ evidence,
788
+ summary,
789
+ ...(artifactSchemaFailure ? { artifactSchemaFailure: true } : {}),
790
+ ...(childBlocked ? { childBlocked } : {}),
791
+ };
710
792
  }
711
793
  /**
712
794
  * The reduced outcome of a step whose fan-out list resolved to EMPTY (`over: []`
@@ -1183,6 +1265,56 @@ async function blockFinalizedStep(input, cause) {
1183
1265
  });
1184
1266
  return { kind: "judge-failed", summary };
1185
1267
  }
1268
+ /**
1269
+ * §3.4's exact `blockStepForChildWorkflow` notes — the ONE place the
1270
+ * blocked-child resume sequence is worded, mirroring {@link judgeFailureNotes}.
1271
+ * Two properties this wording pins (each its own test): the CHILD is resumed
1272
+ * FIRST, and the PARENT's own re-drive is what advances it (the child drive
1273
+ * never calls `resumeWorkflowRun` itself, row A-22); the notes name the child
1274
+ * run id and both commands verbatim, so the text renderer needs no change
1275
+ * (B-N15 — Lane A touches no output module).
1276
+ */
1277
+ function childWorkflowBlockedNotes(runId, stepId, childRunId, childRef, childStepId) {
1278
+ return (`Step "${stepId}" composes child workflow run ${childRunId} (${childRef}), ` +
1279
+ `which is blocked at its own step "${childStepId ?? "(unknown)"}". Nothing in this run advances ` +
1280
+ `until the child does — a gate is a gate for a child workflow too, so \`akm\` will ` +
1281
+ `not resume it for you. Clear it with \`akm workflow resume ${childRunId}\`, then ` +
1282
+ `\`akm workflow resume ${runId}\` and \`akm workflow run ${runId}\` to ` +
1283
+ `continue: re-driving the parent drives the resumed child.`);
1284
+ }
1285
+ /**
1286
+ * Complete a step `blocked` because the child workflow it composes is
1287
+ * blocked, and return the notes written (P3b §3.4). Sits beside
1288
+ * {@link blockStepForJudgeFailure} — the SAME shape of "infrastructure-like"
1289
+ * block: the step is completed `blocked`, and `akm workflow resume` is what
1290
+ * clears it (of the CHILD first, then the parent) rather than an automatic
1291
+ * in-step re-dispatch.
1292
+ */
1293
+ export async function blockStepForChildWorkflow(input) {
1294
+ const notes = childWorkflowBlockedNotes(input.runId, input.stepId, input.childRunId, input.childRef, input.childStepId);
1295
+ await completeWorkflowStep({
1296
+ runId: input.runId,
1297
+ stepId: input.stepId,
1298
+ status: "blocked",
1299
+ notes,
1300
+ ...(input.evidence !== undefined ? { evidence: input.evidence } : {}),
1301
+ ...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
1302
+ });
1303
+ return notes;
1304
+ }
1305
+ /** The finalize path's child-blocked write: the executed units' evidence (the composing unit) is preserved. */
1306
+ async function blockFinalizedStepForChildWorkflow(input, childBlocked) {
1307
+ const summary = await blockStepForChildWorkflow({
1308
+ runId: input.runId,
1309
+ stepId: input.stepId,
1310
+ childRunId: childBlocked.childRunId,
1311
+ childRef: childBlocked.childRef,
1312
+ childStepId: childBlocked.childStepId,
1313
+ evidence: input.result.evidence,
1314
+ ...(input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {}),
1315
+ });
1316
+ return { kind: "child-blocked", summary };
1317
+ }
1186
1318
  /**
1187
1319
  * Perform ONE completion attempt for an executed step:
1188
1320
  *
@@ -1210,6 +1342,12 @@ export async function finalizeExecutedStep(input) {
1210
1342
  const { runId, workflowRef, stepId, stepPlan, completionCriteria, gateLoop, loopsRemaining, result } = input;
1211
1343
  const lease = input.leaseHolder !== undefined ? { leaseHolder: input.leaseHolder } : {};
1212
1344
  if (!result.ok) {
1345
+ // P3b §3.4: a composed child workflow that blocked is never fed into the
1346
+ // bounded gate loop — a gate is a gate for a child workflow too. Checked
1347
+ // FIRST, before the artifactSchemaFailure retry branch below.
1348
+ if (result.childBlocked) {
1349
+ return blockFinalizedStepForChildWorkflow(input, result.childBlocked);
1350
+ }
1213
1351
  // Typed-artifact mismatch with loop budget left: regenerate-with-errors
1214
1352
  // (the validation errors become the next loop's feedback). No judge ran, so
1215
1353
  // no gate row is journaled for this attempt.
@@ -1290,10 +1428,14 @@ export async function finalizeExecutedStep(input) {
1290
1428
  engine: engineName,
1291
1429
  model: gateTarget.request.model?.resolved ?? null,
1292
1430
  runner: gateTarget.runner.kind,
1431
+ // The gate prefix rides the unit prefix's version — unit and gate
1432
+ // hashVersion are one vocabulary (p3a §0.1; the R-R15 fix moved
1433
+ // both 6 → 7 in lockstep even though the gate preimage's own
1434
+ // fields are unchanged).
1293
1435
  inputHash: createHash("sha256")
1294
- .update("akm.workflow.gate\0v5\0")
1436
+ .update("akm.workflow.gate\0v7\0")
1295
1437
  .update(canonicalJsonString({
1296
- hashVersion: 5,
1438
+ hashVersion: 7,
1297
1439
  dispatch: gateTarget,
1298
1440
  invocation: null,
1299
1441
  prompt,
@@ -35,12 +35,71 @@ export function prepareWorkflowExecution(request, prompt = request.prompt) {
35
35
  function message(err) {
36
36
  return err instanceof Error ? err.message : String(err);
37
37
  }
38
+ /**
39
+ * The `eventSource` value `dispatchWorkflowExecution` should forward into
40
+ * `dispatchLoweredExecutionRequest`'s options, or `undefined` to forward
41
+ * nothing.
42
+ *
43
+ * Precedence fix (P1b Lane C code review, round 2). The gap-fix originally
44
+ * forwarded `request.eventSource` unconditionally.
45
+ * `dispatchLoweredExecutionRequest` applies a forwarded value as `env: {
46
+ * ...lowered.options.env, AKM_EVENT_SOURCE: eventSource }`
47
+ * (execution-lowering.ts:998-1001) — an unconditional override of that one
48
+ * key — and `lowered.options.env` IS the unit's own authored/resolved `env:`
49
+ * binding (`request.env`, folded in by `prepareWorkflowExecution` above via
50
+ * `request.runtime.environment`), so the unconditional forward let the
51
+ * provenance stamp win over an authored `env: { AKM_EVENT_SOURCE: ... }`
52
+ * binding. That inverts the precedence pre-P1b had (the child env was built
53
+ * from ambient passthrough with `options.env` — the authored binding —
54
+ * applied AFTER it, at highest precedence, in `buildChildEnv`/`spawn.ts`) and
55
+ * disagrees with the sibling "script"/"shell" arm: `exec-unit.ts`'s own
56
+ * `childEnv` stamps its allowlisted base only when the name is absent there,
57
+ * strictly BEFORE the bindings overlay runs, so an authored binding always
58
+ * wins there. Gating the forward on `request.env` not already binding the
59
+ * name restores agreement: an authored binding leaves `eventSource`
60
+ * unforwarded (so the merge above never touches the key, and the authored
61
+ * value in `lowered.options.env` stands), while an absent binding still
62
+ * forwards the resolved value exactly as before.
63
+ *
64
+ * Exported so this precedence rule is pinned directly:
65
+ * `dispatchWorkflowExecution` itself has no injectable
66
+ * `runAgent`/`executeRunner`/`chat` seam to exercise the decision end-to-end
67
+ * without a live agent/LLM dispatch (see the P1b spec's Review log, which
68
+ * records the same constraint for the gap-fix this corrects).
69
+ */
70
+ export function forwardedDispatchEventSource(request) {
71
+ if (request.eventSource === undefined)
72
+ return undefined;
73
+ if (request.env?.AKM_EVENT_SOURCE !== undefined)
74
+ return undefined;
75
+ return request.eventSource;
76
+ }
38
77
  /**
39
78
  * Dispatch one frozen workflow engine call through the common prepared/lowered
40
79
  * seam. Credential materialization happens inside the final dispatch only.
41
80
  */
42
81
  export async function dispatchWorkflowExecution(request, feedback) {
43
82
  const prompt = feedback ? `${request.prompt}\n\n${feedback}` : request.prompt;
83
+ // B-N11 (P3b, spec docs/plans/specs/p3b-child-executor.md §1.6): an
84
+ // internal-invariant guard, not a user-facing one. `dispatchJournaledAttempt`
85
+ // (native-executor.ts, P3b §3.2) routes a `child-workflow` unit to the child
86
+ // executor (child-workflow.ts) BEFORE dispatch is ever reached, so arriving
87
+ // HERE with one means that seam was BYPASSED — an engine routing bug, never
88
+ // a not-yet-implemented feature (that premise, P3a Review log R8's, is gone
89
+ // now that P3b ships a production caller). A plain `Error` naming the seam
90
+ // that should have been reached instead, not a `UsageError`: nothing a user
91
+ // can author reaches this line once the seam exists, so there is no
92
+ // user-facing code to carry. Kept here, rather than deleted outright, so a
93
+ // bypassed seam still fails closed instead of falling into the generic
94
+ // `kind !== "command"` guard below, which would blame a legitimate target
95
+ // kind as "not a command target" — the exact false, unhelpful message R8
96
+ // was opened to remove.
97
+ if (request.frozenTarget.kind === "child-workflow") {
98
+ throw new Error(`unit ${JSON.stringify(request.unitId)} targets child workflow ${JSON.stringify(request.frozenTarget.ref)}, ` +
99
+ "but reached dispatchWorkflowExecution directly. The child-workflow dispatch seam " +
100
+ "(src/workflows/exec/child-workflow.ts) should have routed it before dispatch was ever reached — " +
101
+ "this is an engine routing bug, not a problem with the workflow itself.");
102
+ }
44
103
  if (request.frozenTarget.kind !== "command") {
45
104
  throw new ConfigError(`unit ${JSON.stringify(request.unitId)} is not a command target.`, "INVALID_CONFIG_FILE");
46
105
  }
@@ -68,12 +127,25 @@ export async function dispatchWorkflowExecution(request, feedback) {
68
127
  }
69
128
  let result;
70
129
  try {
130
+ const eventSource = forwardedDispatchEventSource(request);
71
131
  result = await dispatchLoweredExecutionRequest(lowered, {
72
132
  runOptions: {
73
133
  stdio: "captured",
74
134
  parseOutput: "text",
75
135
  ...(request.signal ? { signal: request.signal } : {}),
76
136
  },
137
+ // Gap fix (P1b Lane C code review, spec §5.2(2)); precedence-gated
138
+ // (round 2, see forwardedDispatchEventSource above): forward the
139
+ // resolved provenance event source so an "agent"/"sdk" unit's
140
+ // dispatched child env carries AKM_EVENT_SOURCE too, not only a
141
+ // "script"/"shell" unit's — but only when the unit's own authored
142
+ // `env:` binding does not already set the name, so an authored binding
143
+ // still wins, mirroring exec-unit.ts's childEnv guard.
144
+ // dispatchLoweredExecutionRequest applies a forwarded value as exactly
145
+ // one child-env key (execution-lowering.ts:998-1001) — the same
146
+ // mechanism the R-07 command-arm fix (command-execution.ts) already
147
+ // uses.
148
+ ...(eventSource !== undefined ? { eventSource } : {}),
77
149
  });
78
150
  }
79
151
  catch (err) {
@@ -0,0 +1,94 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The freeze-time child-output reference check (P3b, spec docs/plans/specs/
6
+ * p3b-child-executor.md §4.4, rows B-28…B-32).
7
+ *
8
+ * A parent step may read `steps.<child>.output(.<name>)*` where step
9
+ * `<child>`'s frozen target is `kind: "child-workflow"`. Only the FIRST path
10
+ * segment is a freeze-time concern: it must name one of the child's declared
11
+ * `outputs:` names, or — when the child declares none — `runId` or `status`
12
+ * (the default `workflowRunExportedResult` shape, `runtime/run-outputs.ts`).
13
+ * A reference AT `steps.<child>.output` with no further segment always
14
+ * accepts — it names the whole exported object. Anything deeper resolves (and,
15
+ * if wrong, fails) at pre-attempt through the existing, unchanged resolver —
16
+ * the value's shape past the first segment is unconstrained unless the
17
+ * output declares a `schema:`.
18
+ *
19
+ * Pure over the frozen step list — no IO, no config.
20
+ */
21
+ import { UsageError } from "../../core/errors.js";
22
+ import { formatReference, parseReference } from "../program/expressions.js";
23
+ /** The frozen target a step's root unit (or map template) dispatches, if any. */
24
+ function stepFrozenTarget(step) {
25
+ const root = step?.root;
26
+ if (!root)
27
+ return undefined;
28
+ const unit = root.kind === "map" ? root.template : root;
29
+ return unit.frozenTarget;
30
+ }
31
+ /** Every reference string a step's `inputs[]`, `map.over`, `route.input`, and reference-kind `inputBindings[].from` carry. */
32
+ function collectReferenceSites(step) {
33
+ const sites = [];
34
+ const push = (reference) => {
35
+ sites.push({ stepId: step.stepId, reference });
36
+ };
37
+ if (step.route)
38
+ push(step.route.input);
39
+ const root = step.root;
40
+ if (root) {
41
+ const unit = root.kind === "map" ? root.template : root;
42
+ if (root.kind === "map")
43
+ push(root.over);
44
+ for (const reference of unit.inputs ?? [])
45
+ push(reference);
46
+ const bindings = unit.frozenTarget.inputBindings ?? [];
47
+ for (const binding of bindings) {
48
+ if (binding.kind === "reference")
49
+ push(binding.from);
50
+ }
51
+ }
52
+ return sites;
53
+ }
54
+ /** The names a `child-workflow` target's own exported result carries at its first segment. */
55
+ function acceptedFirstSegments(target) {
56
+ const declared = target.frozenPlan.outputs;
57
+ return declared ? Object.keys(declared) : ["runId", "status"];
58
+ }
59
+ function exportsDescription(target) {
60
+ const declared = target.frozenPlan.outputs;
61
+ return declared ? `outputs: ${Object.keys(declared).join(", ")}` : "only {runId, status} — it declares no `outputs:`";
62
+ }
63
+ function checkSite(site, stepsById) {
64
+ const parsed = parseReference(site.reference);
65
+ if (!parsed.ok || parsed.expr.kind !== "stepOutput")
66
+ return;
67
+ const targetStep = stepsById.get(parsed.expr.stepId);
68
+ const frozenTarget = stepFrozenTarget(targetStep);
69
+ if (!frozenTarget || frozenTarget.kind !== "child-workflow")
70
+ return;
71
+ const first = parsed.expr.path[0];
72
+ if (first === undefined)
73
+ return; // bare steps.<child>.output — the whole exported object, always accepted.
74
+ const accepted = acceptedFirstSegments(frozenTarget);
75
+ if (typeof first === "string" && accepted.includes(first))
76
+ return;
77
+ throw new UsageError(`Workflow step ${site.stepId} reads "${formatReference(parsed.expr)}", but child workflow ${frozenTarget.ref} ` +
78
+ `exports ${exportsDescription(frozenTarget)}. Declare the output in the child's \`outputs:\` frontmatter, or ` +
79
+ `reference one of the names above.`, "COMPOSITION_INVALID", `Fix the reference to name one of: ${accepted.join(", ")} — or add the missing name to ${frozenTarget.ref}'s ` +
80
+ "own `outputs:` frontmatter.");
81
+ }
82
+ /**
83
+ * Assert every reference into a `child-workflow`-targeted step's output
84
+ * names an output the child actually exports. Throws `UsageError`
85
+ * (`COMPOSITION_INVALID`) on the first violation found.
86
+ */
87
+ export function assertChildOutputReferences(steps) {
88
+ const stepsById = new Map(steps.map((step) => [step.stepId, step]));
89
+ for (const step of steps) {
90
+ for (const site of collectReferenceSites(step)) {
91
+ checkSite(site, stepsById);
92
+ }
93
+ }
94
+ }