Crucible implementation contract (frozen interface)
This is the specification of the interface between the engine (crucible) and a
domain. The engine implements it, and a domain satisfies it. A minimal fake domain
(examples/counter/) tests it end to end without EPP and without a cluster.
Concept: The loop in one read. Trust line (engine hands the agent a World, never a Judge): ADR-0001.
Contract status: NORMATIVE. Words like must / exactly one are binding. The engine and every domain (EPP included) conform to this; tests assert it.
1. The manifest (crucible.toml)
The engine reads exactly one manifest per run. Default path ./crucible.toml (override
--manifest <path>). The manifest directory = dirname(manifest); it anchors all
config-relative paths below.
edition = 2 # pack contract edition; absent = 1 (§3)
[repo]
# Exactly one of url|path. The upstream the agent edits a checkout of. A playbook may omit the
# whole table: its workspace is then an empty dir the injects populate.
url = "https://github.com/owner/name.git" # OR
path = "." # local path, relative to manifest dir
ref = "main" # optional; default: clone default branch
[workspace]
dir = "workspace" # checkout dir, relative to manifest dir. default "workspace"
setup_cmd = "git clone ... && git checkout" # optional; default: engine git clone+checkout of [repo]
# Frozen-judge / fixture injection (optional). After setup, the engine copies each `src` (relative
# to the manifest dir, a baked artifact outside the clone target, so the agent has no pre-clone
# copy) to `dst` (relative to the workspace), then commits the result as the baseline. A `frozen`
# inject (default true) is ALSO re-copied before every scored measure and before every plan task,
# so a candidate can't edit the gate (a T1 scoring harness, a seeded regression test) to game it;
# set `frozen = false` for a one-time fixture the agent may then edit. This is the generic
# alternative to hand-chaining an `install` into `setup_cmd`.
#
# A string entry is a frozen copy to the same path. A directory or a glob (`*`, `?`, `[`) expands
# to every regular file under it, sorted; it must stay under the manifest dir and must match
# something. Use the table form to rename, to unfreeze, or to reach outside the manifest dir.
inject = [
"measure.sh", # -> measure.sh, frozen
"tools/*.py", # every script, frozen
{ src = "judges/1489/judge_harness_test.go", dst = "pkg/.../judge_harness_test.go" },
{ src = "fixtures/seed.json", dst = "testdata/seed.json", frozen = false },
]
[agent]
model = "claude-opus-4-6" # default engine constant
method_prompt = "method.md" # manifest-relative file; template, {{GOAL}}/{{STATUS}}/{{STEER}}
goal = "raise the score" # OR goal_file = "goals/x.md" (manifest-relative)
toolbox_dir = "commands" # optional; copied into <workspace>/.claude/skills
backend = "local" # local | openshell | command (see §6)
sandbox_image = "ghcr.io/<org>/<domain>-sandbox:<tag>" # openshell backend only
allow_unverified_image = false # launch on an image the controller's catalog cannot vouch for
[agent.requires] # what the sandbox image must provide; the controller refuses a launch otherwise
"toolchain.go" = ">=1.25"
[agent.prefers] # ranks compatible images in the controller's picker
"toolchain.go" = ">=1.26"
[agent.resources] # openshell backend only: what the sandbox is scheduled with
gpus = 1 # nvidia.com/gpu; the deploy profile's [cluster.gpu_sandbox] says where
cpu = "8" # Kubernetes quantities, requested and limited alike
memory = "32Gi"
node_selector = { "nvidia.com/gpu.product" = "NVIDIA-H100-80GB-HBM3" } # node labels, passed to the pod as written
agent_cmd = "..." # command backend only (§6)
[agent.env] # injected into the agent process (creds, Vertex, etc.)
ANTHROPIC_VERTEX_PROJECT_ID = "my-gcp-project"
[[agent.tool_plugins]] # optional; absent = repeat_guard(25), classify, tool_stats
plugin = "repeat_guard" # see tool-plugins.md
limit = 25
[judge]
measure_cmd = "./measure.sh" # REQUIRED. §3. Any executable, any language.
direction = "lower" # lower | higher. REQUIRED.
tiebreak_direction = "lower" # optional; direction of the `tiebreak` scalar (§4). default: direction
objective = "score" # display label (the old `gate` name). default "score"
env = ["KUBECONFIG", "EPP_ENDPOINT?"] # edition 2: engine env measure_cmd receives (§3); NAME? only when set
[judge.selftest] # optional (ADR-0012). Runs in `crucible check`, never in a loop iteration.
good_cmd = "..." # stages a known-good config in the workspace
bad_cmd = "..." # stages a known-bad config in the workspace
runs = 1 # measurements averaged per control. default 1
[world]
# All three omitted → GitWorld (tree-only reversibility; the 80% case).
apply_cmd = "..." # optional. §3.
snapshot_cmd = "..." # optional. §3.
restore_cmd = "..." # optional. §3.
env = ["KUBECONFIG"] # edition 2: engine env the three commands receive (§3); NAME? only when set
[search] # optional (ADR-0010). Absent or wide=0 → pure-deep, the default.
wide = 0 # u32. N parallel propose turns before the deep loop. default 0
approaches = ["...", "..."] # REQUIRED when wide > 0: one distinct approach string per candidate slot
policy = "top-k" # only v1 policy. default "top-k"
policy_k = 1 # how many wide-round winners seed the deep loop. default 1, must be in 1..=wide
Rules:
-
Required:
[agent]. A scored run also requires[repo](url xor path),[judge].measure_cmd, and[judge].direction. A playbook can omit[repo]. A manifest without[judge]is a task (the task lane). -
[world]with no commands ⇒ GitWorld (§5). Any command given ⇒ CommandWorld (§5), which still owns git memory and layers the given commands on top. -
[judge.selftest], if present, requires bothgood_cmdandbad_cmd(a self-test that only stages one side isn't a control);runsmust be>= 1. -
[search], if present withwide > 0, requiresapproaches.len() >= wide(hard error, diversity is engineered, not auto-generated) andpolicy_kin1..=wide. -
Edition.
editionis 1 when absent; 1 and 2 are the editions this engine runs, and any other value fails the parse naming them. Anenvlist in[judge],[world], or[workflow]needs edition 2 and fails the parse of an edition-1 manifest (§3). -
Unknown keys are an error (typo protection), not silently ignored.
-
Frozen loading (
load_frozen). When the manifest file lives inside the workspace it targets (the BYO on-ramp:crucible initscaffolds[repo] path = "."), the engine parses it from the workspace's pristine base commit, not the current working tree (so an in-flight agent edit tocrucible.tomlcan't retarget its own gate mid-run). Before any base commit exists (the very first run), it hard-warns and trusts the working tree for that run only; later runs freeze to the base commit. A manifest that lives outside the workspace (any out-of-workspace pack) is unaffected. -
Variants. One manifest can carry per-environment variants as
[variant.<name>]tables:[agent] backend = "openshell" [variant.offline.agent] backend = "command" agent_cmd = "./stand-in.sh"plan run --variant offline,check --variant offlineanddeploy render --playbook --variant offlineread the variant: each top-level table it names replaces the base table of the same name whole ([variant.offline.agent]replaces all of[agent]; nothing is merged below the top level). Without--variantthe base is read, without any[variant]table. Every declared variant is validated on every load, andcrucible checkcompiles every variant's graph, so a broken variant fails whichever one runs. A resume must name the variant the run started with. A composite manifest declares no variants.
1.1 The gate self-test (crucible check, ADR-0012)
[judge.selftest] declares two controls the gate must tell apart before it's trusted. crucible check (§9) runs it pre-loop, never inside a loop iteration:
- snapshot the pristine workspace,
- restore to pristine, stage
good_cmd, measurerunstimes through the domain's ownJudge, restore to pristine again, - same for
bad_cmd, - pass iff both controls' readings are all
validandgood's mean score is strictly better thanbad's per[judge].direction.
The workspace is restored to pristine on every exit path (pass, fail, or error). A manifest with
no [judge.selftest] isn't an error, crucible check warns instead, since the gate hasn't been
proven to discriminate.
1.2 Wide-round search ([search], ADR-0010)
[search].wide > 0 (or --wide N on the CLI, which overrides the manifest) fans out N
independent PROPOSE turns in per-candidate git worktrees under the state dir before the deep
loop starts, one turn per approaches entry biased into its prompt. Each candidate's diff is
applied (cherry-picked) into the shared main workspace and measured serially there (measurement
never runs concurrently, only proposal does). The scored set is ranked by [search].policy (v1:
"top-k"); the policy_k (or --wide-keep K) winner(s) seed the deep loop, which then runs as
normal. Session rows from the wide round carry an additive phase: "wide" field (§7) so a
consumer can tell a wide-round row from a deep-loop row without a wire-shape change.
1.3 Scope-authored workflows (workflow.star)
A scoped pack may include workflow.star beside crucible.toml. It is authoring syntax, not a
runtime interpreter: scope compiles it to the existing [[workflow.task]] manifest IR before
validation and again before freeze. The generated TOML is the runtime authority. This keeps plan
authorship readable without moving the frozen judge or execution semantics into Starlark.
Topology is authorable; authority is not. A workflow declares type = "autoresearch" or
type = "custom", and an engine or outer orchestrator admits it only when it advertises the
matching workflow capability. Each engine task also requires its own capability. Crucible's loop
advertises workflow.autoresearch plus propose/apply/measure/grade/decide, while its generic plan runner
does not. It adds agent.session.persist only when the selected backend and harness can honor an
opaque continuation. Serializing an engine task never grants access to the World or frozen
Judge.
An autoresearch workflow is checked by semantics rather than reserved task names. Its selected
result must be a decide task sourced from a frozen measure or authored grade, with apply
and propose ancestors.
Tasks may be inserted anywhere, operations may be renamed, and multiple candidate or measurement
branches may exist. A custom workflow has no autoresearch-shape requirement; universal DAG,
source-typing, and operation-capability rules still apply.
The basic autoresearch flow is explicit and editable:
candidate = propose(name = "invent", session = "solver")
critics = [
agent(
name = "correctness",
prompt = prompt_file("prompts/correctness.md"),
model = "claude-opus-4-6",
effort = "high",
isolated = True,
depends_on = [candidate],
),
agent(
name = "novelty",
prompt = prompt_file("prompts/novelty.md"),
required = False,
isolated = True,
depends_on = [candidate],
),
]
synthesize = agent(
name = "synthesize",
prompt = prompt_file("prompts/synthesize.md"),
session = "solver",
depends_on = critics,
join = "passed",
)
smoke = command(name = "smoke", run = "./smoke.sh", depends_on = [synthesize])
live = apply(name = "deploy-preview", depends_on = [smoke])
score = measure(name = "benchmark", depends_on = [live])
decision = decide(name = "keep-if-better", measurement = score)
workflow(
type = "autoresearch",
tasks = [candidate] + critics + [synthesize, smoke, live, score, decision],
result = decision,
)
default_autoresearch(extra_tasks) is the compatibility convenience. It expands to the same
ordinary propose/apply/measure/decide tasks, attaching unconnected extras after proposal and before
apply. A missing workflow.star retains the historical default loop behavior. The old positional
workflow(tasks) form remains accepted as a splice adapter while packs migrate.
Measurement can remain the historical opaque measure() call, or be authored as a visible DAG:
live = apply(name = "deploy", depends_on = [candidate])
correctness = evaluate(
name = "correctness",
run = "./correctness.sh",
depends_on = [live],
isolated = True,
)
latency = evaluate(
name = "latency",
run = "./latency.sh",
depends_on = [correctness],
threshold = 12.5,
direction = "lower",
isolated = True,
)
racecheck = evaluate(
name = "racecheck",
run = "./racecheck.sh",
depends_on = [correctness],
required = False,
isolated = True,
)
measurement = grade(
name = "grade",
evidence = [correctness, latency, racecheck],
score = latency,
)
decision = decide(name = "choose", measurement = measurement)
Dependencies define measurement rungs; ready isolated siblings run concurrently. evaluate()
expects a JSON object on its last stdout line. pass = false vetoes a result, malformed pass
fails closed, and paired threshold/direction fields grade numeric score. Without a threshold,
omitted pass means success. grade() selects a passing score evaluator and folds passing
evidence by default; set join = "all" for a strict join. The legacy measure() path and default
workflow are unchanged.
session = "solver" binds agent-producing tasks to a durable logical conversation. The checkout
may roll back after a discarded candidate while the solver session continues forward and retains
what it learned. Tasks sharing a session must be dependency-ordered and cannot be isolated;
parallel critics should stay fresh or use distinct sessions. Admission requires
agent.session.persist. A missing session preserves the historical fresh-turn behavior.
Sessions can also be declared first-class with session(...) and bound by value:
solver = session(name = "solver", model = "claude-opus-4-6", effort = "high")
candidate = propose(name = "invent", session = solver)
refine = agent(name = "refine", prompt = prompt_file("prompts/refine.md"), session = solver,
depends_on = [candidate])
Declarations are compile-time only; the generated manifest carries the same per-task session,
harness, model, and effort fields as before. The rules:
- A declaration's
harness/model/effortare defaults that materialize onto every agent task bound to it. A bound task may repeat a value but not contradict it: one session is one serial conversation under one agent config. - A session carrying defaults cannot bind to
propose(), whose agent config is owned by the manifest's[agent]. A default-free declaration binds to it exactly as a string does. - Duplicate declarations of one name, and declarations never bound to a task, are compile errors.
- While a file declares no sessions, bare strings keep the historical pass-through behavior.
Once any
session()exists, every stringsession = "x"must name a declared session (declared before use), so a typo can no longer silently open a second fresh conversation.
Tasks may also declare their output contract: emits = ["score", "pass"] on agent(),
command(), or evaluate() names fields the task's JSON output promises to include.
Compilation rejects a top_k dependency, grade score source or tiebreak, or thresholded
evaluate whose declared emits omits score; at runtime a passing attempt missing a declared
field becomes a measured failure at the producing task instead of a mystery downstream. An absent emits
declares nothing and changes nothing. A dict, emits = {"score": "number", "tier": ["high", "low"]}, also promises each field's type; a wrongly typed field fails the producing task the
same way, and compilation checks the types against over, score readers, and output-decided
routes. A type may also be schema_file("path"), a JSON Schema the value must satisfy, and
emits_files takes a dict from path to schema_file(...) or None. See
Work graphs.
Compile errors carry file:line:col and a did-you-mean suggestion for unknown functions,
kwargs, variables, and session names. A behavioral change from earlier releases: a task
constructed in workflow.star but omitted from workflow(tasks = ...) is now a compile error
naming the construction site, because a silently dropped task is a silently weakened
measurement. Delete the assignment or include the task.
Crucible's private ledger contains only the logical name, an opaque harness cursor, and a
completed-turn count. It never copies that cursor or Claude's native transcript into
session.jsonl; the existing live harness event policy, including streamed thinking events, is
unchanged. Claude Code implements native start/resume for both the local and Vertex/OpenShell paths. Because OpenShell sandboxes are
per-turn-fresh, Crucible saves Claude's native transcript as mode-0600 private engine state and
restores it at the exact pinned config path in the next sandbox; that file is never included in the
published run record. The other harnesses have no equivalent continuation store, so they fail closed
for a persistent binding; none silently degrades the binding to a fresh prompt.
The first turn receives the complete method and goal prompt. A resumed proposer receives only the
new authoritative delta: current regime, current-best status, and new steering. Its retained
session already holds the stable instructions and hypotheses, while the current checkout and
RESULTS.md remain authoritative after any world rollback.
A custom orchestrator can admit a graph without a research lifecycle. For example, a creative studio fans out three treatments, curates them, and publishes a contact sheet:
treatments = [
agent(name = "surreal", prompt = prompt_file("prompts/surreal.md"), isolated = True),
agent(name = "minimal", prompt = prompt_file("prompts/minimal.md"), isolated = True),
agent(name = "documentary", prompt = prompt_file("prompts/documentary.md"), isolated = True),
]
curate = agent(
name = "curate",
prompt = prompt_file("prompts/curate.md"),
depends_on = treatments,
join = "passed",
)
publish = command(name = "contact-sheet", run = "./render.sh", depends_on = [curate])
workflow(type = "custom", tasks = treatments + [curate, publish], result = publish)
That graph requires workflow.custom. It does not satisfy the autoresearch shape, although it
uses agents and commands.
This is Starlark over a constructor-only global surface: assignments, strings, numbers, booleans,
lists, def, if/else, for, comprehensions, and calls to the functions below, at the top
level or inside a def. load() resolves against the pack directory only. Task, session, and
workflow values are opaque and immutable, so they can be referenced directly in depends_on,
measurement, and result without repeating names, and a library cannot mutate one after
handing it back.
The declared lane decides which constructors exist. Which lane each belongs to, and what arguments each takes, is the DSL reference, generated from the compiler's own tables. A playbook does not have the scored lane's constructors in scope at all, so naming one is an unknown-name error where it was written, and a did-you-mean never offers one.
propose(...),apply(...),measure(...),grade(...), anddecide(...)create capability-owned engine tasks.decide(measurement = score)selects its measurement. Scored lanes only.agent(...)creates an agent task.isolated = Truegives it a disposable worktree, ideal for concurrent read-only critics; leave it false for a synthesizer whose edits must survive.session = "name"opts into an engine-managed durable conversation.session(name = ..., harness = ?, model = ?, effort = ?)declares a durable conversation with optional agent defaults, bindable as thesession =value onagent()andpropose().command(...)creates a deterministic shell task in the candidate workspace.evaluate(...)creates a typed measurement command with optional threshold grading.top_k(...)creates a reducer for wider authored graphs. Scored lanes only.prompt_file(path)reads a regular UTF-8 file below the pack directory and embeds its contents in the generated manifest. Absolute paths,.., symlinks, non-files, and oversized inputs are rejected.agent(..., repair = N)andskill(..., repair = N)resume the task's conversation up to N times (at most 3) to fix a passing turn whose output breaks its declared fields or files. See Work graphs.schema_file(path)reads a JSON Schema (draft 2020-12) below the pack directory, under the same path policy, and embeds its content as a field type inemitsor a file's schema inemits_files. A file that is not a valid 2020-12 schema, or references a remote$ref, is rejected.load(path, name, ...)pulls symbols from another.starfile under the pack, resolved by the same policy asprompt_fileand refused for absolute paths,.., symlinks, non-files, and cycles. Loaded modules run before the root against the same globals and the same compile state: theirprompt_file()andschema_file()calls resolve against the pack root and charge the same byte budget, theirsession()declarations precede every root reference, and a task they construct at module level must still appear inworkflow(tasks = ...). Re-export is off, so a symbol a library loads is not visible through it.workflow(type = ..., tasks = ..., result = ...)is the explicit final expression. A list of tasks is accepted anywhere a list of task names is, intasksand independs_onalike, so a list never needs wrapping to be passed.default_autoresearch(extra_tasks)expands the historical loop into fully visible nodes. Scored lanes only.
Five kwargs govern how a task runs and what its failure costs. They are independent, and this is the whole of it:
| kwarg | values | what it decides |
|---|---|---|
required | True (default), False | whether this task's failure invalidates the run. An advisory task's failure blocks only its dependents. |
join | "all" (default), "passed" | what this task needs of its dependencies. "all" needs every one to have passed; "passed" runs on whatever survived. |
isolated | False (default), True | whether the task gets a disposable worktree. Today this is also what buys concurrency, because non-isolated peers would race on the shared result file. |
needs | "any" (default), a capability name | a capability the run must have before this task is dispatched. |
stage | "iteration" (default), "epilogue" | whether the task is in the main graph, or runs once after it settles. |
required = False and join = "all" do not compose: a required task may not depend, through a
path of "all"-join edges, on an advisory one. That graph says a failure is both tolerable and
disqualifying, and no run of it yields an honest verdict. Validation rejects it before dispatch,
naming both tasks. join = "passed" is the exemption, because it declares up front that the task
runs on whatever survived.
Agent tasks receive upstream results in their prompt and write one JSON object to
PLAN_TASK_RESULT.json. Required failures discard the candidate; advisory tasks use
required = False. join = "passed" waits for all dependencies, then receives their non-empty set
of successful results. No passing input blocks the task.
The two settings must agree: a required task may not depend on an advisory task with
join = "all", and validation rejects the graph naming both tasks. An advisory task is allowed to
fail, and a join = "all" dependent blocks on that failure, so the required = False would buy
nothing. Consume advisory work through a join = "passed" task, which is exempt along with
everything reachable only through it. The legacy positional workflow(tasks) splice has no lever
for this: its tasks feed the loop's required apply, so a spliced sink cannot be advisory.
For local review, crucible plan compile-workflow --file workflow.star prints stable canonical
JSON. Add --manifest crucible.toml to also replace the generated [workflow] block. Compilation
applies source-size, loaded-module, task-count, constructed-task, evaluation-tick, heap, call-depth,
and prompt- and schema-size ceilings. The compiler exposes no filesystem API except prompt_file,
schema_file, and load,
and no process, environment, network, clock, or randomness API.
Scope validation renders the admitted graph to WORKFLOW.png for the scope PR, grouping
evaluate and grade as Measurement.
1.9 [outputs] and [capabilities]: declared writes and disclosed reach
[outputs] bounds the run's mediated writes (RFC-0001:C-OUTPUTS). The kind vocabulary is closed
and engine-defined; a kind outside it is a manifest error.
# A declaration carries a per-run count, and a target for a kind that addresses one.
[outputs.image-push]
count = 20
target = { fixed = "quay.io/aipcc" }
# An OPEN target names a scope narrower than the kind's whole address space, and may bind that
# scope to a workflow parameter. The target must then equal that parameter's run value, which is
# how a run fanned out per tracker item confines its writes to the item that parameterized it.
[outputs.tracker-comment]
count = 3
target = { open = { scope = "PROJ-*", param = "issue_key" } }
A scope that admits any target ("*", "**", "") is rejected at validation. gpu-capture
addresses nothing, so it takes a count and no target; every other kind requires one.
A kind the pack does not declare resolves to the engine default table. Every default carries a
count and none carries an open target, so a pack shipping no [outputs] gets the conservative
posture. A default whose target does not resolve refuses every write of that kind.
| kind | default count | default target |
|---|---|---|
draft-pr | 2 | [publish].pr_repo, else $AUTORESEARCH_PR_REPO |
tracker-comment | 2 | the run's parameterizing item ($CRUCIBLE_ITEM) |
chat-message | 8 | the engine's operator channel |
image-push | 100 | $FORGE_REGISTRY |
deploy | 100 | $FORGE_DEPLOY_NAMESPACE/$FORGE_DEPLOY_NAME |
workflow-dispatch | 20 | the single [build.*.github].repo, when unambiguous |
gpu-capture | 100 | addresses nothing |
Bounds are enforced in the broker, at one chokepoint every mutating tool routes through. They are
projected there from the frozen manifest as BROKER_OUTPUTS (with BROKER_OUTPUT_PARAMS and
BROKER_SESSION_LOG), handed to the broker child only, so nothing inside the sandbox can alter
one. The engine writes two kinds itself, not through a broker tool: the draft PRs that
publish-on-keep opens, and the workflow_dispatch that a github-actions build fires. The
engine mediates these against the same resolved value. A write over a count or outside a scope fails that
write naming the bound, writes an output_refused row on the session log, and does not terminate
the run: publishing skips the remaining candidates and the run completes.
[[capabilities.secret]] states what a credential authorizes; a name alone does not.
[[capabilities.secret]]
name = "JIRA_API_TOKEN"
context = "broker" # or "agent": whether the value enters the sandbox
system = "jira"
scope = "read + comment on PROJ"
The rest of the disclosure is read from what the manifest already declares: the resolved egress
allowlist (each entry labelled builtin or manifest), every [agent].env name, every
[[agent.relay]] destination, a substituted [agent.broker].bin, and whether the pack runs
commands outside the sandbox (workflow command/evaluate tasks, world and judge hooks). At run
start, an agent-visible env value or a relay file the disclosure does not cover is refused, naming
the grant and what is missing.
crucible check prints the resolved bounds and the disclosure; crucible plan exposure --manifest crucible.toml emits the same as JSON ({version, outputs, capabilities}) for the controller to
extract. Both compute it without executing pack content.
2. Path resolution (portable, never CARGO_MANIFEST_DIR)
| Thing | Resolves to |
|---|---|
config: method_prompt, goal_file, toolbox_dir | manifest-relative |
| agent workspace (the measured checkout) | manifest_dir / [workspace].dir |
runtime state (session.jsonl, admissions.jsonl, control.json) | --state-dir, default manifest_dir/state |
STEER.md | --steer, default manifest_dir/STEER.md |
ESCALATION.json (agent's harness-blocker marker, ADR-0001) | <workspace>/ESCALATION.json: written by escalate, consumed by the engine post-turn |
The binary's own install location is never used to resolve anything. A target repo is
self-describing: drop a crucible.toml at its root and run crucible inside it.
3. The command protocol
Every command (measure_cmd, apply_cmd, snapshot_cmd, restore_cmd, setup_cmd) is a
string executed via sh -c "<cmd>", with:
- cwd = the agent workspace (
manifest_dir/[workspace].dir). - PATH inherited, so a command may be a bare installed tool (
bench) or a workspace-relative script (./measure.sh). - env, edition 2 =
PATH,HOME,LANG,TMPDIR, the run's identity (CRUCIBLE_RUN_NAME,CRUCIBLE_RUN_DISPLAY_NAME,CRUCIBLE_UI_BASE_URL) when the engine has it, the injected variables below, and what the command's table declares inenv:[judge].envformeasure_cmd,[world].envforapply_cmd,snapshot_cmd, andrestore_cmd. A declaredNAMEthe engine's environment does not set refuses the run; a declaredNAME?is passed when set and left out when not. - env, edition 1 = the engine's env plus the injected variables below.
crucible checkand run start warn about it, and the capability disclosure states it. setup_cmdgets the engine's env in every edition.
measure_cmd (the Judge): REQUIRED
- Injected env:
CRUCIBLE_BASELINE_SCORE,CRUCIBLE_BASELINE_TOTAL,CRUCIBLE_BEST_SCORE(absent on the baseline measurement; present thereafter). Values are the engine's current numbers as decimal strings. - stdout: the engine reads the last line that starts with
{and parses it as:{ "valid": true, "score": 12.5, "solved": false, "note": "p99=12.5ms", "detail": { } }valid(bool, REQUIRED): false ⇒ unscoreable candidate, always discarded.score(number|null): the fitness.null/absent ⇒ treated as invalid.tiebreak(number, optional): secondary fitness for functional gates whosescoreis effectively boolean; on an exactscoretie, a strictly bettertiebreakstill keeps (§4).solved(bool, optional, default false): the win condition was met (terminates the loop).note(string, optional): one-line human summary.detail(object, optional): free-form; surfaced in the row + session log. The domain stashes anything extra here (e.g. EPPcache_hit_rate; the test gate'stotal).
- exit code: nonzero ⇒ the reading is forced
valid:falseregardless of stdout.
apply_cmd (make the candidate live): optional
- Run after the agent turn, before
measure, when present. Nonzero exit ⇒ the iteration is treated as an invalid candidate (discard). No stdout contract. - Omit for code/agent-deploys domains (EPP: the agent deploys via skills during its turn; the counter: the edit is the candidate). Present for engine-driven build+push+set-image.
3.1 Build mode: how an edit becomes the thing measured
Between "the agent edited a file" and "measure_cmd read a score" sits a step whose shape you must
choose when you design a pack. It differs per component, it is the dominant term in
per-iteration wall-clock, and picking it wrong fails silently. Full rationale in
ADR-0016.
| Mode | When it applies | What apply_cmd does | Cost |
|---|---|---|---|
| no artifact | the gate compiles + runs the workspace in place | nothing (omit apply_cmd) | seconds |
| no rebuild | the agent changes config of a live rig | push the config, wait for rollout | a rollout |
| derive-layer | the changed sources are interpreted (Python) | append them onto a pinned base as a real OCI layer | ~8s |
| image | the changed sources are compiled (Go, Rust, C++) | a real container build; the compile happens here | minutes |
Rules that are easy to get wrong, and that crucible check should catch before you spend a turn:
derive-layerrequires that the base image and the push target share a registry. The layer mounts server-side only when they match; across registries the same operation streams every base blob (8 seconds becomes 20+ minutes) and nothing warns you.derive-layercannot carry compiled sources. Appending a.gofile to an image changes nothing that runs. The loop measures the base image forever and reports every candidate as a no-op, the failure mode ADR-0007 exists to catch.- A compile failure must be distinguishable from a bad score. In
imagemode the build is the loop's fastest feedback signal: a compile error is returned to the agent as a free retry with no candidate spent. Anapply_cmdthat collapses "did not compile" into "scored badly" throws that away. - A Containerfile on the measured path belongs to the judge. If the
build recipe lives in the agent's workspace, it must be a
frozen = trueinject (§1), re-copied before every scored measure. Otherwise the agent can edit the recipe that builds the artifact it is scored on (vendor a prebuilt binary, neuter the compile), which is the ADR-0001 trust line, broken.
snapshot_cmd / restore_cmd (domain reversibility): optional, must come as a pair
snapshot_cmd: stdout last line = one opaque token (any non-empty string; base64 it if it contains newlines). Nonzero exit ⇒ snapshot failed (engine aborts the keep).restore_cmd: receives the token in envCRUCIBLE_TOKEN. Rolls external state back to that token. Nonzero exit ⇒ restore failed (engine surfaces it). (Env, not argv, so a multi-line base64 payload round-trips without shell-quoting.)- The engine treats the token as opaque and never parses it. (§5 explains how the engine frames its own git ref alongside this token.)
setup_cmd (prepare the workspace): optional
- The one cwd exception: runs with cwd = manifest dir (the workspace does not exist yet, it's what setup creates). Every other command runs with cwd = workspace.
- Default when omitted: engine does
git clone [repo] <workspace> && git checkout [ref]; with no[repo](a playbook), it creates<workspace>empty. Either way the injects land next and the enginegit inits the workspace and commits the result as the baseline when setup left no repo behind, so asetup_cmdonly has to produce the tree, never the commit. Asetup_cmdthat commits before returning freezes a baseline without the injects.
4. The decide rule (universal, no per-domain code)
Given a Reading { valid, score, solved, note, detail }, the current best_score, and the
manifest direction:
keep = valid && score.is_some() && (better(score, best_score, direction)
|| (score == best_score && tiebreak_better)
|| solved)
solved = reading.solved
better(s, b, lower) = s < b
better(s, b, higher) = s > b
tiebreak_better applies only when the reading carries a tiebreak: it is
better(tiebreak, best_tiebreak, tiebreak_direction), where tiebreak_direction is
[judge].tiebreak_direction (optional, defaults to direction) and a best with no recorded
tiebreak counts as the worst value. A reading without a tiebreak ties exactly as before:
discard.
solvedimplieskeep. The engine keeps a candidate that the measure command declaressolved, and ends the loop, even if its score is not strictly better than the best. This rule is necessary for a domain whose win has an equal score. For example, the EPP test gate wins with a passing suite (0 failures, equal to the 0 of the baseline) and a new regression test. Without|| solved, the engine would discard that win, and the loop would not finish.solveddoes not make an invalid reading valid.- The first valid reading sets the baseline (
best_score, andbaseline_total=detail.totalif present) and is always kept. - The loop terminates when a kept iteration is
solved, or budget/iterations exhausted, or stop/escalate. - No domain Rust decides anything. A win condition more complex than "better score" (e.g.
EPP's "green AND a new regression test") is computed inside the measure command, which
reads
CRUCIBLE_BASELINE_TOTALand emitssolved.
5. World = reversibility (engine never names git)
World::Snapshot = String, opaque to the engine (run_loop only round-trips it back to
restore).
- GitWorld (default):
snapshot()= stage+commit the workspace, return the commit SHA;restore(sha)=git reset --hard <sha>+git clean(excluding.claude/,RESULTS.md). The kept-commit chain is the memory. Works for any git repo, zero domain code. - CommandWorld (any
[world]command given): always owns git memory as above, and layers the domain commands. The snapshot token it stores is the composite"<git-sha>\t<domain-token>":- keep: commit (git half) → run
snapshot_cmd, capture its token (domain half) → join. - discard: split →
git reset --hard <sha>+ clean → runrestore_cmdwithCRUCIBLE_TOKEN=<domain-token>. - The engine exposes
last_commit_sha()(the git half) forkept_shas/publish; the domain half is never inspected.
- keep: commit (git half) → run
The engine's loop body calls only world.snapshot() / world.restore(&snap). It contains
no git/vcs:: calls and no kubectl.
6. Agent transport (AgentSource)
The engine renders a prompt (method_prompt with {{GOAL}}/{{STATUS}}/{{STEER}} filled),
hands it + the workspace to an agent that edits the workspace, and never hands it the Judge
(ADR-0001). Backends, selected by [agent].backend:
local: directclaude --output-format stream-jsonwith[agent].env(real Claude/Vertex turn).openshell: sandboxed pod turn driven by the in-Rust OpenShell driver (backend = "openshell",sandbox_image).[agent.resources]sets the sandbox's GPUs, CPU and memory, andnode_selectorpicks the nodes by label. It takes effect only where sandboxes are Kubernetes pods (deploy profile[cluster] sandbox_driver = "kubernetes"); the controller refuses a launch anywhere else, and the engine refuses the table on any other backend. The deployment's[cluster.gpu_sandbox](node selector, tolerations, runtime class) is added to every GPU sandbox, and a selector key it sets keeps its value over the pack's.command: run[agent].agent_cmdviash -cin the workspace as the proposal. A deterministic proposer with no cost (no LLM). It is a real transport. With it, the minimal example is a fast, deterministic e2e test (for example,agent_cmd = "./bump.nu"increments a counter). Use it to test the loop and protocol of the engine without token cost.
One vs two execution environments (the only backend fact a domain must know)
The engine and its measure/apply/snapshot/restore/setup commands always run where
crucible runs. Only the agent turn is sandboxed under openshell. So:
local/command: one environment. Agent and engine share the host PATH + filesystem; the agent edits the workspace in place. Provide one toolbox on PATH. Nothing else.openshell: two environments. (1) the engine/loop-image PATH runs the contract commands; (2) a separate sandbox image runs the agent's skills. The OpenShell driver uploads the workspace into the sandbox and syncs edits back, so anything the agent needs to reach the outside (kubeconfig, tokens) must be relayed via[[agent.relay]]or provided in[agent].env(the sandbox is network/fs-isolated). A domain therefore ships two tool surfaces under openshell: contract commands on the loop PATH, agent skills baked intosandbox_image.
The manifest [agent] block (backend, sandbox_image, env, relay, openshell) selects and
configures the backend. A change between local and openshell is a configuration change.
It needs no code change. The manifest cannot build the sandbox image or supply credentials.
The domain must do this for a sandbox. Reversibility commands (snapshot/restore) run in
the engine environment, so they reach the live system directly with every backend. Only the
agent runs in the sandbox.
6.1 Sandbox egress ([agent.openshell])
The sandbox is deny-by-default. Two lists open it back up, and a flag decides whether they extend the engine's built-ins or replace them:
[agent.openshell]
endpoints = ["api.example.com:443:full"] # host:port:access[:proto[:enforcement]]
binaries = ["/usr/local/bin/claude"] # only these may open a socket
inherit_defaults = true # default
With inherit_defaults = true (the default) the lists are appended, de-duplicated, to the
built-ins: the public forges, PyPI, Vertex, Anthropic, and the agent CLIs. Appending can never
remove a built-in.
Set inherit_defaults = false and the resolved allowlist is exactly what the manifest names,
binaries included. This is the only way to subtract a default, and it is required for two cases:
- Air-gapped or private-registry runs, where the public internet is not reachable (or not permitted) and the agent must talk to an in-cluster model endpoint and registry instead.
- Contamination control. An agent scored on an upstream issue can, with the default
allowlist, read the upstream fix for that issue on
github.com. A measurement that must not be polluted by the open web has to drop that endpoint, and dropping it means opting out.
The broker endpoint is auto-appended by the engine when [agent.broker].enabled is true
(ADR-0015). The engine first resolves the broker URL (an explicit [agent.broker].url
override, or derived from the active compute driver's hostname), then derives the egress
host:port:full entry from that URL's authority. When no explicit port is present, the scheme
default applies (http = 80, https = 443). Both values come from the same resolved URL, so
the allowlist entry and the address that the sandbox contacts are always the same. The engine
appends the broker endpoint for every value of inherit_defaults, because the domain enabled
the broker. A domain cannot remove it as it removes a built-in. A configuration without a
broker and with both lists empty is a valid total air-gap: no host resolves, and no binary can
open a socket.
6.2 MCP token file (MCP_TOKENS_FILE)
An [mcp] server authenticates sandboxes against the file MCP_TOKENS_FILE names. The engine
replaces it by rename on every grant and revoke; the server re-reads it on every request. One line
per sandbox, fields separated by whitespace:
<token> <sandbox> <workdir>
<token> is the bearer the sandbox's requests carry, <sandbox> the gateway's name for the
sandbox, and <workdir> the absolute path its agent runs in (/sandbox/workspace for a loop
turn, /sandbox/task-<sha256> for an isolated plan task). A two-field <token> <sandbox> line
parses with the workdir unknown. A request whose bearer is in no line gets 401.
7. Session wire format (compatibility)
The NDJSON session log (state/session.jsonl) keeps its existing event kinds
(start/phase/row/budget/summary/finished) and field names unchanged. In
particular the objective label is still written under the JSON key gate (now carrying a
free-text label like "score"/"bench", not the deleted enum). This keeps --resume, the
remote viewer, and already-published S3 runs loading. Do not rename the wire key.
A row event's wire record (RowWire) carries an additive, optional phase field
("wide" for a wide-round candidate row, absent for a deep-loop row). It's skip_serializing_if = "Option::is_none", so a deep-only run's wire bytes are unchanged from before wide rounds
existed.
A phase event's phase is one of starting/preflight/baseline/wide/iteration/
paused/parked/distressed/escalated/epilogue/finished, the same token the control
bridge's status reply carries, emitted once each time the loop's (phase, iter) pair changes.
The three tokens logs carried before contract 1.3.0 are a subset, so old logs still decode.
A task_result event whose status is blocked carries an additive blocked object:
{ reason, task? } with reason one of required_task_failed/budget_ceiling/
wall_clock_ceiling/dependency_did_not_pass/staging_refused and task naming the required
task whose failure short-circuited the plan (present only for required_task_failed). note
stays the rendered form of the same reason. report.json's per-task entries carry the same
object.
A task_result event whose status is transport carries an additive transport token
(contract 1.4.0): what the last attempt died on, one of gateway/sandbox/agent/provider/
workspace/command/other. note stays the retry summary with the last attempt's detail.
report.json's per-task entries carry the same token.
A task_result event's status may be not_taken (contract 1.6.0): the task's when was not
satisfied, or it joins all on a task that settled that way. It was never dispatched, cost
nothing, and is not a failure. A plan_admitted task carries an additive when string,
route.question in a|b, empty when the task is unconditional.
A plan_admitted task carries optional (contract 1.20.0), the dependencies declared with
optional(...), omitted when there are none. A task joining all dispatches past such a
dependency that settled not_taken, and its inputs carry no entry for it. An engine older than
1.20.0 ignores optional in a compiled graph and settles the task not_taken instead.
A task_result event carries an additive links array (contract 1.12.0), omitted when empty:
the external results the task reported, one object per url with url, provider
(github/gitlab/jira/other), kind (pull_request/merge_request/branch/commit/
compare/issue/page) and a short label. The engine fills it from the output fields the
task declared link or links, so every url in it is an http(s) url it validated. A reader
renders these rather than reading urls out of output.
A plan_admitted task carries definition (contract 1.17.0), the digest of the compiled task
with its parameters bound and of its dependencies' definitions. A task_result event carries
reused_from when a rerun reused the row instead of dispatching it, naming the run that settled
it; omitted otherwise. A playbook run delivers its git memory as the run-commits artifact: a
gzipped tar of one patch per task commit, tasks.json naming each commit's task in order, and
playbook-run.json, the record of where the run started.
A plan_admitted task carries keyed (contract 1.16.0), each producer.field a mapped task
narrows to its own item's entry, empty when it declares none. A mapped node's folded output and its
task_result fan-out summary count the instances that settled not taken under not_taken; a
reader that predates it reads the count as zero. A route may declare over, and its instances
settle as route[K], each with the decision for item K as its output. A score question's answer
carries score, its expected level position in [0, 1], and asked_as, score or choice, the form
the decision API was asked in.
A route question may be a dynamic_choice (contract 1.22.0), whose options_from = {task, field}
names a dependency field the options are read from at each decision: the list itself, or for a
route with over the item's entry in an object keyed by item. Each entry is a label or
{value, description}, at most 64, a repeated label keeps its first entry, and fewer than two
fails that decision before it is asked. A model-decided route asks it as a choice; a
human-decided route opens its request with the resolved choice in questions. Its answer
carries options, the
{label, description?} list it was asked over, whatever the label; no other answer carries it.
A when on such a question may carry any_option = true, satisfied by any label but
uncertain, and a plan_admitted task's when string spells it *, as in
match.package in *|uncertain.
A plan_admitted task carries emits_files (contract 1.14.0): each declared path, as a string,
or {path, schema} when the declaration gave a schema. A field typed by a schema carries
{"schema": "<JSON Schema text>"} as its type. A task_result event carries repairs (contract 1.14.0), one
{label, round, of, cost_usd, notes} per repair turn an agent task took, omitted when there were
none; their cost is already part of cost_usd.
A task_result event carries an additive agent object (contract 1.13.0) on an agent task and
on the loop's candidate turn, omitted on every task that runs no agent: harness, model and
effort, as the executor resolved them for the attempts it made. The task's own knobs have
already been applied over the CLI flags and the manifest's [agent] defaults, so a task that
pinned its own model reports that model rather than the run's, and model is what the harness
hands the turn, which for a harness carrying a model slot of its own is not always
[agent].model. effort is empty when nothing pinned one. Every field defaults, so a reader
older than the writer keeps the fields it knows.
A plan_admitted task carries an additive timeout string (contract 1.8.0), the task's own
per-attempt limit in the --max-time syntax (90s, 20m, 2h), empty when only the run's
wall-clock ceiling bounds it. A task killed at its deadline settles fail with a note naming
the limit that ended it.
A plan_admitted task's revise (contract 1.11.0) is a list of task names when the task reviews
a chain, and stays a single name, or empty, otherwise. A reader should accept both.
An orchestrator tells the engine where models are reached through one JSON document in
CRUCIBLE_INFERENCE (contract 1.7.0), typed as crucible_contract::inference::ResolvedInference:
{"version":1,"bindings":[{"role","protocol","url"?,"model","key_env"?,"price"?}]}. role is agent or
decision; protocol is messages, chat_completions, responses, system_one, or
decisions (contract 1.15.0, the OpenAI Decisions API, at https://api.openai.com/v1/decisions
when url is absent). A decision binding speaks system_one or decisions. key_env
names the variable holding the credential and the document never holds the value. An unknown
field or version fails the run before any task. A decision binding is what a model-decided
route asks. An agent binding is the whole answer for agent turns: its model is the run's model,
its protocol keeps the selected harness when that harness speaks it and otherwise selects the one
that does, its key replaces [agent.codex]'s own, and the ambient ANTHROPIC_API_KEY,
ANTHROPIC_BASE_URL, OPENAI_BASE_URL, and CRUCIBLE_INFERENCE_WIRE_API are not consulted.
Each replacement is logged. With no agent binding those ambient variables and the manifest
decide, which is how a run from an operator's shell works.
An agent binding may carry price (contract 1.19.0):
{"input","output","cache_read","cache_write"}, each a finite, non-negative USD rate per million
tokens. With a price, every agent turn of the run costs its largest token sample times those
rates, for any model, and whatever cost the harness or OTEL reports is ignored. Without one, a
vendor-billed model (an Anthropic or OpenAI name) costs what the harness reports, or the engine's
table estimate when it reports nothing, and any other model costs nothing whatever the harness
reports. A price on a decision binding fails the run. An engine older than 1.19.0 refuses a
document that carries a price as an unknown field.
An orchestrator that starts a run of a launch series supplies the series' earlier terminal runs
as one JSON document in CRUCIBLE_HISTORY (contract 1.9.0), typed as
crucible_contract::history::SuppliedHistory: {"version":1,"records":[record]}, the most
recent runs by end time, at most 30. A record is exactly
{"run","started_at","ended_at","outcome","verdict","revision","link","entry":{"task","status", "output"}}: the run's identifier, its RFC 3339 start and end, its shutdown outcome token or
null where it wrote none, its verdict (valid/invalid) or null, the pack revision it ran, a
controller-owned link to it, and the history entry it recorded (below). Every field is present,
nullable ones as null. An unknown field or token, a missing field, a version other than 1, more
than 30 records, a repeated or empty run, an unparseable time, or an end before its start
fails the run before any task. With no CRUCIBLE_HISTORY the run belongs to no series.
The engine orders the records by end time, oldest first, and gives a task declaring
history = N the reserved input history: {"records": [...], "dropped": k}, the last N
records exactly as supplied, less whole records from the oldest end until the compact JSON
encoding of that object fits CRUCIBLE_HISTORY_MAX_BYTES (default 65536), with k the number
removed. A run outside a series gives such a task {"records": [], "dropped": 0}. A command or
evaluate task reads it from the inputs file CRUCIBLE_INPUTS_FILE names; an agent task reads it in its prompt, inside the
external-input markers, and never in its upstream-results JSON. crucible check prints the
bound a playbook's run will apply, and refuses a malformed one.
A plan_admitted event carries an additive history_record, the task a playbook records for
its series or empty when it names none, and each task an additive history_depth, zero when it
reads no history (contract 1.9.0).
Additive event kinds beyond the compat set include:
identity: the run'sRunIdentity(below), emitted once at setup and again on--resume(the freshly recomputed identity). A mismatch against the original run's identity is a hard-warningnoteevent, never an abort.history_entry:{ entry: { task, status, output } }, emitted once by a playbook run, just beforeshutdown(contract 1.9.0).taskis the playbook'shistory_record, empty when it names none.statusis that task'stask_resultstatus, andoutputits JSON output cut down to the fields it declares inemits, null unless it passed. A revise target or reviewer records its final result, not a round's. Both are null when the task never settled: the run stopped early, or a ceiling left it undispatched. An orchestrator copies this object into theentryof the record later runs of the series receive.shutdown:{ outcome, reason }, emitted exactly once, as the last line of every run (afterfinished/summary).outcomeis one offinished/solved/budget/stopped/escalated/stalled/error. Session-log consumers key a run's terminal state off this line; a dead stream with noshutdownline means the pod likely died mid-run, not a clean exit.--resumedepends on this invariant: a resumed run classifies the log tail (seerecoverybelow) and a trailingshutdownis the "exited on purpose" signal. In a resumed (appended) log, only the trailingshutdowncounts; one followed by more events belongs to an earlier process.agent_session:{ session, action, turn }, emitted before a persistent agent turn so a viewer can draw continuation lanes and distinguishstartedfromresumed. It deliberately contains neither the provider cursor nor native transcript content.approval_wait:{ handle, trace_id, mode }, emitted when the loop reads the agent's pending-provisioning marker.modeisblock(the loop parks idle) orcontinue(it keeps iterating in the frozen regime). Bracket invariant: everyapproval_waitis closed by anapproval_resolvedexcept on stop-while-parked and process death, so a dangling wait in the log tail means the run ended with the approval outstanding, and a resume re-parks a block-mode one and re-registers the approval key so an operatorapprovestill resolves it.approval_resolved:{ outcome, reason }withoutcomeone ofgranted/denied/timeout. A grant is emitted at the iteration-head rescope drain (the single re-baseline site); a stop deliberately emits nothing (a stop doesn't resolve the ask).recovery:{ class, iter, detail }, emitted once per--resumeright after the resume note: how the resumed process classified its predecessor's end.classis one ofclean_exit/died_in_baseline/died_in_wide_round/died_mid_turn/died_deciding/died_in_plan_task/died_awaiting_approval/died_between_iterations;iteris the iteration the interruption touched (0 when not iteration-scoped);detailis a human-readable evidence summary. Purely a record: the loop acts on the in-process classification, never by re-reading this line.
RunIdentity (crucible/src/identity.rs) is the comparability key: two runs' scores are
comparable only if it matches. It's a hash-of-hashes (v1:<hex>) over, per component (one
unnamed entry for a single-domain run, one per [[component]] for a composite): repo
([repo].url/path) and the workspace's pristine base commit SHA; plus, once per run: the
frozen manifest text's hash, a hash over every [[workspace.inject]]'s source content plus
destination path, [judge].measure_cmd, and [judge].direction. It's computed once at run
setup and doesn't change within a run (a re-scope moves the loop's own Segment fingerprint,
a different hash over goal/objective/regime; the two are deliberately independent).
7.1 Admission ledger (state/admissions.jsonl) and the control-bridge id
Every external input into a run (steer, approve, deny, rescope, set-budget, pause, resume,
stop, abort) is recorded in a second NDJSON file, state/admissions.jsonl, before it takes
effect. Same envelope shape as the session log ({"v":1,"kind":…}, blank/torn lines skipped),
two kinds:
admitted:{ key, seq, ts, input, …payload }.inputis the command token, and the payload fields are at the same level ({"input":"rescope","regime":"c=48"}).settled:{ key, outcome, ts, note }withoutcomeone ofapplied/superseded/rejected.
Contract, per idempotency key: exactly one admitted, then at most one settled, and the
first terminal outcome wins. A key with no settled line is an input the run still owes;
--resume re-arms exactly those (an un-delivered steer, a granted-but-undrained re-scope, the
live budget cap, the pause level) and closes out the ones a resume overrides (stop/abort become
superseded, as do approvals that died before their grant was recorded).
Precedence: admissions.jsonl is authoritative for what an operator asked for; the session
log is authoritative for what the loop was waiting on. Where they disagree about an outstanding
approval, the ledger wins: a re-scope recorded under the key derived from the ask suppresses the
session log's re-park.
Control-bridge commands gain an optional id (string, non-empty, ≤256 bytes) on every
mutating object-form command:
{"cmd":"steer","text":"…","id":"pr-comment:owner/repo#7:12345"}
Redelivering the same id with the same payload converges on the original admission
({"ok":true,"cmd":"steer","key":"…","dup":true}, plus "outcome" when it already settled)
rather than acting twice; the same id with a different payload is refused
({"ok":false,…,"error":"idempotency conflict: …"}) and nothing is written. Omitting id is
exactly the old behavior: the server generates a key and every delivery is a fresh input, so
old clients and old servers interoperate unchanged. A stop/abort whose record cannot be
written still stops the run and says so with "unrecorded":true; every other command fails
closed (no effect) if its admission can't be recorded.
Two consequences worth knowing: the bridge no longer writes STEER.md (a steer command goes
straight into the ledger, and the loop's drain reads both the ledger and whatever the file
channel accumulated), and "applied" for a steer means delivered into a turn's prompt, not
heeded, and not that its iteration was kept.
8. What a domain author writes (the whole surface)
- a
crucible.toml, - a
measurecommand emitting{valid, score, solved?}(any language), - optionally
apply/snapshot/restorecommands, - a method prompt + goal,
- agent creds in
[agent].env.
Everything else (loop, budget, keep/discard, all reporters + remote viewer, steer/stop/resume,
session log, escalation, git memory) is the engine's, for free. The litmus test
(examples/counter/) exercises items 1, 2, 4 with GitWorld and the command backend, no Rust.
9. CLI surface (non-normative pointer)
The manifest/protocol above is the contract; these subcommands are mechanical consumers of it, noted here tersely so this doc stays the map of what's authoritative:
crucible init [--dir <path>]: scaffolds a minimalcrucible.toml+ a measure stub that always reports the same score. Refuses to overwrite existing files.crucible check --manifest <path>: validates a manifest with no agent turn (parses it, unknown keys are a parse error, resolves every referenced file, runsmeasure_cmdonce to prove the measure contract, runs[judge.selftest]if declared (§1.1), and warns if the gate is reachable by the agent's own edits: ameasure_cmdtoken pointing inside the workspace with no matching frozen inject). Exit nonzero with findings on failure; warnings never fail it.crucible scope --pack <dir> [--issue owner/repo#N | --goal-file <f>] [--force] [--json](ADR-0012): pipeline over a hand-written domain pack. resolve the goal (from--issue, fetched natively from the GitHub REST API (honorsGITHUB_TOKEN/GH_TOKENandGITHUB_API_URL), or--goal-file, or the pack's own[agent].goal/goal_file), then validate (crucible check, as a library call), then freeze (writesSCOPE.mdin the pack dir with the goal, the check outcome, and the pack'sRunIdentitydigest). Stops at the first failing stage.S2(propose)/S3(preflight)/S4(approval) are listed inSCOPE.mdas pending, see ADR-0012. A--goal-file's text is goal framing under the same de-prescription rules as a--issue's title/body: the problem for the pack-designing agent to solve, never a solution. §8's_controls/self-test strip at freeze applies identically regardless of which arm sourced the goal.crucible ps [--namespace <ns>] [--json]: lists loop pods across the cluster, selecting on theapp.kubernetes.io/managed-by=cruciblelabel every rendered loop pod carries.ITERships as-(reserved, seecrucible/src/cli/ps.rs's module doc for why it isn't wired up yet).crucible deploy render|apply --manifest <path> --profile <path> [--iterations N] [--max-cost USD] [--no-pin] [--pack [--pack-configmap-name <name>]] [--pr-repo <owner/repo>] [--clusters <path>] [--harness <h>] [--model <m>] [--playbook --max-time <dur> [--param NAME=VALUE]…]: renders (or renders-then-applies) the loop pod + a cross-namespace RoleBinding from the manifest + a deploy profile, image tags resolved to@sha256:…digests. Works for a composite manifest or a plain single-domain one (the latter needs its own[deploy]block naming the build/deploy target, a single domain is a degenerate composite of one).--playbookrenders the second mode: the pod runscrucible plan runover the manifest's[workflow]under the given ceilings and parameters instead of the agent loop. It is an explicit flag, never inferred from the manifest, and it conflicts with the loop-only knobs (--iterations,--controller,--pr-repo,--harness,--model). It requires a positive--max-costand--max-time, and needs neither a[deploy]block nor[agent].sandbox_image(both describe a deployment a playbook never performs).--packis orthogonal: it controls delivery,--playbookcontrols the command, and a controller-dispatched playbook passes both.crucible watch-pr --pr <url> [--pr <url> ...] (--control-addr <host:port> | --reseed <path>) [--once] [--poll-secs N] [--bot-user <login>] [--allow-user <login> ...]: watches one or more draft PRs' review comments (--prrepeatable: a kept composite candidate opens one linked PR per component fork) and either steers a live run over its control bridge or appends to a reseed file the next run's first turn reads.--oncefetches and exits instead of polling.