Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Work graphs

A plan is a versioned DAG of tasks that a deterministic executor runs. Tasks are agent turns, plan-authored commands, or engine-owned reducers. The executor owns advancement: a task never decides what runs next.

Today, the engine supplies a default loop graph and a wide-tournament template, and a human or pack can supply TOML or JSON through the plan CLI. Workflow admission separates authorable topology from authority: an orchestrator must advertise the workflow type and engine operations it can safely execute.

Running a plan

# compile and print it, without executing
crucible plan show --file plan.toml
crucible plan show --file plan.toml --mermaid      # flowchart source
crucible plan show --file plan.toml --render       # PNG, inline if the terminal supports it

# execute: command tasks run as subprocesses, agent tasks through the real harness
crucible plan run --file plan.toml --manifest crucible.toml

# execute without a manifest: agent tasks run a stand-in command instead
crucible plan run --file plan.toml --agent-cmd ./role.sh

# replace the manifest's [agent] harness and model for this run; a task that pins its own keeps it
crucible plan run --manifest crucible.toml --harness codex --model gpt-5.6-luna

--cap <name> (repeatable) declares what the substrate can do; see needs below.

plan run exits nonzero when the plan does not reach a valid verdict.

File format

version = 1                 # the only format version accepted today
# reason = "..."            # reserved for a future replan protocol

[budget]
usd = 5.0                   # required, positive; execution fails closed on overrun

[[task]]
name = "propose"            # unique within the plan
kind = "agent"
prompt = "..."
model = "claude-opus-4-6"   # optional per-task overrides of the manifest's [agent]
harness = "claude"
effort = "high"
session = "solver"         # optional durable logical conversation

[[task]]
name = "measure"
kind = "command"
command = "./bench.sh"
depends_on = ["propose"]
needs = "gpu"               # default "any"
required = true             # default true
isolation = "worktree"      # optional
join = "all"                # default "all"
emits = ["score"]           # optional declared output fields; absent = undeclared

[[task]]
name = "pick"
kind = "top_k"
k = 1
direction = "lower"         # or "higher"
depends_on = ["measure"]

Task kinds

KindWhat it runs
agentOne agent turn. harness / model / effort override the manifest's [agent] defaults per task; session opts into durable continuation.
commandA plan-authored command returning JSON on its last stdout line.
evaluateA measurement command. pass = false vetoes; paired threshold + direction grade numeric score.
top_kEngine-owned reducer: keep the k best inputs by their score field. Needs at least one dependency.
engineCapability-owned operation (propose, apply, measure, grade, decide, or measure_diff). Only an admitting orchestrator can execute it.

Serialization is not authority. A workflow may author and sequence engine nodes, but admission requires matching capabilities such as workflow.autoresearch, engine.apply, and engine.measure. The generic plan runner rejects them because it owns neither a World nor a frozen Judge.

A command string is not a trust boundary by itself. If it invokes a script that must remain trusted after an agent task edits the workspace, declare that script as a frozen [[workspace.inject]] in the manifest. The plan runner restores frozen injects in the task's actual workspace before every task, including isolated worktrees.

A logical session is serial state, so every pair of tasks sharing one must have a dependency path between them. A session task cannot use disposable worktree isolation. The private ledger keeps only an opaque harness cursor and completed-turn count; neither that cursor nor Claude's native transcript is copied into the plan or public session log. Normal streamed harness events retain their existing visibility. Claude Code's native transcript remains in its private local store or a mode-0600 engine store restored into each fresh OpenShell sandbox. Omit the field for the historical fresh-turn behavior.

Task output

A task's output is JSON and becomes its dependents' input.

  • command: the last non-empty stdout line. Nonzero exit is a measured failure; a spawn failure is a transport failure. Upstream outputs arrive as CRUCIBLE_INPUTS (a JSON object keyed by task name), plus CRUCIBLE_TASK.
  • agent under --manifest: the turn writes a single JSON object to PLAN_TASK_RESULT.json in the workspace root. A missing file after a normal turn is a measured failure; an explicit spawn, harness, or stream error is a transport failure and follows the retry policy.
  • agent under --agent-cmd: the stand-in receives CRUCIBLE_PROMPT, CRUCIBLE_HARNESS, CRUCIBLE_MODEL, CRUCIBLE_EFFORT, and returns JSON on its last stdout line.
  • evaluate: requires a JSON object. pass = false fails and malformed pass fails closed. Paired threshold and direction compare numeric score (lower is <=; higher is >=). Without a threshold, a successful command passes unless pass is false.

