UNPKG

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

603 lines (468 loc) • 39.9 kB
# Review #3 — `task-workflow-format-unification.md` (v7) + the implementation it builds on Reviewer: independent reviewer #3 of 3. Repo: `/home/user/akm` @ `claude/akm-markdown-tasks-history-75l6du` (baselined on `claude/release-0-9-0-polish-d6sycl`). Scope: the spec end-to-end, every factual claim it makes about the code, and the readiness of the code it plans to build on. --- ## 0. Summary judgement The **direction** is right and the grounding (§1) is sound: `uses:`/`run:`, the prose rule, and "steps are tasks" genuinely reduce concept count, and the target-key XOR really does make a whole error class structurally impossible. The `.yml` task format is, on inspection, worse than the spec says it is (see F-04) and deserves to die. The **v7 configuration section (§3.4) and the non-goals (§10) do not survive contact with the code.** Three load-bearing claims are false: 1. Freeze's "layer cascade" the spec says it is merely *exposing* does **not** include a config defaults layer for anything but `engine` — `defaults.model` does not exist in the config schema and is read nowhere, so the spec's own worked example silently does nothing today. 2. The "kind-gated hard error" the spec says `llm:` exists to scope is **dead code** in `freeze.ts`. The workflow surface already silently drops `llm` overrides on agent engines. 3. §10's "no changes to IR, freeze, journal, leases, or replay" is contradicted by §3.4/§3.6/§3.7 on at least six counts (agent persona → system prompt/tool policy; shell units without an engine; `env` list-of-mappings; `cwd`/`shell`/`params` at dispatch; non-empty-`instructions` invariant; the unit input-hash preimage). This is not authoring-surface-only work; it is an IR v3 schema + hash-version change. Plus two silent-loss bugs the plan would bake in: deleting `unit.output` conflates two *semantically different* schemas (F-05), and moving `command:` argv → `run:` shell text destroys the `akm`-binary resolution seam every shipped template depends on (F-06). Recommendation: **do not sign off decision 14 as written.** §3.4 needs a config-layer reality pass and §10 needs to be rewritten to admit an IR change. §3.3/§3.6 need the input-hash and `instructions` invariants addressed before anyone starts. --- ## 1. Findings table | # | Sev | Title | Evidence | |---|---|---|---| | F-01 | critical | Cascade's "config defaults" layer does not exist; the worked example is a silent no-op | `config-schema.ts:102-108`, `freeze.ts:169-171,193-209` | | F-02 | critical | §10 "no IR/freeze changes" is false — §3.4/§3.6/§3.7 all require IR v3 schema changes | `ir/schema.ts:417-451`, `unit-dispatch.ts:9-31`, `native-executor.ts:1026-1027` | | F-03 | critical | Shell (`run:`) units cannot exist in the current IR: no engine, empty instructions, no runner kind | `freeze.ts:52-62`, `ir/schema.ts:434-441`, `native-executor.ts:1104+` | | F-04 | critical | Unit input-hash preimage is unaddressed — `run:`/`uses:`/`cwd`/`shell`/`params` would not re-dispatch | `step-work.ts:376-392` | | F-05 | major | Deleting the `unit:` bag conflates two distinct `output` schemas and loses per-item validation on `map` | `program/schema.ts:125,186`, `compile.ts:204,229`, `native-executor.ts:54-56,663` | | F-06 | major | `command:` argv → `run:` shell text destroys `resolveNestedAkmCommand` / akm-bin resolution | `runner.ts:263,343-348`, `command-executable.ts:18-22`, `src/assets/tasks/**` | | F-07 | major | The "kind-gated hard error" §3.4 argues against is dead code; workflows already drop `llm` silently | `freeze.ts:67-73` | | F-08 | major | `agent:` as a cascade selector requires system-prompt + tool-policy plumbing that does not exist | `native-executor.ts:1026-1027`, `renderers.ts:252-262`, `IrInvocation` `ir/schema.ts:78-84` | | F-09 | major | `params:` now means three different things; §5 claims this as a simplification | §3.2 vs §5 vs `renderers.ts:164-176`, `ir/params.ts:34-56` | | F-10 | major | Command-template filling is undefined for the actual placeholder grammar (`$ARGUMENTS`, `$1`..`$9`) | `renderers.ts:164-176`, `indexer/walk/matchers.ts:126` | | F-11 | major | `env:` list-of-mappings breaks the IR `string[]` contract and the "names only" hash invariant | `ir/schema.ts:448`, `step-work.ts:353-357,387` | | F-12 | major | Prose-optional on `uses:`/`run:` steps collides with the non-empty-`instructions` invariant | `workflows/parser.ts:281-296`, `ir/schema.ts:434-441` | | F-13 | major | Migrator depends on the live task parser; yml→md is a path-changing content step in a journaled plan | `task-target-ref-migration.ts:27,44-58` | | F-14 | major | Markdown tasks silently inherit the whole markdown lint/OKF/`improve` surface — including body rewrites of live prompts | `akm-adapter.ts:388-434`, `asset-placement.ts:157-169` | | F-15 | minor | Model aliases are *not* portable to LLM endpoints or non-claude/opencode platforms | `model-aliases.ts:45-104` | | F-16 | minor | Gate judges are outside the cascade; §3.4 never says whether that stays | `freeze.ts:109,263-278` | | F-17 | minor | `on_error` is called steps-only in §3.4 but already exists at document defaults | §3.4 vs `program/schema.ts:210`, `compile.ts:231` | | F-18 | minor | Adapter story for `.md` tasks is underspecified and partly contradicts `okf-support.md` | `adapters/index.ts:73-88`, `akm-workflow-adapter.ts:141-155`, `okf-support.md:47-63` | | F-19 | minor | `status: draft` adds a second, orthogonal installation gate next to `enabled:` | §4 vs `tasks.ts:400-445` | | F-20 | minor | `task_history.target_kind` is a persisted enum the new target vocabulary outgrows | `runner.ts:823-830,870-899` | | F-21 | minor | Embedded-template enumeration fails *silently* on format change | `embedded.ts:91-97` | | F-22 | minor | Composition loses single-file provenance (`IrUnitNode.source`) and can blow the 256 KiB instruction cap | `ir/schema.ts:444-445`, `compile.ts:186` | | F-23 | minor | 0.7 md → 0.8 yml → 0.9 md whiplash; the existing collision guard must be inverted | `tasks.ts:154-163` | | F-24 | minor | `prompt: agents/x` sends raw file bytes (frontmatter included) — worse than §3.5 describes | `runner.ts:592-593` | Claims I checked and found **accurate**: `parser.ts` is 368 lines; `akm-task.json` is 87 lines and loaded by nothing at runtime (`tests/integration/tasks-schema.test.ts:8` only); `start`/`next`/ `complete` are gone (`workflow-cli.ts:522-531`); `runWorkflowTask()` → `runWorkflowSteps()` works (`runner.ts:352-416`); `UnitDispatchRequest` carries prompt/engine/timeoutMs/env/cwd/ sensitiveValues and results carry `failureReason` (`unit-dispatch.ts:9-43`); `output` really is declared twice in `akm-workflow.json` (`:232,344`); command assets really do read an `agent:` frontmatter key (`renderers.ts:235`); agent assets really are model-hint-only (`renderers.ts:260`); `temperature` on an agent-kind engine really is a config error (`engines.ts:105-120`); `resolveModel()` really is a one-level four-tier chain (`model-aliases.ts:89-104`). --- ## 2. Critical ### F-01 — The cascade's far layer does not exist. The worked example is a silent no-op. §2 and §3.4 rest on: *"Freeze already implements a configuration cascade … §3.4 exposes this existing mechanism."* The mechanism exists **for the two document layers only**. `freeze.ts:50` builds `layers = [documentDefaults, unit]`. Resolution: - engine — `selectedEngine()` walks layers nearest-first, then falls back to `config.defaults?.engine` (`freeze.ts:151-155`). ✅ config layer exists. - model — `exactModel()` walks layers, then `engine.model` (`freeze.ts:169-171`). **`config.defaults.model` is never consulted.** - timeout — `effectiveTimeout()` walks layers, then `engine.timeoutMs`, then a builtin constant (`freeze.ts:193-209`). **No config-defaults timeout.** - llm overrides — `mergedLlmOverrides()` deep-merges layers only (`freeze.ts:211-216`). No engine-node or config-defaults participation (the engine's own `temperature`/`maxTokens` go into the *snapshot*, `freeze.ts:231-236`, a different path). And the config schema has no such key at all: ```ts // src/core/config/config-schema.ts:102-108 export const DefaultsSchema = z.object({ engine: engineName.optional(), llmEngine: engineName.optional(), improveStrategy: engineName.optional(), }).passthrough(); ``` `.passthrough()` means the spec's literal worked example — `{ "defaults": { "engine": "claude", "model": "sonnet" } }` (§3.4) — **validates today and does absolutely nothing.** A user following the spec's teaching example gets silent configuration loss, the exact failure mode `config-schema.ts:117-120` says the strict schema exists to prevent. So the honest framing is the opposite of the spec's: freeze implements a *two-layer* nearest-wins resolution with per-field engine fallbacks. §3.4 proposes a **five-layer** cascade. Three of those five layers (config defaults, engine node as a uniform layer, persona node) do not exist as layers and must be built, each with its own schema surface. **Recommendation.** Rewrite §2's third bullet and §3.4's opening to say what is true: "freeze resolves two document layers nearest-wins; §3.4 generalizes that to five." Then add an explicit sub-section enumerating the *new config schema surface* (`defaults.model`, `defaults.timeout`, `defaults.env`, …) with `.strict()` on it, plus a migration note that a pre-existing passthrough `defaults.model` would change meaning from inert to load-bearing — which violates §8's "No key survives with a changed meaning." ### F-02 — §10 "no changes to IR, freeze, journal, leases, or replay" is false. §10 asserts the §3.4 cascade and §3.6 flatten are "authoring-surface changes over the layer resolution freeze already performs." Every one of the following requires an IR v3 shape change, each of which trips `assertKeys` (`ir/schema.ts:417-433`) and therefore changes `plan_hash` (`plan-hash.ts:22-34`) for every plan: | §3.x feature | IR field needed | Blocking code | |---|---|---| | `agent:` persona (system prompt) | `IrUnitNode.systemPrompt` | `native-executor.ts:1026-1027` — "`systemPrompt`/`tools`/`cwd` come from the profile/asset, not the workflow unit" | | `agent:` persona (tool policy) | new IR field + dispatch plumbing | no field on `UnitDispatchRequest` at all (`unit-dispatch.ts:9-31`) | | `cwd:` as a value field | `IrUnitNode.cwd` | today `request.cwd` is populated only by worktree isolation | | `shell:` | `IrUnitNode.shell` | no such concept | | `params:` at step level | `IrUnitNode.params` | params are run-level only (`step-work.ts:326`) | | `run:` target | a target discriminator on the node | `node.kind` is `"unit"\|"map"` only (`ir/schema.ts:392`) | | `env:` literal mappings | `env` type widened | `validateStringArray(node.env, …)` (`ir/schema.ts:448`) | `IrInvocation` today is exactly `{engine, model, timeoutMs, llm?}` (`ir/schema.ts:78-84`). **Recommendation.** Replace §10's first bullet with an explicit IR-change budget: list the new `IrUnitNode`/`IrInvocation` fields, state whether `WORKFLOW_IR_VERSION` bumps to 4 (it should — the plan classifier already has an `unsupported-version` arm, `plan-classifier.ts:14`), and note that every frozen-plan golden and the replay-fuzz suite re-baselines. Pre-release makes this cheap; the spec pretending it is free makes it *unschedulable*. ### F-03 — A `run:` step cannot be frozen at all today. §3.6 says a shell unit "journals like any unit (`workflow_run_units`): no tokens, but status, timing, attempts, and `failure_reason` behave normally." Three hard blocks: 1. **Engine required.** `freezeInvocation()` (`freeze.ts:49-75`) throws `ConfigError("No workflow engine is selected…")` when no layer and no `config.defaults.engine` names one. A workflow of nothing but `run:` steps therefore **fails to freeze on a machine with no agent CLI installed** — the single most obvious reason to want shell steps. 2. **Non-empty instructions required.** `ir/schema.ts:434-441` fails a unit whose `instructions` is not a non-empty string. A `run:` step whose prose section is optional (§3.3/§4) produces `""` (`compile.ts:174`). 3. **No shell runner.** `frozenUnitRunner`/`defaultUnitDispatcher` branch on `llm` / `sdk` / `agent` (`native-executor.ts:1076,1104`). `FrozenEngineSnapshot` is `FrozenLlmEngine | FrozenAgentEngine`. There is no fourth kind, and the two guards at `native-executor.ts:1050-1074` (env-unsupported, isolation-unsupported) are written assuming a child process implies an agent. **Recommendation.** §3.6 must specify a `kind: "shell"` frozen node with **no** `invocation`, make `instructions` optional-when-shell (or synthesise the `run:` text as the instruction bytes), and say explicitly that a shell-only workflow must freeze without any engine configured. Otherwise the headline "`run:` is inline shell, no AI" is undeliverable. ### F-04 — The unit input-hash preimage is never mentioned; replay would be wrong. `step-work.ts:376-392` computes the per-unit `inputHash` over a fixed preimage (`hashVersion: 4`): `template.instructions`, `item`, `inputs`, `params`, `dispatch`, `invocation`, `schema`, `env`, `isolation`, `gateFeedback`. Its own comment states the contract: *"Every field here is a PLAN-FROZEN input that changes what the backend is actually asked to do, so a completed unit is reused ONLY when all of them match."* None of the new dispatch-significant surface is in that preimage: - the `run:` shell text (for a shell unit `instructions` may be empty or unrelated prose — editing `run: bun test` → `run: bun test --coverage` would **reuse the completed row**); - `uses:` ref and the resolved target; - `cwd`, `shell`, step-level `params`; - the appended `uses:`-step prose (§3.3) if the concat happens at prompt assembly rather than at freeze — `instructions` is what is hashed, not the assembled `prompt`. That last one is subtle and important. §3.3 says the append is "a deterministic concat" at "one seam". If that seam is `buildUnitPrompt` (`step-work.ts:331`), the hash preimage does not see it and two materially different asks hash identically — the same class of bug the `gateFeedback` inclusion (`step-work.ts:363-370`) was added to fix. **Recommendation.** Add a §3.8 "Replay identity": every new field enters the preimage, `hashVersion` becomes 5, and the `uses:` prose append happens **at freeze**, folded into `instructions`, so the existing preimage covers it for free. Also note the one-time consequence: bumping `hashVersion` invalidates reuse for every in-flight run at cutover. --- ## 3. Major ### F-05 — Deleting the `unit:` bag silently deletes per-item output validation. §3.6: *"`output:` is declared **both** on the step and inside the bag — a live wart … The duplicate `output` declaration disappears with the bag."* They are not duplicates. - `unit.output` → `WorkflowUnitDraft.schema` (`compile.ts:229`) → `IrUnitNode.schema` → `UnitDispatchRequest.schema` (`native-executor.ts:663`) → constrains the harness's structured output and drives `runStructured` / `parse_error` retries (`native-executor.ts:928-929,1038,1092`). - `step.output` → `IrStepPlan.outputSchema` → validates the **promoted step artifact** after reduction, with a distinct failure mode that feeds the gate loop (`native-executor.ts:54-62`: "the promoted artifact is validated … flagged (`artifactSchemaFailure`) so the engine's bounded gate loop can re-run the step"). For a `map` step these are necessarily different schemas: `map.unit.output` types *one item's* result, `step.output` types the *collected* array. Collapsing them onto one step-level `output:` key makes per-item structured output unexpressible on fan-out, and quietly disables constrained generation for every mapped unit. **Recommendation.** Keep two keys under distinct names after the flatten — e.g. `output:` (step artifact, unchanged) and `result:`/`unit_output:` (per-unit/per-item schema). Correct the §3.6 "wart" claim and the §6 table row that repeats it. ### F-06 — `run:` shell text destroys akm-binary resolution for every shipped template. Today `command:` is **pre-split argv** (`parser.ts:300-306`) executed without a shell (`runner.ts:277`), and before spawning, `resolveNestedAkmCommand()` (`runner.ts:343-348`) rewrites a bare `akm` in the executable position to the *current installation's* invocation via `resolveAkmInvocation()` — including the `env VAR=… akm …` form (`command-executable.ts:10-22`). All ten shipped templates are exactly this shape: ```yaml # src/assets/tasks/core/improve.yml command: akm improve ``` Under `run: akm improve` executed by `sh -c`, that rewrite is impossible without shell-text parsing — which the spec explicitly wants to avoid ("no ref-vs-inline sniffing exists anywhere in the format"). The scheduled invocation captures `PATH` in its descriptor (`scheduler-invocation.ts:86`), so a globally-installed binary survives; a `bun run src/index.ts` dev install, a version-managed install, or a compiled binary not on the captured `PATH` does not. `akm task doctor` already flags `path-selected` bindings as a distinct status (`tasks.ts:739`) — this change makes *every* shell task path-selected. Related: `runCommandTask` runs with `cwd: process.env.HOME ?? os.tmpdir()` (`runner.ts:279`) while `runPromptTask` uses `cwd: stashDir` (`runner.ts:522`). §3.4 introduces `cwd:` as a value field but never states the default, and the two existing paths disagree. **Recommendation.** §3.6 must state: (a) how `akm` self-reference is resolved under shell execution — the honest answer is probably to inject `AKM_BIN` into the env and document `$AKM_BIN improve` in templates, or to prepend the resolved bin dir to `PATH`; (b) the default `cwd` for `run:` in tasks vs steps, explicitly reconciling `runner.ts:279` and `:522`. ### F-07 — The hard error §3.4 argues against does not fire. §3.4: *"`llm:` … was a grouping whose only job was to scope a kind-gated hard error, and that error is incompatible with a cascade."* In `freeze.ts`: ```ts 67 const llm = engine.kind === "llm" ? mergedLlmOverrides(layers) : undefined; 68 if (engine.kind !== "llm" && llm !== undefined) { 69-73 throw new ConfigError(`Workflow engine "${name}" is an agent engine and cannot receive llm overrides.`, …); ``` Line 68 is **unreachable**: `llm` is `undefined` in exactly the branch the guard tests. So today a workflow that writes `defaults: { llm: { temperature: 0.2 } }` under an agent engine gets **silent drop**, not an error. The real hard error lives on the *task* path (`runner.ts:497-501`) and in `akm-task.json`'s `oneOf` (`:36-40`). This matters two ways. First, the spec's premise is wrong about where the behavior is, which weakens the case for dissolving `llm:` (the argument reduces to "the *task* error must relax", which is true but much narrower). Second, the proposed capability notice is a strict *improvement* over the current silent drop — the spec should claim that credit rather than argue against a guard that never runs. **Recommendation.** Fix `freeze.ts:67-73` independently of this proposal (compute `mergedLlmOverrides(layers)` unconditionally, then branch). Amend §3.4 to describe the actual state: workflow = silent drop (bug), task = hard error, target = uniform notice. ### F-08 — `agent:` as a selector needs persona plumbing that does not exist. §3.4 defines `agent:` as picking "the persona node — *who* runs it (system prompt, tool policy, model preference)". Today an agent asset yields three things (`renderers.ts:252-262`): `prompt` (system prompt), `toolPolicy` (`tools` frontmatter), `modelHint` (`model`). Only the third is in the spec's flat value vocabulary. The other two are the ones that make a persona a persona, and neither can reach a workflow unit: `native-executor.ts:1026-1027` states outright that `systemPrompt`/`tools`/`cwd` "come from the profile/asset, not the workflow unit". `UnitDispatchRequest.systemPrompt` exists but is used only by the frozen gate judge (`frozen-judge.ts:38`), and for agent-CLI dispatch it is passed through `buildAgentDispatchRequest` (`native-executor.ts:1035`) with no tool-policy channel at all. There is also a **security seam** the spec walks past: `renderers.ts:246-250` documents that the self-declared `tools` policy is subject to a *provenance ceiling* applied at the show layer, which knows whether the asset came from the operator's own writable stash or a read-only third-party source. Making `agent:` a first-class cascade selector resolved at freeze moves tool-policy selection to a layer that does **not** have that provenance context. A third-party bundle shipping `agents/helper` with a permissive `tools:` block, referenced by `agent: agents/helper` in a workflow step, would bypass the ceiling. **Recommendation.** Either (a) scope `agent:` to model-preference-only in 0.9.0 and say so explicitly, or (b) add a §3.4 sub-section covering system-prompt and tool-policy plumbing plus the provenance ceiling, and move the IR cost into the §10 budget (F-02). ### F-09 — `params:` acquires three meanings; §5 sells that as simplification. §5: *"`inputs:` declared / `with:` passed → `params:` both places — one word beats two."* After this change `params:` means: 1. **Declarations** at workflow document level: name → JSON Schema (`ir/params.ts:34-56`, `akm-workflow.json:26-36`). Not values. 2. **Workflow run arguments** on `uses: workflows/*` (§3.2) — coerced through those schemas. 3. **Template placeholder fills** on `uses: commands/*` (§3.2) — an entirely different mechanism (see F-10). 4. And, per §3.4, a **cascading value field legal at every layer** — so `defaults: { params: … }` would be values sitting one nesting level away from root `params:` which are schemas. This is precisely the "three overloaded meanings" indictment the spec levels at `prompt:` in §3.1. Worse, GHA's `inputs:`/`with:` split exists *because* the two are different things. **Recommendation.** Keep two words. `params:` for declarations (existing), `with:` for passed values at any call site. It is one extra word against four collapsed meanings, and it matches the GHA muscle memory §5 is otherwise chasing. ### F-10 — Command-template filling is undefined against the real placeholder grammar. §3.2: *"`uses: commands/<name>` … `params:` fill the placeholders … a placeholder with no matching param is an error at fill time."* The actual command placeholder grammar is **positional plus named**: `$ARGUMENTS`, `$1`–`$9`, and `{{named}}` (`renderers.ts:164-176`, `indexer/passes/metadata.ts:438-444`, `indexer/walk/matchers.ts:126` — `$ARGUMENTS|$[123]\b` is part of *recognizing* a file as a command asset). A `params:` **mapping** has no defined projection onto `$ARGUMENTS` or `$1`. Combined with the hard-error rule, this is actively breaking: `$ARGUMENTS` is the single most common placeholder, and a scheduled task has no arguments, so `uses: commands/<anything-with-$ARGUMENTS>` becomes an unconditional freeze/fill error. **Recommendation.** §3.2 must state the mapping explicitly: `{{named}}` ← `params.<name>`; `$1`–`$9` ← a positional `args:` list or `params: { "1": … }`; `$ARGUMENTS` ← the joined positional list, defaulting to empty string rather than erroring. Also decide the converse case the spec omits: a `params:` key with no matching placeholder (recommend: notice, consistent with §3.4's capability-notice stance). ### F-11 — `env:` as a list of mappings breaks two existing contracts. §3.7 claims the change "extends the existing schema additively — the workflow `unit.env` is already a list of ref strings, so every currently-valid value stays valid." True of the *authoring* schema (`akm-workflow.json:236-243`). False downstream: - `ir/schema.ts:448`: `validateStringArray(node.env, …)` — a list containing a mapping fails plan decode. - `step-work.ts:353-357`: the input hash includes `env: template.env ?? null` with the documented invariant *"`env` carries NAMES ONLY, never resolved values: hashing a resolved secret would leak it into a durable hash oracle."* Literal mappings are values. Inline literals would land in both `plan_json` and the durable hash. - The spec also never says how `env` list concatenation interacts with **key collisions across layers**: "later entries win" is stated for entries, but an `env/prod` group entry and a literal entry can define the same key — is the winner determined by list position after concatenation (yes, presumably) or by layer? Say it. - `ProgramDefaults` (`program/schema.ts:204-211`) has **no** `env` field, so "legal at every layer" is new surface at the document-defaults layer too. **Recommendation.** State the IR representation for the new `env` shape (recommend: freeze normalizes to an ordered list of `{ref}` / `{literals}` records, hash only refs + literal *keys*, never literal values that resolve from `secrets/`), and add `env` to `ProgramDefaults`. ### F-12 — Optional prose on `uses:`/`run:` steps contradicts a hard parser + IR rule. `workflows/parser.ts:281-296` errors when a unit/map step has no `## <id>` section, and errors again when the section is empty. `ir/schema.ts:434-441` independently rejects empty `instructions`. §3.3 ("Sections on `uses:` steps remain optional either way") and §3.6 (`run: bun test` with no section) both require relaxing this. That relaxation is not free: the "every unit step has prose" rule is what makes the prose rule teachable and what the `instructions`-is-the-hash-preimage design leans on (`step-work.ts:378`). **Recommendation.** Spell out the new body rule as a table: (no target → section **required**), (`uses: commands/*` / `uses: tasks/<agent-task>` → optional, appended), (`run:` / `uses: scripts/*` → optional, documentation only). And say what fills `instructions` in the IR when the section is absent. ### F-13 — The migrator depends on the live parser, and yml→md is a path change inside a journaled plan. `scripts/akm-migrate/migrate/legacy/task-target-ref-migration.ts:27` imports `parseTaskDocument` from `src/tasks/parser`, and its module doc says *"live task parsing remains strict 0.9 grammar."* Replacing that parser with a markdown one breaks the migrator, which must read the **old** format. Second problem: this migrator's plan is a list of `{filePath, before: Buffer, after: Buffer, mode}` rewrites with `durabilityPaths` and a journaled, crash-resumable apply phase (`:44-58` and the module doc). A format conversion **renames** `tasks/<id>.yml` → `tasks/<id>.md`. In-place byte rewrite and rename-plus-write are different durability shapes; a crash between them leaves both files present, which is exactly the "leftover files must never be silently invisible" lesson §8 invokes. Note also that `akmTasksSync` reads only `*.yml` today (`tasks.ts:414`) and `akmTasksAdd` already has an *inverse* collision guard (`tasks.ts:154-163`, "a leftover `<id>.md` still names the same task") that must be flipped. **Recommendation.** §8 should require (a) a frozen copy of the v2 parser vendored into `scripts/akm-migrate/`, with a note that `src/tasks/parser.ts` is free to change; (b) an explicit ordering constraint (ref-rewrite phase completes before format-conversion phase, or they merge into one phase); (c) the rename be journaled as delete+create with both paths in `durabilityPaths`. ### F-14 — Markdown tasks inherit the whole markdown lint / OKF / `improve` surface, including body rewrites. `akm-adapter.ts:401-414` special-cases `type === "task"` to parse as **pure YAML with `frontmatter: null`**, and the comment explains why: *"so the TaskLinter's field checks see real data and `missing-updated` never fires."* Once tasks are markdown with real frontmatter, that branch goes away and tasks join `runBaseValidateChecks` — `missing-updated`, `missing-description`, staleness, OKF stamping, the lot. §6's "Envelope … future stamped keys inherited free" is true, and also means the lint output for every existing task changes on day one. The bigger issue is `improve`. A task body is now indexed prose *and*, for an agent task, the literal executable prompt (§4, §3.1: "the prose *is* the work"). The improve pipeline semantically rewrites markdown asset bodies. That means an automated pass can change what a scheduled agent job does, with no scheduler-side signal — the file mtime changes but `akm task sync` only reconciles `schedule`/`enabled` drift (`tasks.ts:388`), not body drift. **Recommendation.** §4 must state whether task bodies are `improve`-eligible. Recommend: exclude `tasks/` from semantic rewrite passes in 0.9.0, and add a §6 row acknowledging the lint-surface change with an explicit list of newly-applicable base checks. --- ## 4. Minor ### F-15 — Model aliases are not as portable as §3.5 claims. §3.5: *"`model: sonnet` resolves to the right string under a claude engine, an opencode engine, or an LLM endpoint."* `BUILTIN_ALIASES` has exactly two platform columns, `claude` and `opencode` (`model-aliases.ts:45-76`). `resolveModel` returns `entry?.platforms[platform] ?? model` (`:103`) — so under any other harness id in `VALID_HARNESS_IDS` (codex, copilot, gemini, pi, … referenced at `native-executor.ts:1016-1022`) `sonnet` passes through **verbatim** and the CLI gets a bogus model string. For an LLM endpoint, `resolveLlmModel` looks up `tier[engineName] ?? tier["llm"] ?? tier["*"]` (`:100,108`) — none of which the builtin table has — so it also passes through verbatim unless the user has hand-written a global `modelAliases` entry. Since §3.4's whole portability argument is "a persona node saying `model: opus` works under any engine", this is load-bearing. **Recommendation.** Soften §3.5 to "resolves under claude and opencode; other platforms and LLM endpoints require a `modelAliases` entry", and add a §6 row for extending the builtin table (or emitting an alias-unresolved notice, consistent with the capability-notice stance). ### F-16 — Gate judges sit outside the cascade. `freezeGateJudge` (`freeze.ts:263-278`) resolves entirely from `config.workflow.judgeEngine` with `layers = []`. §3.4 never mentions gate judges. Does a step's `model:`/`agent:` apply to its gate? (Almost certainly it should not — but the "legal at every layer, nearest wins" sentence implies it does.) **Recommendation.** One sentence in §3.4: gate judges resolve from `workflow.judgeEngine` only and are outside the cascade. ### F-17 — `on_error` is called steps-only but already lives at document defaults. §3.4: *"**graph keys** (`map`, `route`, `inputs`, `output`, `gate`, `retry`, `on_error`, `isolation`) are steps-only."* `ProgramDefaults.onError` exists (`program/schema.ts:210`), is schema-declared (`akm-workflow.json:199-201`), and is consumed (`compile.ts:231`: `unit?.onError ?? defaults?.onError ?? "fail"`). Removing it is an unannounced breaking change; keeping it makes the tier rule false as stated. **Recommendation.** Either exempt `on_error` (and note it), or list it in §8's conversion table as removed. ### F-18 — Adapter recognition for `.md` tasks is underspecified and partly contradicts `okf-support.md`. §6's last row: *"markdown task recognized by `type: task` / `tasks/` residence."* - `okf-support.md:60-62` states the opposite rule for the `akm` adapter: *"The `akm` adapter still derives native identity and capability from its directory, extension, filename, and content rules; **frontmatter `type` does not override those rules**."* Residence-based recognition is consistent; `type: task`-based recognition is not. - The standalone `akm-task` adapter currently keys entirely on `.yml` (`akm-task-adapter.ts:42,53,106,144`) and `directoryList()` returns `["."]` — tasks live *anywhere* under the component root, so "tasks/ residence" does not describe it either. - Ordering: `akmWorkflowAdapter` precedes `akmTaskAdapter` (`adapters/index.ts:81-83`), and its `looksLikeRoot` fires on a top-level `.md` with `type: workflow` (`akm-workflow-adapter.ts:141-155`). A markdown-task bundle root needs a symmetric probe (`type: task`), and the two probes must be proven disjoint — the goldens under `tests/fixtures/format-family-goldens/akm-task/` all assume `.yml`. **Recommendation.** Add a §6 sub-item covering the `akm-task` adapter rewrite: `.md` extension, `type: task` recognition *within* an akm-task bundle, an explicit `type: task` `looksLikeRoot` probe, and a note that the four `akm-task` goldens re-baseline. ### F-19 — `status: draft` is a second, orthogonal installation gate. §4: *"`status: draft` as author-now, arm-later (draft tasks are never installed by `sync`)."* There is already `enabled:` (`tasks.ts:445+`, `runner.ts:169`) and a `disabled` run status (`runner.ts:179`). Now there are two gates with different vocabularies, and the spec does not say what `status: deprecated` does, what `enabled: true, status: draft` means, or how `akm task list`/`doctor` report the distinction. Since OKF `status` is a lifecycle field that tooling can rewrite, this couples scheduler state to a documentation field. **Recommendation.** Pick one. Recommend: `status:` is documentation only and never gates installation; `enabled:` remains the sole gate. ### F-20 — `task_history.target_kind` is a persisted enum the new vocabulary outgrows. `appendHistory` writes `target_kind` ∈ {workflow, command, prompt} and `target_ref` only for workflow (`runner.ts:823-824`); `taskHistoryRowToResult` decodes exactly those three (`:875-888`), with a `metadataVersion` 1/2 split already in place. The new target space is {prose, `uses:commands/*`, `uses:scripts/*`, `uses:workflows/*`, `run:`}. §8's "the `akm task run <id>` ABI does not change" is about argv, but history rows are also an ABI (`akm task history` is a documented surface). **Recommendation.** Add a `metadataVersion: 3` row to §8 with the old→new `target_kind` mapping and a statement that historical rows continue to decode. ### F-21 — Embedded-template enumeration fails silently on format change. `listEmbeddedTasks()` swallows parse errors (`embedded.ts:91-96`: `catch { continue }`) and skips non-command targets (`:97`). Converting the ten templates to markdown without updating this function yields an **empty template list in `akm setup`** with no error — the wizard just offers nothing. §8 says the templates "convert in this change"; it should also say `embedded.ts` converts with them and that the silent `continue` becomes a loud failure. ### F-22 — Composition loses provenance and can exceed the instruction cap. `IrUnitNode.source` is a single `SourceRef` (`ir/schema.ts:444-445`, `compile.ts:186`). A step composed from `uses: tasks/<id>` has two source files (the task and the workflow). One of them will be dropped from every error message and lint pointer. Separately, `instructions` is capped at 256 KiB (`ir/schema.ts:444`); task body + appended step section makes overflow reachable in a way it was not before, and the error would name only one file. **Recommendation.** Either make `source` an array, or state explicitly that a composed unit's `source` points at the *step* and the referenced task's path is carried in a new sibling field. ### F-23 — Format whiplash and an inverted collision guard. `tasks.ts:154-163` exists because *"Pre-0.8.0 tasks were markdown; the 0.8.0 cutover moved them to pure YAML … A leftover `<id>.md` still names the same task, so creating `<id>.yml` beside it must collide loudly."* §4 calls the new format "the 0.7.x markdown task restored". Users who ran the documented `v0.7-to-v0.8` migration will run it back. That is defensible for a pre-1.0 project but should be *stated* in §8 rather than framed as pure forward progress — and the guard must be inverted (a leftover `<id>.yml` must now collide with `<id>.md`), which is exactly the failure mode §8's "never silently invisible" bullet targets. ### F-24 — `prompt: agents/x` is worse than §3.5 says. §3.5: *"the `.yml` task path **drops** an agent asset's model preference — `prompt: agents/x` resolves the asset to prompt text only."* `resolvePromptText` does `fs.readFileSync(assetPath, "utf8")` with **no frontmatter parse** (`runner.ts:592-593`). So the agent's YAML frontmatter — `model:`, `tools:`, `description:` — is sent to the model **as part of the prompt text**. Same for `prompt: commands/x`: the raw template with unfilled `$ARGUMENTS` placeholders goes out verbatim. This is a real bug in the current implementation and strengthens the case for the change; the spec should cite it accurately as a §6 "Today" entry. --- ## 5. Cross-cutting: what this actually costs The spec's §6 "shared plumbing" table reads as a code-*reduction* story. Counting the work the findings above surface, the honest ledger is: **Genuinely deleted:** `resolvePromptSource` sniffing, `rejectTargetFields`, `TASK_KEYS`, `akm-task.json`'s standalone target `oneOf`, the ~12-flag `task add` surface, the `unit:` bag nesting. Real, and worth having. **Genuinely shared for the first time:** target resolution by ref subdir, command-template filling, env assembly + redaction, the value-field cascade. Also real. **Net-new, not in the table:** an IR v3 schema extension + version bump (F-02); a shell frozen node kind and runner (F-03); an input-hash version bump and preimage extension (F-04); a new config `defaults.*` schema surface (F-01); persona system-prompt/tool-policy plumbing with a provenance ceiling (F-08); a placeholder-fill grammar (F-10); an env normalizer with a hash-safety rule (F-11); a vendored legacy parser + a rename-safe migration phase (F-13); a rewritten `akm-task` adapter + four goldens (F-18); `embedded.ts` (F-21). **Test/golden churn:** 14 test files reference `unit:` (`tests/integration/workflows/*`, `tests/workflows/*`, including `conformance/`, `driver-parity`, and `fuzz/replay-fuzz`); `tests/fixtures/format-family-goldens/akm-task/{recognition,placement,lint,renderer}.json` all assume `.yml`; every frozen-plan golden re-baselines on the IR bump; `docs/reference/workflows.md` and `stash-conventions-code-spec.md` both document `unit:`. None of that is a reason not to do it. It is a reason the spec should not say §10. --- ## 6. Answer to §9's remaining question > Confirm the cascade model and the dissolution of `llm:`. **Dissolving `llm:` — yes.** The grouping earns nothing: its workflow-side guard is dead code (F-07), its task-side guard is a schema `oneOf` that a cascade cannot express anyway, and flat `temperature`/`max_tokens`/`extra_params` with engine-capability notices is strictly better than today's silent drop. Do fix `freeze.ts:67-73` first so the before/after comparison is honest. **The cascade model — yes in shape, not as specified.** Nearest-wins layering is the right mental model and matches what users know. But the spec currently sells it as *exposing an existing mechanism* when three of its five layers do not exist (F-01), and buys that framing by asserting a no-op change budget (F-02). Sign off on the *model*; require §3.4 and §10 to be rewritten with the real layer inventory and the real IR cost before implementation starts. Specifically, before sign-off I would want: the config-schema addition enumerated, `params:` de-overloaded (F-09), and the merge semantics for mapping-valued fields (`params`, `extra_params`) stated — the spec says "merged per-field, nearest wins" but `freeze.ts:211-216` deep-merges `llm` today, and those are different answers.