Skip to content
8 changes: 4 additions & 4 deletions .archon/workflows/defaults/archon-workflow-builder.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ nodes:
# Use for deterministic data transforms the shell would mangle (JSON parsing, etc.)
script: |
// JSON is valid JS expression syntax — assign directly (String.raw breaks on backticks)
const data = $other-node.output;
const data = $<other-node>.output;
console.log(JSON.stringify({ count: data.items.length }));
runtime: bun # required: 'bun' (.ts/.js) or 'uv' (.py)
# deps: [requests] # uv only
Expand Down Expand Up @@ -199,10 +199,10 @@ nodes:
5. Use `bash` nodes for deterministic shell operations (file checks, git commands, installs)
6. Use `script` nodes for typed data transforms (TypeScript JSON parsing, Python with deps)
— stdout is captured as output, stderr is forwarded as a warning.
`$nodeId.output` is NOT shell-quoted in script bodies.
- **TypeScript/bun**: assign directly — `const data = $nodeId.output;`
`$<node-id>.output` is NOT shell-quoted in script bodies.
- **TypeScript/bun**: assign directly — `const data = $<node-id>.output;`
(JSON is valid JS expression syntax; avoid String.raw — it breaks on backticks)
- **Python/uv**: use json.loads — `import json; data = json.loads("""$nodeId.output""")`
- **Python/uv**: use json.loads — `import json; data = json.loads("""$<node-id>.output""")`
Never interpolate into shell syntax.
7. Use `prompt` nodes for AI reasoning tasks
8. Use `approval` nodes to pause for human review at risky gates (plan→execute boundary, destructive actions)
Expand Down
3 changes: 2 additions & 1 deletion packages/docs-web/src/content/docs/book/quick-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,9 @@ All nodes share these base fields:
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `input` | No | string | Data string forwarded as the child's `$ARGUMENTS`. Substituted like a `prompt:` body (`$nodeId.output`, workflow variables) |
| `with` | No | object | Named string values forwarded as the child's `$INPUTS`. Keys must be valid input identifiers. Mutually exclusive with `input` |
| `isolation` | No | `'inherit' \| 'worktree'` | Which checkout the child runs in. Default (and `'inherit'`) shares the parent's. `'worktree'` gives the child its own worktree + branch — opt-in only, never inferred, and it fails the node rather than falling back to the shared checkout when a worktree can't be created (folder projects, surfaces with no resolver) |
| `fan_out` | No | object | Run one child per item of a runtime list: `items` (a `$node.output` ref or literal JSON array), `max_parallel` (default `5`, bounds concurrency not total), `join` (default `all_done`), `as` (reserved, rejected at load). Every child runs to its own terminal state; none cancels another |
| `fan_out` | No | object | Run one child per item of a runtime list: `items` (a `$node.output` ref or literal JSON array), `max_parallel` (default `5`, bounds concurrency not total), `join` (default `all_done`), `as` (names the item as `$INPUTS.<as>` and must not collide with `with`). Every child runs to its own terminal state; none cancels another |

`retry` is rejected on `workflow:` nodes, and `workflow:` is rejected inside a `loop_group` body. The child's terminal output threads back as `$nodeId.output`; a child approval gate pauses the whole tree — approve the **child** by run id and the parent auto-resumes. A child gate is the exception: it works for a 1:1 sub-run, but a child that pauses inside a `fan_out:` expansion **fails the node** instead — a parent has one approval slot and cannot hand it to N children, so gate before or after the fan-out node rather than inside a child of it.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A command is a **markdown file** that serves as a detailed instruction set for a

## File Format

Shared commands live in `.archon/commands/` relative to the working directory and are loaded at runtime. A packaged workflow keeps its commands beside the YAML in `<workflow>/commands/`; those references resolve only inside the owning package and do not fall back to shared commands.
Shared commands live in `.archon/commands/` relative to the working directory. Ordinary command nodes load them at runtime. When a workflow is composed through `include:`, Archon resolves and snapshots its command bodies during load-time composition so references and declared inputs can be validated before the nodes join the parent's DAG. A packaged workflow keeps its commands beside the YAML in `<workflow>/commands/`; those references resolve only inside the owning package and do not fall back to shared commands.

> **`defaults/` is maintainer-territory:** `.archon/commands/defaults/` is reserved for commands shipped with Archon itself (embedded into the binary at build time). For your own commands use an owning workflow package's `commands/`, `.archon/commands/` (project-scoped), or `~/.archon/commands/` (home-scoped). Every file under `defaults/` must be committed in git — `bun run validate` will error if untracked files are found there.

