Task / Workflow Unification: One Target Vocabulary, One Prose Rule
Status: PROPOSAL v9 — v8 (the post-Opus-review redesign) revised against a
four-lens Sonnet panel, then re-verified claim-by-claim against the current
baseline (98 claims checked, 22 corrected; a further 7 corrections from an
audit of that pass). Review corpus:
reviews/task-workflow-unification/.
Baselined on origin/main @ 17a0dca, which merged the 0.9 polish branch
(71bb686). Breaking changes are approved for 0.9.0.
Date: 2026-08-01
Related: workflow-format-unification.md,
okf-support.md,
docs/reference/workflows.md
2026-08-18 owner clarification: Agent, Command, Engine, and Model Resolution is authoritative wherever this proposal discusses agent personas, command execution, command placeholders, engine/model cascading, tool policy, capability handling, or model aliases. In particular, this proposal's
with:/named/positional command-template grammar, primary-bundle tool-policy ceiling, static engine-capability declarations, and built-in wildcard alias recommendations are superseded. The owner subsequently settled the 0.9.2 source-format direction in the 0.9.2 implementation plan: task v3 is GitHub-step-shaped, AKM Markdown and GitHub-shaped YAML are peer workflow formats, and both compile through one internal versioned IR. The concrete schemas and examples below are therefore non-normative. The still-compatible concepts — tasks as scheduling wrappers, agents as selectors rather than targets, task composition in workflow steps, live task resolution, and frozen workflow resolution — remain design input.
1. Grounding — what tasks and workflows are in akm
Owner's model; everything below derives from it.
- A task is a bundle asset that abstracts over OS-scheduled work — discovered like any asset, enabled into a cron/launchd/schtasks entry that executes the defined work or dispatches an agent with the task's prompt. A platform-agnostic way to share repeatable processes behind one CLI.
- A workflow step is a task — defined inline or referenced by ref — so the same repeatable process is composable without duplicating content or config.
- Fewer concepts, lower cognitive load. akm is a simple, intuitive abstraction layer over the tools and processes autonomous agents use.
Not in scope: tasks do not grow steps:; workflows do not grow schedule:.
The unit of sharing is the asset, of scheduling the task, of procedure the
workflow.
2. The format (stable since v5–v6, corrections applied)
2.1 Targets
| Declaration | Work performed |
|---|---|
| (none) | Agent, prompted with the nearest prose — a task's body, or a step's ## <id> section |
uses: <asset-ref> |
Execute the referenced asset per its subdir-declared type (§2.2) |
run: <shell text> |
Shell, no AI — GitHub-Actions semantics; sh on POSIX, powershell on Windows, shell: override |
uses: xor run: — one schema rule; the double-target error class is
structurally impossible. No ref-vs-inline sniffing exists.
The prose rule. The machine surface names the target; the nearest prose is the instructions. With no target key, the prose is the work.
2.2 uses: — a closed set of four executable types
| Ref | Execution | Inputs |
|---|---|---|
uses: commands/<n> |
Fill the template (§2.4), dispatch an agent | with: |
uses: scripts/<n> |
Execute per the script's run/setup/cwd — gated by the execution-activation policy (§5.6) |
env: |
uses: tasks/<n> (steps only) |
Compose the task as this step's work (§2.3) | the task's own, overridable |
uses: workflows/<r> (tasks only) |
runWorkflowSteps() |
with: → declared param flags |
Any other subdir (including agents/ — a persona, not work) is an error
with a targeted hint. with: replaces v5–v7's call-site params: —
review finding M1 (3/3): params: already means declarations (name →
JSON Schema) in workflow frontmatter, and one key carrying declarations,
run-arguments, placeholder fills, and a cascading value was the same
overload this spec deletes prompt: for. GHA's inputs:/with: split
exists precisely here, and §2.4/§4 give with: exactly one meaning:
inputs handed to the referenced asset, never cascaded.
Two uses: modes differ in kind, named for authors: tasks/<n>
composes — the referenced definition becomes this step's own work —
while workflows/<r> invokes — a child run executes and returns.
with: merge on a composition chain (a step referencing a task that
itself targets a command or workflow) is per-key shallow merge, referencing
step wins; with.arguments is one key, replaced whole. The merged mapping
feeds the final target: placeholder fill for a command, declared param
flags for a workflow.
2.3 Steps are tasks — composition by reference
steps:
- id: lint
uses: tasks/lint-check # the task's target, fields, env, and body
timeout: 2m # call-site override (one cascade layer nearer)
- id: fix
run: bun run fix # inline task: same keys, defined here
- Step keys override the referenced task's keys — the task and the step are
adjacent cascade layers (§4);
env:concatenates task-then-step. - Trigger keys (
schedule,enabled) are consumed only byakm task syncand, at fire time, the task runner's enabled gate (src/tasks/runner.ts:169— v7's "sync-only" wording corrected). Anywhere else they are no-ops with a lint notice; a referenced task cannot double-fire. - Call-site prose is appended at freeze, not at dispatch. Review finding (3/3): there is no "one assembled prompt seam" — prompts are built per-unit at dispatch, and the unit hash covers the frozen template, not the assembled string. So: when the resolved work is agent-dispatched, the step's section text is concatenated onto the frozen instruction template (blank-line separator, byte-exact) at freeze, which puts it inside the hash preimage automatically and keeps the append ahead of the item/inputs/gate blocks. For shell work the section is documentation, ignored at runtime.
- Refs resolve at freeze (§5.4), so in-flight runs are immune to edits.
- No nesting: a referenced task targeting
workflows/*is a compile error on a step; a task referencing a task is an error.
2.4 Command templates — filled against the real placeholder grammar
Review finding M2 (3/3): command placeholders are $ARGUMENTS / $1–$9
(positional) and {{name}} per the advertised grammar
(extractParameters, src/output/renderers.ts:164-186); the actual
filler is fillPlaceholders() in
src/commands/agent/agent-dispatch.ts:67-72, which substitutes only
positional {{0}} forms, leniently — a live bug fixed by this work, and
a second call site the new with: contract replaces. Three further
grammars for the same placeholders exist today and diverge from each
other: the indexer's recognition sniff COMMAND_PLACEHOLDER_RE
(src/indexer/walk/matchers.ts:126, consumed at :239) matching only
$ARGUMENTS/$1–$3; extractCommandParameters
(src/indexer/passes/metadata.ts:440-461), a near-clone of the display
grammar that differs from it in case-sensitivity and digit bounding; and
a lenient sniff at
src/core/adapter/adapters/tool-dir-shared.ts:201-204.
Convergence is scoped to the fill and display grammars — the two
recognition sniffs keep their $-only forms. Adding {{name}} to
COMMAND_PLACEHOLDER_RE would be a live mis-classification hazard, not a
cleanup: it returns specificity 18, outranking the directory rules
(10/15), so any knowledge/, facts/, or instructions/ .md
containing a mustache token would be re-typed command. The recognition
gap is narrower than it looks anyway — classifyByDirectory /
classifyByParentDirHint (matchers.ts:43-47, :147-156, :178-197)
already type any .md under commands/ as a command at specificity
10/15, beating classifyBySmartMd's knowledge (5). The sniff is only
the fallback for command-shaped .md living outside commands/
without tools:/agent: frontmatter. The contract, defined against that
reality:
with:mapping keys fill{{name}}placeholders.- The reserved key
with.arguments(string) fills$ARGUMENTS, and its whitespace-split words fill$1–$9. - An unmatched placeholder or unused
with:key is a lint warning and is left verbatim — not a runtime hard error, so imported$ARGUMENTScommands remain usable as targets. with.argumentsis reserved: a literal{{arguments}}placeholder is filled by it too, with a lint warning about the shadowing. Words past$9remain reachable only through$ARGUMENTS.
Filling happens at freeze (workflows) / dispatch (tasks), producing the prompt string. Bodies remain verbatim — templating stays scoped to the command asset type, whose definition is "a template with placeholders."
3. The task asset
<bundle>/tasks/<id>.md: the shared envelope ($ref akm-asset-envelope.json, OKF v0.2 families included), trigger keys, at
most one target, and any §4 fields. No version: key. Recognition
requires frontmatter type: task, honored in two subsystems (R2
finding: v8 cited only one). At the bundle-adapter layer, the workflow
adapter is ordered ahead of the task and generic akm adapters in the
source-root probe list (6th of 11 —
src/core/adapter/adapters/index.ts:73-90; that array is looksLikeRoot
probe order, and only the adapter that claims a root ever calls
recognize() on its files), and inside an akm-workflow bundle it claims
every .md file whose frontmatter does not declare a contrary type:
(akm-workflow-adapter.ts:67-70, :160). At the per-file
indexer classifier — the path that actually types tasks/*.md during
normal indexing — src/indexer/walk/matchers.ts gains a type: task
branch ordered before its existing "agent" in fm → command rule
(matchers.ts:234-236), which would otherwise misclassify any task
carrying an agent: selector — including this section's own example.
Residence is a lint expectation, not a recognition signal.
---
type: task
description: Weekly review digest.
schedule: "0 8 * * 1"
agent: agents/reviewer
timeout: 10m
---
Review the week's completed tasks and summarize action items.
---
type: task
description: Full nightly quality sweep.
schedule: "15 2 * * *"
run: akm improve --strategy thorough --skip-if-locked
---
# Nightly improve sweep
Runbook prose: indexed, shown, never executed.
---
type: task
schedule: "@daily"
uses: commands/weekly-review
with: { scope: team }
env: [env/prod]
---
Lifecycle unchanged: discover via search/show; enable via enabled: +
task sync; the OS entry still invokes akm task run <id>. Lifecycle
interactions pinned (review minors): status: draft → sync never
installs, regardless of enabled:; status: deprecated → installs with a
warning; a previously-installed task that becomes draft (or
enabled: false) is uninstalled by the next sync — reconciliation is to
desired state, both directions.
Two adapter-policy changes with named mechanisms (panel M13 follow-up):
the akm adapter's current type: "task" special-case — parse as pure
YAML, frontmatter: null, so markdown base checks never fire
(akm-adapter.ts:404-412) — is removed with the format, and markdown
tasks get the full markdown base-check suite; and tasks are declared
improve-ineligible. No such site exists on BundleAdapter
(src/core/adapter/bundle-adapter.ts:69-121 declares only adapter-level
optional methods), so v8's "where the adapter already declares per-type
capabilities" was wrong. Three candidate homes, cheapest first: the
existing per-type refusal set DISTILL_REFUSED_INPUT_TYPES
(src/commands/improve/distill.ts:134-142, already gated at
eligibility.ts:380 and documented as driving the planner "for free")
gains task; or the per-type table TYPE_PRESENTATION
(src/core/type-presentation.ts:74) gains an eligibility flag; or
eligibility.ts gains an explicit type filter. A task body is an
executable prompt,
not curatable knowledge. Lint runs; improve never rewrites it.
4. Configuration: two selectors, one value vocabulary, one cascade — built, not "exposed"
v7 claimed freeze "already implements" the cascade. Review finding C4
(3/3): freeze's layer list is exactly [documentDefaults, unit]
(freeze.ts:50); DefaultsSchema has no model field and is
.passthrough(), so v7's own worked example is a silent no-op today
(config-schema.ts:102-108); defaults.llm is hard-rejected as retired;
no persona layer exists. What exists is a two-layer resolver whose shape
proves the pattern fits. This section specifies building the rest.
Selectors (nearest wins; each picks a node whose fields join the cascade):
| Selector | Node | Source |
|---|---|---|
engine: |
execution node | config engines.<name> |
agent: |
persona node | bundle agents/<name> |
Value fields — legal at every layer, merged per-field, nearest wins;
env: concatenates: model · temperature · max_tokens ·
extra_params · timeout · env · cwd · shell · on_error · retry.
(with: is not a value field — it binds to one referenced asset. params
no longer exists as a field name outside workflow declarations. on_error/
retry join the vocabulary because defaults.on_error already exists at
document level today — review M12 — making "graph keys are steps-only"
false for them; they remain meaningless on a task and lint says so. Value
fields other than timeout/env on a uses: workflows/* task draw the
same inapplicable-field notice — they configure a unit, and a workflow
invocation has none.)
Merge classes, stated once (panel finding: a generic "nearest wins"
would silently downgrade the existing deep-merge): scalars (model,
temperature, max_tokens, timeout, cwd, shell, on_error) —
nearest wins whole; mappings (extra_params) — deep-merged along the
chain, preserving today's deepMergeConfig behavior
(freeze.ts:211-216); lists (env) — concatenated, later wins;
retry — replaced whole (half-merging max/on would be
incoherent). with: merges per-key at its target (§2.2), outside the
cascade.
Layers, far → near:
config defaults: → engines.<selected> → agents/<selected> → document defaults: → uses: tasks/<ref> → step/task keys
(document defaults: is the workflow frontmatter's defaults: block and
exists only there — a standalone task IS its own call site, so for tasks
that layer is simply absent, not an implied new task key.)
What must be built (the honest inventory):
DefaultsSchemagains the value fields, and stops silently swallowing unknown keys (strict with migration-safe notices).- A persona snapshot: the agent asset resolved through the
provenance ceiling — 07 P1-D, today inline in the show command
(
src/commands/read/show.ts:433-445): self-declaredtools:honored ONLY for the operator's primary bundle, keyed off primary-bundle identity, explicitly not the writable bit, failing closed. That check is extracted into a shared persona resolver consumed by bothshowand freeze — a named refactor in §9, since its current home is a CLI formatter. The snapshot{systemPrompt, toolPolicy, model, …valueFields}freezes into the plan;toolPolicyis consumed by agent-kind engines and draws the standard unconsumed-field notice on LLM engines. (This also fixes the live bug whereprompt: agents/xships raw file bytes including frontmatter to the model —runner.ts:592-593.) - One cascade module — a new shared module (e.g.
src/core/cascade.ts; there is nosrc/exec/tree today, onlysrc/workflows/exec/andsrc/tasks/) — the only implementation of layer merge, consumed at freeze (workflows) and dispatch (tasks). It consolidates the three engine/model resolution sites that exist today (freeze.ts:163-209,tasks/runner.ts:479-514, engine-resolution) including their subtly divergent opencode-sdkllmEnginefallback rules — one implementation, one fallback rule. - Capability notices replace both kind-gates. The task path's hard
error and freeze's guard — which review M6 showed is dead code
(
freeze.ts:67-73computesllmonly for llm engines, then guardskind !== "llm"), meaning workflows already silently drop these fields — are both replaced by one behavior: engines declare the fields they consume; an unconsumed field is a lint/freeze notice. Strictness stays only where the kind is known at authoring time: the engine node's own schema (which today is.passthrough()+ blacklist; it becomes genuinely strict as part of this work — review minor). - Aliases: resolution runs once, after the cascade settles
model, through the existingresolveModel()chain. Review M4 (3/3): the builtin table has onlyclaude/opencodecolumns, so portability to LLM endpoints is currently false — the builtin entries gain a"*"column, and an alias that resolves nowhere for the selected platform is a freeze/lint notice, not silent pass-through. - The gate judge (
workflow.judgeEngine) stays outside the cascade in 0.9.0 — frozen separately, as shipped. Noted, not changed.
One sentence to teach: set a field where it should usually apply; override it closer when a case differs; the nearest value wins.
5. Execution architecture
5.1 One target model, two executors
A shared target module resolves every declaration to a typed value:
Target = { kind: agent-prose } # nearest prose is the prompt
| { kind: command, ref, with } # template fill → agent prompt
| { kind: script, ref } # script asset, exec
| { kind: shell, text, shell } # inline run:
| { kind: workflow, ref, with } # tasks only
| { kind: task, ref } # steps only; resolves recursively once
Execution is a strategy seam with exactly two implementations:
- AgentUnitExecutor — today's paths (task runner's
executeRunner(); the orchestrator's journaledUnitDispatcher), consuming an assembled prompt + frozen engine + persona snapshot. - ShellUnitExecutor —
Bun.spawn()forrun:text and script assets, used by both the task runner and the orchestrator.
Staging is symmetric and named (panel: the task path's staging was
unstated): tasks run resolve → execute — the same resolve stage
(cascade, persona, targets, templates) with no plan persistence;
workflows run compile → resolve → freeze → execute (§5.4). Both
executors sit behind one interface; each surface wraps results into its
own recording sink (task_history/logs.db vs the run journal) — the
adapter lives at the sink, not inside the executor, which is how the
RunnerSpec-vs-UnitDispatchRequest split stays out of the executors'
type signatures: the resolve stage produces one dispatch plan both
consume. ShellUnitExecutor is extracted from the task runner's existing
hardened command path (runManagedSubprocess, src/core/subprocess.ts,
called at tasks/runner.ts:277-289: process-group spawn, SIGTERM→SIGKILL
kill ladder, injectable spawn/timer seams, AKM_EVENT_SOURCE stamping)
and adopted by the orchestrator — not a new spawn wrapper.
(src/tasks/backends/exec-utils.ts is the scheduler-backend exec seam,
not part of this path.) Composition is monotemporal by rule: a referenced
task's fields join the cascade from the copy resolved at freeze, under the
workflow's config snapshot — a composed task never re-reads config at
dispatch.
Run recording stays split (cron job vs journaled plan) — that boundary is real. Everything upstream unifies: target resolution, template filling, cascade, alias resolution, env assembly.
5.2 Shell units in the IR — v3 → v4, not a non-goal
Review C2 (3/3): IrInvocation.engine and UnitDispatchRequest.engine
are required, the engine snapshot union is closed, freeze hard-errors with
no engine, and the IR rejects empty instructions — v7's "no IR changes"
was false. Owned properly:
IrInvocationbecomes a discriminated union:{ kind: "agent", engine, … }|{ kind: "shell", script, shell, cwd, timeoutMs }(timeoutis a value field forrun:, so the shell member carries it). Shell members exclude engine/llm fields structurally — by union-member shape, not by decoder-validated absence.- Engine resolution happens per unit kind: shell units need no engine, so a shell-only workflow freezes on an engine-less machine (the current "no engine selected" error narrows to plans containing agent units).
- Instructions become optional for shell units (parser + IR relaxation — review M10; agent units keep the non-empty invariant).
- Shell units journal as normal
workflow_run_unitsrows (status, timing, attempts,failure_reason; no tokens). Under the external driver protocol, shell units are orchestrator-owned:brieflists them as non-claimable and the engine executes them natively — a harness never claims shell work. - Crash/restart: shell units use the same claim/lease rows as agent units; a running shell unit whose lease expires re-dispatches on resume under its retry policy. Re-execution idempotency is the author's contract — the same contract every cron job already carries — stated in the docs, not solvable by the engine.
- Plan IR version bumps to 4; decoders reject v4 plans in older binaries (existing plan-version machinery).
5.3 Unit identity — hashVersion 5, preimage stated
Review C3 (3/3): hashVersion 4's preimage (step-work.ts:333-392) covers
the frozen template bytes but none of the other new inputs — shell text,
the uses: target kind/ref/content hash, and the persona snapshot.
Without a bump, editing run: text, re-pointing a uses: ref, or editing
agents/<n> would silently reuse completed journal rows. (Freeze-time
prose appending, §2.3, already rides in via template.) hashVersion 5's
preimage, explicitly:
| Field | Notes |
|---|---|
| template instructions | post freeze-time prose append (§2.3) and post template fill, so with: values are covered via the filled bytes |
target kind + uses: ref + resolved content hash |
a re-pointed or edited referenced asset re-dispatches; also the retained provenance for composed steps (the "single-file provenance" minor). Script bytes are embedded at resolve, like a command's filled text — execution never re-reads live files, keeping composition monotemporal (§5.1) |
| persona snapshot hash | system prompt, tool policy, and value fields of the frozen persona — editing agents/<n> re-dispatches (panel critical: omitted in v8) |
shell text, shell, cwd |
shell units |
| item / inputs / params / dispatch / invocation / schema | as v4 |
| env names + env literal values | see §5.5 — literals are plan content; ref-sourced values stay names-only |
| isolation, gateFeedback | as v4 |
retry/on_error stay excluded (policy, not input) — unchanged rationale.
5.4 Freeze becomes two stages; lint gets a dry freeze
Review M11: uses: resolution needs asset IO inside a function documented
pure, and freeze currently has exactly one caller (run start), so every
composition error would escape lint. Restructure: compile (pure) →
resolve (asset loader injected: personas, referenced tasks, command
templates) → freeze (snapshot + hash). akm lint --type workflows
runs compile+resolve with the real loader and no persistence, so dangling
uses: refs, back-door workflow nesting, placeholder mismatches, and
capability notices all surface at lint time. Composed instructions carry
the existing 256 KiB cap with a lint warning at 80%.
5.5 env: — one shape, two provenance classes
Format unchanged from v6 (list of env-asset refs and literal mappings, later wins, bare-mapping shorthand). Semantics corrected by review M3 (3/3), which showed "strictly additive" was false at the IR/hash/redaction layers. The clean line is provenance, not shape:
- Literal entries are plan content: author-written bytes already
durable in the source file — frozen into the plan, hashed (§5.3), shown
by
brief, never redacted (LOG_LEVEL: debugmust not scrub "debug" from every log — the over-redaction finding). - Ref-sourced entries (
env/<n>groups,secrets/<n>values) are runtime-resolved: hashed by name only, resolved at dispatch, values joined tosensitiveValuesfor redaction, never durable. (Existing contract, now stated as the rule.) - IR
envbecomes a typed entry list (ref | literal-pair) — an IR v4 change, not "additive." - Env assembly moves per-unit where fan-out requires it: a
map:step with a shell target receivesAKM_ITEM(JSON) andAKM_ITEM_INDEXper unit — set as env-object entries on the spawned process (therunManagedSubprocessenv:pattern —src/core/subprocess.ts, as used attasks/runner.ts:284), never spliced into shell text. - Literal entries run the existing secret-shape heuristic
(
param-secrets.ts'sdetectSecretShapedParams, built for exactly this risk on params): a secret-shaped literal is a lint error pointing atsecrets/<n>. - The split is enforced at one place: a single env-assembly resolver (§8
plumbing) is the only producer of env entries and of their
sensitiveValuescontributions, so the literal/ref line cannot silently diverge per call site.
5.6 Executing scripts inherits the tools: ceiling — no new trust machinery
v8 proposed a per-bundle grant key. The panel killed it twice over:
activation-policy.ts documents a standing decision against new
persisted trust machinery, and the claimed precedent — the tools:
provenance ceiling — has no override at all. v9 inherits the ceiling
exactly instead of imitating it loosely:
- Shell-class work (a script asset, or the
run:text of a task) executes only when its defining asset lives in the operator's primary bundle — the same line 07 P1-D draws for self-declared tool grants (show.ts:433-445): keyed off primary-bundle identity, explicitly not the writable bit, failing closed. - Evaluated in the resolve stage (§5.1) — the shared stage both surfaces run before any execution — so there is no sync-time/fire-time gap and no standing grant for a bundle update to swap content under.
- Composition chains inherit the rule from the defining asset's own
source: a primary-bundle task that
uses:a third-party script — or composes a third-party task whose target is shell — refuses with an error naming the ref and its source. No chain launders provenance. - The remedy is an existing verb, not a config bit:
akm clone <ref>copies the asset into the primary bundle, where the operator owns and reviews the bytes; the refusal message says exactly that. - Scope: this ceiling governs the new non-AI exec surface only.
Agent-dispatched third-party tasks remain governed by the existing
scheduling gates (setup/sync review,
enabled:), unchanged.
Zero new config, zero persisted trust state. Loosening later (per-bundle grants) is an additive future decision that must revisit the activation-policy document — deliberately out of 0.9.0 scope.
5.7 Shell ergonomics
- Bare
akmin shell/script work resolves to the current installation by a synthesized shim, not a bare PATH prepend:resolveAkmInvocation()can return a multi-element launcher invocation (bun <script>— the case the argv-splice was built for, panel M9 follow-up), so the runner writes a platform-appropriate shim (sh script /.cmd) invoking the exact resolved invocation into the run's temp dir and prepends that dir to the childPATH(case-insensitivePathmerge on Windows). Replaces the argv rewrite for shell work and covers scripts for free; the task runner keeps the argv rewrite only for legacy.ymluntil retirement. cwd:underisolation: worktreeis resolved relative to the worktree root;cwd:on the LLM runner is a capability notice (review minors).
6. GitHub Actions alignment — familiar, not copied
Adopted: uses:/run: pair with mutual exclusion; with: for inputs
to the referenced thing (returned to the adopted column by M1);
step-level keys with no nesting bag; shell semantics with per-OS defaults
and shell:; env: bare-mapping shorthand; cwd:; on_error/timeout
as step keys. Kept akm: typed asset refs (the subdir says what executes —
GHA's uses: is opaque); params: reserved for declarations; no step
name:; bundles as the sharing unit.
7. Steps flatten to the task shape — with the schema collision fixed
The unit: bag and map.unit are still deleted; targets and value fields
sit on the step; map: keeps over/concurrency/reducer. But v7's
"duplicate output wart" was wrong — review C1 (3/3):
unit.output compiles to the per-dispatch structured-result schema
(driving structured retry / parse_error) while step.output compiles to
outputSchema, the promoted artifact the gate validates
(compile.ts:204,229 — verified). Two concepts, two homes:
output:on a step — the step's artifact schema, all step kinds. On a unit step it also serves as the dispatch result schema (result is the artifact).map.output:— the per-item result schema of a map step's units; the step'soutput:continues to describe the reduced artifact.
- id: review
map: { over: steps.intake.output.files, concurrency: 3, output: { type: object } }
output: { type: array } # what `steps.review.output` resolves to
8. Migration — inside the hardened 0.9.0 cutover
Rebuilt against the 71bb686 migrator and review M7 (3/3):
- The converter joins the content-migration step with a vendored,
frozen copy of the v2 task parser — the current
task-target-ref-migration.tsimports the live parser fromsrc/, which violates the frozen-migrator principle and breaks the moment the live parser is replaced; that gets fixed as part of this work. - Mapping:
command: <shell string>→run:verbatim;command: [argv…]→run:with each element shell-escaped for POSIXsh, the migrated task recordingshell: shexplicitly so the Windowspowershelldefault never reinterprets the quoting;prompt: |→ body;prompt: commands/x→uses:+with:;prompt: agents/x→agent:;prompt: ./file.md→ inlined;workflow:+params→uses: workflows/…+with:;timeoutMs→timeout;llm.maxTokens/llm.temperature/llm.extraParams→ flat fields;name:→ the body's H1 when the converted body has none, else a per-task migration notice (never silently dropped);llm.supportsJsonSchema/contextLength/enableThinkingare endpoint-capability facts that belong on the engine node — the migrator emits a per-task notice naming the value and the config key (lossy-with-notice, never silent). - Scheduler entries untouched: ids and the
akm task run <id>ABI do not change. - The
.ymltombstone rule replaces v7's false "structural" claim (refusals key on config/DB shape, not stray files, and placement filters by extension — a stray.ymltoday would be invisible or wrongly installed): task discovery keeps matchingtasks/*.ymlpermanently, as a diagnostic. In writable bundles,sync,doctor, andlinthard-error naming the file and the fix; in read-only/third-party bundles the same diagnostic is a warning — the operator cannot edit those files, matching the migrator's existing read-only carve-out (task-target-ref-migration.ts:253-271). Either way a.ymlis never installed and never silently skipped.<id>.yml+<id>.mdcolliding on one conceptId is the same named error. Conversion is interruption-safe by ordering: the migrator journals the.mdwrite and verifies it before removing the.yml, and the collision diagnostic special-cases the byte-equivalent mid-migration pair (resume completes the removal instead of erroring on the migrator's own half-applied state — the panel's crash-window finding; the current backup model journals whole-DB/config artifacts only (migration-backup.ts:54), so per-file crash safety lives in the content-migration step's own resume ledger —content-migration-report.json'sreservedRenames(config-migrate.ts:518-575); the.md-write-then-.yml-delete ordering extends that existing ledger rather than introducing per-file journaling, costed in §9). - Embedded templates convert to
.md;listEmbeddedTasksandakm setup's task review move to markdown-aware editing (the current YAML round-trip would destroy bodies — review minor). - The id-grammar work (subdir ids, scheduler-id
/→--mapping with collision lint) lands in the same migration step, before scheduler reconciliation — sequencing the panel flagged as unstated. - The pre-flatten workflow schema (
unit:,map.unit,defaults.llm) converts in the same step: bag fields lift,llmflattens,unit.output→map.outputor stepoutput:per §7.
9. Cost inventory — what this actually changes
Honest replacement for v7's §10 (review: "non-goals false ×4").
| Surface | Change |
|---|---|
| IR | v3 → v4: invocation union, optional instructions (shell), typed env entries, map.output |
| Unit hash | hashVersion 4 → 5 (§5.3 preimage) |
| Freeze | two-stage (compile/resolve/freeze), per-kind engine resolution, persona snapshot, prose append, capability notices |
| Dispatch | executor strategy (agent/shell); persona fields reach UnitDispatchRequest |
| Schemas | shared target+value defs; task schema rewritten; workflow schema flattened; DefaultsSchema extended and made strict; engine schemas made strict |
| Parsers | parseTaskDocument retired to the migrator (vendored); unified parser gains task recognition; section-required rule relaxed per target kind; the indexer classifier (matchers.ts) gains a type: task branch ordered ahead of the "agent" in fm rule |
| Adapters | markdown task recognition (type: task); the type: "task" pure-YAML special-case removed (markdown base checks apply); improve-ineligibility declared per-type (§3 — DISTILL_REFUSED_INPUT_TYPES is the cheapest of three homes); akm-task format-family goldens rewritten directly (they carry specificationGolden: true / adapterStatus: NOT IMPLEMENTED, and DESIGNATIONS.json does not cover that tree at all). DESIGNATIONS re-pointing is mandatory pre-work: six entries are touched — cli/c-tasks-family.json (already re-baseline, chunk 9) plus five goldens pinning tasks/*.yml paths — recognition/all-types.json, placement/all-types.json, minting/oracle.json (chunk 4), lint/all-types.json (chunk 3), renderer/all-types.json (chunk 8) — whose reBaselineChunk must be re-pointed at this change before it lands, per the DESIGNATIONS $policy surface-owner rule |
| Named refactors | 07 P1-D ceiling extracted from show.ts into a shared persona resolver; ShellUnitExecutor extracted from the task runner's hardened command path; the three engine/model resolution sites consolidated into the cascade module; the akm shim mechanism; per-file create-verify-delete ordering added to the content-migration step's existing reservedRenames resume ledger |
| CLI | task create; task add retires with .yml; capability notices in lint/doctor |
| Storage | task_history.target_kind values widened (additive) |
| Docs/tests | reference docs, ~10 templates, format-family goldens, parser/freeze/executor suites |
Inherited bugs fixed en route: {{0}}-only placeholder filling; raw
frontmatter shipped to models via prompt: agents/x; subdir task ids
indexed but unrunnable (validateTaskId rejects / — the id grammar
gains subdir support with the scheduler-id mapping documented); freeze's
dead llm guard.
10. Non-goals (now true)
- No new orchestration semantics:
map/route/gatebehavior, leases, check-ins, replay logic, and the driver protocol are unchanged (shell units are orchestrator-owned under it). - No scheduler-backend,
sync-reconciliation, orakm task run <id>ABI changes. - No change to how command/script/agent/env/secret assets are authored.
- No multi-file config merging; the cascade layers are config nodes, bundle assets, and document/call-site keys.
- Gate judge selection stays outside the cascade in 0.9.0.
11. Findings cross-reference and decision log
| Finding (consolidated) | Resolution |
|---|---|
| C1 output collision (3/3) | §7 — two schemas, two homes (output: / map.output:) |
| C2 shell units unrepresentable (3/3) | §5.2 — IR v4 invocation union, per-kind engine resolution |
| C3 hash preimage (3/3) | §5.3 — hashVersion 5, preimage table; §2.3 freeze-time append |
| C4 cascade layers don't exist (3/3) | §4 — "built, not exposed"; build inventory |
| C5 script execution security (2/3) | §5.6 — the tools: ceiling inherited exactly (primary-bundle only, no override); v8's grant key withdrawn after the panel showed it contradicted activation-policy's no-new-trust-machinery decision |
| M1 params overload (3/3) | §2.2 — with: for inputs; params: declarations only; neither cascades |
| M2 placeholder grammar (3/3) | §2.4 — with:/with.arguments against the real grammar; lenient + lint |
| M3 env not additive (3/3) | §5.5 — literal/ref provenance split; IR v4; per-unit env |
| M4 alias portability (3/3) | §4.5 — "*" columns + unresolved-alias notice |
| M5 agent plumbing + ceiling; false corroboration | §4.2 — persona snapshot via show layer; corroboration claim retracted |
| M6 dead kind-gate (2/3) | §4.4 — both gates → capability notices |
| M7 migration overclaims (3/3) | §8 — vendored parser, full mapping, tombstone rule |
| M8 adapter recognition (2/3) | §3 — type: task required |
| M9 akm-bin rewrite (2/3) | §5.7 — synthesized shim (a bare PATH prepend cannot cover multi-element launcher invocations) |
| M10 prose invariants (2/3) | §5.2 — per-kind relaxation, costed |
| M11 lint never freezes (1/3) | §5.4 — dry freeze in lint |
M12 defaults.on_error (2/3) |
§4 — on_error/retry join the value vocabulary |
| M13 improve surface (1/3) | §3 — improve excluded from task bodies |
| Minors | §3 (draft/deprecated), §5.7 (cwd), §8 (embedded/setup, collisions), §9 (bugs fixed, target_kind, goldens) |
Decisions 1–14 (rounds 1–6) stand as logged in v7 except where this
revision supersedes them with review evidence: call-site params: →
with: (M1); "expose the cascade" → "build the cascade" (C4); "duplicate
output" → two-schema design (C1); §10 non-goals → §9 cost inventory
(C2/C3). v7's remaining question (cascade sign-off) is superseded by this
section's cross-reference — the cascade shape survived review; its cost
didn't, and is now stated.
Round 1 Sonnet panel (v8 → v9). Four independent reviewers (coverage
audit, architecture, fresh adversarial, security+migration; full reports
sonnet-r1-*.md in the session scratchpad). Panel criticals, all applied:
the §5.6 grant key contradicted activation-policy's documented
no-new-trust-machinery decision → replaced by exact ceiling inheritance;
the v8 hash preimage omitted the persona snapshot → added; with: merge
on composition chains was undefined → §2.2; the tombstone hard-error was
unactionable on read-only bundles → warning carve-out. Panel majors, all
applied: task-path staging named (resolve → execute); executor sinks as
the adapter boundary; ShellUnitExecutor extracted from the task runner's
hardened path, not rebuilt; monotemporal composition rule; persona
resolver extraction scoped; merge classes stated (deep-merge preserved
for extra_params); shell invocation carries timeoutMs; shell-unit
crash/lease semantics; toolPolicy under capability notices; improve
exclusion given a named mechanism; migration crash-window ordering;
POSIX-pinned migrated quoting; launcher-aware shim. One panel claim was
verified false and rejected: "the tools: ceiling doesn't exist" — it does
(show.ts:433-445), inline in the show command, which is exactly why §4
scopes its extraction.
Open items: none blocking. Two implementation-time choices are delegated
to the implementer with defaults: the exact capability-declaration shape
per engine kind (default: a static field list per kind), and the
scheduler-id mapping for subdir task ids (default: / → -- with
collision lint).