akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
759 lines (615 loc) • 44 kB
Markdown
# Critical Review — `task-workflow-format-unification.md` v7
Reviewer #1 of 3. Branch `claude/akm-markdown-tasks-history-75l6du`, baselined on
`claude/release-0-9-0-polish-d6sycl`. All citations are `path:line` against the
working tree.
**Verdict.** The *direction* is right and several of the spec's baseline claims
about the code check out exactly. But the document systematically understates
its own blast radius: §10's "no changes to IR, freeze, journal, replay" is
false at least four times over, and three load-bearing factual claims about the
current code (the "duplicate `output`", the command asset "already reads
`agent:`", "the assembled prompt is a frozen string produced at one seam") are
wrong in ways that invalidate the design decisions they justify. One change —
executing script assets' `run:`/`setup:` frontmatter — silently reverses a
documented security boundary.
---
## Findings table
| # | Sev | Title |
|---|---|---|
| C1 | Critical | `uses: scripts/*` executing `run:`/`setup:` reverses a documented "advisory, not executed" security boundary |
| C2 | Critical | The "duplicate `output`" is not a duplicate — `unit.output` and `step.output` are two different schemas; the flatten silently deletes per-unit/per-item structured output |
| C3 | Critical | §10's "no IR/freeze/journal/replay change" is false: shell units, `env:` mappings, optional prose and composition each force IR + input-hash changes |
| C4 | Critical | `run:`/shell work has no representation in the dispatch seam — `IrInvocation.engine` and `UnitDispatchRequest.engine` are required, and freeze hard-fails with no engine configured |
| M1 | Major | "Command assets already read an `agent:` frontmatter field today" is false — it is a *recognition heuristic*, nothing consumes it |
| M2 | Major | Command-template filling by named `params:` conflicts with the actual command placeholder contract (`$ARGUMENTS`/`$1`), and with §10's non-goal |
| M3 | Major | The `config defaults:` cascade layer largely does not exist — `defaults.model` is unschema'd/unread and `defaults.llm` is *explicitly retired in 0.9* |
| M4 | Major | §3.5's alias-portability claim is false for LLM and `opencode-sdk` engines — `model: sonnet` passes through verbatim |
| M5 | Major | The prose-append rule ("append to the assembled prompt at one seam") does not match how prompts are assembled; appending there puts call-site context after the item/inputs/gate/schema blocks |
| M6 | Major | `env:` literal values break the "names only, never values" plan/hash invariant and put literals into `brief` output |
| M7 | Major | `params` as a cascade-merged value field collides with workflow frontmatter `params:` (schema declarations) and with the "params are non-secret and un-redactable" contract |
| M8 | Major | Freeze must become IO-dependent (and probably async) to resolve `uses: tasks/<id>`, breaking its stated purity contract; lint never freezes, so composition refs go unvalidated until run start |
| M9 | Major | Migration losslessness is overclaimed: `name:`, `llm.supportsJsonSchema`/`contextLength`/`enableThinking`, and `prompt: agents/x` semantics are all lost or changed |
| M10 | Major | "Post-migration `sync`/`doctor` name any stray `.yml` by path" is not structural — the placement table and sync both key on extension, so strays become invisible or double-installed |
| m1 | Minor | Value fields legal "at every layer" are not legal on the engine node (`env`/`cwd`/`shell`/`params` silently pass through `.passthrough()` and are ignored) |
| m2 | Minor | Value fields on a `uses: workflows/*` task are structurally inert — the workflow run re-freezes from its own layers |
| m3 | Minor | Frontmatter `agent:`/`model:` on a task collide with indexer type-classification heuristics |
| m4 | Minor | `status: draft` as an execution gate overloads the OKF lifecycle field and duplicates `enabled:` |
| m5 | Minor | Setup wizard's YAML round-trip and `EmbeddedTask.command` will destroy markdown task bodies |
| m6 | Minor | Body-section requirement in the current parser contradicts optional sections for `uses:`/`run:` steps |
| m7 | Minor | `run:` shell semantics silently drop the argv-based nested-akm optimization and change the injection surface |
| m8 | Minor | Existing wart the plan inherits: `prompt: agents/x` sends the agent asset's raw YAML frontmatter to the model |
| m9 | Minor | okf-support.md explicitly names "YAML tasks" as a non-Markdown native asset and gates the promotion allowlist on `workflow` alone |
| m10 | Minor | Test/golden churn is larger than "schema and golden churn" implies |
---
## Critical
### C1 — `uses: scripts/*` executing `run:`/`setup:` reverses a documented security boundary
**Spec claim.** §3.2: "`uses: scripts/<name>` — Execute the script asset per its
own `run`/`setup`/`cwd` metadata — no AI." §6: "one executor: inline shell +
script assets per `run`/`setup`/`cwd`, both surfaces."
**Evidence.** Those three fields are today explicitly, deliberately non-executable:
```
src/indexer/passes/metadata.ts:140
// SECURITY NOTE: run, setup, and cwd are advisory metadata fields for AI agent consumers.
// They are NOT executed by akm directly. Consumers should validate and sanitize before execution. doclint:ignore
```
This is not incidental. It sits alongside the sync attribution boundary whose
comment states the invariant plainly — "registering a bundle never activates
code" (`src/commands/tasks/tasks.ts:397-401`). Under the proposal, any installed
third-party bundle's `scripts/*.md` frontmatter `setup:` / `run:` becomes
executable the moment a local workflow or task names it. `setup:` in particular
implies "run an install command," which is the highest-value supply-chain target
in the whole asset model.
**Why it matters.** The spec's §10 non-goals say "No change to how `command`,
`script`, ... assets are authored or stored — this proposal executes and embodies
them." That sentence *admits* the change and then classifies it as a non-goal.
Executing them is the entire security delta.
**Recommendation.** Either (a) drop `uses: scripts/*` from v1 and keep `run:`
inline-only, or (b) add an explicit section covering: trust scope (own bundle
only? a per-bundle `executable: true` config opt-in?), whether `setup:` runs at
all, whether `cwd:` may escape the bundle root, and how `akm bundle add` warns.
Update `metadata.ts:140`'s SECURITY NOTE in the same change so the comment and
the behavior cannot disagree.
---
### C2 — The "duplicate `output`" is not a duplicate; the flatten deletes a real feature
**Spec claim.** §3.6: "`output:` is declared **both** on the step and inside the
bag — a live wart in `schemas/akm-workflow.json`" … "The duplicate `output`
declaration disappears with the bag."
**Evidence.** They are two distinct schemas with two distinct consumers:
- `ProgramUnit.output` (`src/workflows/program/schema.ts:124-125`, "JSON Schema
the unit's structured result must validate against") → compiled to
`WorkflowUnitDraft.schema` (`src/workflows/ir/compile.ts:229`) → frozen as
`IrUnitNode.schema` → becomes `UnitDispatchRequest.schema`
(`src/workflows/exec/native-executor.ts:663`), the LLM `responseSchema`
(`native-executor.ts:1092`), the structured-output retry trigger
(`native-executor.ts:928-929`), the `--result-file` contract in the driver
brief (`src/workflows/exec/brief.ts:574,656-657`), and per-unit result
validation (`src/workflows/exec/report.ts:1597-1618`).
- `ProgramStep.output` (`program/schema.ts:185-186`, "Step artifact schema") →
`WorkflowStepDraft.outputSchema` (`compile.ts:204`) → `IrStepPlan.outputSchema`
→ validates the *promoted step artifact* (`src/workflows/exec/step-work.ts:555-561`).
For a `map:` step the distinction is load-bearing: `map.unit.output` types each
per-item result, `step.output` types the reduced array. `workflow-format-unification.md`
itself documents exactly that: "`output` describes the REDUCER RESULT: the
default `collect` reducer folds per-item unit results into an array"
(workflow-format-unification.md:127-129) — on a step whose `map.unit` also
carries settings.
**Consequence.** Collapsing both onto one step-level `output:` either (a) drops
per-unit structured output entirely — losing the LLM response-schema path, the
structured-retry path, and the driver `--result-file` contract — or (b) silently
changes the meaning of `output:` on map steps from "reducer result" to "per-item
result", breaking the shipped example and the reducer contract.
**Recommendation.** Fix the spec: there is no duplicate. If the bag must go, the
flatten needs two step-level keys with unambiguous names (e.g. `output:` = step
artifact, `unit_output:` / `item_output:` = per-dispatch schema), and §3.6 must
say which one a map step's `output:` means. Also drop the "live wart" framing
from §6's table row, which currently justifies the deletion on a false premise.
---
### C3 — §10's "no IR / freeze / journal / replay change" is false four times over
**Spec claim.** §10: "no changes to `map`/`route`/`gate` semantics, IR, freeze,
journal, leases, or replay (the §3.6 flatten and §3.4 cascade are
authoring-surface changes…)".
**Evidence — each of these is an IR-level change:**
1. **Optional prose.** `decodeWorkflowPlanV3` rejects an empty
`instructions` (`src/workflows/ir/schema.ts:437-439`: `!node.instructions`
→ `fail`). A `run: bun test` step with no `## run-tests` section produces an
empty-instructions unit. The workflow *parser* rejects it even earlier
(`src/workflows/parser.ts:282-296`, and workflow-format-unification.md:220-224
body rule 2).
2. **`env:` shape.** `IrUnitNode.env` is `string[]`, validated by
`validateStringArray` (`ir/schema.ts:447`). §3.7's list-of-(ref-string |
mapping) is not a string array. The spec calls this "extends the existing
schema additively" — additive for the *authoring* JSON Schema, breaking for
the IR decoder.
3. **Input hash.** `env` is part of the unit input-hash preimage
(`src/workflows/exec/step-work.ts:388`, `hashVersion: 4`). Changing `env`'s
shape changes the preimage → `hashVersion` must bump to 5 → every in-flight
run's journaled rows stop matching → replay divergence
(`native-executor.ts` `classifyUnitReuse`, "diverge" arm). Worse: if `run:` /
`cwd:` / `shell:` are added to `IrUnitNode` but *not* to the preimage
(the spec never mentions the hash), editing a step's shell command reuses the
completed row and never re-dispatches — a silent correctness bug.
4. **Composition.** §3.3 "Resolution happens at freeze" means the frozen unit's
`instructions` now derive from a *different asset*. Nothing in the plan or
hash records which task version was composed, so `akm workflow report` cannot
attribute a unit's prose to its source task, and the plan-hash integrity check
(`src/workflows/runtime/runs.ts:236-241`, `requireExecutableWorkflowPlan`)
gives no signal that an upstream task changed between two runs of the same
workflow.
**Recommendation.** Rewrite §10 honestly: IR v3 gains fields, `hashVersion`
bumps, and pre-existing frozen plans become undecodable. Given 0.9.0 is
unreleased this is cheap — but it must be stated, and the hash preimage must
explicitly enumerate the new dispatch-significant fields (`run`, `shell`, `cwd`,
`env` in its new shape, and the composed task's identity).
---
### C4 — Shell work has no representation in the dispatch seam
**Spec claim.** §3.6: "A shell unit inside a workflow journals like any unit
(`workflow_run_units`): no tokens, but status, timing, attempts, and
`failure_reason` behave normally." §2 leans on `unit-dispatch.ts` being "the one
dispatch seam."
**Evidence.** That seam is agent-shaped, top to bottom:
- `UnitDispatchRequest` requires `prompt: string` and `engine: FrozenEngineSnapshot`
(`src/workflows/exec/unit-dispatch.ts:15,19` — neither optional).
- `IrUnitNode.invocation` is required and `validateInvocation` requires a
non-empty `engine` string (`ir/schema.ts:85, 481-497`).
- Freeze throws `ConfigError` "No workflow engine is selected" for *any* unit
when no engine resolves (`src/workflows/ir/freeze.ts:52-62`). **A workflow
consisting entirely of `run:` steps would refuse to freeze on a machine with
no agent CLI configured** — which is precisely the machine most likely to want
a shell-only workflow.
- `runUnit` bails with `dispatch_error` when `!workUnit.engine`
(`native-executor.ts:636-643`).
- The `brief`/`report` driver protocol surfaces a prompt for an external driver
to execute (`brief.ts:571-577`). What does a shell unit's brief contain? The
spec never says. If it emits the shell text, an external driver is now being
asked to execute arbitrary shell.
**Recommendation.** §6's "honest boundary" paragraph acknowledges *two agent*
dispatch paths; it must acknowledge a *third, non-agent* one. Concretely the
spec needs: a discriminated `IrUnitNode` (`kind: "unit" | "shell"`), an optional
`invocation`, a `ShellDispatchRequest` variant on the seam, an explicit
"shell-only workflows freeze without an engine" rule, defined `failure_reason`
mapping for exit codes/timeouts, and a defined `brief`/`report` behavior for
shell units (most likely: refuse — shell units are engine-executed only).
---
## Major
### M1 — "Command assets already read an `agent:` frontmatter field today" is false
**Spec claim.** §3.4: "(Command assets already read an `agent:` frontmatter field
today — a referenced asset naming its preferred persona is existing behavior,
corroborating the asset-as-layer model.)"
**Evidence.** `agent` in a markdown file's frontmatter is a *type-classification
signal only*:
```
src/indexer/walk/matchers.ts:234-236
if ("agent" in fm) { return { type: "command", specificity: 18 }; }
src/core/adapter/adapters/tool-dir-shared.ts:201
// command-shaped body/frontmatter signals … an `agent` frontmatter key.
```
Nothing reads the *value*. There is no code path that resolves a command asset's
`agent:` to a persona at dispatch. A repo-wide search for a consumer turns up
only these two classification sites.
**Why it matters.** This is the sole cited empirical corroboration for the
asset-as-cascade-layer model in the round-6/7 redesign — the reversal
Decision 14 asks for explicit sign-off on. Sign-off is being requested partly on
a false premise.
**Recommendation.** Strike the parenthetical, or restate it accurately ("`agent:`
exists on command assets today as a recognition signal; this design gives it
semantics for the first time"). Note that giving it semantics also means an
untrusted bundle's command asset can now select a persona — worth a sentence.
---
### M2 — Template filling by named `params:` conflicts with the real command placeholder contract
**Spec claim.** §3.2: "`uses: commands/<name>` — Fill the command asset's
template (its type contract — 'a prompt template with placeholders'), dispatch
an agent with the result | `params:` fill the placeholders." "a placeholder with
no matching param is an error at fill time."
**Evidence.** The command asset's placeholder vocabulary is **positional**, not
named:
```
src/indexer/walk/matchers.ts:126
const COMMAND_PLACEHOLDER_RE = /\$ARGUMENTS|\$[123]\b/;
```
There is no named-placeholder syntax in command assets. `params: { scope: team }`
(the spec's own §4 example) has nothing to bind to. Introducing named
placeholders is a change to how command assets are authored — which §10
explicitly declares a non-goal.
The one named-substitution mechanism that exists is the workflow prompt
preamble's `{{PARAMS_JSON}}` (`src/workflows/exec/step-work.ts:469`), which is a
whole-blob injection, not per-placeholder filling — and workflow-format-unification
§2.3/§1.3 spent a whole section *removing* moustache templating from prose
because "the fake templating trap" (workflow-format-unification.md:57-63) was a
standing cost. §3.2 re-introduces a templating language into asset bodies.
**Recommendation.** Pick one and specify it: (a) positional — `params:` becomes a
list and `$1`/`$ARGUMENTS` fill from it; (b) named — declare the syntax, declare
it a change to the command asset contract, and remove the §10 non-goal; or (c)
no filling — `params:` are attached as context the way workflow params are, which
is consistent with the "no interpolation" decision the sibling spec already made.
Also state whether the "no matching param is an error" rule is symmetric (an
unused param — error or notice?).
---
### M3 — The leftmost cascade layer (`config defaults:`) largely does not exist
**Spec claim.** §3.4's cascade begins at "config defaults: (global)", and the
worked example is
`{ "defaults": { "engine": "claude", "model": "sonnet" } }`.
**Evidence.**
- `DefaultsSchema` declares only `engine`, `llmEngine`, `improveStrategy`
(`src/core/config/config-schema.ts:102-108`). It is `.passthrough()`, so
`defaults.model` would *validate* — and be read by nobody.
- `exactModel()` never consults `config.defaults` — it walks `layers`, then falls
back to `engine.model` (`src/workflows/ir/freeze.ts:169-171`). Same for
`effectiveTimeout` (`freeze.ts:193-209`).
- `defaults.llm` and `defaults.agent` are **explicitly hard-rejected**:
`` `defaults.${key} is retired in 0.9` `` (`config-schema.ts:234-241`). The
spec proposes putting `temperature`, `max_tokens`, `extra_params` at exactly
that scope.
So the "one vocabulary appears at every scope" claim requires: new
`DefaultsSchema` fields, new readers in `exactModel`/`effectiveTimeout`/
`mergedLlmOverrides`, `akm config set defaults.*` support, and an explicit
un-retirement of a key the 0.9 cutover deliberately killed. None of this is
scoped in §6's cost table.
Second-order problem: a global `defaults.model` applies to **all** engines, and
model strings are per-platform. A user with a claude engine and an LLM endpoint
gets one `model` value cascading into both. §3.5 says aliases save this — see M4,
they don't.
**Recommendation.** Enumerate the new config-schema surface in §6. Explain the
interaction with the `defaults.llm` retirement (why is it coming back, in flat
form, one release after being killed?). Decide whether `defaults.model` is
per-engine-kind or genuinely global, and if global, say what happens when it is
an alias with no column for the selected platform.
---
### M4 — Alias portability across engine kinds is claimed but not implemented
**Spec claim.** §3.5: "Aliases are **per-platform**, which is what makes a shared
asset portable: `model: sonnet` resolves to the right string under a claude
engine, an opencode engine, or an LLM endpoint." And: "Aliases are therefore
legal at every layer — a persona node saying `model: opus` works under any
engine."
**Evidence.** `BUILTIN_ALIASES` has exactly two platform columns — `claude` and
`opencode` (`src/integrations/agent/model-aliases.ts:45-76`). `resolveModel`
returns `entry?.platforms[platform] ?? model` — **verbatim pass-through** when
the column is missing (`model-aliases.ts:103`).
- **LLM endpoints.** `resolveLlmModel(selected, name, config.modelAliases)`
(`freeze.ts:189`) passes the *engine name* as the platform column and adds an
`"llm"` fallback column (`model-aliases.ts:107-109`). Neither the engine name
nor `"llm"` exists in `BUILTIN_ALIASES`. So `model: sonnet` under an LLM engine
resolves to the literal string `"sonnet"` — which almost every OpenAI-compatible
endpoint will reject.
- **`opencode-sdk`.** `freeze.ts:190` passes `engine.platform`, which for the SDK
engine is `"opencode-sdk"` — also not a `BUILTIN_ALIASES` column, also verbatim.
The claim is only true if the user has hand-authored a `modelAliases` table, which
is not what "portable by construction" means.
**Recommendation.** Either add `"*"`-style built-in columns / an `opencode-sdk`
alias to `BUILTIN_ALIASES`, or downgrade §3.5's claim to "aliases are portable
across configured platform columns; an unmatched alias passes through verbatim
and will typically fail at the endpoint." Given §3.4 makes a global
`defaults.model` the recommended shape, an unresolvable alias silently becoming a
literal model string is a bad default — consider making it a notice at freeze.
---
### M5 — The prose-append rule does not match how prompts are assembled
**Spec claim.** §3.3: "Investigated (round 6): the assembled prompt is a frozen
string produced at one seam, so appending is a deterministic concat — easy…
the step's section text is appended to the assembled prompt after a blank line,
byte-exact."
**Evidence.** There is no frozen assembled prompt. What is frozen is
`IrUnitNode.instructions` (byte-exact body prose). The *prompt* is built at
dispatch time, per unit, by `buildUnitPrompt`:
```
src/workflows/exec/step-work.ts:500
return `${preamble}\n${instructions}${itemBlock}${inputsBlock}${gateBlock}${schemaDirective}`;
```
Appending the step section to "the assembled prompt" places the call-site context
*after* the fan-out item block (`step-work.ts:473-475`), the declared-inputs
block, the gate feedback block, and the schema directive. "In this workflow,
focus on X" lands after "here is your item" and after "your result must match
this JSON Schema" — the worst position in the prompt.
Appending to `instructions` instead is the right answer, but that is a
**freeze-time / compose-time** concat, not a dispatch-seam one, and it changes
the hash preimage (`step-work.ts:381` hashes `template.instructions`) — see C3.
**Recommendation.** Restate the rule as "the referenced task's body and the
step's section are concatenated at freeze into the unit's `instructions`". Also
specify: does the step's `### gate` sub-heading participate? (The parser already
splits the gate rubric out of the section — `src/workflows/parser.ts:379-381` —
so it does not, but the spec's "section text" wording implies it might.) And
specify ordering for a `uses: commands/*` target: task body, then filled command
template, then step section? The spec says "appended to the assembled prompt"
for both cases without disambiguating.
---
### M6 — `env:` literals break the "names only, never values" plan invariant
**Spec claim.** §3.7 allows literal mappings inline (`LOG_LEVEL: debug`) and
`DATABASE_URL: secrets/db-url`, and says this "extends the existing schema
additively."
**Evidence.** The current invariant is explicit and deliberate:
```
src/workflows/exec/step-work.ts:354-358
// `env` carries NAMES ONLY, never resolved values: hashing a resolved secret
// would leak it into a durable hash oracle and would spuriously re-dispatch
// on every secret rotation.
```
and, from the driver-protocol contract:
```
src/workflows/exec/param-secrets.ts:17-19
secrets belong in **env bindings** (`env:` refs), which `brief` surfaces by
NAME ONLY and never resolves.
```
Under §3.7, literal env values are frozen into the plan (`plan_json` in the run
row), hashed into the unit input hash, and surfaced verbatim by `brief`
(`brief.ts:577` passes `unit.env` straight through). A user who writes
`env: { API_KEY: sk-… }` — a natural GHA-muscle-memory mistake the spec
*encourages* with the bare-mapping shorthand — has just written a credential into
a durable journal and a driver-visible brief.
**Recommendation.** Keep the shape but keep the invariant: freeze/hash/brief must
carry env **keys and ref values only**; literal values must either be (a) hashed
as `key → sha256(value)` or `key → <literal>` sentinel, or (b) rejected for
workflow units and permitted only on tasks. Add a lint that flags secret-shaped
literal env values — `detectSecretShapedParams` (`param-secrets.ts:87`) is a
ready-made heuristic. State explicitly which of `env/` group values,
`secrets/` values, and inline literals join `sensitiveValues`.
---
### M7 — `params` as a cascade value field collides with two existing meanings
**Spec claim.** §3.4 lists `params` in the flat value vocabulary, "legal at every
layer, merged per-field with the nearest layer winning."
**Three collisions:**
1. **Within one workflow file.** Top-level frontmatter `params:` already means
*param schema declarations* (`schemas/akm-workflow.json:22-32`,
`program/schema.ts:99-100`). §3.4 makes `params` legal at the "document
defaults" layer — i.e. `defaults: { params: … }` — meaning *values*. Two
meanings of one word in one file, one nesting level apart.
2. **Across targets.** On a `uses: workflows/*` task, `params:` are declared
run parameters coerced through frozen schemas
(`src/workflows/ir/params.ts:34-72`). On `uses: commands/*`, they are template
placeholder fills (M2). On `uses: tasks/*`, they are "the task's own inputs".
These have different validation, different coercion, and different failure
modes; per-field cascade merging across them is undefined.
3. **Secrets.** Params are contractually **non-secret and un-redactable** because
they are in the prompt and the input hash (`param-secrets.ts:9-19`). Making
`params` inheritable from a global config layer means a global value silently
flows into every prompt of every workflow. That is a new, quiet exfiltration
path for anything a user puts in `defaults.params`.
**Recommendation.** Remove `params` from the cascade value vocabulary. It is a
call-site input, not a setting. If a default-params feature is wanted, scope it
to one layer with an unambiguous key name and re-run the secret analysis.
---
### M8 — Freeze must become IO-dependent; lint never freezes
**Spec claim.** §3.3: "Resolution happens at freeze. The referenced task compiles
into the frozen plan… the existing snapshot rule, unchanged." §10: "§3.3
composition resolves at the existing freeze step."
**Evidence.** Freeze is deliberately pure over `(asset, config)`:
```
src/workflows/ir/freeze.ts:39-43
* The only source-to-runtime boundary. Source compilation remains pure; engine
* selection and every dispatch-significant setting are resolved here once.
export function compileResolveFreezeWorkflow(asset: WorkflowAsset, config: AkmConfig): FrozenWorkflow
```
Resolving `uses: tasks/<id>` requires `resolveAssetPath` + a file read, which is
**async** (`src/sources/resolve.ts`, used async everywhere:
`src/tasks/runner.ts:159`, `src/tasks/validator.ts:54`). Making freeze async is
mechanically fine at the one call site (`src/workflows/runtime/runs.ts:241` is in
an async function) but it destroys the purity property the file's own doc
comment advertises, and it means freeze can now fail with `ASSET_NOT_FOUND` /
bundle-resolution errors it has never produced before.
Second problem: **workflow lint does not freeze.** `compileResolveFreezeWorkflow`
has exactly one caller (grep: `runs.ts:241`). So `uses: tasks/does-not-exist`,
`uses: tasks/x` where `x` targets a workflow (the §3.3 "no nesting through the
back door" error), and a composed task with an unresolvable engine all become
**run-start failures, not lint failures** — the opposite of what §6's lint row
promises.
Third: cross-bundle. Which bundle does `uses: tasks/lint-check` resolve against?
Tasks already carry bundle attribution through the scheduler (`--bundle` token,
`src/tasks/scheduler-invocation.ts:59-78`); a workflow composing a task has no
such context. Undefined in the spec.
**Recommendation.** State that freeze becomes async and IO-bearing; add a
compile-time composition-resolution pass that lint can also run (a resolver
callback injected into `compileWorkflowPlan`, keeping `compile` pure and moving
the IO to the caller is the cleaner shape). Define bundle scoping for `uses:`.
---
### M9 — Migration losslessness is overclaimed
**Spec claim.** §8: "No key survives with a changed meaning — the `.yml`
vocabulary maps onto different spellings, so nothing is silently reinterpreted."
**Gaps, all against `src/tasks/parser.ts` + `src/tasks/schema.ts`:**
| Lost / changed | Evidence |
|---|---|
| `name:` — a real, parsed, displayed field | `parser.ts:89`, `schema.ts:88-89` ("Human-readable display name shown in `akm show` and search results"). Not in the §8 table; not in the envelope (`schemas/akm-asset-envelope.json` has no `name` definition); §4's envelope list omits it. |
| `llm.supportsJsonSchema`, `llm.contextLength`, `llm.enableThinking` | Parsed and validated today (`parser.ts:331-357`), frozen into `IrInvocation.llm` for workflows. §3.4's value vocabulary is a *closed nine-field list* that omits all three; §8's row is `llm.maxTokens / llm.temperature / llm.extraParams / …` — the ellipsis is doing load-bearing work for three fields with no destination. |
| `prompt: agents/x` → `agent: agents/x` | This *is* a changed meaning. Today it reads the agent asset file and sends its bytes as the prompt (`runner.ts:581-593`). After: the agent becomes a persona node and the body is "seeded". §3.5 correctly frames this as a fix, but §8's blanket "nothing is silently reinterpreted" is then false for the one row that matters most. |
| `command:` as an **array** | `readCommand` accepts `string[]` (`parser.ts:286-299`), and `akm-task.json:19-24` declares the `oneOf`. §8 maps only `command: <shell>` → `run: <shell>`. An array command has no lossless single-line shell spelling (quoting/escaping is now the migrator's problem, per-platform). |
| `timeoutMs: null` | `readTimeout` treats `null` as "explicitly no timeout" (`parser.ts:318-323`). Does `timeout: none` (the workflow spelling, `schemas/akm-workflow.json:59-70`) survive the conversion? Unstated. |
**Recommendation.** Complete the §8 table (all keys, including `name` and the
three dropped `llm` fields), decide where `name` goes (`description`? a new
envelope key? dropped with a migration warning?), and either extend the §3.4
vocabulary to the full `llm` set or state explicitly that those three fields are
being dropped and why.
---
### M10 — "Strays named by path" is not structural
**Spec claim.** §8: "The 0.7→0.8 lesson — leftover files must never be **silently
invisible** — is honored structurally: 0.9.0 commands already refuse an
un-migrated installation rather than migrating as a side effect, and
post-migration `task sync` / `task doctor` name any stray `.yml` file by path
instead of skipping it."
**Evidence, both halves:**
- **"Already refuse."** The existing refusals are keyed on *config shape*
(`config-schema.ts:170,207`; `config-walker.ts:446-447`) and *DB schema
version* (`src/storage/engines/sqlite-migrations.ts:242`). Neither observes a
leftover `tasks/foo.yml`. After a successful cutover both gates are satisfied
and the stray file trips nothing.
- **"Name any stray by path."** Nothing does this today, and the structure works
against it. `sync` enumerates `*.yml` only
(`src/commands/tasks/tasks.ts:414`, `.filter((f) => f.endsWith(".yml"))`,
and `:433` `path.join(typeRoot, \`${id}.yml\`)`). Flip that to `.md` and the
stray is invisible; leave it and the stray is **installed into the OS
scheduler**. The placement table (`src/core/asset/asset-placement.ts:159-169`,
`isRelevantFile: ext === ".yml"`) has the same property in the indexer.
- Note the existing precedent runs the *other* direction: `akm task add` already
guards against a leftover pre-0.8 `<id>.md` shadowing a new `<id>.yml`
(`tasks.ts:155-164`). That guard will need inverting, and it demonstrates the
hazard is real and recurring.
- Worst case during the window: `<id>.yml` and `<id>.md` both exist. Both are
recognized (by the `akm-task` and a new markdown-task adapter respectively),
both produce conceptId `tasks/<id>`, and the index gains a duplicate identity.
**Recommendation.** Replace "structurally" with a concrete mechanism: a named
migration step that fails loudly on any surviving `tasks/*.yml`, plus an explicit
raw-directory scan in `sync` and `doctor` that reports `.yml` files by path with
a non-zero exit. Add a `tasks/<id>.{yml,md}` collision check to the adapter or
indexer.
---
## Minor
### m1 — "Legal at every layer" is not true of the engine node
§3.4: "Everything else is a flat set of value fields, legal at every layer."
Then the exception carves out only *strictness*. But four of the nine fields —
`env`, `cwd`, `shell`, `params` — have no declaration on either engine schema
(`src/core/config/schema/engines.ts:66-88, 90-131`). Both schemas are
`.passthrough()` and neither superRefine reject-list mentions them
(`engines.ts:84, 106-117`), so writing `env:` on an engine node **validates
silently and is ignored** — the exact "silently load-bearing" failure §3.4 says
it is avoiding. Recommend: add the four fields to both engine schemas, or add
them to both reject lists, and say which in the spec.
### m2 — Value fields on a `uses: workflows/*` task are structurally inert
Today the parser forbids them: `rejectTargetFields(data, ["params"])` for
workflow targets (`src/tasks/parser.ts:113`), mirrored in
`schemas/akm-task.json:31-34`. Under the cascade they become legal at the task
layer — but a workflow run re-freezes every unit from its *own* layers
(`freeze.ts:50`: `[documentDefaults, unit]`), and `RunWorkflowOptions`
(`src/workflows/exec/run-workflow.ts:98-148`) has no env/model/timeout channel.
So `model: haiku` on a workflow-target task does nothing. The spec never says
whether this is a notice, an error, or plumbed through. Same for §3.7's "For a
`uses: workflows/*` target, `env:` merges into the run's process environment" —
that needs a new `RunWorkflowOptions` field and a decision about whether it is
frozen (a resumed run would otherwise lose it).
### m3 — Frontmatter `agent:`/`model:` on a task collide with type classification
`matchers.ts:234` maps `agent` in frontmatter → `type: command` (specificity 18);
`matchers.ts:243-245` maps `model` in frontmatter → `type: agent` (specificity 8).
A markdown task with `agent: agents/reviewer` and `model: opus` in frontmatter
carries two mis-classification signals. Directory residence should win, but the
spec's §6 adapter row says recognition is "`type: task` / `tasks/` residence" —
both need to be *stronger* than these heuristics, and the interaction is
untested. Add a specificity note and a golden.
### m4 — `status: draft` as an execution gate overloads OKF lifecycle
§4: "`status: draft` as author-now, arm-later (draft tasks are never installed by
`sync`)." `status` is the OKF v0.2 lifecycle family (okf-support.md:88-93,
`schemas/akm-asset-envelope.json`), shared by every asset type. Making it a
scheduler gate for one type means the same value has execution semantics on
tasks and none elsewhere, and it duplicates `enabled:` — two off switches with
different names, different origins, and no stated precedence. Recommend: drop it,
or specify the precedence table (`status: draft` + `enabled: true` = ?).
### m5 — Setup wizard will destroy markdown task bodies
`prepareSetupTaskDefinitions` does a full YAML round-trip on the template —
`yamlParse(plan.task.yaml)` → mutate `schedule`/`enabled` → `yamlStringify`
(`src/setup/steps/tasks.ts:161-169`) — and the existing-file path uses a
line-based `setEnabledInYaml`. Both destroy a markdown body. `EmbeddedTask` also
hard-filters `.yml` and `target.kind === "command"` and exposes
`command: string` (`src/tasks/embedded.ts:81,96,42-45`). §8's "the ten embedded
templates convert in this change" covers the ten files but not these three code
seams. (There are ten templates: five in `src/assets/tasks/core/`, five in
`src/assets/tasks/improve/` — that count checks out.)
### m6 — The parser's must-have-a-section rule contradicts optional sections
`src/workflows/parser.ts:282-296` hard-errors on a missing or empty section for
any non-route step, per workflow-format-unification.md:220-224 body rule 2.
§3.3/§3.6 require that a `run:` or `uses:` step need no section. The sibling spec
must be amended in the same change, or the two specs are in direct conflict.
### m7 — `run:` shell semantics change more than the spec says
Today `command:` tasks spawn argv with no shell (`runManagedSubprocess(spawnCmd,
…)`, `src/tasks/runner.ts:277-289`), which is why `resolveNestedAkmCommand`
(`runner.ts:344-348`) can rewrite a bare `akm` token to the resolved
invocation — an argv-only optimization that a shell string breaks. Moving to
`sh -c` also (a) introduces shell metacharacter interpretation over strings that
today are safe, which matters once `env:` values can come from `secrets/`, and
(b) makes the `AKM_ITEM` JSON injection of §3.6 a quoting hazard inside the shell
text. Also: `cwd` defaults to `process.env.HOME ?? os.tmpdir()` today
(`runner.ts:279`) — what is the default under the new `cwd:` field? Unstated.
### m8 — Inherited wart: `prompt: agents/x` ships raw frontmatter to the model
`resolvePromptText` reads the asset file whole — `fs.readFileSync(assetPath,
"utf8")` (`src/tasks/runner.ts:592-593`) — so the agent asset's YAML frontmatter
delimiters and keys are sent to the model as prompt text. The spec's §3.5
correctly identifies that this path drops the model preference; it does not
mention that it also sends frontmatter. Worth naming, since §8's migration
"seeds the body from the agent asset's prompt" and should seed the *body*, not
the file.
### m9 — okf-support.md consistency is not as clean as claimed
The proposal lists okf-support.md as a "Related" consistency anchor. Two frictions:
- okf-support.md:20-22 lists "**YAML tasks**" among things OKF is explicitly
*not* "a serialization imposed on". Converting tasks to OKF-enveloped markdown
doesn't violate the letter (nothing is imposed), but the sentence goes stale and
should be updated in the same change.
- okf-support.md:131-134: "`workflow` is the only type whose frontmatter is parsed
against a closed allowlist, and that allowlist admits these keys". A markdown
task with a closed frontmatter schema becomes the second such type, so
`promoteProposal`'s OKF v0.2 stamping needs the task allowlist to admit
`generated`/`verified`/`provenance`/`status`/`stale_after` — or stamping falls
back to "promote unstamped, warn". Not mentioned in §4 or §6.
### m10 — Golden/test churn is understated
§3.6 prices the flatten as "schema and golden churn on a pre-release format —
approved." The actual surface: the `akm-task` conformance oracle is marked
**"authored, DO NOT modify"** and has four goldens
(`tests/fixtures/format-family-goldens/akm-task/{recognition,placement,lint,renderer}.json`)
plus a fixture bundle (`tests/fixtures/bundles/akm-task/`); `looksLikeRoot`
(`akm-task-adapter.ts:136-155`) is an install-time probe keyed on top-level
`.yml`; there are ~20 task test files, two CLI goldens
(`tests/fixtures/goldens/cli/c-tasks-*.json`), a schema-drift test pinning
`akm-workflow.json` enums against `program/schema.ts` constants
(`program/schema.ts:22-24`), a `tests/integration/tasks-schema.test.ts` that is
the sole runtime consumer of `akm-task.json`, and an
`all-types` stash fixture (`tests/fixtures/stashes/all-types/tasks/*.yml`). Add a
work-item line for the oracle rewrite — "DO NOT modify" implies an explicit
owner decision to re-author it.
---
## Claims verified as accurate (for the record)
Not everything is wrong; these check out precisely and should not be re-litigated:
- §2 "`run` is the canonical orchestrator; `start`/`next`/`complete` removed" —
`src/commands/workflow-cli.ts:522` subcommands are status/list/create/run/brief/
report/abandon/resume.
- §2 "Freeze already implements a configuration cascade … `layers:
EngineUseConfig[] = [documentDefaults, unit]`, selects engine by nearest layer,
resolves model/timeout/request overrides nearest-wins / deep-merged" —
`freeze.ts:50, 151-155, 163-191, 193-209, 211-216`. Exactly right, and it is
the strongest argument in the document.
- §2 `UnitDispatchRequest` carries prompt / frozen engine snapshot / timeoutMs /
env / cwd / sensitiveValues, results carry `failureReason` —
`unit-dispatch.ts:9-43`. (Nit: `failureReason` is typed `string`, not the
`AgentFailureReason` union.)
- §3.4 "`temperature` on an agent-kind engine node remains a config validation
error … `engines.ts` already enforces this, while `model` remains legal on both
kinds" — `engines.ts:105-120` (reject list) and `:71` / `:99`.
- §3.5's description of `resolveModel`'s four-tier chain, `"*"` fallback column,
`resolveLlmModel`'s `llm` column, one resolution level, no recursion —
`model-aliases.ts:11-16, 89-109`. Accurate.
- §3.5 "today the `.yml` task path drops an agent asset's model preference" —
`runner.ts:581-593` resolves the ref to text only; no model is read.
- §6 "`akm-task.json` standalone (87 lines, loaded by nothing at runtime)" — 87
lines, one consumer and it is a test (`tests/integration/tasks-schema.test.ts:8`).
- §6 "`parseTaskDocument()` + `TASK_KEYS` + `rejectTargetFields()` +
`resolvePromptSource()` (368 lines)" — `src/tasks/parser.ts` is 368 lines.
- §6 "`runCommandTask` runs raw argv only" — `runner.ts:277-289`.
- §8 "task ids and the `akm task run <id>` ABI do not change, so nothing is
reinstalled" — `buildScheduledTaskInvocation` /`parseScheduledTaskArgv`
(`scheduler-invocation.ts:59-78, 198-227`) are extension-agnostic; ids derive
from the basename. Correct, provided the id grammar is unchanged
(`src/tasks/task-id.ts`).
- §8 "0.9.0 already has an explicit, journaled, crash-resumable migration … Task
conversion joins it as one more content step" — `scripts/akm-migrate/migrate/
legacy/content-migration.ts:5-45` is exactly that, and its "PRE-RELEASE
EXTENSION NOTE" explicitly blesses growing the fold set in-branch.
---
## Cross-cutting: what will actually be hard
Ranked by hidden cost, not by spec word count:
1. **A second, non-agent dispatch path through the journal** (C4). Everything
downstream of `runUnit` assumes an engine, a prompt, and token usage. Retry,
worktree isolation, budget accounting, `brief`/`report`, and `failure_reason`
mapping all need shell-shaped answers. This is the single largest unscoped item.
2. **IR + hash version bump with a defined preimage** (C3). Mechanically small,
semantically dangerous — a missed field is a silent replay-reuse bug.
3. **Composition at freeze** (M8): async freeze, bundle scoping, lint-time
validation, and provenance of the composed prose in the report surface.
4. **The cascade's config half** (M3): new `DefaultsSchema` fields, new readers in
three freeze helpers, `akm config set` support, and un-retiring `defaults.llm`
in flat form one release after killing it.
5. **The task-runner / orchestrator split** (§6's "honest boundary") is the right
call but leaves the observable-semantics divergence unaddressed: the *same*
task, run by cron, resolves config at dispatch (`runner.ts:476-514`); composed
into a workflow, it resolves at freeze. A user who edits `config.defaults.model`
mid-flight sees one surface change and the other not. §3.5 notes the timing
difference in one clause; it deserves a paragraph and a documented rule.
## Recommended pre-implementation actions
1. Correct or strike C2, M1, M5 — three design decisions rest on inaccurate
readings of the code.
2. Rewrite §10 to enumerate the real IR/freeze/hash changes (C3).
3. Add a §3.8 "shell units in the orchestrator" covering C4 end to end.
4. Add a security paragraph for `uses: scripts/*` (C1) and update
`metadata.ts:140` in the same commit.
5. Remove `params` from the cascade vocabulary (M7); decide the command-template
placeholder syntax explicitly (M2).
6. Complete the §8 migration table and replace "honored structurally" with a
named mechanism (M9, M10).