Expand Down
70 changes: 41 additions & 29 deletions packages/docs-web/src/content/docs/guides/authoring-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -948,13 +948,17 @@ written the nodes by hand. There is no separate child run.
`id: review` yields `review__verify-pr-base`, `review__sync`, `review__implement-fixes`,
and so on. These namespaced ids are what appear in the event stream and in
`archon workflow get <id>`.
- **Edges.** Internal `depends_on` edges and `$id.output` references in inline node text are
rewired to the namespaced ids automatically. Named `command:` and `loop.command` files
remain external and cannot be rewritten; when a readable command body references a
top-level block node whose id will be namespaced, workflow loading fails. This best-effort
scan includes nested `loop_group` bodies; unresolved files warn and are skipped. The include
node's own `depends_on` / `when` / `trigger_rule` attach to the block's **entry** nodes
(those with no upstream inside the block).
- **Edges and command bodies.** Internal `depends_on` edges and `$id.output` references are
rewired to the namespaced ids automatically. This includes named `command:` and
`loop.command` files: discovery resolves their bodies and compiles them into the flat DAG
before namespacing. Compilation recurses through nested `loop_group` bodies. An unresolved
included command cannot start a fresh execution because its references cannot be proven safe.
Command nodes fail composition immediately; loop commands retain a private compilation error
so a paused loop can still resume from its persisted, validated prompt snapshot.
The include node's own `depends_on` / `when` / `trigger_rule` attach to the block's
**entry** nodes (those with no upstream inside the block). If both the include and an entry
define `when:` and either condition contains `||`, loading fails because the grammar cannot
group them without changing precedence; put the gate only on the include or inside the block.
- **Sink asymmetry (a downstream node depending on the include).** A `depends_on:
[<includeId>]` on a downstream node fans out to **all** of the block's sink nodes (every
node with no dependents inside the block), so it waits for the whole block to finish.
Expand Down Expand Up @@ -1006,21 +1010,28 @@ Substitution applies everywhere the value could reach the model or the shell, in
inside Markdown code fences and inline code spans — `$INPUTS.<name>` has no
documentation-only meaning, so a fenced occurrence is still a live parameter.

#### Command bodies cannot use include inputs

Phase 1 cannot parameterize a `command:` file or `loop.command` file used by an included
block. Command bodies are read at execution time, after load-time include expansion has
finished. When such a file can be read at load time and contains `$INPUTS.<name>` anywhere —
including inside a code fence — workflow loading fails with a message directing you to inline
the prompt text. Use an inline `prompt:` when the block needs include inputs.

