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
package/CHANGELOG.md CHANGED
@@ -6,6 +6,730 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.1-beta.2] - 2026-08-17
10
+
11
+ ### Breaking changes & migration
12
+
13
+ The 0.9.x series carries breaking changes as it works toward the 0.10.x
14
+ stabilization line. Every item here is detailed further down; this section is
15
+ what an upgrader reads first.
16
+
17
+ - **A data directory akm cannot READ is now an error, not an empty result.**
18
+ Commands that previously returned `hits: []` / `entryCount: 0` / "nothing
19
+ eligible" at exit 0 for an index, lockfile or database they lacked permission
20
+ on now raise `DATA_DIR_UNREADABLE` (exit 78) naming the path, errno, mode,
21
+ owner and running uid. *Affected:* anyone whose data dir is partly unreadable
22
+ — most often a `$XDG_DATA_HOME` shared across uids. *Remedy:* fix the
23
+ ownership or mode the error names, or point `AKM_DATA_DIR` somewhere this
24
+ user owns. The old behaviour was a false success, so a script that treated
25
+ exit 0 as "no results" was already being lied to.
26
+
27
+ - **Lockfile writes refuse to run against an unreadable `akm.lock`.**
28
+ `akm bundle add` / `remove` / `update` now fail closed instead of reading the
29
+ lock as empty and writing the single incoming entry over the whole record.
30
+ *Remedy:* as above. This one prevented real data loss — see Fixed.
31
+
32
+ - **`akm workflow run` exits 1 when a run ends `blocked`.** Previously 0.
33
+ *Affected:* CI steps and scheduled wrappers that branched only on `failed`.
34
+ *Remedy:* treat nonzero as "not verified"; resume with
35
+ `akm workflow resume <id>`.
36
+
37
+ - **`akm index --clean` no longer deletes entries whose file it cannot read.**
38
+ It keeps and names them. *Affected:* anyone relying on `--clean` to prune
39
+ aggressively; it is now conservative where it cannot see.
40
+
41
+ - **Workflow documents are bounds-checked at authoring time.** `engine:` name
42
+ grammar, `retry.max` 0–100, `gate.max_loops` 1–100, `map.concurrency` and
43
+ `engines.<name>.concurrency` 1–64, and any `timeout:` ≤ 2 147 483 647 ms are
44
+ now enforced by the parser. *Affected:* documents that parsed at 0.9.0 but
45
+ could never actually run — the frozen-plan decoder already refused them.
46
+ *Remedy:* edit the offending field; the error is now line-anchored.
47
+
48
+ - **`akm health` no longer emits `secret-file-perms`, and no longer exits 4 for
49
+ it.** The check is gone. *Affected:* anything parsing health output for that
50
+ check name.
51
+
52
+ - **Command-target task logs are now redacted.** Output that previously
53
+ persisted verbatim may now contain `[REDACTED]`. *Affected:* anything
54
+ grepping task logs for values that are now recognised as secrets.
55
+
56
+ - **Leftover `isolation: worktree` trees are garbage-collected after 7 days.**
57
+ *Remedy:* copy anything you want to keep out of a retained worktree within a
58
+ week.
59
+
60
+ ### Added
61
+
62
+ - **`exec` workflow units — run a shell command as a workflow step.** A step
63
+ whose `unit:` block declares `exec:` runs a command directly instead of
64
+ dispatching to an LLM or an agent, so deterministic work (test suites,
65
+ builds, lint, scripts) no longer costs a model dispatch, its latency, its
66
+ tokens, or its nondeterminism.
67
+
68
+ ```yaml
69
+ - id: test
70
+ unit:
71
+ exec:
72
+ command: ["bun", "run", "test:unit"]
73
+ pass_env: [CARGO_HOME] # optional: widen the default env allowlist
74
+ timeout: "10m"
75
+ retry: { max: 1, on: [timeout] }
76
+ ```
77
+
78
+ - **`command:` is an argv array; there is no shell-string spelling.** The
79
+ child is spawned directly, so `;`, `|`, `&&`, `$(…)` and `*` inside an
80
+ argument are inert literal bytes — the quoting/injection class is
81
+ structurally absent, not defended against. Write `["bash", "-lc", "…"]`
82
+ when a pipeline is genuinely wanted, and own that choice in the diff.
83
+ - **An exec unit names no engine.** It rejects `engine`/`model`/`llm`, spends
84
+ no tokens, and a workflow made only of exec steps runs on an install with
85
+ no engine configured at all.
86
+ - **Everything else about a unit still applies:** `timeout`, `retry`,
87
+ `on_error`, `output`, `env`, `isolation: worktree`, `map` fan-out and its
88
+ concurrency limits, the unit journal, budget accounting, and replay/reuse
89
+ (a completed exec unit is never re-run on resume).
90
+ - **Output rule:** stdout is the promoted artifact with trailing newlines
91
+ stripped (like shell `$(…)`); with an `output:` schema on the unit, stdout
92
+ must be exactly one JSON value, strictly parsed and validated. stderr is a
93
+ diagnostic channel only. A schema miss is *not* re-prompted — a fixed argv
94
+ cannot answer feedback, but re-running it could deploy twice.
95
+ - **Exit codes:** non-zero → `non_zero_exit`, wall-clock expiry → `timeout`,
96
+ cancellation → `aborted`, failure to start → `spawn_failed`. Those are
97
+ pre-existing `retry.on` reasons. With the default `on_error: fail`, a
98
+ non-zero exit fails the step and the run, which is what makes a `test` step
99
+ a gate.
100
+ - **A partial capture is never promoted as the artifact.** Exiting 0 does not
101
+ prove stdout was read to the end: a pipe can error, and a background
102
+ descendant holding the stdout handle open after the command leader exits
103
+ keeps the pipe alive past the drain deadline. Both leave a *prefix* of the
104
+ real output, so the unit fails — with its own reason,
105
+ `exec_capture_incomplete`, which is deliberately **not** a `retry.on` value.
106
+ The command already ran; re-dispatching identical argv to fix a capture
107
+ problem would run its side effects a second time.
108
+ - **Everything the command can spend is bounded — without inventing failures.**
109
+ Alongside the wall-clock timeout, akm bounds the memory it spends on the
110
+ command's behalf and the environment it can hand the command. Both bounds are
111
+ built so that they only ever *explain* a failure that was going to happen
112
+ anyway; neither fails a run that would otherwise have succeeded.
113
+
114
+ **Retained output: 8 MiB per stream, drain-and-discard.** akm keeps at most
115
+ 8 MiB of stdout and 8 MiB of stderr. Past the cap it keeps *reading* the pipe
116
+ and throws the extra bytes away, so the child never blocks on backpressure:
117
+ the command runs to completion and its real exit code decides the unit. A
118
+ verbose-but-passing test suite is not failed over its log volume. What
119
+ overflow costs is completeness of the artifact, and that is never hidden —
120
+ a step with **no** `output:` schema succeeds and its artifact is the retained
121
+ head with a `__akm_exec_output_truncated__` block appended (naming bytes
122
+ written vs bytes retained), so truncated data can never be mistaken for
123
+ complete data by `steps.<id>.output`, a gate judge, or a human. A step **with**
124
+ an `output:` schema still fails `exec_output_limit`: stdout must parse as
125
+ exactly one JSON value, a truncated prefix cannot, and promoting it would
126
+ corrupt every downstream reference to the typed artifact.
127
+
128
+ **Context environment: this platform's ceiling, not the smallest one.** The
129
+ engine-authored `AKM_*` context is capped at **96 KiB per variable / 128 KiB
130
+ total** on Linux, macOS and BSD, and at **32 767 bytes per variable / 64 000
131
+ bytes total** on Windows. The numbers cite their sources: Linux's
132
+ `MAX_ARG_STRLEN` (`32 * PAGE_SIZE` = 131 072 bytes per `argv`/`environ`
133
+ string), macOS's 256 KiB `ARG_MAX` over argv + environ combined, and Win32
134
+ `SetEnvironmentVariable`'s 32 767-character per-variable limit. Crossing the
135
+ bound fails `exec_context_too_large` *before* the spawn, with an error naming
136
+ the variable, its size, this platform's limit and where that limit comes from
137
+ — replacing a bare `E2BIG` from the spawn syscall that named neither the
138
+ variable nor the data behind it. Converting that inevitable failure into an
139
+ actionable one is the check's *only* job, so it uses the ceiling of the
140
+ platform the run is on: previously it applied Windows' limit everywhere and
141
+ refused spawns Linux and macOS would have accepted. Workflows that must also
142
+ run on Windows should stay under the smaller bound — that is documented
143
+ guidance now, not something a Linux host enforces.
144
+
145
+ `exec_output_limit` and `exec_context_too_large` keep their meanings and
146
+ their place outside the `retry.on` vocabulary, alongside `exec_cwd_escape`:
147
+ each is deterministic, so re-dispatching could only spend the budget again.
148
+ `PROGRAM_RETRY_REASONS` is unchanged.
149
+ - **A failing command's stderr survives to a durable surface.** The unit
150
+ journal now keeps each failed unit's redacted diagnostic (clipped to 2000
151
+ characters), and the step summary carries the first failure's. For an exec
152
+ unit that is the difference between `akm workflow status --units` saying
153
+ `non_zero_exit` and it saying *why* — a command that explains itself only on
154
+ stderr with empty stdout previously left no diagnostic anywhere durable.
155
+ This is an output surface only: the unit input hash is computed from
156
+ plan-frozen inputs, so no completed unit re-dispatches because of it.
157
+ - **The child's environment is an ALLOWLIST, not an inheritance.** The
158
+ command starts from an empty environment and receives `PATH`, `HOME`, the
159
+ identity/locale/temp/terminal variables, the Windows process-creation
160
+ essentials (`SystemRoot`, `SystemDrive`, `WINDIR`, `COMSPEC`, `PATHEXT`)
161
+ and the Windows home/config roots, plus `AKM_EVENT_SOURCE` — then the
162
+ unit's `env:` bindings, then the `AKM_*` context. `exec.pass_env: [NAME…]`
163
+ adds a few more names (for a per-machine toolchain variable like
164
+ `CARGO_HOME`, which a committed `env:` asset cannot express);
165
+ `exec.inherit_env: true` opts all the way back into akm's whole
166
+ environment. Both keys live inside `exec:` because the unit-level `env:`
167
+ key already means "env asset binding refs", and both are dispatch-
168
+ significant, so both are in the input hash.
169
+
170
+ This is not a claim to stop a determined attacker — a command that runs at
171
+ all can read the same credentials off disk. It bounds **accidental**
172
+ exposure (the invoking shell or CI job routinely exports tokens for
173
+ unrelated services), makes the environment surface **explicit and
174
+ reviewable**, and **matches the convention akm already applies** to
175
+ agent-harness children (`profile.envPassthrough`), which now share one
176
+ mechanism with exec units instead of two.
177
+ - **Security:** commands run inside the existing workflow trust model.
178
+ Secrets come from `env:` bindings by NAME — the frozen plan and the replay
179
+ hash carry only ref names, and resolved values are scrubbed from stdout,
180
+ stderr, and failure diagnostics by the same redaction contract every other
181
+ dispatch uses, before anything is journaled. `cwd:` is relative and
182
+ `..`-free, re-checked against the resolved base (symlinks included) before
183
+ spawning.
184
+ - **Cancellation is real:** the child is spawned in its own process group and
185
+ gets a SIGTERM→SIGKILL ladder on timeout or abort, so `--timeout` / Ctrl-C
186
+ stop a running command without orphaning its children.
187
+ - **No replay churn:** the exec spec was added to the unit input-hash preimage
188
+ as a key present only on exec units, so `hashVersion` stays 4 and every
189
+ previously-frozen llm/agent/sdk unit hashes byte-identically — runs already
190
+ in flight neither re-dispatch nor diverge. The env-scope keys are inside
191
+ that same spec and are frozen only in their non-default form (`inherit_env`
192
+ only when `true`, `pass_env` only when non-empty), so an exec unit that says
193
+ nothing about its environment hashes byte-identically too.
194
+
195
+ See [Workflow Schema: Exec (shell) units](docs/reference/workflow-schema.md#exec-shell-units)
196
+ and the worked example in
197
+ [Author's Guide](https://github.com/itlackey/akm/blob/main/docs/guides/author-workflows.md#deterministic-steps-run-a-command-gate-on-it).
198
+
199
+ ### Changed
200
+
201
+ - **`akm workflow run` now exits non-zero when the run ends `blocked`.** A
202
+ verification judge that throws, cannot be resolved, or returns a malformed
203
+ verdict stops the run `blocked` — unverified, and resumable with `akm
204
+ workflow resume <id>`. That previously exited 0, so a CI step or scheduled
205
+ wrapper read an unverified run as a passing one. It now exits 1, matching
206
+ `failed` and gate rejections, and matching how the scheduled-task path
207
+ already reported it.
208
+
209
+ - **Workflow dispatch bounds are enforced at authoring time, not only by the
210
+ frozen-plan decoder.** `engine:` names must match the decoder's own grammar
211
+ (lowercase dash-separated letters/digits, starting with a letter, ≤63
212
+ chars); `retry.max` is 0–100; `gate.max_loops` is 1–100; `map.concurrency`
213
+ and `engines.<name>.concurrency` are 1–64; any `timeout:` must resolve to at
214
+ most 2 147 483 647 ms (~24.8 days, `setTimeout`'s 32-bit ceiling). Every one
215
+ of these was already refused by the frozen-plan decoder, so such a document
216
+ could never actually run — but it *parsed*, so `akm lint`, `akm workflow
217
+ show` and `akm workflow create` all reported it clean and the failure arrived
218
+ at `workflow run` as an unlocated "Invalid frozen workflow plan". The error is
219
+ now line-anchored at parse time. Nothing changes for a document already
220
+ inside the bounds.
221
+
222
+ - **`akm lint` gained an advisory channel.** The result envelope carries
223
+ `warnings: LintIssue[]` alongside `fixed`/`flagged`, `summary` gains a
224
+ `warnings` count, and text output prints a `warnings` section. Advisories
225
+ never route into `flagged`, so `--fail-on-flagged` cannot fail a run over
226
+ one. Workflow compile advisories (`workflow-warning`) are surfaced for the
227
+ first time — a step with no `output:` schema, a `params.<name>` reference to
228
+ an undeclared param, a `gate.max_loops` above 1 on an `exec` step — so a
229
+ bundle that linted clean at 0.9.0 may now report warnings without becoming a
230
+ failure. Findings that know a location carry `line` in `--format json` and
231
+ render as `file:line` in text. A new `lint-failed` code reports a file the
232
+ sweep reached but could not finish.
233
+
234
+ - **Leftover `isolation: worktree` trees are now garbage-collected.** A run
235
+ that crashed, or one whose worktree was retained after a dirty unit, used to
236
+ leave its tree under the worktrees root forever. akm now opportunistically
237
+ removes such trees once they are 7 days old, confined to the worktrees root,
238
+ symlinks skipped, containment re-checked. A worktree still in use is never
239
+ collected: every live tree carries a liveness marker (pid, host, resolved
240
+ path) in git's administrative directory for it, and the sweep skips a
241
+ candidate whose holder is still running here.
242
+
243
+ - **The workflow JSON Schema subset now enforces `allOf`/`anyOf`/`oneOf`/`not`.**
244
+ A step `output:` or `params:` schema may use the combinators, and the runtime
245
+ now evaluates them. Previously it ignored them: a schema using one was
246
+ accepted and simply constrained less than it appeared to. Evaluation stays
247
+ bounded — nesting is capped at 64 levels and one validation at 100 000 checks,
248
+ and exhausting either is reported as an error rather than a truncated pass.
249
+
250
+ **This one reaches runs already in flight.** The combinators live in the
251
+ frozen plan, which the decoder still accepts unchanged, so a run frozen before
252
+ the upgrade is resumed against the *new* evaluation: an artifact that passed
253
+ when the combinators were ignored can fail validation now. There is no
254
+ `irVersion` bump to gate it, because the plan bytes did not change — only what
255
+ they mean. Runs whose schemas use no combinators are unaffected, as is every
256
+ step already completed.
257
+
258
+ `pattern` is **not** part of the subset. It is a recognized-but-unsupported
259
+ keyword like `format` or `const`: using one is a loud, line-anchored authoring
260
+ error naming the keyword, so no schema silently fails to constrain what it
261
+ looks like it constrains. Enforcing it would mean screening every author
262
+ regex for catastrophic backtracking before the match — and any such screen
263
+ also refuses regexes authors legitimately write (the usual hand-rolled email
264
+ pattern among them), which is authoring friction with no workflow asking for
265
+ it. Where a string's shape matters, `enum` lists the allowed values,
266
+ `minLength`/`maxLength` bound the size, and a step's `### gate` rubric can
267
+ check a shape and explain a mismatch. The `format` hint now points at `enum`
268
+ rather than at `pattern`.
269
+
270
+ **Existing workflows that use one of these keywords must be edited before
271
+ they load again.** They previously parsed — the keyword was silently
272
+ non-constraining — so a workflow carrying `format: date-time` or `pattern:`
273
+ ran fine and now fails to parse for every caller: `workflow run`, `workflow
274
+ show`, `workflow create`, and `akm lint`. The quietest surface is `akm index`,
275
+ which skips an asset it cannot parse with a scan warning, so the workflow
276
+ simply stops appearing in the stash index. A run already frozen from such an
277
+ asset still resumes — the frozen-plan decoder does not re-screen keywords —
278
+ so resuming works while re-creating the same workflow errors until it is
279
+ edited.
280
+
281
+ - **A document-level `defaults.llm` is now rejected at freeze when any step
282
+ resolves onto an agent engine**, naming the step and the engine. The guard
283
+ existed before but was unreachable: overrides were computed only for `llm`
284
+ engines, so `defaults.llm` on a document with an agent step was silently
285
+ DROPPED for that step — the run proceeded with the author's sampling settings
286
+ quietly discarded. Failing loudly is the point, but it means a document that
287
+ mixes `defaults.llm` with any agent-engine step no longer freezes.
288
+
289
+ There is no per-step opt-out: `llm: {}` is a no-op, `llm: null` is a parse
290
+ error, and the layer merge is additive. Move the `llm:` block from
291
+ `defaults:` onto the `unit:` of each LLM step that wants it.
292
+
293
+ - **A scheduled workflow task now gets a 6-hour whole-run timeout by default.**
294
+ This applies to task files that declare no `timeoutMs:` — which is every task
295
+ file written before this release, since the key was previously rejected on
296
+ workflow targets. An unattended run that legitimately takes longer will be
297
+ aborted and the attempt reported failed on every firing until the task is
298
+ edited. `timeoutMs: null` opts out entirely, and any number overrides the
299
+ default. The abort itself is graceful: it lands at a step boundary, the
300
+ journal and lease are kept, and the run stays resumable with
301
+ `akm workflow resume <id>` — which the failure message names.
302
+
303
+ - **Workflow `map` steps now fan out in parallel by default.** A `map` step
304
+ that declares no `concurrency:` freezes a width of **4** instead of 1, and an
305
+ LLM engine that declares no `engines.<name>.concurrency` freezes **4** for a
306
+ remote endpoint (loopback endpoints stay at **1** — a local model server holds
307
+ one loaded model and returns HTTP 500 under concurrent inference). Both
308
+ defaults previously froze 1, which made every fan-out serial unless the author
309
+ opted in at two independent layers, and left `workflow.maxConcurrency` and the
310
+ host CPU cap binding on nothing.
311
+
312
+ This is a behavior change on a patch release, so every escape hatch is
313
+ explicit:
314
+ - `map.concurrency: 1` on a step is honored exactly as before — an authored
315
+ `1` is kept distinct from an unset field and always wins.
316
+ - New config key **`workflow.defaultMapConcurrency`** sets the default for
317
+ every workflow on the machine. `akm config set workflow.defaultMapConcurrency 1`
318
+ restores the pre-0.9.1 serial default wholesale.
319
+ - `engines.<name>.concurrency` pins any engine's own limit (and is now clamped
320
+ to `1..64` at freeze time instead of freezing a plan the decoder would then
321
+ refuse to load).
322
+ - **Runs already in flight are unaffected.** Both values are frozen into
323
+ `plan_json` when a run starts and the frozen-plan decoder requires them, so
324
+ a resumed run keeps the widths it began with. The new defaults apply only to
325
+ runs started after the upgrade.
326
+
327
+ The effective width remains the minimum of the step's `concurrency`, the run's
328
+ frozen `workflow.maxConcurrency`, the selected engine's concurrency, and the
329
+ current host's CPU cap.
330
+
331
+ - **`--max-steps` now counts steps, not engine-loop iterations.** The budget is
332
+ spent by the DISTINCT spine steps that finished — completed, failed, or
333
+ gate-rejected with the loop budget spent. It was previously spent by entries
334
+ in the `executed` report,
335
+ which gains one per gate-loop iteration and one per route-skip, so
336
+ `--max-steps 3` against a step with `gate.max_loops: 3` could stop after a
337
+ single step had finished, and an unselected branch target consumed budget for
338
+ work that was never dispatched. Three steps now means three steps, which is
339
+ what the flag has always said (`Stop after executing this many steps`). A step
340
+ the invocation left unfinished — an abort, a judge outage — still consumes
341
+ nothing, because the work is still owed. The same accounting is what a
342
+ `--max-retries` reopen subtracts, so loops and skips no longer shrink a
343
+ retry's remaining budget either, and `maxSteps:` in a workflow task file is
344
+ the same knob and moves with it. The count is now reported: `akm workflow run`
345
+ carries a `stepsProcessed` field alongside `executed`, so the number the
346
+ budget is spent on is visible rather than inferred from a list that counts
347
+ something else.
348
+
349
+ **This loosens the dispatch exposure of one invocation, and the loosening is
350
+ cumulative across steps.** A step's whole bounded gate loop now costs one step
351
+ instead of one per iteration, so the rounds a single `akm workflow run` can
352
+ dispatch go from roughly `N + max_loops` to `N × max_loops`.
353
+
354
+ What did **not** change is what the flag bounds within one step. `--max-steps`
355
+ was never a cap on total dispatch rounds on either version: the budget is
356
+ tested only BETWEEN steps, so a single step's gate loop could always run out
357
+ its full `gate.max_loops` no matter how little budget was left. The per-step
358
+ ceiling is `gate.max_loops` (1–100); the whole-run ceilings are
359
+ `budget.max_units` and `budget.max_tokens`, which are seeded from the unit
360
+ journal and hold across resumes.
361
+
362
+ ### Fixed
363
+
364
+ - **Upgrading Node after installing akm now explains itself.** A native binding
365
+ is built for the Node ABI present at install time, so upgrading Node major
366
+ versions afterwards leaves akm reporting a bare Node internals message —
367
+ *"The module … was compiled against a different Node.js version"*, or on a
368
+ second attempt the even less helpful *"Module did not self-register"*. akm now
369
+ recognises that failure and answers with the one command that fixes it
370
+ (`npm rebuild better-sqlite3`), names the ABI actually running, and says
371
+ plainly that this is not a broken install.
372
+
373
+ The diagnostic had to move to do this. It wrapped the `require`, but
374
+ `require("better-sqlite3")` **succeeds** against a mismatched binding — the
375
+ package resolves its `.node` file lazily — so the error lands at
376
+ `new Database(...)` and the loader's handler never saw it. The previous text
377
+ telling the user to look for a version mismatch "in the error below" was
378
+ unreachable. Found by installing the published build under Node 22 and running
379
+ it under Node 24.
380
+
381
+ - **akm's Node fallback no longer aborts at teardown on Node 24.** On Node
382
+ 24.19.0 and later, any command that opened a database could intermittently
383
+ die with `node::RemoveEnvironmentCleanupHook … Assertion (env) != nullptr`
384
+ and exit 134 — after its work was done, so the failure looked random and
385
+ depended on garbage-collection timing.
386
+
387
+ The cause was upstream and nothing to do with akm's own code.
388
+ `better-sqlite3` ships one prebuilt binary per Node ABI and falls back to
389
+ `node-gyp rebuild` when none matches, and the 11.x line publishes no prebuild
390
+ for Node 24 — so installing it there silently compiled the driver from source.
391
+ Node 24.19.0 had just changed the public `node_object_wrap.h` so that
392
+ `ObjectWrap`'s constructor and destructor register and unregister an
393
+ environment cleanup hook; a binding compiled against those headers
394
+ unregisters the hook after the environment is already gone, and aborts from
395
+ V8's teardown path. Only the Node 24 line was affected, and only from that
396
+ release on.
397
+
398
+ akm now pins `better-sqlite3` to `12.11.1`, which publishes prebuilt binaries
399
+ for Node 22, 24, 25 and 26 — so no Node version akm supports compiles the
400
+ driver at all. This affected real installs, not just CI: an npm user on Node
401
+ 24 LTS was getting the same crash-prone from-source build.
402
+
403
+ The Node-fallback CI job now installs the exact spec `package.json` declares
404
+ instead of carrying a range of its own, and both that job and the smoke
405
+ script fail loudly on a native crash banner — previously an abort was
406
+ reported only as missing output, and the one step that tolerates a non-zero
407
+ exit would not have failed at all.
408
+
409
+ - **A website source interrupted mid-refresh no longer loses the snapshot it
410
+ already had.** A refresh deleted the whole mirror and then rebuilt it page by
411
+ page, so a process killed inside that loop left an empty or partial directory
412
+ with the old content already gone — and the freshness marker still looked
413
+ recent, so the next `sync()` served the wreckage instead of rebuilding. The
414
+ new snapshot is built in a dot-prefixed sibling directory and swapped in with
415
+ renames: an interrupted refresh leaves the PREVIOUS complete snapshot
416
+ untouched. Abandoned staging directories are dot-prefixed so the indexer's
417
+ walk skips them, and are swept by the next refresh once an hour old.
418
+
419
+ - **A resumed workflow run no longer re-dispatches work that already ran.** The
420
+ single-driver guard was checked at the run level, so a run whose lease had
421
+ been stolen left its still-owned unit row `running` and discarded the real
422
+ outcome — the resume then re-dispatched a unit that had already executed its
423
+ side effects and already spent its tokens. The guard now lives on the row, so
424
+ a stale driver's finish matches nothing and a live outcome is never dropped.
425
+
426
+ - **Lowering `retry.max` no longer re-runs finished work.** The completed-attempt
427
+ scan matched only attempts the *current* retry policy could have produced, so
428
+ reducing `retry.max` between invocations hid a journaled `~rN` row and the
429
+ unit was dispatched again. It now matches any journaled attempt of the unit.
430
+
431
+ - **A scheduled `command` task no longer writes your secrets into its log.**
432
+ Task logs were scrubbed for credential *shapes* — `Bearer …`, `sk-…`, webhook
433
+ URLs — but only prompt- and workflow-target runs also scrubbed exact secret
434
+ *values*. A command that echoed a configured secret shaped like nothing in
435
+ particular persisted it verbatim into both the run `.log` and `logs.db`, for
436
+ the whole retention window. Exact-value redaction now runs in the one sink all
437
+ three target kinds share, so every task kind is covered.
438
+
439
+ akm treats a value as secret when your config declares it
440
+ (`engines.<name>.apiKey`, `embedding.apiKey`, and the
441
+ `AKM_ENGINE_<NAME>_API_KEY` / `AKM_LLM_API_KEY` / `AKM_EMBED_API_KEY`
442
+ recipes), and infers others from the variable name (`*_TOKEN`, `*_SECRET`,
443
+ `*_API_KEY`, `*_PASSWORD`, …) when the value is at least 8 characters. The
444
+ floor applies only to the *guesses*: a declared secret is redacted at any
445
+ length. Redaction replaces substrings, so an over-eager rule does real damage
446
+ — treating every non-allowlisted variable in the inherited environment as a
447
+ secret classified 127 of 132 variables as credentials, 25 of them one
448
+ character long, and turned `3 tests passed, 0 failed` into `[REDACTED] tests
449
+ passed, [REDACTED] failed`.
450
+
451
+ For a secret exported under a name none of those rules recognise, any task may
452
+ name it:
453
+
454
+ ```yaml
455
+ command: ./deploy.sh
456
+ redact: [ACME_DEPLOY_TOKEN] # NAMES, never values — max 32
457
+ ```
458
+
459
+ Names only, and a name that is unset at run time contributes nothing. A
460
+ literal secret in a task file would leak far more widely than the redaction
461
+ closes: task files are indexed into the search database, can be sent to an
462
+ embedding provider, are printed verbatim by `akm show`, and ship inside
463
+ bundles over git and npm — the same rule exec units' `pass_env:` follows.
464
+
465
+ - **Redacting a log can no longer explode it.** Exact-value redaction took a
466
+ fast path that rewrote the text once per secret, over an accumulator it had
467
+ already rewritten — so a secret containing any of the letters in `[REDACTED]`
468
+ matched the tokens it had just inserted, and the output grew geometrically.
469
+ Fifty characters against six single-letter values produced 32,450 characters,
470
+ a 649x blowup reachable from ordinary command output. Matches are now found
471
+ against the original text and the result emitted once. Overlapping matches
472
+ merge into a single `[REDACTED]`, and the two redaction paths no longer
473
+ disagree about output shape depending on whether the text happened to contain
474
+ a `%`.
475
+
476
+ - **Redacting a structured value no longer drops fields.** When two distinct
477
+ object keys redacted to the same string, the rebuilt object silently kept only
478
+ the last — `{a, b, ab}` came back with two entries, one of them simply gone
479
+ rather than redacted. Colliding keys are now suffixed, so the value survives
480
+ with its key still hidden. This affected persisted improve results and
481
+ journaled workflow outcomes.
482
+
483
+ - **`akm improve` auto-sync now commits exactly the files the run wrote.**
484
+ Every akm write path records the file it mutated into a run-scoped
485
+ write-provenance journal, and the end-of-run (and crash-path) commit stages
486
+ precisely those paths. A managed-directory file someone else edits while a
487
+ long run is in flight is left dirty for its author instead of being swept into
488
+ akm's commit, and a file that was already dirty when the run started and was
489
+ then rewritten by the run is now committed instead of being silently skipped.
490
+ Deletions are journaled like writes, so a path written and then reverted or
491
+ purged stages its final on-disk state — or produces no commit at all. The run
492
+ reports its journal as `writtenPaths` on the improve result, and the
493
+ `stash_synced` event gains `attributed` / `unattributed` counts. `akm sync` /
494
+ `akm push`, which supply no explicit path list, keep the managed-pathspec
495
+ fallback unchanged. (#652)
496
+
497
+ - **`akm lint` no longer reports a clean scan for a task file that cannot run.**
498
+ A `tasks/*.yml` whose YAML does not parse (bad indentation, an unterminated
499
+ quote, tab characters) produced `flagged: 0`: every task reader collapsed a
500
+ parse failure onto an empty mapping, and every task rule short-circuits on
501
+ one — so a CI gate on `--fail-on-flagged` passed a task that would die at
502
+ schedule time. The parse failure is now its own `invalid-task-yaml` finding.
503
+ A `tasks/*.yaml` file — a spelling akm never indexes and never schedules —
504
+ used to be skipped by the directory walk entirely; it is now collected and
505
+ flagged for the extension, with the rename in the message. Fixed on all three
506
+ task-lint surfaces (the CLI sweep, the `akm` adapter's `validate`, and the
507
+ `akm-task` format adapter) from one shared parse, so they cannot disagree.
508
+
509
+ - **`akm lint --fix` refuses a bundle configured `writable: false`.** Every
510
+ other mutating command checks the flag before touching disk; `--fix` wrote
511
+ directly and never consulted it, so it rewrote frontmatter in a bundle
512
+ explicitly marked read-only. It is now a usage error raised before any file
513
+ is modified.
514
+
515
+ - **A `--fix` write failure no longer aborts the run and hides the fixes that
516
+ already landed.** One unwritable file (read-only file, full disk) threw
517
+ straight out of `akm lint`, so the caller got an exception instead of a
518
+ result — with no way to tell which earlier files in the same sweep had
519
+ already been rewritten. A failed fix is now reported in-band on its own file
520
+ as `fixed: "failed"`, and the sweep continues through the rest of the bundle.
521
+
522
+ - **`akm lint --type` says so when it does nothing.** For a non-akm bundle the
523
+ adapter validates the whole bundle regardless of `--type`, so scoping a run
524
+ silently had no effect. It now warns, naming the flag and the adapter.
525
+ Findings are unchanged (full-bundle validation was already a superset), and
526
+ it is deliberately a warning, not an error, so scripts passing one `--type`
527
+ across mixed-adapter bundle sets keep working.
528
+
529
+ - **`missing-skill-md` fires again for an `agent-skills` package with no
530
+ manifest.** The check iterated pending CHANGES, and a change is always a
531
+ file — so a package directory holding resources but no `SKILL.md`
532
+ contributed nothing it could see, and a skills pack with a broken package
533
+ linted clean. It is now a real directory pass over the bundle root. Related:
534
+ under opencode's supported singular `skill/` alias the same package went
535
+ unflagged while an identical one under `skills/` was caught; both spellings
536
+ are now checked.
537
+
538
+ - **An index akm cannot read no longer reports as an index that does not
539
+ exist.** `fs.existsSync()` answers `false` for a permission error exactly as
540
+ it does for a missing file, and the read path used it as its "is there an
541
+ index?" gate — so `akm search` and `akm curate` returned no hits at **exit 0**
542
+ with the tip *"No search index available. Run 'akm index' to build one."* for
543
+ a populated index sitting right there on disk, and `akm info` reported
544
+ `entryCount: 0, vecAvailable: false` for the same index. Nothing said
545
+ "permission". A consuming agent had no way to tell that from a genuine empty
546
+ result, so it relayed the false answer to its user with an explanation it had
547
+ invented.
548
+
549
+ Absent and inaccessible are now distinct everywhere it matters:
550
+
551
+ - `search` / `curate` / the index openers raise a `ConfigError`
552
+ (`DATA_DIR_UNREADABLE`, exit 78) naming the path, the errno, the mode and
553
+ owner, and the uid actually running — instead of an empty success.
554
+ - `akm info` reports an `indexStats.unreadable` diagnostic rather than zeros
555
+ that look healthy. The field is absent on every healthy run.
556
+ - `akm health` now *diagnoses* an unreadable `state.db` as a failing
557
+ `state-db-readable` check instead of dying on the open before it could
558
+ report anything — it is the command you reach for when this happens.
559
+ - `probeLock` returns a distinct `inaccessible` state instead of classifying a
560
+ permission error as a stale lock. "I cannot read this lock" and "the holder
561
+ is dead" are opposite facts, and `akm improve` now stops rather than
562
+ reclaiming a lease that may be genuinely held.
563
+
564
+ The same conflation existed on the write paths, where the consequence was
565
+ worse than a wrong answer:
566
+
567
+ - **An unreadable `akm.lock` could destroy every bundle record in it.** The
568
+ lockfile read that exists specifically so a write path never sees `[]`
569
+ returned `[]` for *any* read failure, permission errors included — and
570
+ every lockfile write is read-modify-write, so the next atomic write
571
+ replaced the operator's whole lock record with the single entry being
572
+ added. Verified by probe: the symlink was replaced by a regular file
573
+ holding one entry. Lockfile writes now refuse to run against a lock they
574
+ cannot read.
575
+ - **The migration recovery gate failed open.** "I cannot tell whether a
576
+ recovery is pending" cleared the gate exactly as "no recovery is pending"
577
+ did, so akm would open the canonical databases on top of a half-applied
578
+ migration. It now fails closed.
579
+ - **`akm index --clean` deleted rows for files it merely could not look at**,
580
+ and reported the deletions as a clean success. Unreadable entries are now
581
+ kept and named.
582
+ - `indexWrittenAssets` returned `true` — "the index is as you expect" — for
583
+ an index it could not open, on the strength of which `acceptProposal`
584
+ advanced its journal to `index-finalized`.
585
+ - `akm improve` eligibility, `akm feedback`, `akm bundle list` and the graph
586
+ loaders each turned a permission fault into an empty result, a zero count,
587
+ or the advice to "Run `akm index` first".
588
+
589
+ - **akm no longer manages permissions on your data directory, its databases, or
590
+ your task logs — and no longer reports on them either.** Those take your
591
+ process umask; their mode is yours to set, and `chmod`/`umask` are your
592
+ levers.
593
+
594
+ This is scoped, not blanket: akm still creates a handful of files at
595
+ restrictive modes *at creation time*, as it always has — `env` and `secret`
596
+ assets and config backups at `0600`, their directories at `0700`, and the
597
+ scheduler invocation files it writes for cron/launchd/schtasks. Those are
598
+ files akm authors itself and whose contents are credentials; setting their
599
+ mode when creating them is not the same as re-permissioning a directory you
600
+ already owned.
601
+
602
+ Two 0.9.1 pre-release changes are gone. The first chmodded akm's databases
603
+ and task logs to `0600`/`0700` on every open — reverted because
604
+ re-permissioning a directory akm did not create silently broke installs that
605
+ share `$XDG_DATA_HOME` between two uids (agent sandboxes, containers, service
606
+ accounts). If a pre-release tightened your data directory, `chmod` it back.
607
+ The second was an `akm health` advisory (`secret-file-perms`) that reported
608
+ group/other-readable `env`, `secrets` and `config-backups` paths — removed
609
+ too: it is meaningless on Windows, and nagging about modes akm does not set
610
+ is not health reporting. `akm health` no longer emits this check, and no
611
+ longer exits `4` on account of it.
612
+
613
+ - **`timeout: none` on an exec unit is genuinely unbounded again.** The
614
+ stream-drain safety net — a one-hour bound on a pipe still being read after
615
+ the child is gone — was armed when capture STARTED, so a command that ran
616
+ past an hour had its output reader cancelled mid-run and was then failed for
617
+ an incomplete capture even though it exited 0. It is now armed from the
618
+ child's exit, which is the only window it was ever meant to bound.
619
+
620
+ - **A bounded exec unit no longer waits out its whole `timeout` after the
621
+ command has already exited.** The drain deadline for a unit WITH a wall budget
622
+ ran from the moment capture started — budget plus a 2 s grace — so a command
623
+ that exited in milliseconds while a background descendant held a pipe open
624
+ kept the unit, and with it a fan-out slot, occupied for the entire declared
625
+ timeout before reporting. It now runs from the moment nothing living owns the
626
+ pipe: the child's exit, or (for a child that outlived its own kill ladder) the
627
+ budget's expiry, plus the same 2 s grace. A command that really does spend its
628
+ whole budget sees the identical ceiling it saw before; only the case that used
629
+ to stall stopped stalling.
630
+
631
+ - **A stderr drain that never finished no longer fails an exec unit whose
632
+ command succeeded.** `exec_capture_incomplete` was raised when EITHER pipe
633
+ failed to drain, so a command that exited 0 with its stdout captured whole was
634
+ failed — and a valid artifact thrown away — because a background descendant
635
+ was still holding STDERR open. stderr is a diagnostic channel that never
636
+ contributes to the artifact, so only an incomplete STDOUT capture fails the
637
+ unit now; an incomplete stderr drain is reported on the warn stream instead,
638
+ naming the unit and warning that any stderr shown for it may be missing its
639
+ tail.
640
+
641
+ - **A step artifact larger than 1 MiB no longer breaks the next step of the run
642
+ that produced it.** Step evidence is clipped to bound one SQLite row, and the
643
+ engine rebuilt each downstream `steps.<id>.output` scope by re-reading those
644
+ rows — so a large artifact (an exec unit's stdout retains up to 8 MiB) reached
645
+ the very next step as a truncation marker: a path reference failed with a
646
+ missing-property error that never mentioned truncation, and a whole-value
647
+ reference silently handed the marker to the unit as its input. The run now
648
+ carries its own complete values forward; the row stays clipped for resume,
649
+ where a reference into a clipped artifact fails by name.
650
+
651
+ - **A workflow run that completed is no longer reported as timed out.** The
652
+ deadline is observed between steps, so one landing during a run's final
653
+ bookkeeping set the timed-out flag on a run that then finished. On a scheduled
654
+ workflow task that recorded the attempt as failed, with a hint to resume a run
655
+ that had nothing left to resume; under `akm workflow run --timeout` it
656
+ rendered a `timedOut` marker on a `completed` run and exited nonzero. Both
657
+ surfaces now drop the marker once the run reached `completed` — a deadline
658
+ that lands with nothing left to abort has nothing to report.
659
+
660
+ - **A rejected gate on an `exec` step no longer re-runs the command.** A gate
661
+ loop earns its re-dispatch by handing the judge's feedback to a unit that can
662
+ answer it. An exec unit cannot: its argv is frozen and never interpolated, and
663
+ the exec context environment carries no feedback variable — so the loop could
664
+ only re-run the byte-identical command, performing a deploy, a publish, or a
665
+ migration a second time for a verdict that could not change. The gate still
666
+ EVALUATES on an exec step and can still fail it: a rejection is final on the
667
+ first evaluation, carrying the judge's missing criteria and feedback exactly
668
+ as in the one-shot case. What an author sees is
669
+ that `gate.max_loops` is capped at 1 on a step whose unit is `exec:` — not an
670
+ authoring error, and no change at all to an engine step, where a declared
671
+ `max_loops` is still honored in full. This is the same reasoning that already
672
+ makes an exec unit's `output:` schema miss fail without a corrective
673
+ re-dispatch.
674
+
675
+ - **The stale-worktree sweep no longer collects a worktree that is still in
676
+ use.** The opportunistic age-based GC of leftover `isolation: worktree` trees
677
+ judged staleness from the worktree root's mtime, which a unit writing only
678
+ inside subdirectories never touches — so another akm process minting a
679
+ worktree could delete the tree a long-running unit was working in. Every live
680
+ worktree now carries a liveness marker (pid, host, resolved path) in git's own
681
+ administrative directory for it, and the sweep skips a candidate whose holder
682
+ is still running here. A marker from a dead pid, from another host, or for a
683
+ different path is not liveness: crashed runs and retained dirty trees stay
684
+ collectible, which is what the sweep exists for.
685
+
686
+ - On Windows, an agent CLI, gate judge, or prompt task was spawned into an
687
+ environment the loader cannot start from: the shared passthrough allowlist
688
+ named no `SystemRoot`/`SystemDrive`/`WINDIR`, and without `PATHEXT` a
689
+ `bin: "bun"` profile was unresolvable — while an exec unit on the same host
690
+ worked, because its own allowlist names them. Those variables are now added
691
+ when any allowlisted child environment is built.
692
+
693
+ - Scheduler PATH repair skipped itself in the environments it exists for. It
694
+ decided a PATH was "interactive" by testing whether any entry began with the
695
+ user's home directory as a *string*, so a home of `/` — system crontab,
696
+ launchd, service accounts — matched every absolute entry, and a sibling home
697
+ (`/home/alice/bin` against `/home/al`) matched too.
698
+
699
+ - A directory whose name merely begins with two dots (`..data`) was treated as
700
+ a path escape. For a workflow exec `cwd` that meant the parser and the frozen
701
+ plan accepted a spelling the executor then failed as tampering, with a reason
702
+ no retry can clear.
703
+
704
+ - `appendEvent` resolved the state.db path outside its own error handling, and
705
+ did so even when the caller supplied an open connection — so a caller holding
706
+ a perfectly good handle could take a configuration error from a function whose
707
+ contract is that it never propagates one.
708
+
709
+ - Workflow freeze attributed per-step `engine`/`model`/`timeout`/`llm` overrides
710
+ by matching the compiled draft step list against the source document
711
+ **positionally**. That was correct only because compilation happens to be 1:1
712
+ and order-preserving; a compile pass that filtered or reordered steps would
713
+ have silently applied one step's overrides to another. Attribution is now
714
+ keyed by `stepId`.
715
+
716
+ ### Security
717
+
718
+ - **A gate judge's response is now scrubbed before it is journaled.** The judge
719
+ verdict is written into the gate row's `result_json`, and a judge failure's
720
+ message becomes the blocked step's notes — but the judge dispatch bypassed
721
+ the redaction contract every unit dispatch goes through, so a judge that
722
+ echoed a credential out of the promoted artifact persisted it unredacted into
723
+ the workflow journal. Both judge paths (agent and llm) now wrap their
724
+ dispatch in the same scrub, with the sensitive-value set collected per
725
+ dispatch rather than at build time, so a credential rotated between the two
726
+ reads is still caught. The dispatch also carries the real run/step/gate ids
727
+ instead of a synthetic `"gate"` placeholder, so a gate row and its telemetry
728
+ describe the same thing.
729
+
730
+ - **Command-target task logs are scrubbed of exact secret values**, closing the
731
+ last redaction lane — see the `### Fixed` entry above for the full account.
732
+
9
733
  ## [0.9.0] - 2026-08-06
10
734
 
11
735
  0.9.0 is the format-neutral **bundle / adapter** refactor: it replaces the flat