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

438 lines (356 loc) • 21.2 kB
# Workflow Format Unification Status: PROPOSAL v2 — owner decisions from review round 1 applied Date: 2026-07-30 Supersedes: the dual markdown/YAML-program authoring surface (both unreleased) Owner decisions incorporated in v2: no interpolation syntax in prose (§2.3), gate rubrics live in the body (§2.4), steps have no titles — bare ids only (§2.2). ## 0. Why now The workflow feature has never shipped (`experimental.workflowEngine` opt-in, 0.9.0-rc). There are currently **two authoring formats** for one asset type, and the seams between them have started leaking into unrelated systems — the OKF provenance work (#730) tripped over workflow's closed frontmatter allowlist, which exists only because the markdown format grew its own bespoke validation instead of sharing the asset envelope every other type uses. This is the moment to unify: nothing on disk to migrate, no users to break. ## 1. Review of the two current formats ### 1.1 Format A — markdown workflow (`src/workflows/parser.ts`, ~500 lines) A bespoke prose grammar: one magic `# Workflow:` H1, magic `## Step:` H2s, a magic `Step ID:` body line, magic `### Instructions` / `### Completion Criteria` subsection names. Capabilities: **linear steps only** — no routing, fan-out, retry, timeout, engine/model selection, output schemas, or budgets. The compile path (`ir/compile.ts` `compileWorkflowPlan`) lowers every step to one fail-fast unit node with verbatim instructions. ### 1.2 Format B — YAML program (`src/workflows/program/*`, ~1,300 lines) The full orchestration surface: `unit | map | route` steps, `${{ … }}` template expressions, retries keyed on the failure taxonomy, timeouts, per-unit engine/model/llm overrides, JSON-Schema-typed params and step outputs, gates with `max_loops`, budgets, worktree isolation. Instructions are embedded YAML block scalars. ### 1.3 The divergences (each one is a standing cost) | Concern | Markdown | YAML program | |---|---|---| | Step id grammar | `[A-Za-z0-9][A-Za-z0-9._-]*` — dots allowed | `[A-Za-z_][A-Za-z0-9_-]*` — dots forbidden | | Params | name → description string, untyped | name → JSON Schema | | Templating | **none** — instructions verbatim | `${{ … }}` expressions, real substitution | | Gate | `### Completion Criteria` bullets; no `max_loops` | `gate:` with criteria/max_loops | | Capabilities | linear only | full orchestration | | Frontmatter validation | closed hand-maintained allowlist | closed key lists, JSON Schema published | | Title | required `# Workflow:` H1 prefix | `name:` field duplicating the filename | | Version | internal `schemaVersion 1` | `version: 2` (IR is v3) | Beyond the table, four structural costs: 1. **The fake templating trap.** Every shipped example workflow (`scripts/akm-eval/example-stash/workflows/*.md`) uses `{{ repo }}`-style moustaches that **the engine never substitutes** — markdown instructions compile `templating: "verbatim"`. The YAML format substitutes `${{ … }}` for real. Two syntaxes; one is decorative and exists only as a convention the executing agent is hoped to honor. 2. **Content sniffing everywhere.** Two structural probes (`looksLikeWorkflow`, `looksLikeWorkflowProgram`) consulted by the indexer matchers, the workflow adapter (`recognize()` returns `"markdown" | "yaml-program"`), the asset loader (extension dispatch + probe), and the proposal validator (which sniffs *content* when no path is available). `canonicalizeWorkflowName` collapses `foo.md` / `foo.yaml` / `foo.yml` to one identity, so the two formats can silently collide on one ref. 3. **The closed-allowlist special case.** Workflow markdown is the only AKM type validating frontmatter against a closed hand-maintained key set. It is why #730's provenance stamping needed a re-validation fallback in the promotion path, why the allowlist had to learn `generated`/`verified`/ `provenance` by name, and why its error message is now stale (`validator.ts:94` still enumerates the pre-#730 keys). Every future machine-stamped key repeats this. 4. **Double everything.** Two parsers, two templates, two doc sections, two test suites, two JSON representations feeding one `WorkflowPlanDraft`. ~1,800 lines of format code for one asset type. ### 1.4 What is genuinely good and must survive - **The IR and engine.** Both frontends compile to one `WorkflowPlanDraft` → frozen IR v3. Deterministic replay, journaled units, leases, brief/report — none of that is format-coupled. This proposal changes **frontends only**. - **The closed reference grammar** (`program/expressions.ts`) — though in v2 of this proposal it *shrinks* from a template language to a bare reference string format (§2.3). - **The program vocabulary** (unit/map/route, gate control, retry taxonomy, budgets, defaults): well-designed, schema-validated, kept nearly verbatim. - **Markdown prose as the instruction medium.** The example workflows prove the point: real instructions are long structured prose with code fences, sub-headings, and lists. Markdown is their native medium. ## 2. Design ### 2.1 Principle > **One file. Machine surface in frontmatter, prose surface in the body, > joined by step id. Prose is never templated — data reaches units as > attached context, not string splices.** A workflow is an ordinary AKM markdown asset — same envelope as every other type, OKF-conformant frontmatter + body — whose frontmatter carries the entire orchestration graph and whose body carries per-step instructions and gate rubrics under plain headings. ### 2.2 The format ````markdown --- type: workflow description: Drive a batch of GitHub issues to merged PRs. params: repo: { type: string, description: Target repository owner/name } issues: { type: array, description: Issue numbers to implement } defaults: { timeout: 10m, on_error: fail } budget: { max_units: 60 } steps: - id: intake output: { type: object } - id: implement map: over: steps.intake.output.issues concurrency: 3 unit: { isolation: worktree, retry: { max: 2, on: [timeout] } } # `output` describes the REDUCER RESULT: the default `collect` reducer # folds per-item unit results into an array. output: { type: array } # Retry lives here, not in a backward route: a failed gate re-runs this # step with the judge's feedback, bounded by max_loops. gate: { max_loops: 2 } - id: verdict inputs: [steps.implement.output] output: { type: object } - id: pick-outcome route: input: steps.verdict.output.status when: [{ match: clean, step: announce }] default: escalate # both targets are LATER steps — routes are forward-only - id: escalate inputs: [steps.verdict.output] - id: announce inputs: [steps.implement.output] --- # GitHub Issues Parallel Implementer Free preamble prose. Indexed for search, shown in `akm show`, never dispatched. Any headings except level-2 are fine here. ## intake Everything under this heading, byte-exact, is the step's instructions — sub-headings, fences, lists, all of it. The run's params arrive as attached context; refer to them by name in prose ("clone the `repo` parameter's repository"). ## implement This section is the **map unit template**. The engine attaches each unit's item (and its index) as context; the prose says "the issue you were given." ### gate The gate rubric — as long as it needs to be. Full prose, bullets, examples of passing and failing artifacts. The judge receives this whole section. - Every issue in the working set has a mergeable PR or a recorded blocker. - No PR was opened against a branch other than the declared base. ## verdict Inspect the implementation results and report whether the batch is clean or needs escalation. ## escalate Escalate blockers from the verdict, with the verdict artifact attached as context (declared via `inputs:` above). ## announce Post the summary. The `implement` step's artifact is attached as context (declared via `inputs:` above). ```` **Frontmatter** (validated by one published JSON Schema): - The standard AKM asset envelope — `type`, `description`, `tags`, `when_to_use`, `xrefs`, `updated`/`timestamp`, and the OKF v0.2 families (`generated`, `verified`, `provenance`, `status`, `stale_after`) — via a shared `$ref`'d envelope definition (§2.5). - The orchestration keys, adopted from program v2 with these changes: - `version:` and `name:` **dropped**. Identity is the ref; the frozen plan already versions execution semantics (IR v3); the authoring schema evolves additively like every other asset type. - `instructions:` keys **removed** — prose lives only in the body. - `gate.criteria` **removed** — rubrics live in the body (§2.4). `gate:` retains only the optional `max_loops` configuration. - **No titles anywhere.** No step `title:` key, no display-title heading suffix. A step is its id; the asset's human name is its `description` and H1 like any other asset. - New `inputs:` key on unit/map steps: the prior-step artifacts this step consumes, as bare reference strings (§2.3). Replaces prose splicing as the way a step sees upstream data, and gives replay hashing its exact input set. Sub-paths are legal (`steps.x.output.issues`, not just `steps.x.output`) so a step re-dispatches only when the slice it consumes changes. - A step with no `map:` and no `route:` **is** a unit step; bare `- id: validate` is the complete minimal declaration. `unit:` remains as the optional dispatch-override bag (engine/model/llm/timeout/retry/ on_error/env/isolation). - One id grammar everywhere: `[A-Za-z_][A-Za-z0-9_-]*`. - Keys stay snake_case (`on_error`, `max_loops`) — the existing AKM/OKF frontmatter convention. **Body** (three rules): 1. Every level-2 heading must be `## <step-id>` for a declared step, exactly. (Fenced code blocks are skipped when scanning for headings.) 2. A unit or map step **must** have a section (its instructions / per-item template, byte-exact to the next H2 or EOF). A route step **may** have one (documentation, plus a gate rubric if gated). Everything before the first H2 is free preamble. 3. Inside a step section, an optional `### gate` sub-heading starts the step's gate rubric (running to the section end). It is the format's **single reserved marker**. Omitted or empty rubric text skips validation; a non-empty rubric enables optional validation. Frontmatter `gate:` without a rubric is valid but inert. No `Step ID:` lines, no `# Workflow:` prefix, no reserved H3s beyond `gate`. ### 2.3 No interpolation syntax — references and attached context v1 of this proposal kept `${{ … }}` templating in prose and added a `$${{` escape. The owner asked the right question — *why have the syntax at all?* — and the answer is that only the **engine** ever needed deterministic value resolution, and the engine only reads **whole-value frontmatter positions**: - `map.over` — the list to fan out over. - `route.input` — the value routing matches on. - `inputs:` — the artifacts a step consumes (new, replacing prose splicing). Whole-value positions need no delimiters. These become **bare reference strings** with the same closed grammar, shrunk from four roots to two: params.<name> steps.<id>.output( .<ident> | [<int>] )* `item` and `item_index` are **deleted from the language**: with no splicing there is nothing to substitute — the engine attaches each map unit's item and index as context alongside the prompt. **Prose is never templated.** Each dispatched unit receives, as structured attached context: the run params (params are run-scoped and documented non-secret), its item + index (map units), and the artifacts named by its step's `inputs:`. Instructions refer to these in plain language — which is exactly how the classic driver and all nine example workflows already functioned, since their moustaches were never substituted. What this buys: - The escape-syntax question evaporates — there are no delimiters in prose. - The P1 injection class dies at the root: data never enters the instruction string at all, spliced or otherwise. - `program/expressions.ts` shrinks from a template parser (segments, literals, offsets) to a small reference-string parser. - Bodies are pure markdown — cohesive with every other asset, no syntax an OKF reader or a human editor has to know about. Determinism and replay identity are preserved: unit identity hashes the frozen template bytes + canonical item JSON + declared-input artifact hashes + the params snapshot — the same inputs the journal's replay machinery keys on today, minus the string splice. `inputs:` gives the hash its exact upstream set, so a step re-dispatches only when data it actually consumes changes (the precision splicing used to provide implicitly). The honest trade-off: prose can no longer compose a value mid-string with engine-guaranteed fidelity ("run `git checkout <item>`") — the executing agent performs that mapping from attached context. Every current executor is an agent/LLM engine, so this is the same trust the instructions already extend everywhere else. If a non-agent unit kind (raw shell/exec) is ever added, *that unit kind* reintroduces substitution as its own need — scoped there, not in prose. ### 2.3a Routes are forward-only; gates are the retry mechanism Two invariants inherited unchanged from the program format, doubly enforced today (`program/parser.ts:459` and `ir/schema.ts:227`), and preserved here: - **A route target must be a LATER step**, and a step never routes to itself. The plan stays a DAG, so termination is structural rather than a runtime budget's job. - **Retry is a gate, not a backward edge.** A failed gate re-runs its own step with the judge's feedback, bounded by `gate.max_loops`; a declared `output:` schema that the artifact fails is the one error the gate loop may retry through (`artifactSchemaFailure`). Anything wanting "go back and fix it" expresses it as a gate on the step being fixed. A backward `default:` therefore does not mean "loop" — it is a lint error. ### 2.3b `output:` describes the reducer result For a map step, `output:` validates the **promoted artifact**, i.e. what the reducer produced — not one unit's result. The default `collect` reducer folds per-item results into an **array**, so a collected map step's schema is `{ type: array }`. (`vote` folds to the winning value.) A unit step's `output:` describes that single unit's structured result. ### 2.4 Gates: control in frontmatter, rubric in the body Owner decision: rubrics are often long — real rubric documents, not one-liners — and belong in the body. The split: - **Frontmatter `gate:`** optionally carries `max_loops`. It does not enable validation by itself; without a non-empty body rubric it is inert. - **Body `### gate`** (inside the step's section) carries the rubric: full prose, bullets, worked examples. The judge receives the whole section byte-exact. Omitted or empty text skips validation. This replaces both the markdown format's `### Completion Criteria` bullets and the program's `gate.criteria` string list. ### 2.5 Validation model — and the end of the allowlist special case One published JSON Schema (`schemas/akm-workflow.json`, repurposed) validates the entire frontmatter. Its top level `$ref`s a new **shared asset-envelope schema** — the common keys every AKM markdown type carries, including the machine-stamped OKF families. Consequences: - Workflow stops being the only type with a bespoke closed key set; "closed" becomes `additionalProperties: false` over `envelope ∪ workflow-keys`, defined in one place. - A future machine-stamped key is added to the envelope definition **once**, and every schema-validated type inherits it — the #730 fallback dance and the stale hand-written key-list message become structurally impossible. The promotion-path re-validation backstop is deleted. - Editors get frontmatter completion/validation via the published schema (yaml-language-server association) — tooling no bespoke markdown grammar can offer. Semantic passes keep their existing implementations from the program parser, retargeted at frontmatter: duplicate ids, route targets exist / are forward-only / are unique, reference strings resolve to earlier steps, timeout format, retry-reason taxonomy, resource limits. Body checks are the three rules of §2.2. Recognition simplifies to: frontmatter `type: workflow`, or residence under `workflows/` — no content sniffing. `WORKFLOW_EXTENSIONS` shrinks to `.md`. ### 2.6 OKF cohesion The unified asset **is** an OKF-shaped document: frontmatter + markdown body, no inline syntax. Provenance stamping applies with zero special-casing. A third-party OKF reader sees a normal document — prose overview, sections per step — degrading gracefully to readable procedure documentation. `status: draft` / `stale_after` become meaningful on workflows for free (e.g. a future rule that draft workflows refuse `run` — noted, not proposed here). ## 3. What gets deleted - `src/workflows/parser.ts`'s grammar: `# Workflow:` / `## Step:` / `Step ID:` / reserved-H3 subsections (frontmatter/toc plumbing is reused by the new body binder). - `looksLikeWorkflow` / `looksLikeWorkflowProgram` sniffing and the `"markdown" | "yaml-program"` mode split in the adapter, matchers, loader, and proposal validator. - The YAML program as a distinct on-disk format; its field validation survives as the frontmatter semantic passes. - **The template expression language**: `${{ … }}` delimiters, segment parsing, `item`/`item_index` roots, and the escape question — replaced by bare reference strings in three frontmatter positions plus context attachment. - The workflow-only closed frontmatter allowlist and its hand-maintained error string, replaced by the schema. - The #730 promotion-path re-validation fallback. - Step titles in all forms; the `name:` and `version:` keys. - One of the two authoring templates, one of the two doc sections, the extension-collapse logic beyond `.md`, `isWorkflowProgramPath`. Net: two formats' worth of parsing/dispatch (~1,800 lines) replaced by one frontmatter schema + semantic passes + a three-rule body binder, with the IR, engine, and journal reused unchanged — and the template language itself reduced to a reference-string parser. ## 4. Blast radius (all unreleased surfaces) - **Engine seam (the one non-frontend change):** unit dispatch gains context attachment (params / item / declared inputs) and unit-identity hashing keys on (template, item, input hashes, params) instead of instantiated strings. Journal shape is already compatible; freeze pins the plan either way. - **Templates**: both authoring templates → one. - **Examples**: 9 example-bundle workflows rewritten; their fake moustaches become plain prose references to attached context — which is how they already behaved in practice. - **Fixtures/goldens**: `format-family-goldens/`, `all-types` fixtures, parser/program suites (program-parser tests largely port). - **Docs**: `docs/reference/workflows.md` collapses its two format sections; the expression-language section shrinks to the reference grammar. - **Index cache**: `workflow_documents` re-derives on reindex (unreleased). - **CLI**: `akm workflow create` emits the unified template; `create <name>.yaml` dies. ## 5. Decision log and remaining questions Resolved by owner (review round 1): 1. ~~Escape syntax~~ — **moot**: no interpolation in prose at all (§2.3). 2. ~~Gate criteria location~~ — **body**, under the single reserved `### gate` marker; control fields stay in frontmatter (§2.4). 3. ~~Step titles~~ — **none**; a step is its id. 4. ~~Heading form~~ — **bare `## <id>`**. Resolved by owner (review round 2): 5. **`inputs:` accepts sub-paths.** `steps.x.output` and `steps.x.output.issues` are both legal — the reference grammar already parses the path form, and a narrower declared input means a narrower replay hash, so a step re-dispatches only when the slice it actually consumes changes. 6. **All run params attach to every unit.** No per-step params declaration. Params are run-scoped and documented non-secret (`docs/reference/ workflows.md`, "Params are not secret"); secrets travel as `env:` refs, which remain per-unit. This keeps the attachment model uniform: every unit receives params, its item + index if it is a map unit, and the artifacts its step's `inputs:` names. No open questions remain. This proposal is ready to implement. ## 6. Non-goals - No engine, IR, freeze, journal, lease, or brief/report changes beyond the dispatch-context seam noted in §4. - No new orchestration capabilities (no new step kinds, reducers, or reference roots). - No change to task/env/script/secret formats; task remains a YAML type — tasks are machine schedules, not prose procedures.