Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,7 +450,7 @@ Structured logging uses Pino via `createLogger('<module>')` from `@archon/paths`
2. **Workflows** (YAML-based):
- Stored in `.archon/workflows/` (searched recursively)
- Multi-step AI execution chains, discovered at runtime
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `loop_group:` (multi-node sub-DAG body repeated per iteration until `until` signal / `until_bash` exit 0 / `max_iterations`; body is sealed for `depends_on` but may read outer outputs via `$nodeId.output` and the previous iteration via `$LOOP_PREV.<nodeId>.output`; a failed body node fails the group immediately; group-level `model`/`provider` become body defaults), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`), `include:` (load-time inlining of another workflow's nodes as a flattened, namespaced sub-DAG — each included node becomes `<includeId>__<nodeId>`; the include node's `depends_on`/`when`/`trigger_rule` attach to the block's entry nodes, and `$includeId.output` resolves to the block's terminal (primary) sink; expansion happens at discovery so the executor sees ordinary nodes — see the "Reusing a Shared Sub-DAG" guide), `workflow:` (runtime sub-run — starts another workflow by static name as a separate governed CHILD run with its own `workflow_runs` row (`parent_run_id`), artifacts, gates, cost, and audit trail; `input:` forwards a data string (substituted like prompt bodies) as the child's `$ARGUMENTS`; the child's terminal output threads back as `$nodeId.output`; a child gate pauses the whole tree (approve the CHILD by run id — the parent auto-resumes on child completion); `isolation:` chooses the child's checkout — `inherit` (default; shares the parent's) or `worktree` (its own git worktree + branch, opt-in only, never inferred; requires an injected child-isolation resolver, so it fails fast on folder projects and surfaces that don't wire one), `with:` and `retry:` rejected, disallowed inside a `loop_group` body; abandon cascade-cancels descendants; `fan_out:` runs 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 count or spend), `join` (default `all_done`: every terminal outcome aggregates with failures as `{error,status}`; `all_success` for the genuinely dependent case), `as` reserved and rejected at load. Children are INDEPENDENT: every index spawns, each runs to its own terminal state, and none cancels another — the sole exception is a child that pauses at a gate, which is cancelled because a parent has one approval slot (gate before/after the fan-out, never inside a child). Racing (`join: first_success`) is rejected outright, not deferred. Concurrent children on a SHARED checkout collide on the path lock, so a spawn-time preflight refuses that expansion unless the child declares `mutates_checkout: false`, the node sets `isolation: worktree`, or `max_parallel: 1`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (SDK-enforced on Claude/Codex/OpenCode; best-effort prompt-augmentation + repair on Pi/Copilot — the parsed output is **validated against the declared schema for every provider**, best-effort providers (Pi/Copilot) re-ask up to 3× on a validation miss, and a node that declares `output_format` but returns no schema-valid output **fails** rather than degrading silently; `$nodeId.output.field` access is strict — a field not in the producer's schema, or a schemaless node whose output isn't JSON / lacks the key, fails the consuming node, while an author-declared-optional field resolves to `''`), `allowed_tools`/`denied_tools` for per-node tool restrictions (all providers except Codex), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (all providers except Pi, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (per-node injection on Claude/Pi/OpenCode/Copilot; Codex instead auto-discovers skills from `.agents/skills/` on the filesystem — the `skills:` list is informational for Codex nodes), `agents` for inline sub-agent definitions invokable via the Task tool (Claude only), and `effort`/`thinking` for reasoning depth (Claude/Pi/Copilot) plus the Claude-only SDK advanced options `maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` (also settable at workflow level), and `persist_session` for cross-run provider session continuity (node-level opt-in; workflow-level default via `persist_sessions: true`; requires a provider with the `sessionResume` capability), and `output_type` (any node type) for engine-written typed output sidecars — when set, the executor writes `$ARTIFACTS_DIR/nodes/<id>.md` + `<id>.meta.json` after the node completes (best-effort) so downstream nodes and later runs can locate output by type instead of guessing filenames
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured), `loop:` (iterative AI prompt until completion signal), `loop_group:` (multi-node sub-DAG body repeated per iteration until `until` signal / `until_bash` exit 0 / `max_iterations`; body is sealed for `depends_on` but may read outer outputs via `$nodeId.output` and the previous iteration via `$LOOP_PREV.<nodeId>.output`; a failed body node fails the group immediately; group-level `model`/`provider` become body defaults), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, receives managed per-project env vars in its subprocess environment when configured, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`), `include:` (load-time inlining of another workflow's nodes as a flattened, namespaced sub-DAG — each included node becomes `<includeId>__<nodeId>`; the include node's `depends_on`/`when`/`trigger_rule` attach to the block's entry nodes, and `$includeId.output` resolves to the block's terminal (primary) sink; expansion happens at discovery so the executor sees ordinary nodes; `with:` passes an identifier-keyed string map the block reads as `$INPUTS.<name>`, substituted VERBATIM at load time (never expressions) across every inline text surface including inside code fences — an unsupplied name is a load error, and `$INPUTS` in a `command:`/`loop.command` file is rejected because a command body is read after expansion and can never be parameterized (best-effort: top-level command nodes only, unresolvable files warn and are skipped) — see the "Reusing a Shared Sub-DAG" guide), `workflow:` (runtime sub-run — starts another workflow by static name as a separate governed CHILD run with its own `workflow_runs` row (`parent_run_id`), artifacts, gates, cost, and audit trail; `input:` forwards a data string (substituted like prompt bodies) as the child's `$ARGUMENTS`; the child's terminal output threads back as `$nodeId.output`; a child gate pauses the whole tree (approve the CHILD by run id — the parent auto-resumes on child completion); `isolation:` chooses the child's checkout — `inherit` (default; shares the parent's) or `worktree` (its own git worktree + branch, opt-in only, never inferred; requires an injected child-isolation resolver, so it fails fast on folder projects and surfaces that don't wire one), `with:` and `retry:` rejected, disallowed inside a `loop_group` body; abandon cascade-cancels descendants; `fan_out:` runs 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 count or spend), `join` (default `all_done`: every terminal outcome aggregates with failures as `{error,status}`; `all_success` for the genuinely dependent case), `as` reserved and rejected at load. Children are INDEPENDENT: every index spawns, each runs to its own terminal state, and none cancels another — the sole exception is a child that pauses at a gate, which is cancelled because a parent has one approval slot (gate before/after the fan-out, never inside a child). Racing (`join: first_success`) is rejected outright, not deferred. Concurrent children on a SHARED checkout collide on the path lock, so a spawn-time preflight refuses that expansion unless the child declares `mutates_checkout: false`, the node sets `isolation: worktree`, or `max_parallel: 1`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (SDK-enforced on Claude/Codex/OpenCode; best-effort prompt-augmentation + repair on Pi/Copilot — the parsed output is **validated against the declared schema for every provider**, best-effort providers (Pi/Copilot) re-ask up to 3× on a validation miss, and a node that declares `output_format` but returns no schema-valid output **fails** rather than degrading silently; `$nodeId.output.field` access is strict — a field not in the producer's schema, or a schemaless node whose output isn't JSON / lacks the key, fails the consuming node, while an author-declared-optional field resolves to `''`), `allowed_tools`/`denied_tools` for per-node tool restrictions (all providers except Codex), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (all providers except Pi, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (per-node injection on Claude/Pi/OpenCode/Copilot; Codex instead auto-discovers skills from `.agents/skills/` on the filesystem — the `skills:` list is informational for Codex nodes), `agents` for inline sub-agent definitions invokable via the Task tool (Claude only), and `effort`/`thinking` for reasoning depth (Claude/Pi/Copilot) plus the Claude-only SDK advanced options `maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` (also settable at workflow level), and `persist_session` for cross-run provider session continuity (node-level opt-in; workflow-level default via `persist_sessions: true`; requires a provider with the `sessionResume` capability), and `output_type` (any node type) for engine-written typed output sidecars — when set, the executor writes `$ARTIFACTS_DIR/nodes/<id>.md` + `<id>.meta.json` after the node completes (best-effort) so downstream nodes and later runs can locate output by type instead of guessing filenames
- Workflow-level `requires: [github]` hard-blocks invocation (before any worktree/clone/AI cost) when the originating user hasn't connected their GitHub identity — enforced only when per-user GitHub is enabled (GitHub App + `TOKEN_ENCRYPTION_KEY`); a no-op for solo PAT installs
- Provider inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides supported
- Model and options can be set per workflow or inherited from config defaults
Expand Down
55 changes: 51 additions & 4 deletions packages/docs-web/src/content/docs/guides/authoring-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -954,12 +954,59 @@ written the nodes by hand. There is no separate child run.
- **Output.** `$<includeId>.output` in another node resolves to the block's primary sink.
In the example, `$review.output` is the output of the block's `implement-fixes` node.

### Passing values into an included block

An include can pass an identifier-keyed string map through `with:`. The included block uses
those values through `$INPUTS.<name>` in its inline text:

```yaml
# parent workflow
nodes:
- id: plan
prompt: Plan the requested change.

- id: review
include: reusable-review
depends_on: [plan]
with:
plan: $plan.output
base_branch: main

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change this to base_branch: $BASE_BRANCH.

Two reasons, and the second is the one that matters:

1. main is the wrong branch to name in this repo. main is the release branch here; everything targets dev. A reader who copies this example inherits the wrong default. (Unrelated but adjacent: a stale local refs/remotes/origin/HEAD on this machine was also pointing at main, so the word showed up twice from two unrelated causes — that one's fixed separately.)

2. It teaches hardcoding a branch name in the same paragraph that introduces the feature. Archon already provides $BASE_BRANCH precisely so authors don't do this. Using it here demonstrates something more interesting than a literal: a workflow variable flowing through with: into a block.

Verified it worksapplyInputsMacro is a pure verbatim text.replace, and this file has zero references to substituteWorkflowVariables or BASE_BRANCH. So $BASE_BRANCH lands as literal text in the block's node body and resolves through normal runtime substitution, by exactly the mechanism the prose above already describes for $node.output. No code change needed — the doc line is the whole fix.

Worth a sentence in the surrounding prose too: a with: value can carry any variable the runtime substitutes, not just $node.output.

```

```yaml
# reusable-review workflow
nodes:
- id: inspect
prompt: Review $INPUTS.plan against $INPUTS.base_branch.
```

Input names must start with a letter or underscore and may then contain letters, numbers,
underscores, or hyphens. Values must be strings and are inserted verbatim during load-time
expansion — they are **never expressions**: nothing is evaluated, computed, or interpreted,
and the value is spliced in as text exactly as written. An inserted `$node.output` reference
remains a reference and resolves through the normal runtime output substitution. A missing
input is a load error; extra caller keys are ignored until workflow input declarations ship.

Substitution applies everywhere the value could reach the model or the shell, including
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 the block's
top-level `command:`/`loop.command` nodes only, so a command nested inside a `loop_group`
body is not scanned; and 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:`;
named `with:` mappings for `workflow:` sub-runs have not shipped.

### Non-goals (Phase 1)

- **No `with:` input mapping yet.** Passing values into an included block is not supported;
an include node with a `with:` key is rejected with a clear error. A block reaches parent
context only through workflow variables (`$BASE_BRANCH`, `$ARTIFACTS_DIR`, …) and command
files, which is enough for the shared-review-block use case.
- **No deep access.** A parent can read `$includeId.output` (the terminal) but not the
output of an individual node inside the block. The block's internal node names are an
implementation detail.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ The test the rule actually applies is *"does one child's outcome end another's?"
| Parentheses & nested boolean grouping in `when:` | ❌ rejected (see policy below) | The first step of home-growing an expression language |
| Templating (Jinja-style interpolation, computed node ids) | ❌ rejected | Evaluation inside declaration — the Helm road |
| Dynamic include targets (`include: $x.output`) | ❌ rejected | Turns structure into a runtime value; the engine can no longer statically validate the graph |
| `with:` include parameters carrying expressions | ⚠️ constrained | Admissible only as **data-only** mapping (values or `$node.output` refs) — the moment values can be computed inline, it is function application |
| `with:` include parameters | ✅ shipped (data-only) | Identifier-keyed string values are substituted during load-time expansion; inserted `$node.output` values continue through normal runtime output substitution. `workflow.with` is not yet shipped |

## The five smells — and the management lever for each

Expand All @@ -107,7 +107,7 @@ These are the specific mechanisms by which workflow languages rot. Each is liste

**Mechanism.** Reuse primitives are the most dangerous axis because they converge on function application: includes become calls, parameters become arguments, loop-carried state becomes variables — and suddenly the config format has scoping rules, evaluation order, and abstraction. This is how Helm charts became programs.

**Archon today.** `loop_group` already carries loop-state (`$LOOP_PREV`); `include:` Phase 1 adds textual reuse. Both were held on the declarative side deliberately: `include` is load-time expansion with zero runtime semantics, `with:` was **deferred and rejects fail-fast**, deep output access across the include boundary is unsupported, and dynamic targets are out of scope.
**Archon today.** `loop_group` already carries loop-state (`$LOOP_PREV`); `include:` adds textual reuse. Both are held on the declarative side deliberately: `include` is load-time expansion with zero new runtime semantics, and its shipped `with:` surface is a data-only string mapping resolved during expansion. Expressions, deep output access across the include boundary, `workflow.with`, and dynamic targets remain unsupported.

**Lever — composition must be resolvable at load time.** Any reuse feature must fully resolve before execution begins (the engine executes a flat, static DAG). Parameterization, if ever added, is data-only mapping. Anything requiring runtime resolution of *structure* is Phase-2 sub-run territory — where it becomes a governance object with its own run record, not a language feature.

Expand Down
9 changes: 9 additions & 0 deletions packages/workflows/src/command-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { DagNode } from './schemas';
import { isCommandNode, isLoopNode } from './schemas';

/** Return the command-file name used by a node, including deferred loop prompts. */
export function getFileBackedCommandName(node: DagNode): string | undefined {
if (isCommandNode(node)) return node.command;
if (isLoopNode(node) && typeof node.loop.command === 'string') return node.loop.command;
return undefined;
}
Loading
Loading