top_k reads a finite numeric score from each input, so an upstream task that wants to rank must emit one. That contract is declarable: emits = ["score"] on an agent, command, or evaluate task names fields its JSON output promises to include. Validation rejects a top_k dependency, grade score source, or thresholded evaluate whose declared emits omits score, before anything runs; at runtime a passing attempt missing a declared field is converted to a measured failure at the producing task (never retried, blocks dependents), so output drift fails where it happened instead of downstream. An empty or absent emits declares nothing and is never checked. top_k and engine tasks cannot declare emits; their outputs are engine-defined.

Execution semantics

How the executor walks a graph, what one task goes through and how the plan as a whole ends, is its own generated diagram: Plan execution states.

Readiness. A task dispatches when its dependencies are terminal and its join is satisfied. Dispatch order is declaration-stable, so the event stream is deterministic.

Truncation is fail-closed. If a required task can never run on this substrate (its needs is not in the declared caps, or a dependency cannot run), the whole plan is truncated and nothing is dispatched. A truncated DAG cannot produce an honest pass. An advisory task in the same position is skipped, along with its dependents, and validity is unaffected.

Failure. A required task that fails short-circuits the plan; everything undispatched is blocked. An advisory failure blocks only its dependents.

Retry is not recheck. Transport failures retry, bounded (2 by default). A measured failure never reruns: a task that failed, failed.

Budget. Cost is known only after an attempt completes, so an in-flight attempt may report a total above budget.usd. Any overrun invalidates the plan and blocks all further dispatch and retries. Reaching the budget exactly is valid only when no further retry or task is needed.

needs

The substrate capability a task requires. "any" runs everywhere. Anything else must be declared with --cap, otherwise the task is unrunnable, and plan show reports the truncation verdict before you spend anything.

isolation

isolation = "worktree" gives the task a private clone of the workspace, including its uncommitted state. Two effects:

  • Tasks isolated this way and ready at the same time run concurrently. Without isolation they would collide on the single PLAN_TASK_RESULT.json in the shared workspace.
  • The task's edits are discarded. What leaves is its declared output, so this is for review and analysis work, not for a task whose diff has to survive.

A runner that cannot isolate refuses the task rather than silently running it in the shared workspace.

join

join = "all" (default) requires every dependency to pass. join = "passed" waits for every dependency, then folds the non-empty passing set. It fails closed if none can run or pass.

The loop as a plan

Each loop iteration runs as a capability-admitted autoresearch workflow. With no authored workflow, the default expands to ordinary tasks:

flowchart LR
    propose["propose (engine)"] --> apply["apply"] --> measure["measure"] --> decide["decide"]

Task names and intervening topology are author-defined. An autoresearch result must be a decision fed by a frozen measurement with apply and proposal ancestors. A custom workflow has no such shape requirement; the outer orchestrator only admits it when it advertises workflow.custom.

[workflow]
type = "autoresearch"
result = "keep-if-better"

[[workflow.task]]
name = "invent"
kind = "engine"
op = "propose"
session = "solver"

[[workflow.task]]
name = "review"
kind = "command"
command = "./review.sh"
depends_on = ["invent"]

[[workflow.task]]
name = "deploy-preview"
kind = "engine"
op = "apply"
depends_on = ["review"]

[[workflow.task]]
name = "benchmark"
kind = "engine"
op = "measure"
depends_on = ["deploy-preview"]

[[workflow.task]]
name = "keep-if-better"
kind = "engine"
op = "decide"
source = "benchmark"
depends_on = ["benchmark"]