This check is best-effort, so a clean load is not a guarantee. It covers `command:` and
`loop.command` files throughout the block, including nested `loop_group` bodies. A command
file that cannot be resolved at load time is logged as a warning and skipped rather than
failing the workflow. This restriction applies to `include:`
only — a `workflow:` sub-run's named inputs **do** reach `command:` bodies, because they
resolve at runtime rather than at load time (see the binding-time table in
[Workflow Signature](#workflow-signature-inputs-returns-and-inputs)).
#### Command bodies use the same explicit interface

Named `command:` and `loop.command` files are the preferred home for substantial prompts and
can use both workflow-local `$node.output` references and declared `$INPUTS.<name>` values.
For an `include:`, Archon resolves and snapshots the command body during load-time composition,
then applies the same input binding and node-id namespacing as an inline prompt. The authored
workflow stays command-first; the executor receives a deterministic flat DAG.

Every live reference in the command body must belong to the included workflow's lexical node
scope. A direct `$caller.output` reference is rejected whether or not the parent happens to
have a node called `caller`; declare an input and pass it with `with:` instead. Failure to
resolve or read an included command is never a warning or best-effort bypass: a fresh execution
fails before an AI turn. A paused loop remains resumable from its saved prompt snapshot even if
the command is later deleted or made invalid.
Canonical references are live even inside Markdown code fences and inline code because runtime
substitution is syntax-agnostic.

Named `script:` files are different: they are opaque programs, not prompt templates. Archon
does not scan or rewrite their source. An `include:` may bind `$INPUTS` in the YAML `script:`
selector, but flattening does not inject those values into the selected program's environment.
The documented `INPUTS_<UPPER_SNAKE>` environment variables apply to `workflow:` sub-runs,
whose concrete inputs are persisted in child-run metadata.

### Non-goals (Phase 1)

Expand Down Expand Up @@ -1088,12 +1099,13 @@ which surfaces can read it:

| Caller | When `$INPUTS` resolves | Reaches `prompt:`/`bash:`/`script:` | Reaches `command:` file bodies |
|--------|-------------------------|-------------------------------------|--------------------------------|
| `include:` | **Load time** (the block is inlined; values are spliced into node text) | Yes | **No** — a command file is read after expansion, so `$INPUTS` in one is a load error |
| `include:` | **Load time** (the block and command bodies are compiled into the flat DAG) | Yes | **Yes** — resolved command bodies receive the same input binding before execution |
| `workflow:` sub-run | **Runtime** (values become `$INPUTS` variables on the child run) | Yes | **Yes** — every child node flows through runtime substitution |

That asymmetry is the point: a sub-run's named inputs reach `command:` bodies precisely because
they resolve at runtime, where an include's load-time macro cannot. Sub-run inputs are also
persisted to the child run's metadata at spawn, so `$INPUTS` reconstitutes on a cold resume.
Both composition paths support command-backed prompts; their binding time differs. Includes
snapshot and bind the resolved command body at load time, while sub-runs resolve inputs at
runtime. Sub-run inputs are persisted to the child run's metadata at spawn, so `$INPUTS`
reconstitutes on a cold resume.

### `$INPUTS` in `bash:`/`script:` nodes uses env vars

Expand Down Expand Up @@ -1392,7 +1404,7 @@ can line results up against the input list positionally.
| Field | Default | What it does |
|-------|---------|--------------|
| `items` | required | A `$node.output` (or `$node.output.field`) reference that must resolve to a **JSON array** at run time. Anything else — an object, a bare string, malformed JSON, a dangling ref — fails the node before any child is created. It never fans out over the characters of a string, and never silently degrades to zero items. An empty array is legal: the node completes immediately with `[]`. |
| `as` | — | Reserved for a future `$INPUTS.<as>` channel ([#2214](https://github.com/coleam00/Archon/issues/2214)) and **rejected at load** until then, rather than accepted and ignored — writing `as: task` and then `$INPUTS.task` in the child would otherwise deliver the literal string to the model. The item reaches the child as `$ARGUMENTS`. |
| `as` | — | Names the current item as `$INPUTS.<as>` in each child. It must not collide with a `with:` key. The item also reaches the child as `$ARGUMENTS`. |
| `max_parallel` | `5` | How many children may be **in flight at once**. |
| `join` | `all_done` | How N child outcomes reduce to one node outcome (below). |

Expand Down Expand Up @@ -1605,8 +1617,8 @@ re-deriving it.

### Non-goals (this slice)

- **No `with:` named-parameter mapping** — use `input:` (a single data string). A
`workflow:` node with a `with:` key is rejected with a clear error.
- **Choose one input form** — `input:` sends a single data string as `$ARGUMENTS`;
`with:` supplies named `$INPUTS` values. The two forms are mutually exclusive.
- **No racing** (`join: first_success`) — rejected outright, not deferred (see [Why there is no racing join](#why-there-is-no-racing-join)).
- **Not inside a `loop_group` body** — a `workflow:` node, fanned out or not, is rejected
there at load time ([#2439](https://github.com/coleam00/Archon/issues/2439)).
Expand Down
20 changes: 14 additions & 6 deletions packages/docs-web/src/content/docs/guides/loop-nodes.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,20 @@ owning workflow's `commands/` directory for packaged workflows or in
`.archon/commands/` for legacy workflows, the same way it does for `command:`
nodes.

The file is **read once per run** — loaded when the loop node starts and
reused for every iteration, including across interactive-gate pauses: the
loaded text is persisted with the pause, so editing or deleting the file while
a run sits paused neither changes nor breaks the resumed loop's prompt. A
missing, empty, or unreadable target fails the node immediately with an
actionable error — no iterations execute.
For an ordinary workflow, the file is **read once per run** when the loop node
starts. For a workflow composed through `include:`, Archon resolves and compiles
the command body during load-time composition so its node references and declared
inputs are proven before the child joins the parent's flat DAG. A matching file
that is unreadable fails closed; Archon never falls through to a lower-precedence
command with the same name. A missing, empty, unreadable, or non-hermetic included
command fails before a fresh AI turn.

For any interactive loop — whether authored with `prompt` or `command` — Archon
persists the resolved prompt template at the gate. A resumed run prefers that
snapshot, so editing inline YAML or a command source while the run is paused does
not change its prompt; deleting a command source does not break that resume either.
Source edits affect fresh runs, which still require successful command resolution
or compilation.

Once loaded, the text behaves identically to an inline `prompt`: all the
variable substitution above applies unchanged (including `$LOOP_PREV_OUTPUT`
Expand Down
25 changes: 25 additions & 0 deletions packages/workflows/src/compiled-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/** Engine-private included-loop compilation metadata. Symbols survive object spreads
* but stay out of YAML, JSON, API payloads, and persisted workflow definitions. */
export const COMPILED_LOOP_COMMAND = Symbol('archon.compiled-loop-command');

export type CompiledLoopCommand =
| { prompt: string; error?: never }
| { prompt?: never; error: string };

export interface LoopWithCompiledCommand {
[COMPILED_LOOP_COMMAND]?: CompiledLoopCommand;
}

export interface IncludeCommandReadError {
path: string;
message: string;
operation: 'inspect' | 'read';
}

export type IncludeCommandContent = string | null | IncludeCommandReadError;

export function isIncludeCommandReadError(
value: IncludeCommandContent | undefined
): value is IncludeCommandReadError {
return typeof value === 'object' && value !== null;
}
Loading
Loading