akm-cli 0.9.0 → 0.9.1-beta.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 (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -0,0 +1,1014 @@
1
+ # Workflow Schema
2
+
3
+ This is the authoritative reference for a workflow asset's exact frontmatter
4
+ and body syntax — every key, its shape, and the validation rules enforced by
5
+ the published JSON Schema (`schemas/akm-workflow.json`) and
6
+ `akm lint --type workflows`. Other pages link here instead of reproducing this
7
+ detail.
8
+
9
+ - For a task-oriented walkthrough of writing your first workflow, see the
10
+ [Author's Guide](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md).
11
+ - For how a frozen plan actually executes — persistence, dispatch, resume —
12
+ see [Architecture: The Workflow Engine](https://github.com/itlackey/akm/blob/main/docs/architecture/workflow-engine.md).
13
+ - For operating a run day to day (`run`, `status`, `resume`, `abandon`), see
14
+ [Running Workflows](https://github.com/itlackey/akm/blob/main/docs/guides/run-workflows.md).
15
+
16
+ ## One format
17
+
18
+ A workflow is an ordinary AKM markdown asset — the same envelope as every
19
+ other type, OKF-conformant frontmatter plus a markdown body — whose
20
+ frontmatter carries the entire orchestration graph (params, and how each step
21
+ dispatches, fans out, routes, and gates) and whose body carries each step's
22
+ instructions and gate rubric under plain headings, joined to the frontmatter
23
+ by step id. There is **one** format: no separate YAML "program" surface, no
24
+ `.yaml`/`.yml` workflow files.
25
+
26
+ ## Frontmatter keys
27
+
28
+ Frontmatter is the standard AKM asset envelope (`type`, `description`, `tags`,
29
+ `when_to_use`, `xrefs`, `updated`/`timestamp`, and the OKF v0.2 trust/lifecycle
30
+ families) plus the orchestration keys:
31
+
32
+ - `params` — name → `{ type, description }` (JSON-Schema-typed, unlike a bare
33
+ description string).
34
+ - `defaults` — run-level dispatch defaults (`engine`, `model`, `llm`,
35
+ `timeout`, `on_error`), overridable per unit. `defaults.llm` is the
36
+ exception: `llm:` tuning applies only to engines of kind `llm`, and a
37
+ document-level `llm:` reaches EVERY step, so a document that also has a step
38
+ on an agent engine fails to freeze — naming the step and the engine — rather
39
+ than dropping the settings for that step. There is no per-step opt-out (`llm:
40
+ {}` is a no-op and `llm: null` is a parse error), so in a mixed document put
41
+ `llm:` on the `unit:` of each LLM step instead of in `defaults:`.
42
+ - `budget` — run-lifetime ceilings (`max_units`, `max_tokens`; see
43
+ [Budget ceilings](#budget-ceilings) below).
44
+ - `steps` — an ordered list. Each step has an `id`
45
+ (`[A-Za-z_][A-Za-z0-9_-]*` — no dots) and **at most one** of `unit`, `map`,
46
+ or `route`. A step with neither is **still a unit step** — bare
47
+ `- id: validate` is the complete minimal declaration. `unit:` is the
48
+ optional dispatch-override bag (`exec`, `engine`, `model`, `llm`, `timeout`,
49
+ `retry`, `on_error`, `env`, `isolation`; see
50
+ [Exec (shell) units](#exec-shell-units),
51
+ [Failure policy](#failure-policy) and
52
+ [Worktree isolation](https://github.com/itlackey/akm/blob/main/docs/architecture/workflow-engine.md#worktree-isolation)).
53
+ - `inputs` — on a `unit`/`map` step, the prior-step artifacts this step
54
+ consumes, as bare reference strings (sub-paths legal:
55
+ `steps.x.output.issues`, not just `steps.x.output`). This is how a step's
56
+ attached context sees upstream data, and how replay hashing gets its exact
57
+ input set — a step re-dispatches only when the slice it actually consumes
58
+ changes.
59
+ - `output` — a JSON Schema for the step's promoted artifact.
60
+ - `gate` — optional validation-loop configuration: `max_loops` bounds
61
+ evaluator-optimizer retries (see
62
+ [Gates and verification](#gates-and-verification)). The rubric itself lives
63
+ in the body's `### gate` section. Without non-empty rubric text, the
64
+ configuration is inert.
65
+
66
+ No `version:`/`name:` keys — identity is the ref, and the frozen plan already
67
+ versions execution semantics — and no step titles anywhere: a step is its id,
68
+ and the asset's human name is its `description` and H1 like any other asset
69
+ type.
70
+
71
+ ## Body structure and rules
72
+
73
+ Checked by `akm lint --type workflows`:
74
+
75
+ 1. Every level-2 heading must be `## <step-id>` for a step declared in
76
+ frontmatter, exactly — no titles, no `Step:`/`Step ID:` lines, no
77
+ `# Workflow:` prefix on the H1. (Fenced code blocks are skipped when
78
+ scanning for headings.)
79
+ 2. A `unit` or `map` step **must** have a body section — its instructions,
80
+ or its per-item template for a map step, byte-exact to the next H2 or
81
+ EOF. A `route` step **may** have one (documentation, plus a gate rubric
82
+ if it is gated). Everything before the first H2 is free preamble —
83
+ indexed for search, shown in `akm show`, never dispatched.
84
+ 3. Inside a step's section, an optional `### gate` sub-heading starts that
85
+ step's gate rubric, running to the section end — the format's **single
86
+ reserved marker**. The judge that evaluates the step receives this whole
87
+ section byte-exact. An omitted or empty `### gate` section needs no
88
+ verification. A non-empty rubric enables mandatory fail-closed
89
+ verification; frontmatter `gate:` only tunes its retry bound.
90
+
91
+ Prose is never templated — see [The reference grammar](#the-reference-grammar)
92
+ for how a step's instructions refer to run params, upstream artifacts, and a
93
+ map unit's item.
94
+
95
+ ## Minimal example
96
+
97
+ ```markdown
98
+ ---
99
+ type: workflow
100
+ description: Ship a tagged release to production
101
+ params:
102
+ version: { type: string, description: The semver version string to release }
103
+ steps:
104
+ - id: validate
105
+ - id: build
106
+ inputs: [steps.validate.output]
107
+ ---
108
+
109
+ # Ship Release
110
+
111
+ ## validate
112
+
113
+ Check that the `version` parameter follows semver and the tag does not
114
+ already exist.
115
+
116
+ ### gate
117
+
118
+ - `git tag v<version>` does not already exist.
119
+ - The version string matches `^\d+\.\d+\.\d+$`.
120
+
121
+ ## build
122
+
123
+ Run `npm run build && npm test`, using the validation from `validate`,
124
+ attached to this unit as input. Fix any failures before proceeding.
125
+ ```
126
+
127
+ ## Richer example
128
+
129
+ Fan-out, routing, retries, gates, and a run budget:
130
+
131
+ ```markdown
132
+ ---
133
+ type: workflow
134
+ description: Review changed files and route the outcome
135
+ params:
136
+ changed_files: { type: array, description: Files to review }
137
+ defaults: { engine: reviewer, model: balanced, timeout: 10m, on_error: fail }
138
+ budget: { max_units: 40, max_tokens: 200000 }
139
+ steps:
140
+ - id: discover
141
+ output: { type: object, properties: { files: { type: array } }, required: [files] }
142
+ - id: review
143
+ map:
144
+ over: steps.discover.output.files
145
+ concurrency: 8
146
+ unit:
147
+ engine: reviewer
148
+ model: deep
149
+ timeout: 5m
150
+ retry: { max: 1, on: [timeout, llm_rate_limit] }
151
+ on_error: continue
152
+ isolation: worktree
153
+ output: { type: object, properties: { file: { type: string }, verdict: { type: string } }, required: [file, verdict] }
154
+ # `output` here describes the REDUCER RESULT, not one unit's result: the
155
+ # default `collect` reducer folds per-item unit results into an array.
156
+ output: { type: array }
157
+ gate: { max_loops: 2 }
158
+ - id: aggregate
159
+ inputs: [steps.review.output]
160
+ output: { type: object, properties: { verdict: { type: string } }, required: [verdict] }
161
+ - id: triage
162
+ route:
163
+ input: steps.aggregate.output.verdict
164
+ when: [{ match: pass, step: ship }, { match: fail, step: rework }]
165
+ default: manual-triage
166
+ - id: ship
167
+ - id: rework
168
+ - id: manual-triage
169
+ ---
170
+
171
+ # Review Changes
172
+
173
+ ## discover
174
+
175
+ List the files that need review, drawn from the `changed_files` parameter.
176
+
177
+ ### gate
178
+
179
+ Every file named by `changed_files` is listed in the reported result.
180
+
181
+ ## review
182
+
183
+ This section is the **map unit template** — the engine attaches each unit's
184
+ item (the file to review) and its index as context; instructions refer to
185
+ "the file you were given," never a template expression.
186
+
187
+ Review the file you were given for correctness bugs.
188
+
189
+ ### gate
190
+
191
+ Every changed file has a verdict of `pass` or `fail`.
192
+
193
+ ## aggregate
194
+
195
+ Combine the per-file review verdicts — attached to this unit as input via
196
+ `inputs: [steps.review.output]` above — into one overall verdict, `pass` or
197
+ `fail`.
198
+
199
+ ## triage
200
+
201
+ Routes on the verdict `aggregate` reported: `pass` proceeds to `ship`, `fail`
202
+ proceeds to `rework`, anything else goes to `manual-triage`.
203
+
204
+ ## ship
205
+
206
+ Ship the change.
207
+
208
+ ## rework
209
+
210
+ Address the review findings. Confirming the fix is a fresh `akm workflow run`
211
+ of this workflow, not a step this run routes back to.
212
+
213
+ ## manual-triage
214
+
215
+ Summarize the ambiguous verdict for a human to triage.
216
+ ```
217
+
218
+ ## The reference grammar
219
+
220
+ Workflow prose is **never templated** — there is no `${{ … }}`/`{{ … }}`
221
+ interpolation anywhere in a workflow's body, and no escape syntax to learn,
222
+ because there are no delimiters in prose to escape.
223
+
224
+ Bare reference strings appear in exactly three frontmatter positions, each an
225
+ unquoted-style YAML string:
226
+
227
+ | Position | What it names |
228
+ | --- | --- |
229
+ | `map.over` | The list a map step fans out over. |
230
+ | `route.input` | The value a route step matches on. |
231
+ | `inputs` (each entry) | A prior step's artifact this step consumes. |
232
+
233
+ Every reference resolves against exactly two roots:
234
+
235
+ | Reference | Meaning |
236
+ | --- | --- |
237
+ | `params.<name>` | A run parameter, by name. |
238
+ | `steps.<id>.output( .<ident> \| [<int>] )*` | A prior step's artifact, addressed by producer step id; the path walks properties (`.name`) and array indexes (`[0]`). |
239
+
240
+ Nothing else parses: no functions, no clock, no randomness, no ambient
241
+ lookup. `item` and `item_index` are **not** part of the language — a map
242
+ unit's item and its index are never referenced from anywhere in frontmatter
243
+ or body. They arrive as **attached context** instead, the same way as
244
+ everything else a unit needs.
245
+
246
+ **Context attachment, not string splicing.** Each dispatched unit receives,
247
+ alongside its byte-exact instructions, structured context:
248
+
249
+ - every run **param** (params are run-scoped — see
250
+ [Params are not secret](#params-are-not-secret) below);
251
+ - for a **map** unit, its **item** and **item index**;
252
+ - the artifacts named by its step's **`inputs:`**.
253
+
254
+ Instructions refer to this context in plain language — "clone the repository
255
+ named by the `repo` parameter," "review the file you were given," "using the
256
+ intake step's artifact attached to this unit" — never by splicing a value
257
+ into the instruction string. This closes the injection class at the root:
258
+ data never enters the instruction string, spliced or otherwise.
259
+
260
+ `akm lint --type workflows` still checks every bare reference statically —
261
+ unknown step, unknown param, bad path — at lint time.
262
+
263
+ ### Params are not secret
264
+
265
+ Run params are copied verbatim into every unit's dispatched instructions and
266
+ are part of the unit's content-derived input hash — the same hash that makes
267
+ resume-without-replay possible (see
268
+ [Resume is journaled replay](https://github.com/itlackey/akm/blob/main/docs/architecture/workflow-engine.md#resume-is-journaled-replay)).
269
+ Redacting a param would change what gets hashed and make a resumed run
270
+ diverge from the original, so params are **declared non-secret and
271
+ un-redactable** by design: secrets belong in `env:` refs instead, which carry
272
+ by name only through the plan and are resolved from akm's env/secret store
273
+ at dispatch (see [Reference: Env & Secrets](https://github.com/itlackey/akm/blob/main/docs/reference/env-and-secrets.md)).
274
+
275
+ As a best-effort guardrail, `akm workflow run` scans a new run's params for
276
+ values that *look* like credentials — secret-suggesting key names (`token`,
277
+ `password`, `apikey`, `credential`, …) or long, high-entropy strings matching
278
+ known token prefixes — and surfaces a warning naming the param path and
279
+ recommending an `env:` ref instead. This is advisory only: it never blocks a
280
+ run and never mutates params, and false positives/negatives are expected.
281
+
282
+ ## What a step's output is
283
+
284
+ `steps.<id>.output` resolves to the value the step's execution produced:
285
+
286
+ - a `unit` step → the unit's structured result (when the unit declares
287
+ `output`) or its text;
288
+ - a `map` step → the collected array of per-item results, in item order
289
+ (under `on_error: continue`, a failed item's slot is `null`), unless the
290
+ step's own `output` schema describes a reduced, single-value shape instead;
291
+ - an [exec unit](#exec-shell-units) → its stdout (trailing newlines stripped),
292
+ or the JSON value stdout parsed to when the unit declares an `output` schema.
293
+
294
+ **An empty successful free-text output is treated as no output.** When a
295
+ schemaless unit (one that declares no `output` schema) succeeds but returns
296
+ the empty string, akm normalizes it to *absent*: nothing is journaled for its
297
+ result, and its contribution to the step artifact is `null` — a `null` slot
298
+ in a collected array, or `output = null` for a solo step. This absence is
299
+ deliberate, so a live run and a resumed run promote the identical artifact.
300
+ The practical consequence: a downstream step that declares an empty upstream
301
+ result in its `inputs:` gets nothing meaningful attached for it — akm
302
+ surfaces this loudly rather than silently attaching an empty string. A unit
303
+ that declares an `output` schema is unaffected — an empty response is not
304
+ valid JSON, so it fails as a parse error and can never satisfy a schema as a
305
+ silent `null`.
306
+
307
+ ## Typed step artifacts
308
+
309
+ When a step declares `output`, the promoted step artifact (the unit's
310
+ structured result, the collected array, or a reduced single value — see
311
+ [What a step's output is](#what-a-steps-output-is) above) is validated
312
+ against that schema **before** the step can complete. A mismatch fails the
313
+ step with the validation errors in its summary. This is fail-fast on purpose:
314
+ a bounded gate loop (see [Gates and verification](#gates-and-verification))
315
+ can re-run the step with those errors as corrective feedback.
316
+
317
+ ### The enforced JSON Schema subset
318
+
319
+ `output` and `params` schemas are validated **as schemas** at parse time
320
+ (`akm lint --type workflows`, `akm workflow create`), because the runtime
321
+ enforces only a subset of JSON Schema:
322
+
323
+ `type`, `enum`, `properties`, `required`, `items`, `additionalProperties:
324
+ false`, `minItems`, `maxItems`, `minLength`, `maxLength`, `minimum`,
325
+ `maximum`, `allOf`, `anyOf`, `oneOf`, `not`.
326
+
327
+ Anything outside it is an authoring **error**, not a silent no-op. A typo'd
328
+ type name (`type: strig`) and a recognized-but-unenforced keyword (`$ref`,
329
+ `$defs`, `const`, `pattern`, `format`, `patternProperties`, `if`/`then`/`else`,
330
+ `uniqueItems`, `multipleOf`, `exclusiveMinimum`/`exclusiveMaximum`,
331
+ tuple-form `items`, schema-form `additionalProperties`, …) both fail with the
332
+ offending keyword named, a suggested replacement where one exists (`const` →
333
+ a single-value `enum`; `$ref` → inline the schema), and the location anchored
334
+ to the line. A gate that depends on a schema constraining nothing is worse
335
+ than a loud failure. Annotation keywords (`description`, `title`, `default`,
336
+ `examples`) constrain nothing in full JSON Schema either, so they pass through
337
+ untouched.
338
+
339
+ `pattern` is among the unsupported keywords. Matching an author-supplied regex
340
+ inside a synchronous gate decision would have to be bounded before the match
341
+ starts — a static safety analysis — and any such analysis also refuses regexes
342
+ authors legitimately write. Rather than carry machinery that fails authoring
343
+ for no benefit, the subset does not evaluate `pattern` at all and says so at
344
+ the point of authoring. Where a string's shape matters, list the allowed values
345
+ with `enum`, bound its size with `minLength`/`maxLength`, or check the shape in
346
+ the step's gate rubric, which can explain a mismatch in a way a regex cannot.
347
+
348
+ Evaluation is bounded: schema nesting is capped at 64 levels and one validation
349
+ may make at most 100 000 checks. Exhausting either is reported as an error — a
350
+ truncated evaluation never reports a value as valid.
351
+
352
+ ### Bounds
353
+
354
+ These are enforced identically by the parser, the published JSON Schema, and
355
+ the frozen-plan decoder (they share one set of constants in
356
+ `src/workflows/resource-limits.ts`), so a document that lints clean cannot
357
+ fail later at `akm workflow run`:
358
+
359
+ | Field | Bound |
360
+ | --- | --- |
361
+ | `gate.max_loops` | 1 – 100 |
362
+ | `map.concurrency` | 1 – 64 |
363
+ | `retry.max` | 0 – 100 |
364
+ | `timeout` | ≤ 2147483647 ms (~24.8 days), or `none` |
365
+ | `engine` names | `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`, ≤ 63 chars |
366
+
367
+ `timeout` is resolved **once, at freeze time**, and the frozen value is what
368
+ dispatch applies — there is no separate engine-side ceiling on top of it. The
369
+ first of these that is set wins: the unit's `timeout`, then the document's
370
+ `defaults.timeout`, then `engines.<name>.timeoutMs`, then the engine-kind
371
+ default — **10m for `kind: llm` engines, and none for agent engines**, which
372
+ manage their own process lifetime. Writing `timeout: none` is an explicit opt
373
+ out and leaves the unit genuinely unbounded; nothing later re-imposes a cap.
374
+
375
+ ## Exec (shell) units
376
+
377
+ A step whose `unit:` block declares `exec:` runs a **shell command** instead of
378
+ dispatching to an engine. Deterministic work — running the test suite, building,
379
+ linting, copying a file, invoking a script — is a command, not a prompt, and
380
+ paying for an LLM or agent dispatch to get it done buys nondeterminism, latency,
381
+ and tokens in exchange for nothing.
382
+
383
+ ```yaml
384
+ steps:
385
+ - id: test
386
+ unit:
387
+ exec:
388
+ command: ["bun", "run", "test:unit"]
389
+ cwd: packages/core # optional, relative
390
+ timeout: "10m"
391
+ retry: { max: 1, on: [timeout] }
392
+ ```
393
+
394
+ An exec unit names **no engine**. It carries no `engine`, `model`, or `llm` (the
395
+ parser rejects those alongside `exec:`), it consumes no tokens, and a workflow
396
+ made only of exec steps freezes and runs on an install with no engine configured
397
+ at all. Everything else about a unit still applies unchanged: `timeout`, `retry`,
398
+ `on_error`, `output`, `env`, `isolation`, `map` fan-out and its concurrency, the
399
+ unit journal, budget accounting, and replay/reuse.
400
+
401
+ The step's body prose is still required (it is the step's section, like any
402
+ other unit step) and is **not** passed to the command — it documents what the
403
+ command does, for the human reading the workflow.
404
+
405
+ ### `command` is an argv array, never a shell string
406
+
407
+ There is deliberately **no shell-string spelling**. The child is spawned
408
+ directly, so nothing ever parses the words: `;`, `|`, `&&`, `$(…)`, backticks,
409
+ `>`, and `*` inside an argument are inert literal bytes. The entire
410
+ quoting/injection class a `sh -c "<string>"` surface opens is *structurally
411
+ absent*, not defended against — a value that happens to contain `; rm -rf /`
412
+ is one argument containing those characters, and always was.
413
+
414
+ If you genuinely want a pipeline or a shell builtin, name the interpreter
415
+ yourself:
416
+
417
+ ```yaml
418
+ command: ["bash", "-lc", "bun run build | tee build.log"]
419
+ ```
420
+
421
+ That is allowed and sometimes right — but it is now a visible, reviewable line
422
+ in the frontmatter diff rather than something the format did for you silently.
423
+
424
+ Bounds: 1–64 argv entries, each a non-empty string of at most 4096 bytes.
425
+
426
+ ### `cwd`
427
+
428
+ Optional and **relative**. It resolves inside the unit's working directory —
429
+ the engine invocation's working directory normally, or the unit's fresh
430
+ detached worktree under `isolation: worktree`. Absolute paths, Windows drive
431
+ letters, `~`, and `..` segments are rejected by the parser *and* by the
432
+ frozen-plan decoder, and containment is re-checked against the *resolved* base
433
+ (symlinks included) immediately before the command is spawned. An exec unit
434
+ cannot step outside the tree its isolation promised.
435
+
436
+ ### The output rule
437
+
438
+ - **No `output:` schema** → the artifact is the command's **stdout**, with
439
+ trailing newlines stripped (exactly like shell `$(…)`). Empty stdout follows
440
+ the ordinary [empty-output rule](#what-a-steps-output-is): it is treated as
441
+ *no output*.
442
+ - **With an `output:` schema on the unit** → stdout must be **exactly one JSON
443
+ value** (surrounding whitespace tolerated, nothing else), which is parsed and
444
+ validated against the schema. This is a *strict* parse, unlike the forgiving
445
+ embedded-JSON scan used for model output: a command that claims a schema
446
+ prints JSON, and log noise that happens to contain a JSON object must never
447
+ be promoted as the artifact. Non-JSON stdout fails as `parse_error`; JSON that
448
+ misses the schema fails as `validation_error`.
449
+ - **stderr is never part of the artifact.** It is a diagnostic channel: the tail
450
+ of a failed command's stderr is included (clipped, redacted) in the unit's
451
+ failure diagnostic and its journal row. Because a failing command's stderr is
452
+ frequently the *only* explanation of the failure, that diagnostic is durable —
453
+ it survives to `akm workflow status --units` and to the step's summary in the
454
+ run output, not just to the in-memory result. It is clipped to 2000 characters
455
+ and goes through the same redaction contract as everything else journaled.
456
+ - **Retained output is bounded at 8 MiB per stream.** akm keeps at most that much
457
+ of stdout and of stderr; past the cap it keeps *reading* and discards, so the
458
+ command still runs to completion and its exit code still decides the unit. See
459
+ [Output limits](#output-limits) — the artifact is then explicitly marked
460
+ truncated, never silently shortened, and only a unit with a declared `output:`
461
+ schema fails for it.
462
+ - **An incomplete STDOUT capture is a failure, not a partial artifact.** Exiting
463
+ 0 is not on its own proof that stdout was fully read: a pipe can error, and a
464
+ background descendant that keeps the stdout handle open after the command
465
+ leader exits will hold the pipe past the drain deadline. Either way the
466
+ captured text is a *prefix* of the real output, so the unit fails
467
+ `exec_capture_incomplete` rather than promoting the prefix. That reason is
468
+ deliberately outside `retry.on`: the command already RAN, and re-dispatching
469
+ identical argv to fix a capture problem would run its side effects again. If
470
+ you hit this, have the command wait for its children or redirect their
471
+ output.
472
+
473
+ Only **stdout** is fatal here. stderr never contributes to the artifact, so a
474
+ stderr drain that did not finish leaves the unit's actual result — a completed
475
+ command, a fully captured stdout — intact and the unit succeeds; failing it
476
+ would discard a valid artifact over a lost log tail. akm warns when that
477
+ happens, naming the unit, so stderr shown for it is never mistaken for the
478
+ whole of it.
479
+
480
+ The drain deadline itself starts when nothing living owns the pipe any more —
481
+ the command's exit, or for a unit with a declared `timeout` the earlier of
482
+ that and the budget expiring — plus a short grace. A unit does not sit until
483
+ its whole `timeout` because a descendant outlived the command.
484
+
485
+ Note that a schema failure is **not** retried by the corrective-feedback loop
486
+ that model units use. Re-prompting is meaningless to a fixed argv — the same
487
+ command cannot produce different output — but re-running it *can* deploy twice.
488
+ A declared `retry:` still applies, because that is a policy you opted into per
489
+ failure reason.
490
+
491
+ ### Exit codes and failure reasons
492
+
493
+ | Outcome | `failure_reason` | In `retry.on`? |
494
+ | --- | --- | --- |
495
+ | exit 0 | — (unit succeeds) | — |
496
+ | non-zero exit | `non_zero_exit` | yes |
497
+ | exceeded `timeout` | `timeout` | yes |
498
+ | run cancelled (`Ctrl-C`, `--timeout`, budget) | `aborted` | yes |
499
+ | binary missing / working directory unusable | `spawn_failed` | yes |
500
+ | **stdout** capture never completed | `exec_capture_incomplete` | **no** (the command already ran) |
501
+ | **stderr** capture never completed | — (unit succeeds; a warning says the stderr tail may be missing) | — |
502
+ | stdout past the retention limit, **and** the unit declares `output:` | `exec_output_limit` | **no** (deterministic) |
503
+ | stdout past the retention limit, no `output:` schema | — (unit succeeds; artifact marked truncated) | — |
504
+ | `AKM_*` context too large for **this platform** to spawn | `exec_context_too_large` | **no** (an authoring/data problem) |
505
+ | `cwd` resolved outside its base | `exec_cwd_escape` | **no** (tampering, never transient) |
506
+
507
+ A non-zero exit is an ordinary unit failure, so it flows through the ordinary
508
+ policy: with the default `on_error: fail` it fails the step and the run — which
509
+ is exactly what makes a `test` step a **gate**. With `on_error: continue` the
510
+ failure is recorded in the step's evidence and the completion gate decides.
511
+
512
+ On timeout or cancellation the child's whole **process group** gets a
513
+ SIGTERM→SIGKILL ladder, so a command that spawned its own children does not
514
+ leave them orphaned.
515
+
516
+ ### Output limits
517
+
518
+ akm captures the command's stdout and stderr into memory — stdout *is* the
519
+ artifact — so it **retains** at most **8 MiB** of each stream.
520
+
521
+ This is a cap on akm's memory, not on your command. Past the cap akm keeps
522
+ reading the pipe and throws the extra bytes away, so the command never blocks on
523
+ a full pipe: it runs to completion and **its exit code is what decides the
524
+ unit**. A chatty-but-passing test suite is not failed for its log volume.
525
+
526
+ What overflow does cost is honesty about the artifact, and that depends on what
527
+ the unit promised:
528
+
529
+ | The unit declares… | On overflow |
530
+ | --- | --- |
531
+ | no `output:` schema | the unit **succeeds** (given exit 0). Its artifact is the retained first 8 MiB with a `__akm_exec_output_truncated__` block appended, naming the bytes written and the bytes retained. |
532
+ | an `output:` schema | the unit **fails** `exec_output_limit` and **nothing is promoted**. |
533
+
534
+ Nothing is ever truncated *silently*. The marker block is the last thing in the
535
+ artifact, so a downstream `steps.<id>.output` reference, a completion gate's
536
+ judge, and a human reading `akm workflow status` all see plainly that the text is
537
+ incomplete. Truncated data can never be mistaken for complete data — that is the
538
+ rule, and the marker is how it is kept.
539
+
540
+ The schema case stays a failure because a truncated prefix is not a JSON value:
541
+ with `output:` declared, stdout must parse as exactly one JSON value, so there is
542
+ nothing to validate and nothing safe to promote. `exec_output_limit` is therefore
543
+ still outside the `retry.on` vocabulary — the command is deterministic, so
544
+ re-dispatching it can only spend the budget to produce the same oversized output
545
+ again.
546
+
547
+ stderr overflow never fails anything. stderr is a diagnostic channel; the journal
548
+ already clips and marks what it keeps.
549
+
550
+ The cap is generous (8× the 1 MiB evidence-persistence cap), so an ordinary build
551
+ or test log is nowhere near it. If a command legitimately produces more and you
552
+ want the whole thing, have it write to a file and print the **path**:
553
+
554
+ ```yaml
555
+ command: ["bash", "-lc", "bun run build > build.log 2>&1; echo build.log"]
556
+ ```
557
+
558
+ ### Context reaching the command
559
+
560
+ A frozen argv is never interpolated (this format has no substitution language),
561
+ so data reaches an exec unit as **environment**, the argv analogue of the
562
+ context blocks a model unit gets in its prompt:
563
+
564
+ | Variable | Value |
565
+ | --- | --- |
566
+ | `AKM_RUN_ID`, `AKM_STEP_ID`, `AKM_UNIT_ID` | ids of this dispatch |
567
+ | `AKM_PARAMS` | the run params, canonical JSON |
568
+ | `AKM_ITEM`, `AKM_ITEM_INDEX` | a `map` unit's item (canonical JSON) and 0-based index |
569
+ | `AKM_INPUTS` | the step's declared `inputs:` artifacts, keyed by reference string |
570
+
571
+ These are applied *after* your `env:` bindings, so a binding can never shadow
572
+ them.
573
+
574
+ #### Context size limits
575
+
576
+ An environment variable is an operating-system object with a hard ceiling, and a
577
+ workflow artifact has no comparable bound — so a perfectly legitimate declared
578
+ input can grow past what **process creation itself** accepts. akm therefore
579
+ bounds what it puts in the child's environment, **against the ceiling of the
580
+ platform the run is actually on**:
581
+
582
+ | Bound | Linux / macOS / BSD | Windows |
583
+ | --- | --- | --- |
584
+ | One `AKM_*` context variable | 98 304 bytes (96 KiB) | 32 767 bytes |
585
+ | All `AKM_*` context variables combined | 131 072 bytes (128 KiB) | 64 000 bytes |
586
+
587
+ Where those numbers come from:
588
+
589
+ - **Linux** caps a single `argv`/`environ` string at `MAX_ARG_STRLEN`, defined as
590
+ `32 * PAGE_SIZE` — 131 072 bytes. The 96 KiB bound leaves 32 KiB of margin for
591
+ the variable's name, the `=`, the `NUL`, and the kernel's own accounting.
592
+ - **macOS** has no per-string cap; its binding constraint is `ARG_MAX`
593
+ (262 144 bytes) over `argv` + `environ` *combined*. The 128 KiB total keeps
594
+ akm's own contribution to half of that, leaving the rest for the argv, the
595
+ environment allowlist and your `env:` bindings.
596
+ - **Windows** caps a single user-defined environment variable at 32 767
597
+ characters (`SetEnvironmentVariable`), and the environment block has limits of
598
+ the same order.
599
+
600
+ Crossing either bound fails the unit `exec_context_too_large` **before anything
601
+ is spawned**, with an error naming the variable, its actual size, this platform's
602
+ limit and where that number comes from. That translation is the check's *only*
603
+ job: without it the same workflow dies inside the spawn syscall with a bare
604
+ `E2BIG` ("argument list too long") that names neither the variable nor the step
605
+ that produced the data.
606
+
607
+ Because that is its only job, the check uses **this** platform's ceiling rather
608
+ than the smallest supported one. A guard that applied Windows' 32 767-byte limit
609
+ on Linux would refuse spawns the kernel would happily have accepted — inventing a
610
+ failure instead of explaining an inevitable one.
611
+
612
+ > **Portability guidance (not enforcement).** If a workflow is meant to run on
613
+ > Windows as well, keep `AKM_*` context under **32 767 bytes per variable**. akm
614
+ > will not fail your Linux or macOS run for exceeding that — but a Windows runner
615
+ > will. The fix in both cases is the same: emit a reference instead of bulk data.
616
+
617
+ If you hit this, have the producing step emit a **reference** — a file path, an
618
+ id, a key — instead of inline bulk data:
619
+
620
+ ```yaml
621
+ - id: extract
622
+ unit:
623
+ exec:
624
+ command: ["bash", "-lc", "./extract.sh > /tmp/rows.json; echo /tmp/rows.json"]
625
+ - id: load
626
+ inputs: [steps.extract.output] # a PATH, not the rows
627
+ unit:
628
+ exec:
629
+ command: ["./load.sh"]
630
+ ```
631
+
632
+ akm deliberately does **not** transparently spill an oversized context to a file
633
+ and pass a path instead. That would make the `AKM_INPUTS` contract conditional
634
+ on the size of the data — sometimes JSON, sometimes a filename — so every
635
+ command would have to handle both shapes, and the spill file would have to be
636
+ placed, isolated and cleaned up inside a unit's worktree. A stable contract plus
637
+ an explicit error is the smaller, more predictable surface.
638
+
639
+ ### The child's environment is an allowlist
640
+
641
+ An exec unit's command does **not** inherit akm's environment. The child starts
642
+ from an **empty** environment and is built in three layers, in this order:
643
+
644
+ 1. the **default allowlist** below, copied through from akm's own environment
645
+ (plus any names the unit adds with `pass_env:`);
646
+ 2. the unit's resolved **`env:` bindings**;
647
+ 3. the engine-authored **`AKM_*` context** — last, so a binding can never
648
+ shadow it.
649
+
650
+ The default allowlist is exactly these names (a name absent from akm's own
651
+ environment is simply absent from the child):
652
+
653
+ | Group | Names | Why |
654
+ | --- | --- | --- |
655
+ | Command resolution | `PATH` | without it only an absolute `command[0]` can be spawned |
656
+ | Home | `HOME` | the config/cache root git, npm, bun, cargo and ssh all read |
657
+ | Identity | `USER`, `LOGNAME`, `SHELL` | read by git/ssh and by tools that re-exec a login shell |
658
+ | Locale | `LANG`, `LC_ALL`, `LC_CTYPE` | without a locale, non-ASCII stdout — *which is this unit's artifact* — gets mangled |
659
+ | Terminal / clock | `TERM`, `TZ` | some CLIs abort with no `TERM`; `TZ` keeps printed timestamps stable |
660
+ | Scratch space | `TMPDIR`, `TEMP`, `TMP` | POSIX and Windows temp roots |
661
+ | Windows essentials | `SystemRoot`, `SystemDrive`, `WINDIR`, `COMSPEC`, `PATHEXT` | Windows **process creation itself** fails with an empty environment; `PATHEXT` is what makes `bun.exe`/`bun.cmd` resolvable at all |
662
+ | Windows home/config | `USERPROFILE`, `HOMEDRIVE`, `HOMEPATH`, `APPDATA`, `LOCALAPPDATA`, `ProgramData`, `ProgramFiles` | the Windows analogues of `HOME` and the machine-wide install roots toolchain shims resolve against |
663
+ | akm provenance | `AKM_EVENT_SOURCE` | a command that calls `akm` records machine traffic, not user demand |
664
+
665
+ `PATH` is additionally supplemented with well-known user binary directories
666
+ when akm is running in a scheduler context (cron/launchd/Task Scheduler) that
667
+ stripped it — the same treatment an agent-harness child gets.
668
+
669
+ Deliberately **not** on the list: credentials of any kind, cloud/CI variables,
670
+ and the proxy family (`HTTP_PROXY` and friends — proxy URLs routinely embed
671
+ credentials). Reach them with `pass_env:`, an `env:` binding, or `inherit_env:`.
672
+
673
+ #### `pass_env:` — widen the allowlist by name
674
+
675
+ ```yaml
676
+ unit:
677
+ exec:
678
+ command: ["cargo", "build", "--release"]
679
+ pass_env: [CARGO_HOME, SCCACHE_DIR]
680
+ ```
681
+
682
+ Names only, 1–32 of them, matching `^[A-Za-z_][A-Za-z0-9_]*$`. Use it for a
683
+ **per-machine** variable an `env:` binding cannot express — an env asset stores
684
+ a committed *value*, so it cannot carry "whatever this build agent's
685
+ `CARGO_HOME` happens to be".
686
+
687
+ Values passed through this way are **not** redacted from the command's output
688
+ the way `env:` binding values are, so never list a credential here.
689
+
690
+ #### `inherit_env:` — opt back into full inheritance
691
+
692
+ ```yaml
693
+ unit:
694
+ exec:
695
+ command: ["./scripts/deploy.sh"]
696
+ inherit_env: true
697
+ ```
698
+
699
+ `inherit_env: true` gives the command akm's **entire** environment, verbatim —
700
+ what it would see if you had typed it yourself in the shell that ran
701
+ `akm workflow run`. Reach for it when a command genuinely needs the
702
+ caller's whole environment (a wrapper script, a toolchain with many ambient
703
+ variables) and enumerating names would be a losing game. Prefer `pass_env:` or
704
+ `env:` bindings when you can, because those keep what the command can see
705
+ visible in the frontmatter diff.
706
+
707
+ Both keys are **dispatch-significant**: they change what the command can see,
708
+ so both are part of the unit's input hash. Changing either re-dispatches the
709
+ unit rather than reusing a journaled row produced under the other scope.
710
+
711
+ ### What `akm show` reports for an exec step
712
+
713
+ `akm show <workflow> --format json` summarizes each step under
714
+ `steps[].orchestration`. For an exec step that summary carries an `exec` object
715
+ and **no `engine`/`model`** — an exec unit names no engine, so reporting the
716
+ workflow's `defaults.engine` there would describe a dispatch that never
717
+ happens. Field presence is the discriminator, the same way `fanOut` marks a
718
+ `map` step and `route` marks a route step:
719
+
720
+ ```json
721
+ {
722
+ "id": "test",
723
+ "title": "test",
724
+ "instructions": "Run the unit tests.",
725
+ "orchestration": {
726
+ "timeoutMs": 600000,
727
+ "exec": {
728
+ "command": ["bun", "run", "test:unit"],
729
+ "cwd": "packages/core",
730
+ "passEnv": ["CARGO_HOME"],
731
+ "inheritEnv": true
732
+ }
733
+ }
734
+ }
735
+ ```
736
+
737
+ - `command` is the argv **in full, never clipped** — the point of the field is
738
+ that what `show` prints is what runs, and a truncated argv would be the same
739
+ misdescription in miniature. It is safe to print because it is authored
740
+ literally in the asset: this format has no substitution language, so no part
741
+ of it is resolved from your environment, from a secret ref, or from a prior
742
+ step's output. Every byte is already visible in the workflow file (and stored
743
+ verbatim in `plan_json`) — which is also why you never inline a secret there.
744
+ - `cwd`, `passEnv` and `inheritEnv` appear only when the unit declares them.
745
+ `passEnv` is a list of variable **names**; no value is ever projected.
746
+ - `timeoutMs` is still reported, because an exec unit really does inherit
747
+ `defaults.timeout` — that number is true for it.
748
+
749
+ Everything else in the summary is unchanged: a `map` of exec units carries both
750
+ `fanOut` and `exec`, and `hasSchema`/`env` mean what they mean for any unit.
751
+
752
+ ### Security
753
+
754
+ Exec units sit inside the existing workflow trust model — see
755
+ [Security: workflow sources are executed code](https://github.com/itlackey/akm/blob/main/docs/guides/run-workflows.md#security-workflow-sources-are-executed-code).
756
+ They do not widen it, and they do not narrow it:
757
+
758
+ - **The child's environment is an allowlist, not an inheritance.** Be clear
759
+ about what that does and does not buy. It does *not* stop a determined
760
+ attacker: a command that runs at all can read the same credentials off disk
761
+ that the environment would have handed it, and a workflow source is executed
762
+ code either way. What it does buy is real but narrower — it bounds
763
+ **accidental** exposure (the shell or CI job that invoked `akm` routinely
764
+ exports tokens for unrelated services, and a third-party step that merely
765
+ prints its environment, or a tool that ships one in a crash report, should
766
+ not get them for free), it makes the environment surface **explicit and
767
+ reviewable** (this list plus lines in the frontmatter diff, rather than
768
+ "whatever the invoking shell happened to export"), and it **matches the
769
+ convention akm already applies to spawned children** — agent-harness children
770
+ have always been built from `envPassthrough` this way, and exec units now use
771
+ the same mechanism rather than a second one. Operators who need a harder
772
+ boundary still scope the *akm process* (dedicated account, ephemeral working
773
+ directory, external network/filesystem policy); that is the boundary that
774
+ actually holds.
775
+ - **Secrets come from `env:` bindings by name.** The frozen plan carries only
776
+ the ref names, the replay hash carries only the ref names, and the resolved
777
+ values are collected and scrubbed out of stdout, stderr, and the failure
778
+ diagnostic by the same redaction contract every other dispatch uses — before
779
+ anything is journaled. Never inline a secret into `command:`; argv is stored
780
+ verbatim in `plan_json`.
781
+ - **Read a workflow before you run it.** `exec:` makes what a workflow will run
782
+ explicit and auditable in one place, which is a real improvement over
783
+ instructing a model to "run the tests" — but a bundle you do not trust is
784
+ still a stranger's script.
785
+
786
+ ## Fan-out and concurrency
787
+
788
+ A `map` step is a fan-out: it expands `over:` into one unit per item, runs
789
+ those units, and folds the results with its `reducer`. The units are
790
+ independent by construction — no unit can read another's result — so they run
791
+ **in parallel by default**.
792
+
793
+ ### The default
794
+
795
+ **Since 0.9.1, a `map` step that declares no `concurrency:` freezes a width of
796
+ 4.** (Before 0.9.1 it froze 1, so every fan-out ran one item at a time unless
797
+ the author opted in.) 4 rather than "as wide as the machine allows" is
798
+ deliberate: it is a predictable 4× on any fan-out longer than four items, it
799
+ stays under the host CPU cap on any machine with 6 or more cores, and it is a
800
+ number an author can reason about without knowing which box the run lands on.
801
+
802
+ Three ways to change it:
803
+
804
+ | You want | Write |
805
+ | --- | --- |
806
+ | A specific width for one step | `map.concurrency: <n>` in that step |
807
+ | **Serial execution for one step** | `map.concurrency: 1` |
808
+ | A different default for every workflow on this machine | `akm config set workflow.defaultMapConcurrency <n>` |
809
+
810
+ `concurrency: 1` is a real, honored opt-out, not the absence of a value: an
811
+ authored `1` is kept distinct from an unset field, and it always beats the
812
+ config default. Set `workflow.defaultMapConcurrency` to `1` to restore the
813
+ pre-0.9.1 serial-by-default behavior everywhere at once.
814
+
815
+ A step with no `map:` is one unit, not a fan-out. It is unaffected by any of
816
+ this.
817
+
818
+ ### The four limits
819
+
820
+ The width a step really runs at is the **minimum** of four independent values.
821
+ Raising one never raises the others:
822
+
823
+ | Limit | Set by | Default when unset |
824
+ | --- | --- | --- |
825
+ | `map.concurrency` | the step | `workflow.defaultMapConcurrency`, else **4** |
826
+ | `execution.maxConcurrency` | `workflow.maxConcurrency` config | CPU-derived `min(16, max(1, cores − 2))` |
827
+ | the selected LLM engine's concurrency | `engines.<name>.concurrency` | **1** for a loopback endpoint, **4** for a remote one |
828
+ | the host CPU safety cap | nothing — reapplied at dispatch | `min(16, max(1, cores − 2))` |
829
+
830
+ The engine limit is per **endpoint kind** on purpose. A local model server (LM
831
+ Studio, Ollama) has one loaded model, and concurrent inference makes it reload
832
+ and return HTTP 500 — a hard failure, so loopback endpoints stay at 1 and a
833
+ `map` against a local model is still effectively serial unless you raise
834
+ `engines.<name>.concurrency` yourself. Remote providers fail softly (a
835
+ retryable 429), and four concurrent completions is well inside any hosted
836
+ provider's entry tier. Agent engines carry no concurrency limit of their own —
837
+ except an `opencode-sdk` engine with an `llmEngine` fallback, which inherits
838
+ that fallback engine's limit.
839
+
840
+ #### What counts as a loopback endpoint
841
+
842
+ The whole loopback space, not one address:
843
+
844
+ | Recognized | Examples |
845
+ | --- | --- |
846
+ | all of `127.0.0.0/8` | `http://127.0.0.1:1234`, `http://127.0.0.2:11434` |
847
+ | `localhost` and any `*.localhost` name | `http://localhost:1234`, `http://lmstudio.localhost` |
848
+ | IPv6 `::1`, in any spelling | `http://[::1]:1234`, `http://[0:0:0:0:0:0:0:1]:1234` |
849
+ | the unspecified addresses (a client connecting there reaches loopback) | `http://0.0.0.0:11434`, `http://[::]:11434` |
850
+
851
+ `127.0.0.2` matters in practice: running a second LM Studio or Ollama on
852
+ another address inside the `127.0.0.0/8` block is ordinary, and that server is
853
+ exactly as single-model as one on `127.0.0.1`.
854
+
855
+ **IPv4-mapped IPv6 is recognized for `127.0.0.0/8`** in both spellings —
856
+ `[::ffff:127.0.0.1]` and the hex `[::ffff:7f00:1]` — because the URL parser
857
+ re-serializes the dotted form to the hex one before the check ever sees it. A
858
+ mapped public address such as `[::ffff:8.8.8.8]` still reads as remote. The
859
+ deprecated IPv4-compatible form `[::127.0.0.1]` is not recognized; if you use
860
+ it, set `engines.<name>.concurrency: 1`.
861
+
862
+ The check is **purely syntactic — it never resolves a name.** A frozen plan has
863
+ to come out the same on your laptop, on CI, and on a machine with no network at
864
+ all, and a DNS lookup would make the frozen width depend on what a resolver
865
+ happened to answer. So a *name* that resolves to loopback (a hosts-file alias,
866
+ `host.docker.internal`, an internal DNS record) is treated as **remote**; point
867
+ the engine at the address itself, or set `engines.<name>.concurrency: 1`
868
+ explicitly. In the other direction the classification is deliberately
869
+ conservative: an endpoint akm cannot parse at all is treated as loopback, since
870
+ freezing 4 for a config it does not understand is the failure worth avoiding.
871
+
872
+ The host cap is re-derived from the CURRENT machine at every dispatch, not
873
+ frozen. A plan frozen on a 32-core CI box narrows itself when it resumes on a
874
+ 4-core laptop.
875
+
876
+ ### Frozen widths
877
+
878
+ Every one of these numbers except the host cap is resolved **once, when the run
879
+ starts**, and stored in the run's plan. Editing config, upgrading akm, or
880
+ changing the defaults above never alters a run that is already in flight or
881
+ being resumed — it keeps the widths it froze. The new defaults apply only to
882
+ runs started after the upgrade.
883
+
884
+ ## Routing
885
+
886
+ A `route` step makes classify-and-dispatch first-class: the engine resolves
887
+ the explicit `input:` expression, selects the matching `when:` branch (or
888
+ `default:`), and auto-skips the unselected branch targets as the spine
889
+ reaches them. **Routes are forward-only**: every target (each `when.step`
890
+ and `default`) must be a step declared *later* in the workflow than the
891
+ routing step, and a step never routes to itself — this keeps the plan a DAG,
892
+ so termination is structural rather than a runtime budget's job. A
893
+ `default:` that names an earlier step is a lint error, not a loop. An
894
+ unroutable value with no `default` fails the step rather than letting every
895
+ branch run.
896
+
897
+ **"Go back and fix it" is a gate, not a backward route.** A failed gate
898
+ re-runs its *own* step with the judge's feedback, bounded by `gate.max_loops`
899
+ — and a declared `output:` schema the promoted artifact fails is specifically
900
+ the error a gate loop retries through. A workflow that used to describe "loop
901
+ back to an earlier step until this passes" expresses that as a bounded gate
902
+ on the step doing the work, not as routing.
903
+
904
+ Route decisions are journaled, so a resumed run replays the same choice.
905
+ Skips cascade: when a route step is itself skipped (it was the unselected
906
+ target of an earlier route), its own branch targets are skipped too — a
907
+ router that never decided selects nothing.
908
+
909
+ ## Failure policy
910
+
911
+ Fail-fast is the default. Per unit (or via `defaults.on_error`):
912
+
913
+ - `on_error: fail` — the first failed unit fails the step, which fails the
914
+ run (`akm workflow resume` re-opens it; `akm workflow run` re-dispatches
915
+ only incomplete units).
916
+ - `on_error: continue` — failures are recorded in the step's results and the
917
+ completion gate decides whether the step passes.
918
+ - `retry: { max: <n>, on: [<failure_reason>…] }` — re-dispatches a failed
919
+ unit up to `max` extra times when its recorded `failure_reason` is listed
920
+ (e.g. `timeout`, `llm_rate_limit`, `spawn_failed`, `non_zero_exit`); every
921
+ attempt is journaled separately. For an
922
+ [exec unit](#exec-shell-units) a non-zero exit is `non_zero_exit`, a
923
+ wall-clock expiry is `timeout`, and a failure to start is `spawn_failed`.
924
+
925
+ A unit's `output` schema is validated on every runner; a validation miss
926
+ re-dispatches once with corrective feedback before the unit is recorded as
927
+ failed. Exec units are the one exception: a fixed argv cannot answer feedback,
928
+ so a schema miss fails immediately rather than re-running a side-effecting
929
+ command (see [The output rule](#the-output-rule)).
930
+
931
+ ## Gates and verification
932
+
933
+ **Gates judge the artifact; `max_loops` bounds the retry.** Under
934
+ `akm workflow run`, a step with a body `### gate` rubric is gated on its
935
+ **artifact**, not on engine prose: the judge receives the step's artifact as
936
+ canonical JSON (clipped at 4000 characters) alongside the `### gate` section
937
+ byte-exact, so the gate evaluates real results rather than a machine summary
938
+ like "Executed 3 units". Each engine-driven gate evaluation is itself an LLM
939
+ call and is journaled as its own unit row.
940
+
941
+ `gate.max_loops: <n>` (frontmatter) turns the gate into a bounded
942
+ evaluator-optimizer loop: on a rejection (or a typed-artifact schema
943
+ mismatch) with loop budget left, the engine re-executes the step's units
944
+ with the gate feedback and the missing-criteria list appended as attached
945
+ context. The feedback changes each unit's inputs, so the re-run naturally
946
+ dispatches fresh units instead of replaying journaled results. When the loop
947
+ budget is spent, the rejection stands exactly as in the one-shot case.
948
+
949
+ **An [exec step](#exec-shell-units) is judged, but never looped.** A gate loop
950
+ earns its re-dispatch by handing the judge's feedback to a unit that can answer
951
+ it, and an exec unit cannot: its argv is frozen and never interpolated, and the
952
+ `AKM_*` context carries no feedback variable. A second loop would re-run the
953
+ byte-identical command — deploying, publishing, or migrating twice — for a
954
+ verdict that cannot change. So a step whose unit is `exec:` still has its
955
+ artifact judged and can still be failed by the verdict; a rejection simply lands
956
+ on the first evaluation, carrying the same missing criteria and feedback the
957
+ one-shot case does. `gate.max_loops` on such a step is capped at 1 rather than
958
+ rejected, and an engine step's declared `max_loops` is untouched. This is the
959
+ same reasoning that makes an exec unit's `output:` schema miss fail without a
960
+ corrective re-dispatch.
961
+
962
+ **Fail-closed verification.** With no non-empty `### gate` rubric, no
963
+ verification runs. When a rubric is present, the workflow requires
964
+ `workflow.judgeEngine` to name a configured LLM or agent engine before the
965
+ plan can be frozen — see
966
+ [Author's Guide: Troubleshooting](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md#troubleshooting)
967
+ if that engine isn't configured yet. That verifier invocation is frozen into
968
+ the run.
969
+
970
+ Only a well-formed `complete: true` verdict advances a criteria-bearing step.
971
+ A missing verifier, dispatch failure, or malformed result rejects the gate
972
+ instead of silently bypassing it. A well-formed `complete: false` verdict
973
+ returns its missing criteria and feedback and can trigger another bounded
974
+ `max_loops` attempt.
975
+
976
+ ## Budget ceilings
977
+
978
+ The top-level `budget:` key declares run-lifetime ceilings: `max_units`
979
+ (total dispatched units) and `max_tokens` (total reported token usage). Both
980
+ counters are seeded from the unit journal, so they measure the **whole run
981
+ across resumes**, not just the current invocation. Hitting a ceiling aborts
982
+ the step's still-pending dispatches and fails the step with a
983
+ `budget exceeded (<which> ceiling)` summary — budget exhaustion is a hard
984
+ stop that ignores `on_error: continue`. Because the plan is frozen, raising a
985
+ budget means starting a new run.
986
+
987
+ ## Model references
988
+
989
+ Reference semantic aliases in `model:` fields instead of exact model ids so a
990
+ workflow stays harness-agnostic. Recommended vocabulary (convention, not
991
+ hardcoded) via the config-root `modelAliases` key:
992
+
993
+ ```jsonc
994
+ {
995
+ "modelAliases": {
996
+ "fast": { "llm": "claude-haiku-4-5", "*": "claude-haiku-4-5" },
997
+ "balanced": { "llm": "claude-sonnet-4-6", "*": "claude-sonnet-4-6" },
998
+ "deep": { "claude": "claude-fable-5", "opencode": "opencode/claude-fable-5", "*": "claude-fable-5" }
999
+ }
1000
+ }
1001
+ ```
1002
+
1003
+ For an LLM engine, resolution checks its engine-name column, then `llm`, then
1004
+ `*`. Agent engines check their harness platform and then `*`. The built-in
1005
+ aliases `fable`, `opus`, `sonnet`, and `haiku` resolve per platform with no
1006
+ config. See the [Author's Guide](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md#choosing-engines-and-models)
1007
+ for guidance on which tier to pick per step.
1008
+
1009
+ ## See also
1010
+
1011
+ - [Running Workflows](https://github.com/itlackey/akm/blob/main/docs/guides/run-workflows.md) — operating a run day to day
1012
+ - [Author's Guide: Writing Workflows](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md) — a task-oriented walkthrough
1013
+ - [Architecture: The Workflow Engine](https://github.com/itlackey/akm/blob/main/docs/architecture/workflow-engine.md) — persistence, dispatch, and resume internals
1014
+ - [CLI Reference](cli.md) — full flag documentation for `workflow` and `lint`