The corresponding admission needs workflow.autoresearch, engine.propose, engine.apply, engine.measure, engine.decide, and—because it binds solveragent.session.persist. A custom orchestrator can instead admit type = "custom" and any subset of operations it implements. Task-level needs still controls where an admitted task can run; workflow capabilities control what the orchestrator is authorized to mean.

Same decisions and same session events as the default path, plus additive plan_admitted and task_result lines. Cross-round state, keep/discard, and every between-round control (parking, steering, re-scoping, budget) stay with the driver. The wide round runs as a template compiled from [search] on both paths.

The templates carry no budget of their own: the run budget is the driver's, checked between rounds, so a turn that overruns the cap is still measured and decided.

Authored measurement subgraphs

The compatible measure() path remains available. For visible measurement, use evaluate() and grade(): dependencies define rungs, isolated peers can run concurrently, and grade() selects the score source for decide().

candidate = propose(name = "invent")
live = apply(name = "deploy", depends_on = [candidate])

refcheck = evaluate(
    name = "cpu-reference",
    run = "./refcheck.sh",
    depends_on = [live],
)
diff = evaluate(
    name = "single-gpu-diff",
    run = "./diff.sh",
    depends_on = [refcheck],
    threshold = 0.001,
    direction = "lower",
    needs = "gpu",
    isolated = True,
)
latency = evaluate(
    name = "latency",
    run = "./latency.sh",
    depends_on = [diff],
    direction = "lower",
    threshold = 12.5,
    isolated = True,
)
racecheck = evaluate(
    name = "racecheck",
    run = "./racecheck.sh",
    depends_on = [diff],
    required = False,
    isolated = True,
)
measurement = grade(
    name = "final-grade",
    evidence = [diff, latency, racecheck],
    score = latency,
)
decision = decide(name = "choose", measurement = measurement)
workflow(
    type = "autoresearch",
    tasks = [candidate, live, refcheck, diff, latency, racecheck, measurement, decision],
    result = decision,
)

The renderer groups evaluate and grade as Measurement while preserving edges. During crucible scope, validation writes the admitted graph to WORKFLOW.png; agents cannot replace it.

Default off while it soaks.

Run-scoped epilogue tasks

stage = "epilogue" on a [[workflow.task]] (Starlark: stage = "epilogue" on agent(), command(), or evaluate()) removes the task from the per-iteration graph. The epilogue subgraph runs once, after the loop concludes cleanly (finished, budget, or solved), against the final kept candidate, and only if the run kept something. This is where a 90-minute compute-sanitizer racecheck or a slow perf benchmark belongs.

Each task's CRUCIBLE_INPUTS carries the kept candidate under the reserved kept key: {"iter", "score", "tiebreak", "sha", "snapshot", "note"}. Dependencies may not cross stages, engine ops cannot be epilogue, and the workflow result must iterate.

Epilogue results are advisory: they cannot un-keep the candidate. Rows land in the session log and RESULTS.md (epilogue / epilogue-skip / epilogue-fail), and the PR body gets an "Epilogue checks (advisory)" section with failures marked FAILED.

report(name = "publish-report", destination = {"kind": "slack"}, template = "reports/slack.md.j2", result = roundup, required = True) is the engine-owned publication epilogue. Destinations are engine-known keys, not URLs or secret names; slack is the only destination in the first version. The optional result selector projects only that main-graph task's declared JSON fields into an engine-built Block Kit card. It does not expose prompts, stdout, workspaces, undeclared fields, raw Slack blocks, channels, or credentials. Selected output defaults to a 16 KiB encoded limit; an operator may lower or raise it up to 64 KiB with CRUCIBLE_REPORT_RESULT_MAX_BYTES. Oversize data fails without truncation. A required report makes rendering or delivery failure fail the workflow; it does not rely on an agent remembering to call a tool.

Worked example

examples/adversarial-review puts a review task between a code node and the gate below it, in single-reviewer and two-reviewer panel shapes. The panel runs isolated reviewers concurrently and joins them on a policy gate: correctness blocks, copy-edit is advisory. It runs free against a stand-in manifest or against real models with the live one.