Foundry

Foundry runs engineering work across registered Git repositories. You set an objective or outcome, authorise a budget, and answer the decisions that need you. Foundry admits the work, runs coding agents, checks gates and review, and records what landed or remains unfinished.

The stories in Using Foundry follow that work through ordinary situations: a morning check, a preserved change, a broader mission, and an outcome that spans repositories.

For day-to-day engineering work, the main entry points are:

  • foundry task for one concrete objective in an isolated, reviewed worktree;
  • foundry campaign for a durable mission that derives one task at a time from live evidence;
  • foundry initiative for a shared outcome across repositories and campaigns;
  • foundry validate, iterate, and scout for project health and intent;
  • foundry run and sentinels for maintenance and scheduled operations.

Start with Using Foundry for stories that follow work from a user's need to its outcome. For setup, read Getting Started. The command guides, architecture chapters and reference provide details when you need them.

How We Got Here

Foundry is the latest step in work that started in the summer of 2025, with a series of hand-made harnesses for running coding agents against real projects. Each harness tested ideas about how to hand an agent a piece of work and how to check what came back.

Hopper, a personal work queue for AI agents, came out of those harnesses in February 2026. It fixed principles that Foundry still follows: one discrete objective per unit of work, agents as first-class users of a CLI with structured output, and an explicit handoff between claiming work and completing it. Foundry started in March 2026 and took over executable work from Hopper in July 2026.

Beside the queue, nightly maintenance ran as a patchwork of shell scripts and launchd jobs. It grew into a linear pipeline: iterate projects, audit for vulnerabilities, cut releases, install locally. Each step waited for all previous steps to complete. Projects couldn't run in parallel. An audit couldn't start until every project finished updating. A vulnerability discovered at 2pm had to wait for the 2am maintenance window.

As the scripts accumulated, the concepts started to clarify. Events, task blocks, throttle control, self-filtering — these patterns kept emerging from the scripts, so we started extracting them into something more intentional. Foundry is the result: a strongly typed Rust framework for building event-driven engineering workflows, replacing the fragile shell glue with compiler-checked event flows and composable task blocks.

Foundry decouples the work from the scheduling. The same task blocks that run during nightly maintenance can be triggered individually, at any time, with throttle controlling how deep the ripple goes.

Where We Are Today

Foundry has two layers, both defined in Rust:

  • Event library — the vocabulary of immutable facts that flow through the system. Each event type is a variant of the EventType enum with a well-defined payload structure.
  • Block library — the catalogue of reusable task blocks. Each block implements the TaskBlock trait, declaring which events it sinks on, what work it performs, and what events it emits.

All blocks are registered into a single engine at startup. Workflows are not declared anywhere — they emerge from the sink/emit relationships between blocks. The engine routes by event type; blocks self-filter by inspecting payload fields. This means a different entry event (or a different payload) activates a different subset of blocks, producing a different workflow — all from the same block library.

See Workflow Formations for the formations that exist today and the possibilities the current block library opens up.

Where We're Headed

Events and blocks will remain Rust-defined — they benefit from strong type safety and compile-time guarantees. The composition layer is what we intend to extract: allowing teams to declare which blocks participate in a formation and how events flow between them through configuration rather than code, enabling situational customisation without recompilation.

Key Ideas

  • Events are immutable facts — something happened. They carry a payload but have no opinion about what should happen next.
  • Task blocks are reusable units of work. Each block sinks on specific event types, does work, and emits new events.
  • Tasks isolate one coding objective, verify mechanical gates, obtain a skeptical typed review, and either land or preserve the result.
  • Campaigns hold a mission, evidence, context, owner policy, and a bounded cycle budget. They derive the next objective only after observing current repository state and the previous task result.
  • Throttle sits on a task block's output side. It controls whether Mutators execute: full (everything runs and propagates) or dry_run (Mutators are simulated, no side effects).
  • Workflows are compositions of task blocks wired together by events. They emerge from the emitter/sink relationships — not from a central orchestrator.

Charter

Mission

Provide a reliable, observable, event-driven engine for automating engineering workflows — from vulnerability remediation to dependency maintenance to release management.

Principles

  1. Events over orchestration. Work is triggered by events, not by position in a script. Any emitter can start any workflow.

  2. Composable task blocks. Small, reusable units of work. The same "Cut Release" block serves the vulnerability workflow and the maintenance workflow.

  3. Throttle controls depth. Every invocation declares how far the ripple should go. Audit without releasing. Release without installing. The same workflow, different throttle.

  4. Observability is paramount. Every event, every task block execution, every throttle decision is logged and traceable. If it happened, you can see it.

  5. Correctness through types. Rust's type system enforces exhaustive event handling, valid throttle states, and safe concurrency. Malformed events are compiler errors, not runtime surprises.

Scope

Foundry automates engineering workflows for a registered project portfolio:

  • Vulnerability detection and remediation
  • Dependency maintenance (iterate, maintain, commit, push)
  • Release management (tag, build, distribute)
  • Local tool installation
  • Release pipeline observation

It does not replace the existing evt-cli event logging system. Foundry emits events into the same JSONL intake files, coexisting with the current tooling. Over time, evt-cli may be rewritten in Rust to share Foundry's event type crate.

Non-Goals

  • General-purpose workflow engine (this serves specific engineering needs)
  • CI/CD replacement (Foundry orchestrates local work and observes pipelines)
  • Real-time monitoring dashboard (use Grafana, ops-visualizer, or similar)

Using Foundry

You have a few repositories, several things underway, and a limited amount of attention. These stories follow the person doing that work. Start with the situation closest to yours.

The people, companies, projects and work-item IDs are fictional. The situations combine patterns seen in real runs: preserved work, exhausted budgets, questions that affect several repositories, and intent that changes during implementation. They describe possible runs, not transcripts of specific customer work.

Your situationStart here
You want to know what needs your attentionA morning check
A useful change did not landFinish a change that stopped
You know the outcome, but cannot yet name every changeMake a tool safe to adopt
Several repositories must change togetherDeliver one outcome across repositories
You need to replace the engine that runs everyone else's workChange Foundry while work continues

Each story follows a need, a decision, an action and an observable ending. Commands show the important moments. The linked guides contain setup details and the full command options.

Before you follow a story

Use Getting started to connect to a daemon and register a project. The coding stories assume a clean registered checkout, a reachable origin, a project charter and meaningful required gates. Replace the fictional project names and IDs with your own.

Commands use your configured daemon. Check that destination before starting work. In Foundry's own development setup, real projects belong to the production daemon on ops-01. The Mac daemon accepts synthetic sandbox projects only.

The initiative story includes integration evidence and owner-confirmed Tracker closure, introduced in Foundry 0.49.0.

The journeys behind the stories

These stories follow Foundry's Epilogue Tracker journeys. Tracker describes product intent. It can include work that Foundry does not implement yet. Each story limits its instructions to the behaviour available today.

Tracker journeyStory
what_needs_me_nowA morning check
recover_unlanded_workFinish a change that stopped
mission_to_done, answer_an_escalationMake a tool safe to adopt
outcome_across_reposDeliver one outcome across repositories
promote_foundryChange Foundry while work continues

For a detailed examination of an actual run, read the formation audit. It records how new evidence changed a campaign's objectives and where its accounting had limits.

A morning check

Maya runs a small software company. Overnight maintenance has finished, a campaign is still underway, and a coding session left a change that did not land. She has twenty minutes before a meeting. She wants to find the decisions that need her attention.

See what is happening

Maya reads the queue:

foundry queue
foundry pacing show

In this example, one task is running in parcel-cli. Another task for that repository is waiting for it to finish. A task in receipt-reader is waiting for a specified earlier item to land.

The wait reasons matter. The second parcel-cli task needs no intervention: Foundry allows one mutation at a time in that repository. Maya leaves it alone. The dependent task will need attention if its prerequisite ends without landing.

Find the questions and unfinished obligations

Maya asks for the open list:

foundry queue open

She finds a campaign asking whether an old configuration format must remain supported. She also finds a preserved parser change. Open means that something is still owed, even when no agent is running.

She reads each record before acting:

foundry campaign show safe-import
foundry queue show wi_0123456789abcdef01234567

The campaign needs her product policy. The preserved change needs another coding run. These lead to different actions.

Maya records the policy and continues the campaign:

foundry campaign decide safe-import \
  --decision "Keep the old configuration readable for this release"
foundry campaign advance safe-import

She follows Finish a change that stopped for the parser work. If she discovers that another change already delivered it, she checks that evidence before closing the obligation:

foundry queue close wi_0123456789abcdef01234567 \
  --reason "The replacement parser passed the acceptance cases on trunk"

Closing records her reason and retains preserved work. It does not merge a branch or prove that the original task landed.

Leave with a smaller set of decisions

Maya reads queue open again. The policy is now in the campaign's durable record. The remaining coding obligation is visible, and ordinary queued work can continue without her.

Her morning check ends when she understands what waits and has answered the questions she can answer. An empty queue is not the goal.

This follows Tracker journey what_needs_me_now. Read The work queue for states, wait reasons and controls.

Finish a change that stopped

Jon helps Maya maintain parcel-cli. A customer supplied a file with an empty record between two valid records. The importer stopped at the empty record and silently skipped everything after it.

Jon can name the expected behaviour and its acceptance cases. He submits one task with an explicit provider:

foundry task parcel-cli \
  "Skip empty import records; prove records after them are still imported" \
  --agent codex --origin "Customer supplied an import with an empty record"

Read the result before starting again

Foundry runs the task in an isolated worktree. The tests pass, but the separate review finds that whitespace-only records still stop the import. In this example, the verdict is defect. Foundry commits and preserves the work rather than landing it on trunk.

Jon finds the obligation and reads its evidence:

foundry queue open
foundry queue show wi_0123456789abcdef01234567

He reads the task result and reviewer diagnosis through the trace and evidence references. The preserved implementation may be useful. He does not need to start from a blank checkout.

Continue the preserved implementation

The original objective still describes the desired behaviour. Jon resumes its work item:

foundry queue resume wi_0123456789abcdef01234567 \
  --origin "Continue the preserved import fix"

Foundry records a new linked task. The new task keeps the original objective and starts from the preserved work. It waits for the normal pacing rules. The original item stays open until the child actually lands.

If trunk changed, Foundry first brings the continuation onto current trunk. If that rebase conflicts, it refuses the continuation before starting an agent. The preserved reference remains intact. Jon reads that failure before choosing how to reapply the work.

The --origin note explains why Jon resumed it. It does not replace the task's objective. If he needs a different objective, he uses the continuation options rather than assuming a resume rewrites the brief.

Check the ending

In this run, the continuation adds the whitespace case, passes the required gates and receives a complete review. It lands on trunk.

Jon reads the child and parent records. The child has the landing commit and a resumes link to the parent. The parent now records that the continuation landed. He also runs the customer's representative import through the updated tool before telling Maya the importer now handles those records.

The useful ending is a working import and a settled obligation. A preserved branch by itself would leave both unresolved.

This follows Tracker journey recover_unlanded_work. Read Tasks and campaigns for verdicts and landing rules.

Make a tool safe to adopt

Maya wants another team to adopt config-kit, a tool that reads configuration files. She knows what success looks like: old files remain readable, unsupported formats produce useful errors, and the adoption checks run in CI. She does not yet know which changes the repository needs.

A campaign suits this work. Maya states the mission and the evidence that will prove it. Formation will derive one objective at a time from that evidence.

Authorise a bounded mission

Maya saves safe-import.json:

{
  "name": "safe-import",
  "project": "config-kit",
  "mission": "A new team can adopt the tool without losing old configuration compatibility.",
  "done_evidence": [
    {
      "kind": "review",
      "statement": "Old files remain readable; unsupported formats have useful errors; CI runs the adoption checks."
    }
  ],
  "budget": { "max_cycles": 3 },
  "escalation": ["Ask the owner before removing compatibility."],
  "authorized_by": "Maya",
  "agent_provider": "codex"
}

The project already has required gates. The review statement describes the mission beyond those mechanical checks. For stronger completion evidence, Maya can also name existing acceptance tests as campaign done gates.

She adds the campaign and starts it:

foundry campaign add safe-import.json
foundry campaign advance safe-import

Let implementation change the next objective

The first assessment finds missing format checks. It dispatches a task. Review then finds a diagnostic defect, and Foundry preserves the implementation. The next assessment uses that result and the live repository to choose a repair. The repaired implementation lands after gates and review.

On reassessment, formation finds that compatibility tests already exist under an unexpected name. The next objective can connect those tests to the adoption checks. There is no need to implement the same behaviour again.

Maya reads the result, rather than counting landed tasks as proof of completion:

foundry campaign show safe-import
foundry campaign report safe-import

Answer the question that belongs to the owner

Suppose the campaign has used its three cycles and still needs a compatibility decision. queue open exposes that escalation. campaign show includes the question, evidence and commands available to Maya.

She checks the adoption team's requirement. They need the old format for one more release. She records that policy and authorises two more cycles:

foundry campaign decide safe-import \
  --decision "Keep the old format readable for this release" --add-cycles 2
foundry campaign advance safe-import

The decision becomes binding context. The extra cycles are explicit permission to continue. Without that extension, a spent budget cannot fund more tasks.

Prove the whole mission

After the remaining work lands, formation checks the whole mission against trunk and done evidence. It can complete the campaign when that evidence holds. A complete verdict for one task proves only that task's objective.

If completion needs evidence outside the repository, Maya checks that evidence herself. She then records an owner completion with a specific reason:

foundry campaign complete safe-import \
  --reason "The adoption team loaded its old files and checked the CI results"

These are two possible endings. Maya uses owner completion only when she has checked the mission's outcome, rather than to remove an inconvenient escalation.

This follows Tracker journeys mission_to_done and answer_an_escalation. Read Tasks and campaigns for definitions and controls.

Deliver one outcome across repositories

Maya's customer wants to see why a delivery is late. dispatch-api owns the delivery status. customer-portal displays it. Changing either repository alone will not give the customer a useful answer.

In Tracker, Maya has an approved goal, explain-delays, and a journey, understand-delay. Its interactions cover publishing a reason and displaying it to the customer. The journey puts the API interaction before the portal one.

This story includes initiative outcome evidence, owner attention in the dashboard and owner-confirmed Tracker closure.

Bring the intent into Foundry

Maya has registered both execution projects with charters and required gates. She saves the shared check as delay-gates.json:

[
  {
    "kind": "gate",
    "command": "python3 dispatch-api/scripts/check-delay.py customer-portal",
    "required": true,
    "artifacts": [
      "dispatch-api/scripts/check-delay.py",
      "dispatch-api/contracts/delay-v2.json",
      "customer-portal/package.json"
    ]
  }
]

Both projects verify the same delay-v2 executable contract in their required gates. The contract includes older responses that omit the optional reason field. Provider verification checks API behavior; consumer tests check the portal's handling of the same cases. Test doubles come from that contract. The results identify the contract hash and the tested commits on both sides.

The shared check starts both applications with their deployment configuration. It tests whether a customer can read a useful explanation for a delayed delivery. Maya runs derivation from the repository that supplies the Tracker intent:

foundry initiative derive --from et:journey:understand-delay \
  --projects dispatch-api,customer-portal --authorized-by Maya \
  --integration-gates delay-gates.json
foundry initiative show understand-delay

The initiative takes its name from the selected journey ID. Approved interactions become candidates, with ordering from the journey. Derivation records those candidates without starting campaigns or changing Tracker state.

Maya reads the outcome and candidates. She imports a brief that explains the customer's language and compatibility requirements:

foundry initiative brief set understand-delay delay-brief.md

Foundry keeps the authoritative brief and its content hash. Members receive that identity along with the shared decisions and applicable findings.

Check what already exists

Maya explicitly enables steering:

foundry initiative advance understand-delay

The first assessment audits the registered codebases. In this example, the API already publishes a reason, but the portal does not display it. The audit records checked paths and classifies the two candidates as realised and absent.

The realised interaction gets a close proposal. Foundry leaves Tracker unchanged. Maya inspects the proposal before treating the interaction as done. The portal candidate still needs work, and its prerequisite needs evidence.

Each assessment chooses one action. It may relay the API finding or ask Maya to confirm that the existing response satisfies the prerequisite. An answer or a later explicit advance gives steering another assessment. A relay does not create an idle loop.

Give both sides the same answer

The existing response uses an internal status code. The members need to know whether customers should see that code. Both campaigns ask the same question. Maya opens Sources in ops-visualizer. The initiative appears above its campaigns. She sees one question with both members and the time it has waited.

Maya selects both members and writes one policy in the decision form. The form retains her draft if a new member result arrives while she types. She submits the answer. Both campaigns receive it, and the shared question closes. The equivalent CLI command is:

foundry initiative show understand-delay
foundry initiative decide understand-delay \
  --decision "Show a plain-language reason; keep internal codes out of the portal"

That decision reaches applicable campaign members, including active ones. Formation that used the older policy must reassess before applying its result.

Another question needs the customer's input. Maya leaves it open overnight. After it has waited more than a day, the ops digest names the initiative and question. She can return to Sources and answer the affected members.

The initiative allows three open questions at once. If another campaign escalates, its question remains on that campaign until a slot opens. The dashboard shows the waiting count. Unblocked members can still make progress.

Foundry can now advance eligible member work. Each campaign still chooses its own next coding objective. After Foundry records a member's result and finishes repository cleanup, the initiative chooses what should happen next.

Maya inspects the coordinated work:

foundry initiative show understand-delay
foundry finding list understand-delay
foundry queue --source initiative:understand-delay

The shared cycle budget limits accepted work across members. Queued formation, reserved admissions and tasks count toward the active member limit. A budget extension needs an owner decision with --add-cycles.

Keep following current intent

During implementation, the customer withdraws a proposed tooltip. Maya updates the approved intent in its supplying repository. The next steer reads that intent again. It stops work owned by the withdrawn interaction gracefully and keeps its findings, evidence and cycle history.

If Maya uses a committed projection, she commits that update before steering. When the source uses the read-only Tracker CLI instead, steering reads its current intent. Editing an uncommitted projection does not change the input.

Discover a gap between the two applications

The portal change lands. Both projects pass verification of the same contract, including the fallback for older responses. The initiative's shared check runs the current API and portal trunks together. It finds that the portal's deployment configuration still names a retired API address. The contracts agree, but the customer cannot reach the explanation.

Foundry records a regression finding. Steering stages a configuration repair campaign in customer-portal because the existing mission did not cover this deployment setting. Maya can see the new member before its next advance starts work. It uses the initiative's existing authorization, providers and remaining cycle budget.

The campaign corrects the API address and lands it. The next integration check passes against both current trunks. Steering reviews the customer's journey and proposes completion. The proposal contains the tested commits and the evidence for each review statement.

Check the customer's ending

Maya reads the proposal:

foundry initiative show understand-delay

She confirms that the customer can see a useful explanation and that older API clients still work. She records completion:

foundry initiative complete understand-delay \
  --reason "The portal explained a delayed delivery using the API response; old clients still worked"

Foundry checks that the tested trunks and intent are still current. It closes the verified Tracker interactions, then the goal when its success criteria and children are satisfied. The initiative becomes complete after those changes succeed. A failed Tracker call leaves it open with receipts for the changes that did succeed.

This follows Tracker journey outcome_across_repos. Read Initiatives for gate definitions, milestones, holds and findings.

Change Foundry while work continues

Leah maintains Foundry. She has changed how Foundry admits a queued continuation. Other repositories are using the production daemon, so replacing its binary also affects their running work.

Her goal is a working change and a production restart that does not interrupt another project's agent session.

Prove the change on the development host

Leah develops on the Mac. She installs the changed binaries and reloads the development daemon through the repository's local procedure. Its registry contains synthetic sandbox projects, including sample-rs.

She uses an explicit development address:

foundry --addr http://127.0.0.1:50051 validate sample-rs
foundry --addr http://127.0.0.1:50051 queue

She also runs the changed continuation path against sandbox work and checks its result. Unit tests alone would not show whether the installed daemon admits and settles that work correctly.

The repository gates pass. The sandbox records match the intended behaviour. Leah records the source revision and evidence, then waits for the owner's promotion approval.

Make a quiet point after approval

After approval, Leah reads the production host's playbook and maintenance log. She uses the manual release procedure in the repository's AGENTS.md. She builds the tagged Linux artifact on the ops host and checks its release gates.

Outside the configured nightly window, she drains production:

ssh mojility-ops-01 foundry pacing drain --timeout 30m

Draining pauses every lane and waits for running work to settle. A timeout names what still runs. Leah proceeds with replacement only after the drain reports an idle daemon.

She installs the release artifact and restarts the daemon using the manual procedure. The drain's pause survives the restart. She checks the installed version and daemon state before allowing new work:

ssh mojility-ops-01 foundry --version
ssh mojility-ops-01 foundry status
ssh mojility-ops-01 foundry sentinel list
ssh mojility-ops-01 foundry pacing show

Once those checks pass, she resumes the lanes:

ssh mojility-ops-01 foundry pacing resume --lane all
ssh mojility-ops-01 foundry pacing show
ssh mojility-ops-01 foundry queue

Leave a record of what is running

Leah checks release publication separately and records the installation and verification in the host's maintenance log. The ending includes a known installed version, a responsive daemon, restored pacing and durable evidence.

The commands here show the decision points. Use the repository's manual release procedure for the complete installation instructions. Foundry itself is not released through foundry release foundry or a task on its production daemon.

This follows Tracker journey promote_foundry. Read Getting started for the daemon setup and The work queue for drain semantics.

Concepts

Events

An event records that something happened. It is an immutable fact appended to the event log. Events carry:

  • id — deterministic hash of content (same input always produces same ID)
  • event_type — what happened (e.g., vulnerability_detected)
  • project — which project this relates to
  • throttle — propagated through the chain, controls downstream behaviour
  • payload — event-type-specific data as JSON
  • occurred_at / recorded_at — timestamps

Events have no opinion about what should happen next. That's the job of task blocks.

Task Blocks

A task block is a unit of work. It has:

  • Name — human-readable identifier
  • Kind — Observer (reads/scans) or Mutator (writes/deploys)
  • Sinks — which event types trigger this block
  • Work — what it does when triggered
  • Emits — which events it produces on completion

Task blocks are registered with the engine at startup. When an event arrives, the engine finds all blocks that sink on that event type and executes them.

Observer vs Mutator

The distinction matters for throttle behaviour:

KindExamplesThrottle behaviour
ObserverAudit tag, audit main, validate projectAlways executes, always emits
MutatorCut release, install locally, commit+pushExecution and emission controlled by throttle

Throttle

The throttle sits on a task block's output side. After a block completes its work, it checks the throttle before emitting downstream events.

LevelObserversMutators
fullExecute + emitExecute + emit
dry_runExecute + emitSkip execution + simulate success

The throttle is set when an event is first emitted (e.g., via CLI) and propagated through the entire chain. This means a single command controls how deep the ripple goes.

Workflows

A workflow is not a first-class object in Foundry. It emerges from the emitter/sink wiring between task blocks. When you emit vulnerability_detected, the chain of task blocks that fire in response is the vulnerability remediation workflow.

This means workflows are composable by nature — adding a new task block that sinks on an existing event type automatically extends every workflow that produces that event.

Engine

The engine is the runtime that:

  1. Receives an event
  2. Finds task blocks that sink on that event type
  3. Checks throttle to decide whether to execute and/or emit
  4. Executes matching blocks
  5. Collects emitted events and feeds them back into step 2
  6. Continues until no more events are produced (the chain is complete)

The engine processes events depth-first within a single invocation.

Event Model

Foundry's event model defines the vocabulary of immutable facts that flow through the system. Events carry payloads describing what occurred but have no opinion about what should happen next — task blocks make those decisions.

Authoritative Source

The canonical event type definitions live in foundry-sdk/src/event.rs (EventType enum). The implementation roadmap for completing all task blocks and workflows is in IMPLEMENTATION_PLAN.md at the project root.

Event Categories

Run Lifecycle

EventEmitterPurpose
maintenance_run_startedOrchestrator / manualBegins a per-project maintenance chain
maintenance_run_completedOrchestrator (fan-in)All projects finished; carries aggregate results

Per-Project: Iterate / Maintain

EventEmitterPurpose
project_validation_completedValidateProjectPre-flight check (dir, branch, gates)
project_iteration_completedRouteGateResultOne structural improvement attempted
project_maintenance_completedRouteGateResultDependencies updated, gates verified
project_changes_committedCommitAndPushGit commit created
project_changes_pushedCommitAndPushPushed to remote

Per-Project: Release Audit

EventEmitterPurpose
release_tag_auditedAuditReleaseTagLatest tag scanned for vulnerabilities
main_branch_auditedAuditMainBranchMain branch checked for same vulnerability
release_requestedAudit chainIntent to cut a patch release
release_completedCutReleaseTag pushed

Vulnerability Remediation

EventEmitterPurpose
vulnerability_detectedExternal / nightly auditEntry point for remediation workflow
remediation_startedRemediateVulnerabilityFix attempt underway
remediation_completedRemediateVulnerability, RemediatePipelineFix attempt finished (success or failure); needs_review is set when the agent pushed directly or added an advisory suppression

Distribution Pipeline

EventEmitterPurpose
release_pipeline_completedWatchPipelineGitHub Actions finished building and publishing
local_install_completedInstallLocallyTool reinstalled on local machine

Event Structure

Every event has:

  • id — Deterministic SHA256 hash of (type + project + occurred_at + payload)
  • event_type — One of the EventType enum variants
  • project — Which project this event relates to
  • occurred_at / recorded_at — When it happened vs. when it was logged
  • throttle — Propagated through the chain to control downstream behaviour
  • payload — Event-type-specific JSON data

Payload Conventions

Downstream blocks read payload fields to make routing decisions (self-filtering). The engine routes by event type only — it cannot inspect payloads.

FieldUsed ByValues
vulnerableAuditMainBranchtrue/false — whether the tag has known CVEs
dirtyRemediateVulnerability, CutReleasetrue/false — whether main still has the vulnerability
cveAll vulnerability blocksCVE identifier string
statusDownstream blocks"ok"/"error" — validation and completion status
has_changesCommitAndPushWhether there are uncommitted changes to persist

Tracing

Foundry's tracing system gives every event in the system a place inside a nested causal tree. When a maintenance cycle kicks off, every workflow it spawns, every block those workflows dispatch, and every subprocess those blocks launch can be reconstructed as a hierarchy — not just a flat list of events that share a trace ID.

The model is borrowed directly from OpenTelemetry. If you have read OTel's spec on traces, spans, and context propagation, the shapes will feel familiar. The Foundry-specific parts are which events open new spans and how the engine stamps that lineage onto every emitted event.

Why Nested Tracing

Earlier versions of Foundry tagged events with a single trace_id. That worked when each invocation produced one linear chain, but it broke down the moment a cycle fanned out across multiple projects or a workflow launched sub-workflows.

The flat model could answer "what happened in this run?" but not:

  • Which project run owns this block execution?
  • What sub-workflows did this iteration spawn?
  • If a remediation failed three levels deep, what triggered it?

The nested model adds two more identifiers — span_id and parent_span_id — on top of the existing trace ID. The same trace can now be rendered as a tree, with each branch corresponding to a logical scope of work.

The Four Span Levels

Foundry recognizes four levels of span nesting. Every event carries the identifiers of the deepest span active when it was emitted.

LevelSpan opens atSpan closes at
CycleMaintenanceCycleStarted eventMaintenanceCycleCompleted event
Project runProjectRunStarted eventProjectRunCompleted event
WorkflowA *Requested or *Started opener eventMatching completion event
BlockEngine dispatches a blockBlock returns

A typical maintenance run produces a tree shaped roughly like this:

flowchart TD
    Cycle[Cycle span<br/>MaintenanceCycleStarted]
    Cycle --> RunA[Project run span<br/>ProjectRunStarted: foundry]
    Cycle --> RunB[Project run span<br/>ProjectRunStarted: epilogue-tracker]
    RunA --> WfA1[Workflow span<br/>ProjectIterationRequested]
    RunA --> WfA2[Workflow span<br/>ProjectMaintenanceRequested]
    WfA1 --> BlockA1[Block span<br/>Assess Project]
    WfA1 --> BlockA2[Block span<br/>Execute Plan]
    BlockA2 --> WfA3[Workflow span<br/>InnerIterationStarted]

Not every Foundry invocation uses all four levels. A bare foundry emit greet_requested only produces a workflow span and the block spans beneath it — no cycle or project run wraps it.

Campaigns use the same structure with a different boundary. Each manual or automatic CampaignAdvanceRequested is a span opener and mints a fresh cycle span under the campaign run's trace. Task-side events carry a separate campaign_cycle domain field in addition to tracing context. The field makes the cycle directly queryable from persisted events even when concurrent campaigns target the same project or events arrive out of timestamp order.

Identifiers

Every event carries up to five optional identifiers that locate it in the trace tree and any fan-out coordination:

  • trace_id — 32-character lowercase hex string (OTel format). Identifies the entire causal tree. All events under one cycle share the same trace_id.
  • span_id — 16-character lowercase hex string. Identifies one specific span. Multiple events under the same workflow share a span_id.
  • parent_span_id — 16-character lowercase hex string, or None for the root span of a trace. Points at the span that contains this one.
  • causation_id — the id of the event that triggered the block which emitted this one, or None for a root event. Points at the direct causal parent in the event graph.
  • gather_id — identifies the fan-out (scatter/gather) group an event belongs to, or None when the event is not part of a fan-out. Propagates verbatim like trace_id (see below).
  • source — what dispatched the unit of work the event belongs to: a campaign cycle, a sentinel, a person at a client, or a parent work item (see the work queue). None when the root named none. Propagates verbatim like trace_id (see below).

A canonical event therefore carries something like:

trace_id        4bf92f3577b34da6a3ce929d0e0e4736
span_id         00f067aa0ba902b7
parent_span_id  b9c7c989f97918e1
causation_id    evt_a1b2c3d4e5f6
gather_id       gth_9f8e7d6c5b4a
source          sentinel:nightly-maintenance

Spans Versus Causation

trace_id, span_id, and parent_span_id describe observability structure — how work nests for the purpose of rendering trace trees. causation_id describes domain causality — precisely which event caused which. The two often parallel each other, but they are distinct: a span groups many peer events under one workflow, whereas causation_id records the single edge from a trigger to each event a block emits in response. Coordination logic (fan-out/fan-in) relies on causation, not spans.

Legacy Trace IDs

Events emitted by Foundry prior to OTel-shaped tracing carry IDs in the trc_<uuid> form. These remain valid and queryable — Foundry continues to recognize them via foundry_sdk::is_legacy_trace_id, which returns true for any ID starting with trc_. Tooling that renders trace trees falls back to a flat chronological view when it detects a legacy trace.

The Two Stamping Rules

The engine stamps tracing fields onto every emitted event using two rules. Together they preserve the parent/child relationship without requiring task blocks to think about it.

Default rule — peers under the same workflow span

When a block emits an event whose type is not a registered span opener, the new event inherits the trigger event's (trace_id, span_id, parent_span_id) triple verbatim. Every event emitted inside one workflow is a peer under the same workflow span.

flowchart LR
    T[charter_validated<br/>span: W1] --> B([Assess Project])
    B --> E[project_assessed<br/>span: W1]

The block runs inside its own block span, but the event it emits is stamped with the workflow span (W1) so siblings stay flat under the workflow rather than burrowing into per-block sub-spans.

Span-opener rule — mint a new child workflow

When a block emits an event whose type is a registered span opener, the engine mints a fresh span_id and stamps the new event with:

  • trace_id = unchanged from the trigger
  • span_id = newly minted
  • parent_span_id = the emitting block's own span ID

The result is a new workflow span hanging off the emitting block. The parent linkage points at the block, not at the trigger event, because the block is what caused the new workflow to exist.

flowchart LR
    T[project_iteration_completed<br/>span: W1] --> B([Route Project Workflow<br/>block span: B1])
    B --> E[maintenance_requested<br/>span: W2<br/>parent: B1]

This is the only place fresh span IDs appear during execution. Trace IDs are minted only at the very top — when a brand new top-level event enters the system.

Causation stamping

Alongside the two span rules, the engine stamps causation_id on every emitted event, setting it to the id of the triggering event. This is unconditional — it does not depend on whether the emitted event is a span opener — because causation tracks the domain edge from trigger to emitted event regardless of how spans nest. Like the span fields, stamping is "set if unset": a block that emits an event with an explicit causation_id keeps it. Root events entering the engine directly carry no causation_id.

Gather-ID propagation

The engine also propagates gather_id onto every emitted event, inheriting it verbatim from the trigger — the same rule as trace_id, and deliberately unlike causation_id. A scattered child workflow may run many causal hops deep, crossing span-opener boundaries; carrying the gather_id unchanged all the way down means the child's terminal *Completed event still identifies the fan-out group it belongs to, so the engine can count it toward the gather. Stamping is "set if unset", and an event outside any fan-out simply carries None.

A gather still open when the traversal has nothing left to run can no longer be satisfied: some child's chain ended in a failed block before its completion. The engine then closes the innermost such group itself and delivers its reduce event, whose missing field names each child that did not arrive and the block that stopped it. One failed child therefore never stops the fan-in.

Work-source propagation

source propagates by the same rule as gather_id: every emitted event inherits the trigger's source verbatim, across span-opener boundaries and into scattered children and the synthesized reduce event, set if unset. A submitter names the source once, on the root it emits — the CLI's typed commands name operator with their host, the scheduler names the sentinel that fired, a campaign advance names the campaign and cycle on the cycle's ExecutionRequested — and the work-item ledger records it off whichever event opens an item's chain, however many hops below the root that is. This is why the source lives on the envelope rather than in a payload: the maintenance fan-out builds each per-project root with an empty payload, and the hops from there to a PipelineChecked or MainBranchAudited do not forward payload context.

Two places copy it across a trace boundary explicitly, because a fresh trace inherits nothing: the maintenance summary phase takes the cycle root's source, and each majors-lane task takes the plan event's. A block that sets an explicit source on an event it emits keeps it; that is how a cycle dispatched by a manual foundry campaign advance names the campaign rather than the operator whose root triggered the advance.

Span-Opener Registry

The span-opener registry is implemented as EventType::is_span_opener in foundry-sdk. The current openers are:

  • Cycle and project-run openers
    • MaintenanceCycleStarted
    • ProjectRunStarted
  • Workflow request openers
    • ProjectIterationRequested
    • ProjectMaintenanceRequested
    • ExecutionRequested
    • CampaignAdvanceRequested
    • ValidationRequested
    • DriftAssessmentRequested
    • ReleaseRequested
    • PipelineCheckRequested
    • GreetingRequested
    • MaintenanceSummaryRequested
  • Explicit lifecycle openers
    • RemediationStarted
    • StrategicCycleStarted
    • InnerIterationStarted
    • CommitDigestStarted
    • OpsDigestStarted
    • SupplyChainScanStarted

Compiler-enforced exhaustiveness

is_span_opener is an exhaustive match with no wildcard arm. Every EventType variant is classified as opener (true) or non-opener (false) at this single authoritative site. Adding a new EventType variant without classifying it here is a compile error — the compiler, not discipline, keeps the classification complete.

To add a new opener, edit the match in is_span_opener and move the new variant from the false arm group to the true arm group. No other tracing code needs to change — the engine reads the result at emit time.

[EventType::Custom] is always false here; third-party workflows that need a custom root event to open a span can register it at runtime via Engine::with_span_openers.

To decide whether a new event type belongs in the opener set, ask: does this event represent the start of a logically distinct unit of work whose internal events should be grouped together? If yes, add it to the true arm. If the event is just one step in an existing workflow, leave it in the false arm.

Subprocess Propagation

Workflows often launch external processes — shell commands and AI agent streams. To keep their work attached to the right span, Foundry injects a W3C Trace Context traceparent header as a TRACEPARENT environment variable on every spawned process.

The format is:

00-<trace_id>-<span_id>-01
  • 00 — version byte
  • <trace_id> — the current trace's 32-hex-char ID
  • <span_id> — the currently active block span's 16-hex-char ID
  • 01 — trace flags (sampled)

Injection happens transparently:

  • foundryd::shell::run injects TRACEPARENT into every spawned command if a span context is active.
  • foundryd::agent_stream injects the same variable when streaming an AI agent.

When a subprocess turns around and runs foundry emit, the CLI reads TRACEPARENT from its environment, parses it, and stamps the emitted event with parent_span_id set to the block's span. The new event arrives back at the daemon already correctly parented — no manual plumbing required from inside the subprocess.

If no span context is active (for example, the daemon is starting up), no TRACEPARENT is injected and no value leaks into spawned processes.

Querying Spans

Two gRPC RPCs on foundryd give clients access to trace data:

  • Trace — returns every event sharing a given trace_id. The whole tree, in one response.
  • Span — returns events and block executions belonging to a single span_id. Just that subtree.

foundry trace

The CLI command for inspecting a trace is foundry trace <event-id>. Given any event ID, it resolves the trace that event belongs to and renders it.

By default the output is the nested span tree — cycle at the root, project runs underneath, workflows nested inside, and block executions at the leaves. Each event is rendered under the span it belongs to.

For legacy traces, or when explicit chronology is more useful than hierarchy, pass --flat:

foundry trace <event-id> --flat

Flat mode renders events in chronological order without any span nesting, which matches the original behaviour of the command before nested tracing existed.

foundry status --span

foundry status lists active workflows. To narrow that listing to a specific span — for example, "what's currently running inside this project run?" — pass --span:

foundry status --span <span-id>

This is the fastest way to drill down from a known span without fetching the whole trace tree. If the span belongs only to a block execution and has no events of its own, the daemon still returns the owning trace_id, so foundry status --span narrows the active workflow list to that trace instead of falling back to an unfiltered listing.

Legacy Traces

Foundry retains read-side compatibility with traces emitted before the nested model existed. Specifically:

  • Events with trc_* IDs (the pre-OTel format) are still queryable. foundry_sdk::is_legacy_trace_id identifies them, and both the Trace and Span RPCs accept them.
  • foundry trace detects legacy traces — events that carry a trace_id but no span_id — and automatically falls back to the --flat chronological view, since there is no span structure to render.

A migration script lives at scripts/migrate-event-names.sh. It renames event types in older event logs to match the current taxonomy but does not backfill span_id or parent_span_id onto historical events. The information needed to reconstruct the nested structure after the fact simply is not present in old logs, and any inferred reconstruction would be guesswork.

The practical effect is that history is preserved and queryable, but only events emitted after the upgrade carry full span structure. New runs render as trees; older runs render as flat chronologies.

Worked Example

Consider a maintenance run for a single project. The cycle starts, a project run is opened, iteration is requested, the iteration completes, and the engine routes through to a maintenance request that also completes. The resulting span tree is:

flowchart TD
    M[MaintenanceCycleStarted<br/>span: C1, parent: none]
    P[ProjectRunStarted: foundry<br/>span: R1, parent: C1]
    I[ProjectIterationRequested<br/>span: W1, parent: R1]
    IC[ProjectIterationCompleted<br/>span: W1, parent: R1]
    Mreq[ProjectMaintenanceRequested<br/>span: W2, parent: B1 from W1]
    Mc[ProjectMaintenanceCompleted<br/>span: W2, parent: B1 from W1]
    PC[ProjectRunCompleted: foundry<br/>span: R1, parent: C1]
    MC[MaintenanceCycleCompleted<br/>span: C1, parent: none]

    M --> P --> I --> IC --> Mreq --> Mc --> PC --> MC

Notice that ProjectIterationCompleted sits inside the same span (W1) as ProjectIterationRequested — the default stamping rule — while ProjectMaintenanceRequested mints a new workflow span (W2) parented to whichever block emitted it, because ProjectMaintenanceRequested is a registered span opener.

That hierarchy is what foundry trace renders, and it is what makes "who triggered this?" answerable from a single command.

Task Block Library

Task blocks are the reusable processing units of Foundry. Each block is defined once and can participate in multiple workflows.

Implementing the TaskBlock Trait

Every task block implements foundry_sdk::task_block::TaskBlock:

#![allow(unused)]
fn main() {
pub trait TaskBlock: Send + Sync {
    fn name(&self) -> &'static str;
    fn kind(&self) -> BlockKind;
    fn sinks_on(&self) -> &[EventType];
    fn execute(
        &self,
        trigger: &Event,
    ) -> Pin<Box<dyn Future<Output = anyhow::Result<TaskBlockResult>> + Send + '_>>;

    // Optional — defaults to no retries
    fn retry_policy(&self) -> RetryPolicy {
        RetryPolicy::default()
    }
}
}

The trait provides default implementations for should_emit() and should_execute() based on kind() and the throttle level. Override retry_policy() to enable automatic retry of transient failures.

See the Writing Task Blocks guide for step-by-step instructions and a full example including RetryPolicy.

Foundry records block_started and block_completed observations around every block execution. These observations are persisted and streamed for operational auditability, but are not routed through sinks_on() and can never trigger another task block. The daemon applies the same durability rule to every event published on Watch, including observations emitted outside the engine.

Current Blocks

Hello-World (validates engine mechanics)

BlockKindSinks OnEmits
Compose GreetingObservergreet_requestedgreeting_composed
Deliver GreetingMutatorgreeting_composedgreeting_delivered

Vulnerability Remediation

These blocks form two paths through the vulnerability remediation workflow. Both Remediate Vulnerability and Cut Release sink on main_branch_audited and self-filter based on the dirty flag in the payload — only one path fires per event.

Audit Release Tag also sinks on project_changes_pushed to perform a post-push re-audit, confirming the fix is clean before anything downstream acts.

BlockKindSinks OnEmitsSelf-filters
Scan DependenciesObserverscan_requestedvulnerability_detected—
Audit Release TagObservervulnerability_detected, project_changes_pushedrelease_tag_auditedSkips post-push when project not in registry
Audit Main BranchObserverrelease_tag_auditedmain_branch_auditedSkips when vulnerable=false
Remediate VulnerabilityMutatormain_branch_auditedremediation_completedOnly when dirty=true
Commit and PushMutatorremediation_completed, project_iteration_completed, project_maintenance_completedproject_changes_committed, project_changes_pushedPushes whenever the branch is ahead of origin/<branch>, even when the tree is clean or changes=false
Cut ReleaseMutatormain_branch_auditedrelease_completedOnly when dirty=false
Watch PipelineMutatorrelease_completedrelease_pipeline_completed—
Install LocallyMutatorproject_changes_pushed, release_pipeline_completedlocal_install_completed—

Maintenance

The maintenance workflow uses an explicit routing Observer (Route Project Workflow) to delineate which sub-workflow runs. This keeps each downstream block focused on a single responsibility.

BlockKindSinks OnEmitsSelf-filters
Validate ProjectObservermaintenance_run_startedproject_validation_completedSkips projects not in active registry
Route Project WorkflowObserverproject_validation_completediteration_requested or maintenance_requestedStops when status != "ok" or no actions enabled
Commit and PushMutatorproject_iteration_completed, project_maintenance_completedproject_changes_committed, project_changes_pushedPushes whenever the branch is ahead of origin/<branch>
Audit Release TagObserverproject_changes_pushedrelease_tag_auditedSkips when project not in registry

The actions.maintain flag is forwarded inside the iteration_requested payload so that the gate routing can chain directly to maintenance_requested after a successful iteration without re-querying the project configuration.

Gate Orchestration

These blocks provide native gate resolution, execution, and routing for iterate, maintain, and validation workflows.

BlockKindSinks OnEmitsSelf-filters
Resolve GatesObserveriteration_requested, maintenance_requested, validation_requestedgate_resolution_completed—
Run Preflight GatesObservergate_resolution_completedpreflight_completedSkips maintain workflow
Run Verify GatesObserverexecution_completedgate_verification_completed—
Route Gate ResultObservergate_verification_completedproject_iteration_completed / project_maintenance_completed / retry_requestedRoutes based on pass/fail and retry count
Route Validation ResultObserverpreflight_completedvalidation_completedOnly handles validate workflow

Iterate Workflow

These blocks form the native iterate chain, running inside the gate orchestration lifecycle.

BlockKindSinks OnEmitsSelf-filters
Check CharterObserverpreflight_completedcharter_validatedOnly handles iterate workflow
Assess ProjectMutatorcharter_validatedproject_assessed—
Triage AssessmentMutatorproject_assessedassessment_triaged—
Create PlanMutatorassessment_triagedplan_created—
Execute PlanMutatorplan_createdexecution_completed—

Maintain Workflow

BlockKindSinks OnEmitsSelf-filters
Execute MaintainMutatorgate_resolution_completedexecution_completedOnly handles maintain workflow
Retry ExecutionMutatorretry_requestedexecution_completed—
Summarize ResultObserverproject_iteration_completed, project_maintenance_completedgenerate_summary—

Validation Workflow

A dedicated read-only workflow for checking project gate health. No Mutator blocks are involved — validation never modifies code.

validation_requested
  → Resolve Gates → gate_resolution_completed
    → Run Preflight Gates → preflight_completed
      → Route Validation Result → validation_completed

Vulnerability Workflow Chain

flowchart TD
    A[scan_requested] --> B([Scan Dependencies])
    B --> C[vulnerability_detected]
    C --> D([Audit Release Tag])
    D --> E[release_tag_audited]
    E --> F([Audit Main Branch])
    F --> G[main_branch_audited]
    G --> H{dirty?}
    H -->|dirty=true| I([Remediate Vulnerability])
    I --> J[remediation_completed]
    J --> K([Commit and Push])
    K --> L[project_changes_committed]
    K --> M[project_changes_pushed]
    M --> N([Audit Release Tag post-push])
    N --> O[release_tag_audited]
    M --> P([Install Locally])
    P --> Q[local_install_completed]
    H -->|dirty=false| R([Cut Release])
    R --> S[release_completed]
    S --> T([Watch Pipeline])
    T --> U[release_pipeline_completed]
    U --> V([Install Locally])
    V --> W[local_install_completed]

Maintenance Workflow Chain

flowchart TD
    A([maintenance_run_started]) --> B[[Validate Project]]
    B --> C([project_validation_completed])
    C --> D[[Route Project Workflow]]
    D -->|iterate=true| E([iteration_requested])
    D -->|iterate=false, maintain=true| F([maintenance_requested])
    D -->|no actions| G([end — no automation])
    E --> H[[Resolve Gates]]
    H --> I[[Run Preflight Gates]]
    I --> J[[Check Charter]]
    J --> K[[Assess Project]]
    K --> L[[Triage Assessment]]
    L --> M[[Create Plan]]
    M --> N[[Execute Plan]]
    N --> O[[Run Verify Gates]]
    O --> P[[Route Gate Result]]
    P -->|pass| Q([project_iteration_completed])
    P -->|fail, retries left| R[[Retry Execution]]
    Q -->|maintain=true| F
    F --> S[[Resolve Gates]]
    S --> T[[Execute Maintain]]
    T --> U[[Run Verify Gates]]
    U --> V[[Route Gate Result]]
    V -->|pass| W([project_maintenance_completed])
    V -->|fail, retries left| X[[Retry Execution]]

Gateway Pattern

Every block that executes an external process (a shell command or an audit tool) receives its I/O capability through a gateway trait rather than calling the implementation module directly. Two gateway traits live in gateway.rs:

  • ShellGateway — wraps crate::shell::run for arbitrary command execution.
  • ScannerGateway — wraps crate::scanner::run_audit for vulnerability scanning.

Production blocks hold an Arc<dyn ShellGateway> (and/or Arc<dyn ScannerGateway>) which is initialised to the real implementation in new(). A #[cfg(test)] constructor accepts a fake instead, enabling hermetic unit tests for every block.

This separation means:

  • The happy path and every failure/edge-case branch can be tested without spawning real processes.
  • shell.rs and scanner.rs stay untouched; the gateway is a thin adapter.
  • No async_trait macro is required — the return type uses an explicit Pin<Box<dyn Future + Send + '_>> (the same pattern as TaskBlock::execute).

See Testing with Fakes for usage examples.

RetryPolicy

Blocks can declare automatic retry behaviour by overriding retry_policy():

#![allow(unused)]
fn main() {
use std::time::Duration;
use foundry_sdk::task_block::RetryPolicy;

fn retry_policy(&self) -> RetryPolicy {
    RetryPolicy {
        max_retries: 3,
        backoff: Duration::from_secs(5),
    }
}
}

max_retries: 0 (the default) means the block runs exactly once. With max_retries: N, the engine retries up to N times after any failure (either a returned Err or a TaskBlockResult { success: false, .. }), sleeping backoff between each attempt. The final result (success or failure) is what the engine records in the BlockExecution trace.

Crate Structure

Foundry is organised as a Cargo workspace with five crates:

foundry/
├── Cargo.toml              # Workspace root
├── proto/foundry.proto     # gRPC service definition
├── crates/
│   ├── foundry-sdk/        # Stable SDK contract (library)
│   ├── foundry-engine/     # Core event processing engine (library)
│   ├── foundry-blocks/     # Built-in task block implementations (library)
│   ├── foundryd/           # Daemon (binary)
│   └── foundry-cli/        # CLI controller (binary)
└── book/                   # This documentation

foundry-sdk

Shared types used by both the daemon and CLI:

  • event.rs — Event struct, EventType enum, deterministic ID generation
  • throttle.rs — Throttle enum (Full, DryRun)
  • task_block.rs — TaskBlock trait, BlockKind, TaskBlockResult, RetryPolicy
  • registry.rs — Registry, ProjectEntry, ActionFlags, Stack, InstallConfig
  • trace.rs — TraceIndex, BlockExecution, ProcessResult — the structured types used to persist and display execution traces. Moved here from foundryd so the CLI can deserialise on-disk traces without depending on the daemon crate.

This crate has no async runtime dependency. It defines the vocabulary that the rest of the system speaks.

foundryd

The daemon process. Listens on gRPC (127.0.0.1:50051 by default, override with FOUNDRYD_LISTEN_ADDR) and runs the workflow engine. Only one daemon runs per Foundry home: instance_lock.rs holds an exclusive lock on ~/.foundry/foundryd.lock (override with FOUNDRYD_LOCK_PATH), taken after the listen address is bound and before any start-up recovery sweep. See Getting Started.

Core engine

  • engine.rs — event router: matches events to task blocks, executes them with retry logic, propagates emitted events respecting the throttle level. Exposes BlockExecution and ProcessResult for structured telemetry.
  • service.rs — gRPC service implementation (Emit, Status, Watch, Trace)

Daemon support modules

  • orchestrator.rs — coordinates per-project maintenance runs with concurrency control. Dispatches MaintenanceRunStarted per project, enforces max_concurrent via a semaphore, and prevents double-running via an active project set with a drop-guard cleanup.
  • event_writer.rs — appends every event to monthly JSONL files (YYYY-MM.jsonl) inside ~/.foundry/events/ (or FOUNDRY_EVENTS_DIR). Crash-safe: each write opens, flushes, and closes the file. A Mutex serializes concurrent writes.
  • trace_store.rs — in-memory store of recent ProcessResult chains, keyed by root event ID. Used for fast Trace RPC lookups of workflows still in progress or recently completed.
  • trace_writer.rs — persists completed ProcessResult objects to disk as pretty-printed JSON files under ~/.foundry/traces/YYYY-MM-DD/{event_id}.json. Traces written here survive daemon restarts indefinitely and are read by foundry history and foundry trace when the in-memory store has no match.
  • workflow_tracker.rs — tracks workflows that are currently being processed by background tasks. Thread-safe via RwLock. Each Emit RPC inserts an ActiveWorkflow entry on start; a RAII WorkflowGuard removes it on completion or panic. The Status RPC reads this tracker to show live in-flight workflows.
  • shell.rs — async shell runner used by block implementations. Runs an external command with configurable timeout (default 5 min), captures stdout and stderr, and returns a CommandResult.
  • scanner.rs — vulnerability scanner abstraction. Dispatches to the stack-appropriate tool (cargo audit, npm audit, pip-audit, mix deps.audit, osv-scanner for Swift, the project's Gradle Dependency-Check task for Kotlin) and normalizes output into a Vec<Vulnerability>.
  • gateway.rs — I/O abstraction layer for task blocks. Defines ShellGateway and ScannerGateway traits with ProcessShellGateway and ProcessScannerGateway production implementations. Also provides FakeShellGateway and FakeScannerGateway test doubles (available under #[cfg(test)] only) that record invocations and return pre-configured results, enabling hermetic unit testing of every block without spawning real processes.
  • summary.rs — renders a MaintenanceRunSummary as a Markdown report (project table with success/failure/skipped, a failures section, and timing statistics).

Task block implementations (blocks/)

  • validate.rs — ValidateProject: pre-flight checks before a maintenance run
  • resolve_gates.rs — ResolveGates: reads .hone-gates.json and emits gate definitions
  • run_preflight_gates.rs — RunPreflightGates: runs gates on unmodified codebase
  • run_verify_gates.rs — RunVerifyGates: runs gates after code changes
  • route_gate_result.rs — RouteGateResult: routes pass/fail to completion or retry
  • route_validation_result.rs — RouteValidationResult: routes validation-only results
  • check_charter.rs — CheckCharter: validates project charter before iteration
  • assess_project.rs — AssessProject: AI-driven project assessment
  • triage_assessment.rs — TriageAssessment: prioritises assessment findings
  • create_plan.rs — CreatePlan: generates an execution plan from triaged findings
  • execute_plan.rs — ExecutePlan: executes the generated plan
  • execute_maintain.rs — ExecuteMaintain: runs maintenance tasks
  • retry_execution.rs — RetryExecution: retries failed executions with context
  • summarize_result.rs — SummarizeResult: generates workflow summary and traces
  • git_ops.rs — CommitAndPush: commits what the run left uncommitted, then pushes every commit ahead of origin/<branch> (re-running gates after a rebase)
  • audit.rs — AuditReleaseTag, AuditMainBranch: vulnerability scanning
  • release.rs — CutRelease, WatchPipeline: tagging and CI monitoring
  • install.rs — InstallLocally: reinstalls the project locally after a fix
  • remediate.rs — RemediateVulnerability: invokes the AI agent to fix a CVE
  • scan.rs — ScanDependencies: scans for known vulnerabilities
  • greet.rs — ComposeGreeting, DeliverGreeting: hello-world engine validation

foundry-cli

The CLI controller. Connects to foundryd over gRPC.

  • main.rs — clap-based argument parsing; subcommands: emit, status, watch, trace, run, history, registry
  • commands.rs — async implementations of each subcommand via tonic gRPC client; also contains the history command which reads on-disk traces directly from ~/.foundry/traces/ without a daemon connection
  • registry_commands.rs — registry CLI handlers. init is explicit offline-only recovery; list, show, add, remove, and edit use typed gRPC against foundryd by default and touch ~/.foundry/registry.json only when --offline is set

proto/foundry.proto

The gRPC contract between CLI and daemon:

  • Emit — fire an event with type, project, throttle, and optional JSON payload
  • Status — query active workflow states (all or by workflow ID)
  • Watch — server-side streaming of live events, filterable by project
  • Trace — retrieve the full event chain and block execution records for a completed workflow

Getting Started

This guide takes Foundry from installation to a registered project and a first evidence-reviewed task. For the underlying event model, continue to Concepts afterward.

Install

Homebrew

brew tap svetzal/tap
brew install foundry

From source

Source builds require Rust 1.85 or newer and the Protocol Buffers compiler.

brew install protobuf
git clone https://github.com/svetzal/foundry.git
cd foundry
./install.sh

install.sh builds and installs both binaries to ~/.cargo/bin. On macOS it also gives them stable ad-hoc signing identifiers. Use the script for subsequent source upgrades so macOS privacy grants remain attached to the binaries.

The installation contains:

  • foundryd — the long-running workflow daemon;
  • foundry — the CLI controller.

Verify the CLI:

foundry --version

Start the Daemon

Run the daemon in a terminal:

foundryd

By default it listens on 127.0.0.1:50051, loads project, campaign, and sentinel state from ~/.foundry/, and registers the production task-block library.

To expose the daemon on a trusted LAN, set FOUNDRYD_LISTEN_ADDR at startup:

FOUNDRYD_LISTEN_ADDR=0.0.0.0:50051 foundryd

The CLI resolves its daemon URL by precedence: explicit --addr, then FOUNDRY_DAEMON_ADDR, then http://127.0.0.1:50051. See Trusted-LAN Control Plane for the plaintext networking model and the Mac-to-mojility-ops-01 migration runbook.

One daemon per Foundry home

Only one foundryd can run against a Foundry home. When the daemon starts, it does these two steps before it does anything else:

  1. It binds its listen address.
  2. It takes an exclusive advisory lock on ~/.foundry/foundryd.lock and writes its pid into that file. Set FOUNDRYD_LOCK_PATH to use a different lock file.

The daemon holds the lock until it exits. The operating system releases the lock when the process stops, also after a crash, so you never delete a stale lock by hand.

The daemon does these two steps before it runs the start-up recovery sweeps. These sweeps settle work items that are still running as failed with the reason daemon restarted, and close interrupted maintenance cycles. The steps also come before the daemon seeds or merges a store, starts the sentinel scheduler, or emits an event. The sweeps assume that no other daemon is alive. If a second daemon ran them beside a live daemon, it would record live work as failed, and no RPC can undo that record.

If the address is in use or another process holds the lock, foundryd does not start. It prints one line that names the running daemon's pid when it is known, exits non-zero, and changes nothing on disk:

foundryd: cannot listen on 127.0.0.1:50051: address already in use (foundryd pid 1519730 is running); refusing to start, nothing was changed

foundryd takes no arguments. foundryd --version (-V) and foundryd --help (-h) print their output and exit 0. They do not take the lock, bind the address, read or write a store, or start anything, so you can run them safely beside a live daemon. foundryd rejects any other flag or argument with a usage error and a non-zero exit code.

The repository also includes service definitions for unattended operation:

Do not run foundryd as root. It needs the same repositories, agent credentials, GitHub credentials, and ~/.foundry state as the user who owns the workspaces.

Register a Project

Open another terminal and add a Git checkout:

foundry registry add \
  --name my-project \
  --path /absolute/path/to/my-project \
  --stack rust \
  --agent codex \
  --repo owner/my-project \
  --branch main \
  --iterate \
  --maintain \
  --push

The online command updates daemon-owned state through gRPC and persists it to ~/.foundry/registry.json. Confirm the result:

foundry registry show my-project

See The Project Registry for every field, action flag, skip reason, install strategy, and explicit offline recovery.

Establish Quality Gates

Ask Foundry to inspect the repository and write .hone-gates.json:

foundry gates --init my-project

Review that file in the project, then run the gates without changing code:

foundry validate my-project

Required gates are the mechanical safety boundary for task landing. A project should have at least one meaningful required gate before it accepts autonomous mutations.

Run One Task

Use a task for one concrete, immediately executable objective:

foundry task my-project \
  "Add a --quiet flag and prove it suppresses progress output"

Foundry creates an isolated worktree, runs the coding agent, executes the project gates, performs a separate skeptical review, and returns one typed verdict. Passing complete work lands on the registered branch. Safe, converging remainder work can also land when required gates passed; defects and blocked work are preserved without reaching trunk.

See Tasks and Campaigns for verdicts, preservation, landing rules, and broader multi-cycle missions.

Inspect the Workflow

Convenience commands stream block progress and render the completed trace. You can return to it later:

foundry history --project my-project
foundry trace <event-id>
foundry trace <event-id> --verbose

While work is running:

foundry status
foundry watch --project my-project

Traces survive daemon restarts under ~/.foundry/traces/.

Try a Dry Run

Throttle lets a workflow retain its observation and routing behaviour while simulating mutators:

foundry run --project my-project --throttle dry_run

Observers execute normally. Mutators emit their simulated success events without changing repositories or external systems. See Throttle Control for the execution rules.

Next Steps

  • Use foundry campaign when the mission is broader than one task and the next objective should be derived from current evidence.
  • Use foundry iterate for charter-driven quality improvement.
  • Use foundry scout for read-only intent-drift discovery.
  • Use foundry run for registered maintenance actions.
  • Inspect Sentinels for scheduled maintenance and digest workflows.
  • Read Workflow Formations to understand how events activate the shared task-block library.

Build Foundry Itself

Contributors can build the workspace directly:

cargo build --workspace

The repository's required gates are:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps
cargo deny check
mdbook build book

Emitting Events

From the CLI

foundry emit <event_type> --project <project> [--throttle <level>] [--payload <json>]

Arguments

ArgumentRequiredDescription
event_typeYesEvent type name (e.g., greet_requested, vulnerability_detected)
--projectYesTarget project name
--throttleNofull (default) or dry_run
--payloadNoJSON string with event-specific data
--addrNoDaemon address (overrides FOUNDRY_DAEMON_ADDR, else defaults to http://127.0.0.1:50051)

Examples

# Simple event, default throttle
foundry emit greet_requested --project hello

# With payload
foundry emit greet_requested --project hello --payload '{"name": "Stacey"}'

# Dry run — observe without mutating
foundry emit vulnerability_detected \
  --project my-tool \
  --throttle dry_run \
  --payload '{"cve": "CVE-2026-1234", "severity": "high"}'

# Dry run — no side effects at all
foundry emit maintenance_run_started \
  --project evt-cli \
  --throttle dry_run

What Happens When You Emit

  1. The CLI sends the event to foundryd via gRPC
  2. The engine creates an Event with a deterministic ID
  3. The engine finds all task blocks that sink on the event type
  4. For each matching block:
    • Check throttle: should this block execute? Should it emit?
    • Execute the block's work
    • Collect emitted events
  5. Feed emitted events back into step 3 (the chain continues)
  6. Return the initial event ID to the CLI

The chain continues until no more events are produced.

Inspecting a Completed Chain

After emitting an event, use foundry trace with the returned event ID to see the full propagation tree and what each block did:

$ foundry emit greet_requested --project hello --payload '{"name": "Stacey"}'
Event emitted: evt_47fcb603e1b18c8435b8cc3b

$ foundry trace evt_47fcb603e1b18c8435b8cc3b
greet_requested (evt_47fcb603e1b18c8435b8cc3b) project=hello
  → ComposeGreeting: ok — composed greeting for Stacey
    greeting_composed (evt_a1b2c3d4e5f6) project=hello
      → DeliverGreeting: ok — delivered greeting: Hello, Stacey!
        greeting_delivered (evt_f6e5d4c3b2a1) project=hello

Pass --verbose to also show trigger payloads, emitted payloads, raw shell output, and paths to any audit artefacts produced.

Persistent Trace Storage

Every completed event chain is written to disk under ~/.foundry/traces/YYYY-MM-DD/{event_id}.json (overridable via FOUNDRY_TRACES_DIR). Traces persist indefinitely across daemon restarts — there is no expiry TTL on disk.

To browse past traces, use foundry history:

# Show the last 7 days
foundry history

# Show a specific date
foundry history 2026-03-22

# Filter by project
foundry history --project my-tool

# Explicit local-file diagnostics when the daemon is stopped
foundry --offline history 2026-03-22 --project my-tool

By default foundry history is daemon-authoritative: it asks foundryd for the durable trace store and does not inspect the client-side FOUNDRY_TRACES_DIR. Use --offline deliberately when you want direct file diagnostics.

You can retrieve the full trace for any persisted event with foundry trace, even if the daemon has been restarted since the event was processed. If the trace is missing, the CLI reports No trace found for <event-id> (expired or unknown).

The Project Registry

The registry is Foundry's source of truth for which projects exist on your machine and what automation applies to each one. Without a populated registry, the daemon starts successfully but skips all project-specific work.

Where the Registry Lives

By default: ~/.foundry/registry.json

Override the path with the environment variable:

export FOUNDRY_REGISTRY_PATH=/path/to/my-registry.json

The daemon reads the registry on startup. If the file is missing it logs a warning and continues with an empty registry (no projects will be processed).

Single Source of Truth

The running daemon holds an in-memory copy of the registry in a read-write lock. Online foundry registry list, show, add, edit, and remove commands all go through the daemon's typed gRPC API so reads and writes observe the same daemon-owned state. Direct file access is reserved for explicit recovery with --offline. Without --offline, an unreachable daemon is an error and the client-side registry file is left untouched. Online registry commands also do not create FOUNDRY_REGISTRY_PATH when it does not already exist. The one exception is foundry registry init, which is always an explicit offline-only recovery command:

foundry registry add --name my-tool …             # daemon-authoritative
foundry --offline registry add --name my-tool …   # direct registry.json recovery

Registry Format (v2)

{
  "version": 2,
  "projects": [
    {
      "name": "my-tool",
      "path": "/Users/alice/projects/my-tool",
      "stack": "rust",
      "agent": "claude",
      "repo": "alice/my-tool",
      "branch": "main",
      "skip": false,
      "actions": {
        "iterate": true,
        "maintain": true,
        "push": true,
        "audit": true,
        "release": false
      },
      "install": {
        "command": "cargo install --path ."
      }
    }
  ]
}

Top-level fields

FieldTypeDescription
versionnumberMust be 2
projectsarrayList of ProjectEntry objects

ProjectEntry fields

FieldRequiredTypeDescription
nameYesstringUnique human-readable identifier used in events and logs
pathYesstringAbsolute path to the project on your local filesystem
stackYesstringTechnology stack — see Stack values
agentYesstringAI agent name used for automation (e.g. "claude")
repoYesstringGitHub repository slug (owner/repo) used by Watch Pipeline
branchYesstringDefault branch (e.g. "main") — validation checks out this branch
skipNostring or nullAbsent or null means not skipped; a non-empty string value is the skip reason. Accepts true (treated as "skipped") and false/null for backwards compatibility
actionsNoobjectWhich automation steps are enabled; all default to false
installNoobjectHow to reinstall locally after automation — see InstallConfig
installs_skillNobool or objectWhether to install the project's agent skill after the local install — see Skill install
notesNostringHuman-readable notes about the project (informational only)
timeout_secsNonumberTimeout in seconds for long-running commands. Defaults to 3600 (60 minutes) when absent
update_policyNostringHow far nightly maintenance may move dependencies: "patch", "minor" or "major". Absent means not set: maintenance behaves as "minor" and the maintenance summary flags the project. See Dependency update policy
audit_exceptionsNostring[] (default [])Legacy: advisory IDs accepted without an expiry. Kept for compatibility. Prefer the repository's .supply-chain-allow.json, which records a reason and an expiry in git.

Accepted-risk advisories

Record an accepted advisory in the repository's .supply-chain-allow.json (see Supply-chain scan). Every entry has a written reason and an expiry, and it lives in git. The post-push auditor, the scan_requested scan and the nightly supply-chain scan all read it. An active entry accepts the finding; a lapsed one resurfaces it. An entry matches when it names any identifier the scanner reports for the advisory (for example PYSEC, CVE or GHSA for the same pip-audit finding).

The older registry field audit_exceptions still works, with the same alias matching, but it has no expiry and no registry edit flag:

{
  "name": "my-service",
  "audit_exceptions": ["CVE-2026-45829"],
  "notes": "CVE-2026-45829: chromadb vector store path is not exposed; accepted risk 2026-06-27."
}

Matching is case-insensitive. Each accepted finding is logged at info level and named in the audit's result line (accepted: PYSEC-2026-311 (allowlist)), so suppression is never silent. Remove an entry once the upstream advisory is patched.

Stack values

The stack field tells Foundry which audit tool to use and how to run stack-specific commands.

ValueAudit toolNotes
"rust"cargo audit --jsonRequires cargo-audit to be installed
"typescript"npm audit --jsonExit code 1 = vulnerabilities found (not a tool failure)
"python".venv/bin/pip-audit --skip-editable --format=jsonRuns the project's own pip-audit from .venv; a project without it is reported as not scanned. --skip-editable leaves out the project's own install, which is not a dependency. No JSON report (for example after a PyPI error) is a scan failure, never a clean scan
"elixir"mix deps.audit --format=json (mix_audit) in each Mix projectFoundry audits every Mix project in the repository that declares mix_audit as a dependency: the root mix.exs, or subprojects such as apps/* and vendor/* when there is no root project. deps, _build, node_modules and hidden directories are never searched. A repository where no Mix project declares mix_audit is reported as not scanned. Exit 1 means findings; output without a JSON report, from any subproject, fails the whole scan and names the subproject. mix hex.audit (retired packages) is not part of the audit: it is not an advisory database and has no JSON output; run it as a gate
"cpp"—Placeholder for C++ projects; audit tooling not yet wired
"swift"osv-scanner scan source --format json --lockfile Package.resolvedRequires osv-scanner on the daemon's PATH and a committed Package.resolved. Exit code 1 = vulnerabilities found; 127/128 are tool failures. Findings carry the CVE alias when the advisory has one, otherwise the GHSA id
"kotlin"./gradlew dependencyCheckAggregate --rerun --no-parallel --no-daemonRuns the project's own OWASP Dependency-Check task, so its suppression file and settings apply. The project must configure formats to include JSON; Foundry reads build/reports/dependency-check/dependency-check-report.json (Dependency-Check 13 and later) or build/reports/dependency-check-report.json (earlier), whichever this run wrote most recently, and accepts a report only when this run wrote it. Gradle exits 1 both for a failBuildOnCVSS finding and for a broken build, so a missing or stale report is a scan failure. Findings follow the project's failBuildOnCVSS: a finding whose highest CVSS score is below it does not count (the project's build treats it as triage), suppressed findings never count, and without a numeric failBuildOnCVSS every live finding counts. --rerun stops Gradle skipping the task as UP-TO-DATE, which would leave the previous report in place. Timeout is 60 minutes because the NVD download is slow; set NVD_API_KEY in the daemon's environment

Supply-chain auto-fix support differs by stack: Rust, TypeScript, and Python have a full update and a targeted pin; Swift has the full update (swift package update) only; Kotlin, Elixir, and C++ report no_fixer. Dependency updates in the maintain workflow are agent-driven and do not depend on the stack.

ActionFlags

The actions object controls which steps run during a maintenance run. All flags default to false when the actions key is absent.

FlagWhat it enables
iterateRuns the iterate workflow (assess, plan, execute) after project validation passes
maintainRuns the maintain workflow — either after iterate completes (when both are enabled) or directly after validation (when only maintain is enabled)
pushPushes commits to the remote via git push after a commit is made
audit(Reserved for future use — currently informational only)
release(Reserved for future use — currently informational only)

InstallConfig

The install field configures how the project is reinstalled locally after automation completes. Exactly one variant is used per entry:

Command — runs an arbitrary shell command in the project directory:

"install": { "command": "cargo install --path ." }

Brew — installs via a Homebrew formula:

"install": { "brew": "my-formula" }

Homebrew installs run on macOS only. On any other host, Install Locally skips the step with "install via brew not supported on " and never starts brew; the maintenance summary shows the skip and its reason. Clear the config on that host with foundry registry edit <name> --clear-install, or give it a command install that works there.

An install command that fails, or cannot be started at all, is recorded as a failed local_install_completed event with the reason in details, and the maintenance summary lists it under Local Installs.

Skill install

The installs_skill field tells Foundry to run a skill-install command after a successful local install. A failed skill install is a warning. It does not fail the install.

ValueResult
absent or falseNo skill install
trueRun <binary> init --global --force. The binary is the brew formula for a brew install, and the project name for a command install. With no install config, Foundry does not guess a binary: it skips the skill install, and foundry registry show gives the reason
{ "command": "..." }Run this command exactly as written

Use the object form when the binary name is not the project name (for example, project hone-cli with binary hone). Set the field from the CLI with --installs-skill true, --installs-skill false, or --installs-skill "hone init --global --force".

Minimal Project Entry

Only the six required fields are needed. All optional fields default to safe values (no actions enabled, no install step, not skipped):

{
  "version": 2,
  "projects": [
    {
      "name": "minimal-project",
      "path": "/Users/alice/projects/minimal-project",
      "stack": "rust",
      "agent": "claude",
      "repo": "alice/minimal-project",
      "branch": "main"
    }
  ]
}

Excluding a Project Temporarily

Set skip to a string reason to pause automation without removing the entry:

{
  "name": "on-hold",
  "path": "/Users/alice/projects/on-hold",
  "stack": "typescript",
  "agent": "claude",
  "repo": "alice/on-hold",
  "branch": "main",
  "skip": "Waiting for CI to stabilise"
}

The value of skip is the human-readable reason displayed in foundry registry show. Absent, null, false, or an empty string all mean "not skipped". For backwards compatibility, true is treated as the reason string "skipped".

The Validate Project block silently acknowledges skipped projects (emits project_validation_completed with status: "skipped") so the engine trace remains complete.

Managing the Registry with the CLI

Rather than editing registry.json by hand, use the foundry registry subcommands. All commands respect FOUNDRY_REGISTRY_PATH for non-default locations.

foundry registry list, show, add, edit, and remove are daemon-authoritative in normal online use: they talk to foundryd over gRPC and operate on the daemon-owned in-memory registry state. Direct filesystem access is reserved for explicit offline recovery with --offline. Without --offline, an unreachable daemon is an error and leaves the client-side registry file untouched. If FOUNDRY_REGISTRY_PATH is absent, the online path still leaves it absent. If daemon persistence fails during an online add/edit/remove, the RPC returns a stable INTERNAL error and leaves the daemon-owned registry unchanged.

# Create an empty registry during offline recovery
foundry --offline registry init

# List all projects from the daemon-owned registry
foundry registry list

# Inspect one project from the daemon-owned registry
foundry registry show my-tool

# Add a new project through the daemon
foundry registry add \
  --name my-tool \
  --path /Users/alice/projects/my-tool \
  --stack rust \
  --agent claude \
  --repo alice/my-tool \
  --iterate --maintain --push \
  --install-command "cargo install --path ." \
  --notes "Main CLI toolchain"

# Offline recovery: mutate the file directly while the daemon is stopped
foundry --offline registry add \
  --name my-tool \
  --path /Users/alice/projects/my-tool \
  --stack rust \
  --agent claude \
  --repo alice/my-tool

# Edit an existing project
foundry registry edit my-tool --timeout-secs 3600

# Let maintenance take major upgrades (each one becomes its own task)
foundry registry edit my-tool --update-policy major

# Remove the install configuration
foundry registry edit my-tool --clear-install

# Run a skill install after the local install (true | false | "<command>")
foundry registry edit hone-cli --installs-skill "hone init --global --force"

# Skip a project temporarily
foundry registry edit my-tool --skip "Waiting for CI to stabilise"

# Clear a skip (resume automation)
foundry registry edit my-tool --skip ""

# Remove a project
foundry registry remove my-tool

See CLI Commands — foundry registry for the full option reference.

Multiple Projects

A single registry file can declare any number of projects. They are processed concurrently during a maintenance run (up to max_concurrent at a time, which defaults to the number of active projects unless the orchestrator is configured otherwise):

{
  "version": 2,
  "projects": [
    {
      "name": "api-server",
      "path": "/Users/alice/projects/api-server",
      "stack": "rust",
      "agent": "claude",
      "repo": "alice/api-server",
      "branch": "main",
      "actions": { "iterate": true, "maintain": true, "push": true }
    },
    {
      "name": "frontend",
      "path": "/Users/alice/projects/frontend",
      "stack": "typescript",
      "agent": "claude",
      "repo": "alice/frontend",
      "branch": "main",
      "actions": { "maintain": true, "push": true }
    }
  ]
}

Workflow Formations

A workflow formation is not a first-class object in Foundry. It is the logical result of which blocks sink on which events — a chain that emerges when you emit a particular entry event. All blocks live in one engine. The formation that fires depends entirely on the entry event and the payload values that flow through it.

This page documents the formations that exist today and explores how the current block library could be recombined for different purposes.

The Block Library at a Glance

Every block declares its sinks (what triggers it), its emits (what it produces), and its kind (Observer or Mutator). The engine does the rest.

Shared Infrastructure

These blocks appear in multiple formations:

BlockKindSinks OnEmits
Resolve GatesObservercharter_check_completed, maintenance_requested, validation_requestedgate_resolution_completed
Run Preflight GatesObservergate_resolution_completedpreflight_completed
Run Verify GatesObserverexecution_completedgate_verification_completed
Route Gate ResultObservergate_verification_completedproject_iteration_completed / project_maintenance_completed / retry_requested
Retry ExecutionMutatorretry_requestedexecution_completed
Summarize ResultObserverproject_iteration_completed, project_maintenance_completedsummarize_completed
Commit and PushMutatorremediation_completed, project_iteration_completed, project_maintenance_completedproject_changes_committed, project_changes_pushed

Iteration Blocks

BlockKindSinks OnEmits
Check CharterObserveriteration_requestedcharter_check_completed
Assess ProjectObserverpreflight_completedassessment_completed
Triage AssessmentObserverassessment_triagedtriage_completed
Create PlanObservertriage_completedplan_completed
Execute PlanMutatorplan_completedexecution_completed

Maintenance Blocks

BlockKindSinks OnEmits
Execute MaintainMutatorgate_resolution_completedexecution_completed

Vulnerability Blocks

BlockKindSinks OnEmits
Scan DependenciesObserverscan_requestedvulnerability_detected (per CVE)
Audit Release TagObservervulnerability_detected, project_changes_pushedrelease_tag_audited
Audit Main BranchObserverrelease_tag_auditedmain_branch_audited
Remediate VulnerabilityMutatormain_branch_auditedremediation_completed
Cut ReleaseMutatormain_branch_auditedrelease_completed
Watch PipelineMutatorrelease_completedrelease_pipeline_completed
Install LocallyMutatorproject_changes_pushed, release_pipeline_completedlocal_install_completed

Task/Prompt Workflow Blocks

BlockKindSinks OnEmits
Direct PromptObserverpreflight_completed (task)task_run_started, plan_completed
Execute PlanMutatorplan_completed (task)execution_completed
Run Verify GatesObserverexecution_completed (task)gate_verification_completed
Review TaskObservergate_verification_completed (task)task_reviewed
Finalize TaskMutatortask_reviewedtask_run_completed
Request Campaign AdvanceObservertask_run_completed (campaign)campaign_advance_requested

Campaign Blocks

BlockKindSinks OnEmits
Advance CampaignMutatorcampaign_advance_requestedcampaign_advance_completed, execution_requested, campaign_completed, campaign_escalated, or campaign_paused
Surface Campaign TerminalObservercampaign_completed, campaign_escalatedops_digest_started

Strategic Loop Blocks

BlockKindSinks OnEmits
Strategic AssessorObserveriteration_requested (strategic=true)strategic_assessment_completed
Strategic Loop ControllerObserverstrategic_assessment_completed, inner_iteration_completediteration_requested (loop) / project_iteration_completed (done)

Orchestration Blocks

BlockKindSinks OnEmits
Validate ProjectObservermaintenance_run_startedproject_validation_completed
Route Project WorkflowObserverproject_validation_completediteration_requested / maintenance_requested

Current Formations

These are the formations that fire today, depending on which entry event you emit.

The Full Nightly Run

Entry event: maintenance_run_started

This is the broadest formation. It validates the project, routes to iteration and/or maintenance based on the project's registry flags, and chains the two sub-workflows together when both are enabled.

flowchart TD
    A([maintenance_run_started]) --> B[[Validate Project]]
    B --> C[[Route Project Workflow]]
    C -->|iterate=true| D([iteration_requested])
    C -->|maintain=true| E([maintenance_requested])
    D --> F[Iterate Formation]
    F -->|success + maintain=true| E
    E --> G[Maintain Formation]

Iterate Formation

Entry event: iteration_requested

The full assessment-to-execution pipeline with gate verification and bounded retry.

flowchart TD
    A([iteration_requested]) --> B[[Check Charter]]
    B -->|passed| C[[Resolve Gates]]
    C --> D[[Run Preflight Gates]]
    D -->|passed| E[[Assess Project]]
    E --> F[[Triage Assessment]]
    F -->|accepted| G[[Create Plan]]
    G --> H[[Execute Plan]]
    H --> I[[Run Verify Gates]]
    I --> J[[Route Gate Result]]
    J -->|pass| K([project_iteration_completed])
    J -->|fail, retries left| L[[Retry Execution]]
    L --> I
    K --> M[[Summarize Result]]
    K --> N[[Commit and Push]]

Task Formation

Entry event: execution_requested

A task executes one user-provided objective in an isolated Git worktree. It skips assessment and triage: the description is the plan. Unlike iterate and maintain, it never enters the generic retry loop. A separate reviewer produces a typed verdict before Foundry decides whether the work can land.

flowchart TD
    A([execution_requested]) --> B[[Check Charter]]
    B -->|passed| C[[Resolve Gates]]
    C --> D[[Run Preflight Gates]]
    D -->|task preflight skipped| E[[Direct Prompt]]
    E --> F([task_run_started])
    E --> G[[Execute Plan in worktree]]
    G --> H[[Run Verify Gates]]
    H --> I[[Review Task]]
    I --> J[[Finalize Task]]
    J --> K([task_run_completed])
    K -->|campaign task| L[[Request Campaign Advance]]

Usage:

foundry task my-project "Pick the highest priority interaction from et and implement it."

The older direct event shape remains supported for compatibility by emitting execution_requested with workflow="prompt" or workflow="task" in the payload.

complete work lands when required gates pass. A converging remainder also lands when at least one required gate ran and every required gate passed; its reviewer gaps remain in the typed result. defect, blocked_on_decision, and runner_error never land. Non-landing work is committed and preserved on a remote task branch or in a Git bundle.

What lands from a task

Finalize Task commits every change the executor left in the worktree, except under .foundry/. In a task worktree, .foundry/ is Foundry scratch for execution evidence: proof, logs, screenshots, harness output. Nothing under it lands, on trunk or on the preserved branch, even when the executor staged it. The one exception is a deletion of a file that HEAD tracks under .foundry/. That deletion is committed, so a task can remove legacy content. A project that already tracks files under .foundry/ keeps them until a task or a person deletes them. An executor's edits to those files do not land. These rules apply whether or not the project's .gitignore ignores .foundry/.

Before Finalize Task removes the worktree, it copies every .foundry file that did not land to ~/.foundry/evidence/<trace-id>/<unique-attempt>/worktree/<repository path>. This includes new files, gitignored files, and the executor's version of edited tracked files. The task result's uncommitted_artifacts field names that worktree directory. The field is absent when there was nothing to copy. Then Finalize Task restores the edited tracked files to HEAD and removes the other files. If a copy fails, finalization stops before it removes or restores a file.

So a task puts anything the project must keep in a normal project path, for example docs/ or the test tree. Evidence under .foundry/ lives in the evidence store and is referenced from the work item, not from repository files. The execution prompt tells every task executor this. The reviewer treats a deliverable that leaves project material only under .foundry/, or that cites .foundry/ paths as repository content, as a defect.

Campaign Formation

Entry event: campaign_advance_requested

Campaign formation evaluates a durable mission against delivered trunk, campaign done evidence, neutral context artifacts, recent objectives, owner decisions, and any accumulated preserved work. It chooses exactly one of done, one next objective, or escalation.

flowchart TD
    A([campaign_advance_requested]) --> B[[Advance Campaign]]
    B -->|done| C([campaign_completed])
    B -->|advance| D([campaign_advance_completed])
    D --> E([execution_requested<br/>workflow=task])
    E --> F[Task Formation]
    F --> G([task_run_completed])
    G --> H[[Request Campaign Advance]]
    H --> A
    B -->|owner or budget| I([campaign_escalated])
    B -->|provider unavailable| J([campaign_paused])
    C --> K[[Surface Campaign Terminal]]
    I --> K

Each advance mints a trace and cycle span. Task-side events carry campaign_cycle, making concurrent cycles reconstructible from the event stream. A final budgeted task still receives completion evaluation; only a request for another task exceeds the budget. See Tasks and Campaigns for evidence, landing, preservation, pause, decision, and recovery rules.

Strategic Iterate Formation

Entry event: iteration_requested with strategic: true

A nested loop that wraps the iterate formation. The strategic assessor identifies multiple areas for improvement, then the loop controller enters the inner iterate formation for each area. After each inner iteration completes, an AI assessment decides whether to continue. Changes are committed per iteration.

flowchart TD
    A([iteration_requested<br/>strategic=true]) --> B[[Strategic Assessor]]
    B --> C([strategic_assessment_completed])
    C --> D[[Strategic Loop Controller]]
    D --> E([iteration_requested<br/>with loop_context])
    E --> F[Iterate Formation<br/>inner loop]
    F --> G([inner_iteration_completed])
    G --> H[[Commit and Push]]
    G --> D
    D -->|continue| E
    D -->|done| I([project_iteration_completed])
    I --> J[[Summarize Result]]
    I --> K[[Commit and Push]]

The inner iterate formation runs exactly as documented above, with one difference: Route Gate Result emits inner_iteration_completed instead of project_iteration_completed when loop_context is present in the payload. This allows the strategic loop controller to intercept the completion and decide whether to continue.

Terminal blocks (Summarize Result and Commit and Push) self-filter on loop_context — they skip intermediate completions and only fire on the final project_iteration_completed emitted by the strategic loop controller (which strips loop_context before emitting it).

Maintain Formation

Entry event: maintenance_requested

Dependency updates and general maintenance. Preflight gates are skipped (the codebase may be in a pre-maintenance state), but verification gates run after execution.

flowchart TD
    A([maintenance_requested]) --> B[[Resolve Gates]]
    B --> C[[Run Preflight Gates]]
    C -->|skipped| D[[Execute Maintain]]
    D --> E[[Run Verify Gates]]
    E --> F[[Route Gate Result]]
    F -->|pass| G([project_maintenance_completed])
    F -->|fail, retries left| H[[Retry Execution]]
    H --> E
    G --> I[[Summarize Result]]
    G --> J[[Commit and Push]]

Vulnerability Remediation Formation

Entry event: vulnerability_detected

Two paths through the same blocks, governed by the dirty payload flag.

flowchart TD
    A([vulnerability_detected]) --> B[[Audit Release Tag]]
    B --> C[[Audit Main Branch]]
    C --> D{dirty?}
    D -->|true| E[[Remediate Vulnerability]]
    E --> F[[Commit and Push]]
    F --> G[[Install Locally]]
    D -->|false| H[[Cut Release]]
    H --> I[[Watch Pipeline]]
    I --> J[[Install Locally]]

Scan Formation

Entry event: scan_requested

A broader entry point that discovers vulnerabilities and feeds them into the remediation formation. Scan Dependencies emits one vulnerability_detected event per CVE found, so a single scan can trigger multiple parallel remediation chains.

flowchart TD
    A([scan_requested]) --> B[[Scan Dependencies]]
    B -->|per CVE| C([vulnerability_detected])
    C --> D[Remediation Formation]

Validation Formation

Entry event: validation_requested

A read-only health check. No Mutator blocks fire — it just resolves gates, runs them, and reports the results.

flowchart TD
    A([validation_requested]) --> B[[Resolve Gates]]
    B --> C[[Run Preflight Gates]]
    C --> D[[Route Validation Result]]
    D --> E([validation_completed])

Possible Formations

The block library already supports formations that aren't part of the nightly run. Because the engine routes by event type and blocks self-filter on payload, you can trigger these directly.

Iterate Without Maintenance

Emit iteration_requested with actions.maintain=false. The iterate formation runs, and on success Route Gate Result emits project_iteration_completed without chaining to maintenance_requested.

foundry emit iteration_requested my-project \
  --payload '{"actions":{"iterate":true,"maintain":false}}'

This is useful when you want to improve code quality without touching dependencies — a focused structural improvement pass.

Maintenance Without Iteration

Emit maintenance_requested directly. The maintain formation runs on its own, skipping assessment, triage, and planning entirely.

foundry emit maintenance_requested my-project

This is a pure dependency update pass — update libraries, run gates, commit if they pass.

Scan Without Remediation

Emit scan_requested with dry_run throttle. Scan Dependencies and the audit blocks run (they are Observers), but Remediate Vulnerability and Cut Release (Mutators) are simulated rather than executed.

foundry emit scan_requested my-project --throttle dry_run

This tells you what vulnerabilities exist and whether main is dirty, without making any changes.

Remediation Without Scanning

Emit vulnerability_detected directly with the CVE details. This skips the scan entirely and jumps straight into the audit-and-fix chain.

foundry emit vulnerability_detected my-project \
  --payload '{"cve":"CVE-2026-1234","vulnerable":true,"dirty":true}'

This is how you would handle a vulnerability reported through a channel other than Foundry's scanner — a security advisory, a colleague's finding, or a CI notification.

Post-Push Audit

The Audit Release Tag block also sinks on project_changes_pushed. This means that after an iterate or maintain formation commits and pushes, the release tag is automatically re-audited. If the push introduced a vulnerability (or resolved one), the audit chain picks it up without a separate scan.

Strategic Iteration

Emit iteration_requested with strategic: true to enter the nested loop. The strategic assessor analyses the codebase holistically and the loop controller runs multiple inner iterate cycles until the AI determines the codebase has plateaued.

foundry emit iteration_requested my-project \
  --payload '{"strategic":true,"max_iterations":5}'

The max_iterations field caps the loop to prevent runaway iterations. Each inner cycle commits its changes independently.

Gate Check Only

Emit validation_requested to run all gates without modifying anything. This is the lightest formation — it tells you whether the project is healthy right now.

foundry emit validation_requested my-project

Designing New Formations

The current block library is a toolkit. The formations above are the ones we use today, but the same blocks can participate in formations we haven't built yet. A few principles guide what's possible:

  1. Entry events define scope. The deeper into a chain you emit, the narrower the formation. Emitting maintenance_run_started runs everything; emitting plan_completed skips assessment entirely and just executes a plan you provide.

  2. Payload values steer routing. Blocks self-filter on payload fields like dirty, accepted, workflow, and actions. Changing a payload value changes which blocks fire without changing any code.

  3. Throttle controls depth. The same formation behaves differently under full and dry_run. This gives you two versions of every formation for free.

  4. Shared blocks multiply formations. Commit and Push sinks on three different event types. Install Locally sinks on two. Every block that participates in multiple formations is a junction point where chains can converge or diverge.

Vulnerability Remediation Workflow

The vulnerability remediation workflow is Foundry's primary use case. It replaces a linear shell script pipeline with an event-driven chain of task blocks that branches based on the state of the codebase.

The Two Paths

When a vulnerability is detected, the chain branches based on whether the main branch still contains the vulnerability:

flowchart TD
    A[vulnerability_detected] --> B([Audit Release Tag])
    B --> C[release_tag_audited]
    C --> D([Audit Main Branch])
    D --> E[main_branch_audited]
    E --> F{dirty?}
    F -->|"dirty=true (vulnerability present on main)"| G([Remediate Vulnerability])
    G --> H[remediation_completed]
    H --> I([Commit and Push])
    I --> J[project_changes_committed]
    I --> K[project_changes_pushed]
    K --> L([Install Locally])
    L --> M[local_install_completed]
    F -->|"dirty=false (main already fixed, no release cut yet)"| N([Cut Release])
    N --> O[release_completed]
    O --> P([Watch Pipeline])
    P --> Q[release_pipeline_completed]
    Q --> R([Install Locally])
    R --> S[local_install_completed]

Dirty path — the vulnerability exists on main. Foundry remediates it (e.g., dependency update), commits, pushes, and reinstalls locally.

Clean path — main is already fixed (perhaps by a developer), but no release has been cut. Foundry tags a patch release, watches the CI pipeline, and reinstalls locally.

If the release tag is not vulnerable at all, the chain stops after release_tag_audited — Audit Main Branch self-filters and emits nothing.

The remediation agent is checked, not trusted

An agent asked to fix a CVE is the agent most likely to make it go away some other way. So Remediate Vulnerability (and Remediate Pipeline, whose CI failure can be an audit job) runs its agent between the same checks as the maintain agent:

  1. Before the agent, Foundry records HEAD and origin/<branch>.
  2. The agent runs with pushing to origin disabled, and its prompt says to commit locally and never suppress an advisory.
  3. After the agent, Foundry checks whether origin/<branch> moved to the agent's commits (a direct push), and diffs the checkout against the recorded start for new suppressions: an allowlist entry in .supply-chain-allow.json, ignore_advisories, a Dependency-Check <suppress>, pip-audit --ignore-vuln, cargo audit --ignore, an advisory in deny.toml or audit.toml, or an npm override pinned to a vulnerable version. New files the agent never added to Git count too. The ignore flags count only in files that run commands, such as .hone-gates.json, CI workflows and scripts. A log or a document that quotes a flag does not count. See Suppressions are not fixes.

If either check trips, remediation_completed carries success: false and a needs_review reason that starts with "needs review: ". Commit and Push keeps a failed run's commits local (push_failure: "run_failed"), so project_changes_pushed is never emitted, and the post-push audit, the release and the local install that hang off it never run. There is no retry.

A suppression commit left local this way is still checked later: the next guarded run diffs from the commit HEAD shared with origin/<branch>, so a stranded suppression fails that run too instead of being pushed with it. Resolve it by hand: upgrade and drop the commit, or, if accepting the advisory is the right call, commit the allowlist entry yourself.

Self-Filtering

The engine routes events by type only — it cannot inspect payloads. When both Remediate Vulnerability and Cut Release sink on main_branch_audited, both blocks receive every main_branch_audited event. Each block checks the dirty flag in the payload and returns an empty result if the condition doesn't match. This ensures only one path fires.

Running the Workflow

Full run (default)

All blocks execute and emit. The complete chain runs to local_install_completed:

foundry emit vulnerability_detected \
  --project my-tool \
  --payload '{"cve": "CVE-2026-1234", "vulnerable": true, "dirty": true}'

Then inspect the trace:

foundry trace <event_id>
vulnerability_detected (evt_...) project=my-tool
  → Audit Release Tag: ok — Release tag audited: CVE-2026-1234 vulnerable=true
    release_tag_audited (evt_...) project=my-tool
      → Audit Main Branch: ok — Main branch audited: CVE-2026-1234 dirty=true
        main_branch_audited (evt_...) project=my-tool
          → Remediate Vulnerability: ok — Remediated CVE-2026-1234
            remediation_completed (evt_...) project=my-tool
              → Commit and Push: ok — Committed and pushed fix for CVE-2026-1234
                project_changes_committed (evt_...) project=my-tool
                project_changes_pushed (evt_...) project=my-tool
                  → Install Locally: ok — Installed locally
                    local_install_completed (evt_...) project=my-tool
          → Cut Release: ok — Skipped: main branch is dirty

Notice that Cut Release still appears in the trace — it received the event but self-filtered and returned an empty result.

Dry run

Observers run and emit; mutators are simulated rather than executed:

foundry emit vulnerability_detected \
  --project my-tool \
  --throttle dry_run \
  --payload '{"cve": "CVE-2026-1234", "vulnerable": true, "dirty": true}'

This tells you what would happen without actually remediating, committing, or releasing. The audit blocks produce their findings, and the Mutators emit simulated dry_run: true events so the rest of the chain stays visible.

Dry run

Only observers execute. Mutators are skipped entirely:

foundry emit vulnerability_detected \
  --project my-tool \
  --throttle dry_run \
  --payload '{"cve": "CVE-2026-1234", "vulnerable": true, "dirty": true}'

The chain produces vulnerability_detected → release_tag_audited → main_branch_audited and stops. No remediation, no commits, no releases.

Clean Path Example

When the main branch is already fixed but no release has been cut:

foundry emit vulnerability_detected \
  --project my-tool \
  --payload '{"cve": "CVE-2026-5678", "vulnerable": true, "dirty": false}'

The trace shows the release path instead:

vulnerability_detected (evt_...) project=my-tool
  → Audit Release Tag: ok — ...
    release_tag_audited (evt_...) project=my-tool
      → Audit Main Branch: ok — Main branch audited: CVE-2026-5678 dirty=false
        main_branch_audited (evt_...) project=my-tool
          → Remediate Vulnerability: ok — Skipped: main branch is clean
          → Cut Release: ok — Cut patch release for CVE-2026-5678
            release_completed (evt_...) project=my-tool
              → Watch Pipeline: ok — Release pipeline completed successfully
                release_pipeline_completed (evt_...) project=my-tool
                  → Install Locally: ok — Installed locally
                    local_install_completed (evt_...) project=my-tool

Not Vulnerable

If the release tag has no vulnerability, the chain stops early:

foundry emit vulnerability_detected \
  --project my-tool \
  --payload '{"cve": "CVE-2026-9999", "vulnerable": false}'

Only two events: vulnerability_detected → release_tag_audited. The Audit Main Branch block sees vulnerable=false and emits nothing.

Payload Fields

FieldUsed ByValuesDefault
cveAll blocksCVE identifier string"unknown"
vulnerableAudit Release Tag, Audit Main Branchtrue / falsetrue
dirtyAudit Main Branch, Remediate, Cut Releasetrue / falsetrue

Real Implementations

All blocks in the vulnerability remediation workflow perform real work:

  • Audit Release Tag — shells out to the stack-appropriate tool (cargo audit --json, npm audit --json, pip-audit --format=json, mix deps.audit, osv-scanner, or ./gradlew dependencyCheckAggregate) via the ScannerGateway abstraction
  • Audit Main Branch — checks out the main branch and runs the same scan
  • Remediate Vulnerability — invokes the configured AI agent (e.g. via the claude CLI) to apply a fix, then runs the direct-push and suppression checks described above
  • Commit and Push — runs git add, git commit, and optionally git push
  • Cut Release — creates and pushes a git tag using the AI agent
  • Watch Pipeline — polls the GitHub Actions API until the release workflow completes or times out
  • Install Locally — runs the project's configured install command (e.g. cargo install --path .) or Homebrew formula after a successful release

Iteration Workflow

The iteration workflow is Foundry's code quality improvement engine. It identifies the most-violated engineering principle in a project, creates a targeted fix plan, executes it, and verifies the result against quality gates — with automatic retries on failure.

The Chain

flowchart TD
    A([iteration_requested]) --> B[[Resolve Gates]]
    B --> C([gate_resolution_completed])
    C --> D[[Run Preflight Gates]]
    D --> E([preflight_completed])
    E --> F[[Check Charter]]
    F --> G([charter_validated])
    G --> H[[Assess Project]]
    H --> I([project_assessed])
    I --> J[[Triage Assessment]]
    J --> K([assessment_triaged])
    K --> L[[Create Plan]]
    L --> M([plan_created])
    M --> N[[Execute Plan]]
    N --> O([execution_completed])
    O --> P[[Run Verify Gates]]
    P --> Q([gate_verification_completed])
    Q --> R[[Route Gate Result]]
    R -->|pass| S([project_iteration_completed])
    R -->|fail, retries left| T[[Retry Execution]]
    T --> O
    R -->|fail, retries exhausted| U([project_iteration_completed — failure])
    S -->|maintain=true| V([maintenance_requested])

Phase by Phase

1. Gate Resolution

Resolve Gates reads .hone-gates.json from the project directory and emits the gate definitions with workflow: "iterate". The actions object from the trigger event is forwarded through the chain so that gate routing can chain into the maintenance workflow afterwards.

Each gate has a name, a command, a required flag, an optional timeout (seconds; 10 minutes when absent; both multiplied by the daemon's FOUNDRY_GATE_TIMEOUT_SCALE), and an optional fix_command — an in-place command that mechanically repairs the gate's failure. A gate looks like:

{
  "name": "format",
  "command": "cargo fmt --check",
  "required": true,
  "fix_command": "cargo fmt"
}

Only declare a fix_command for gates whose failures are safely auto-fixable — formatters and lint autofixers (cargo fmt, ruff format, biome check --write, a clang-format target). Never for tests, typecheck, build, or security gates, where a "fix" would mask a real problem.

2. Preflight Gates

Run Preflight Gates executes every gate against the unmodified codebase.

When a gate fails and declares a fix_command, the runner runs the fix once and re-checks; a passing re-check resolves the gate (recorded with fix_applied: true on the result), and the in-place changes left in the working tree are committed by the downstream Commit and Push step. This is what lets formatter/lint gates self-heal: without it, a required formatting gate would abort the run before the maintain step that reformats the code — a deadlock where the failing gate blocks its own fix.

If a required gate fails and cannot self-heal (no fix_command, or the fix didn't resolve it), required_passed is false and the chain stops — Assess Project self-filters on preflight success.

This establishes a baseline: the iterate workflow only attempts improvements when the project is already in a passing state (or can be brought to one mechanically).

Gate output and full logs

Each gate result carries an output field with a bounded view of what the command printed. The view holds at most 200 lines per command run. Stdout and stderr are tailed separately and share that budget. When both streams are longer than 100 lines, each keeps its last 100 lines. When one stream needs fewer lines, the other stream gets the lines it does not use. A --- stderr --- line separates the two tails when both streams printed something. So a tool that writes thousands of compile lines to stderr cannot push the failing test name on stdout out of the view. A failed self-heal attempt adds the fix or re-check view after an --- autofix attempted, gate still failing --- or --- autofix command failed --- line.

When a gate fails, Foundry also writes the complete stdout and stderr of each command the gate ran. This includes the fix and re-check runs when self-heal was attempted. The files go under ~/.foundry/evidence/gate-logs/, in one directory per gate run. The directory name starts with a UTC timestamp, so the names sort by time (for example 20261016T221500Z-<uuid>/). The files are named for the gate and the run:

coverage.stdout.log
coverage.stderr.log
format.fix.stdout.log
format.recheck.stderr.log

The result's logs field lists the absolute paths. Prompts and reports that show a failed gate to an agent or to you include these paths, and foundry validate prints them under the failed gate. A passing gate writes no files and has no logs field. A stream larger than 8 MiB keeps its last 8 MiB, after a one-line note that gives the original size.

Foundry prunes this directory after each write. It deletes run directories older than 14 days. Then, if the directory is still larger than 1 GiB, it deletes the oldest run directories until the total is 1 GiB or less. The prune only removes directories inside evidence/gate-logs/ and never touches other evidence. If a log cannot be written, Foundry logs a warning and the gate result is unchanged, with an empty logs field.

3. Charter Validation

Check Charter verifies that the project has intent documentation (e.g. CHARTER.md). If the charter is missing, passed is false and the chain stops at this point — there is no assessment without context.

4. Assessment

Assess Project invokes an AI agent with reasoning capability and read-only access to analyse the codebase. It identifies the single most-violated engineering principle, returning a severity score (1–10), the principle name, category, and a detailed assessment.

A second quick agent call generates a kebab-case audit filename for traceability.

5. Triage

Triage Assessment uses a quick agent call to decide whether the finding is worth acting on. It rejects issues with severity below 4 or findings that amount to busy-work rather than substantive improvement. On agent failure, triage defaults to accepted — it is better to attempt a fix than to silently skip.

If triage rejects the finding, Create Plan self-filters on accepted=false and the chain stops cleanly.

6. Plan Creation

Create Plan invokes an AI agent with reasoning capability and read-only access to produce a step-by-step correction plan. The plan is concrete — it names exact files and functions — minimal, and testable. Gate definitions are forwarded so the execution phase knows what must pass.

The plan agent is also asked to emit a machine-readable JSON block at the end of its response declaring whether correction is actually needed:

{ "correctionNeeded": true, "reason": "<one sentence>" }

Setting correctionNeeded to false signals that the agent examined the codebase and found the original assessment inaccurate — the codebase already satisfies the principle and no changes are warranted. This becomes a legitimate no-op (see §7). The flag defaults to true on any parse failure (fail-closed).

7. Execution

Execute Plan is the only Mutator in the assessment-to-execution pipeline. It invokes an AI agent with coding capability and full filesystem access. The agent receives the plan plus gate context (the gates it must satisfy) and applies the changes.

Under dry_run throttle, this block returns a simulated success without modifying any files.

Legitimate no-ops — When correctionNeeded is false in the PlanCompleted payload, a clean working tree after execution is treated as a success rather than a flake. The downstream gate verification runs normally and, assuming gates pass, the iteration completes successfully. This mirrors hone's busy-work-containment semantics: an agent that correctly concludes "nothing to do" should not be penalised with retries.

Silent no-op guard — When correctionNeeded is true (the default), an agent that exits successfully but makes no meaningful file changes is overridden to success: false, triggering the retry loop. This prevents agents that silently skip their work from consuming a passing iteration slot.

8. Gate Verification

Run Verify Gates re-reads .hone-gates.json from disk (a fresh read, not cached from phase 1) and runs every gate against the modified codebase. The retry_count from the payload tracks which attempt this is.

9. Routing and Retry

Route Gate Result makes the terminal decision:

ConditionAction
All required gates passEmit project_iteration_completed (success)
Required gates fail, retry count < 3Emit retry_requested with failure context
Required gates fail, retry count ≥ 3Emit project_iteration_completed (failure)

When retrying, Retry Execution receives the failure context (which gates failed and their output) and invokes a coding agent to fix only the issues causing those failures. The result loops back to Run Verify Gates for another round.

The maximum is 3 retries (4 total attempts: 1 initial + 3 retries).

10. Chaining to Maintenance

On success, if actions.maintain=true was forwarded through the chain, Route Gate Result also emits maintenance_requested. This triggers the Maintenance Workflow without re-querying the project configuration.

Self-Filtering

Several blocks use self-filtering to stop the chain gracefully without errors:

  • Assess Project — skips when workflow != "iterate" or preflight failed
  • Check Charter — skips when workflow != "iterate"
  • Create Plan — skips when accepted != true (triage rejected)
  • Summarize Result — skips when success != true

The engine routes by event type only and cannot inspect payloads. Each block checks the relevant payload fields and returns an empty result when the condition does not match.

Running the Workflow

Direct trigger

To run the iterate workflow for a single project:

foundry emit iteration_requested my-project \
  --payload '{"actions":{"iterate":true,"maintain":false}}'

With maintenance chaining

To iterate and then run maintenance:

foundry emit iteration_requested my-project \
  --payload '{"actions":{"iterate":true,"maintain":true}}'

Via the maintenance run

The full maintenance lifecycle triggers iteration automatically when iterate=true in the project's registry entry:

foundry emit maintenance_run_started my-project

Dry run

Only observers execute. Mutators are skipped entirely:

foundry emit iteration_requested my-project \
  --throttle dry_run \
  --payload '{"actions":{"iterate":true,"maintain":false}}'

Agent Capabilities

Foundry delegates AI work to the Claude CLI, mapping each block's capability hint to a concrete model:

CapabilityModelUse Case
Reasoningclaude-opus-5Deep analysis and planning
Codingclaude-sonnet-5Code generation and modification
Quickclaude-haiku-4-5-20251001Fast, lightweight decisions

Access levels control which CLI tools the agent may use:

AccessAllowed Tools
Read-onlyRead, Glob, Grep, WebFetch, WebSearch
FullAll tools (no restrictions)

Each phase in the iterate workflow maps to a specific capability and access level:

PhaseCapabilityModelAccessPurpose
AssessmentReasoningOpusRead-onlyDeep analysis of codebase
Audit namingQuickHaikuRead-onlyGenerate kebab-case filename
TriageQuickHaikuRead-onlyAccept/reject decision
Plan creationReasoningOpusRead-onlyStep-by-step correction plan
ExecutionCodingSonnetFullApply code changes
RetryCodingSonnetFullFix gate failures
SummarisationQuickHaikuRead-onlyGenerate headline and summary

Agent profile (~/.foundry/agents.json)

Blocks ask for a model tier (deep, balanced, fast) and a reasoning effort (minimal, low, medium, high, max). They never name a concrete model. ~/.foundry/agents.json maps these values to concrete values for each provider. Set FOUNDRY_AGENT_CONFIG_PATH to use a different file.

  • models maps a tier to the model id that the provider CLI gets.
  • effort maps an effort level to the provider CLI's reasoning-effort token.
  • effort_caps (optional) sets the maximum effort for a tier.

The daemon writes the full default seed on first start. On each restart, it adds the provider, tier, and effort keys that are missing. It does not change your model or token edits.

Per-tier effort caps

Several blocks ask for high effort on the balanced tier. Use a cap to limit effort for each model tier, not only for each requested level:

{
  "version": 1,
  "providers": {
    "codex": {
      "models": { "deep": "gpt-6-astra", "balanced": "gpt-5.6-sol" },
      "effort_caps": { "deep": "high", "balanced": "medium" }
    },
    "claude": {
      "effort_caps": { "deep": "high" }
    }
  }
}

Foundry applies the cap before it maps the effort to a CLI token:

  • If the requested effort is more than the tier's cap, Foundry uses the cap. With the example above, a balanced request for high runs at medium.
  • If the requested effort is less than the cap, Foundry does not change it.
  • If a tier has no cap, Foundry does not change the requested effort.

The default seed has no caps. The seed merge does not add, remove, or change caps. Caps are your policy.

The AgentSessionStarted event records both values. effort is the level the block requested. effective_effort is the level the session used after the cap. Compare the two fields to see where a cap applied.

All agent invocations use the --print flag (non-interactive output) and --dangerously-skip-permissions (unattended execution). Blocks that reference a project agent file pass it via --agent. Timeouts are set per-project from the registry entry, except Triage and Summarisation which use a fixed 120-second timeout for their lightweight Quick calls.

Payload Fields

FieldCarried ByPurpose
actionsAll events{iterate, maintain} flags for workflow routing
gatesGates resolved through plan creationGate definitions for execution context
audit_nameAssessment through plan creationKebab-case audit filename for traceability
severityAssessment through plan creationViolation severity (1–10)
principleAssessment through plan creationName of the violated principle
categoryAssessment through plan creationCategory of the violation
assessmentAssessment through plan creationDetailed assessment text
retry_countExecution through gate routingCurrent retry attempt (0-based)
failure_contextRetry requestedGate output from failed verification
correction_neededPlan completed through executionfalse when plan agent concluded no changes are warranted; defaults to true
correction_reasonPlan completed through executionOne-sentence explanation when correction_needed is false

Strategic Iteration

Strategic iteration wraps the standard iterate workflow in an outer loop. Instead of finding and fixing one issue, it assesses the project holistically, identifies multiple areas for improvement, and works through them one at a time — committing changes after each cycle and re-evaluating whether further work is warranted.

This is a two-layer nested loop:

  1. Outer loop (strategic) — AI identifies areas to improve, decides when to stop
  2. Inner loop (operational) — the existing iterate formation: assess, triage, plan, execute, verify gates, retry

Prerequisites

Before running a strategic iteration you need the same setup as a standard iterate workflow:

  • A project registered in the Foundry registry (foundry registry add)
  • A CHARTER.md (or equivalent intent documentation) in the project root
  • A .hone-gates.json defining quality gates
  • The foundryd daemon running

If you can successfully run foundry iterate my-project, you are ready for strategic iteration.

Step 1: Start the Daemon

foundryd

Verify the strategic blocks are registered in the startup logs:

INFO foundryd::engine: registered task block block="Strategic Assessor" sinks=[IterationRequested]
INFO foundryd::engine: registered task block block="Strategic Loop Controller" sinks=[StrategicAssessmentCompleted, InnerIterationCompleted]

Step 2: Dry Run First

Before committing to a full strategic run, use dry_run throttle to see the shape of the event chain without modifying any files:

foundry emit iteration_requested my-project \
  --throttle dry_run \
  --payload '{"strategic": true, "max_iterations": 3}'

Watch the event stream in another terminal:

foundry watch --project my-project

You should see events flowing through the strategic assessment, into the inner iterate chain, and back to the strategic controller. Mutator blocks (Execute Plan, Commit and Push) emit simulated success events under dry_run.

Step 3: Run a Strategic Iteration

foundry emit iteration_requested my-project \
  --payload '{"strategic": true, "max_iterations": 5}'

What happens

flowchart TD
    A([iteration_requested<br/>strategic=true]) --> B[[Strategic Assessor]]
    B -->|AI holistic assessment| C([strategic_assessment_completed])
    C --> D[[Strategic Loop Controller]]
    D -->|pick first area| E([iteration_requested<br/>with loop_context])
    E --> F[Inner Iterate Formation]
    F --> G([inner_iteration_completed])
    G --> H[[Commit and Push<br/>per-iteration commit]]
    G --> D
    D -->|AI: continue?| I{More work?}
    I -->|yes| E
    I -->|no| J([project_iteration_completed])
    J --> K[[Summarize Result]]
    J --> L[[Commit and Push]]
  1. Strategic Assessor analyses the codebase and produces a ranked list of improvement areas (e.g., "test coverage in auth module", "inconsistent error handling in API layer").

  2. Strategic Loop Controller picks the top area and enters the inner iterate formation by emitting iteration_requested with a loop_context payload.

  3. The inner iterate formation runs exactly as described in the Iteration Workflow guide — charter check, gate resolution, preflight, assessment, triage, plan, execute, verify, retry. The only difference is that Route Gate Result emits inner_iteration_completed instead of project_iteration_completed.

  4. Commit and Push fires on inner_iteration_completed, creating a per-iteration commit so each improvement is isolated in git history.

  5. Strategic Loop Controller receives inner_iteration_completed and asks an AI agent: "Is there more meaningful work to do, or has the codebase reached a plateau?" Based on the response and the iteration cap, it either re-enters the inner loop or completes.

  6. When the loop completes, the controller emits project_iteration_completed without loop_context, which triggers Summarize Result and a final Commit and Push.

Step 4: Monitor Progress

Watch the live event stream:

foundry watch --project my-project

Key events to look for:

EventMeaning
strategic_assessment_completedAI identified areas; check areas in payload
iteration_requested (with loop_context)Inner loop entered for an area
inner_iteration_completedOne cycle done; check success
project_changes_committedPer-iteration commit created
project_iteration_completedStrategic loop finished
summarize_completedFinal summary generated

Step 5: Review the Trace

After completion, inspect the full event chain:

foundry trace <event-id>

Use the event ID from the original iteration_requested event. The trace shows every block that executed, what it emitted, and how long it took — across all iterations of the loop.

Payload Reference

Entry payload

FieldTypeDefaultPurpose
strategicboolfalseMust be true to activate strategic mode
max_iterationsinteger5Maximum number of inner loop cycles
strategic_promptstring(built-in)Custom directive for the assessment and continue checks (see below)
actionsobject—Optional {maintain: true} to chain maintenance after the loop completes

loop_context (internal)

The loop_context object is managed by the strategic blocks. You do not set it manually — it is created by the Strategic Assessor and threaded through the chain automatically.

{
  "loop_context": {
    "strategic": {
      "iteration": 2,
      "max": 5,
      "total_areas": 3,
      "current_area": {"area": "test coverage", "severity": 8}
    }
  }
}

All blocks in the iterate chain forward loop_context transparently. Terminal blocks (Summarize Result, Commit and Push) skip when they see it on a completion event — they only fire on the final completion that the Strategic Loop Controller emits without loop_context.

Controlling the Loop

Iteration cap

The max_iterations field is the hard stop. Even if the AI wants to continue, the loop completes when this count is reached. Start with a low number (2–3) and increase once you are comfortable with the results.

Custom prompt

By default the Strategic Assessor analyses broad code quality and the continue check looks for remaining violations. You can replace both directives with a strategic_prompt to steer the loop toward a specific goal:

foundry emit iteration_requested my-project \
  --payload '{
    "strategic": true,
    "max_iterations": 5,
    "strategic_prompt": "Pick the highest priority interaction from et and implement it."
  }'

The prompt you supply is used in two places:

  1. Assessment — the Strategic Assessor wraps your prompt in a request to produce the areas JSON. The AI reads the codebase with your directive as context and returns a ranked list of work items.
  2. Continue check — after each inner iteration, the Strategic Loop Controller asks the AI the same prompt again to decide whether the loop should continue. The AI responds with {"continue": true/false}.

The prompt is stored inside loop_context.strategic.prompt so it survives the full inner chain and is available on every re-entry.

Some examples:

GoalPrompt
Product work from a backlog"Pick the highest priority interaction from et and implement it."
Focused refactoring"Find the module with the worst test coverage and add tests."
Dependency modernisation"Identify deprecated dependencies and upgrade them one at a time."
Security hardening"Find the most critical OWASP Top 10 risk in this codebase and fix it."

AI continue assessment

After each inner iteration, the Strategic Loop Controller asks an AI agent whether further improvement is warranted. When no strategic_prompt is set, the agent considers:

  • Are there remaining violations with severity >= 4?
  • Would another iteration produce meaningful improvement?
  • Has the codebase reached a diminishing-returns plateau?

When strategic_prompt is set, your prompt replaces this default reasoning — the AI uses your directive to decide whether more work remains.

If the inner iteration failed (gates did not pass after retries), the loop stops immediately rather than attempting further iterations on a broken codebase.

Throttle interaction

ThrottleStrategic AssessorInner IterateCommits
fullRunsRuns (modifies files)Real commits
dry_runRunsSimulated successNo commits

Chaining with Maintenance

To run a strategic iteration and then chain into maintenance:

foundry emit iteration_requested my-project \
  --payload '{"strategic": true, "max_iterations": 3, "actions": {"maintain": true}}'

Maintenance chaining is suppressed during the inner loop (to prevent maintenance from running after every cycle). It only fires after the strategic loop completes — the final project_iteration_completed event triggers Route Gate Result's normal chaining logic.

Example: Full Session

# Terminal 1: start daemon
foundryd

# Terminal 2: watch events
foundry watch --project my-api

# Terminal 3: trigger strategic iteration
foundry emit iteration_requested my-api \
  --payload '{"strategic": true, "max_iterations": 3}'

# After completion, review the trace
foundry trace evt_abc123def456

Expected event flow for a 2-iteration run:

iteration_requested (strategic=true)
  strategic_assessment_completed (areas: [test coverage, error handling])
    iteration_requested (loop_context: iteration=1)
      charter_check_completed
      gate_resolution_completed
      preflight_completed
      assessment_completed
      triage_completed
      plan_completed
      execution_completed
      gate_verification_completed
      inner_iteration_completed (success=true)
        project_changes_committed
    iteration_requested (loop_context: iteration=2)
      charter_check_completed
      ...
      inner_iteration_completed (success=true)
        project_changes_committed
    project_iteration_completed (no loop_context — loop done)
      summarize_completed
      project_changes_committed

Agent Capabilities

Strategic iteration adds two blocks with their own agent invocations on top of the standard iterate chain:

PhaseCapabilityModelAccessPurpose
Strategic AssessorReasoningclaude-opus-5Read-onlyHolistic codebase analysis producing ranked improvement areas
Continue checkQuickclaude-haiku-4-5-20251001Read-onlyDecide whether the loop should continue after each cycle

The inner iterate formation uses the same model assignments described in the Iteration Workflow guide.

When a strategic_prompt is set, both the assessment and continue-check prompts incorporate it — the same custom directive steers area selection and termination decisions.

Comparison with Standard Iterate

AspectStandard IterateStrategic Iterate
Entry payload{} or {actions: ...}{strategic: true, max_iterations: N}
ScopeOne issue per runMultiple issues per run
CommitsOne at the endOne per inner cycle + one final
Continue logicNone (single pass)AI re-assessment after each cycle
Inner retryUp to 3 retries on gate failureSame (unchanged)
Maintenance chainingAfter single completionAfter loop completion only

Maintenance Workflow

The maintenance workflow runs iterate and maintain automation against each registered project, committing and pushing any changes they produce. It is triggered nightly by the in-daemon nightly-maintenance sentinel (see Sentinels) and can also be invoked manually via foundry run or foundry emit.

How It Works

Each project goes through its own independent chain. The chain is driven entirely by events — no mutable state is shared between projects.

Per-Project Chain

flowchart TD
    A([maintenance_run_started]) --> B[[Validate Project]]
    B --> C([project_validation_completed])
    C --> D[[Route Project Workflow]]
    D -->|iterate=true| E([iteration_requested])
    D -->|iterate=false, maintain=true| F([maintenance_requested])
    D -->|no actions enabled| G([end])
    E --> H[[Resolve Gates]]
    H --> I[[Run Preflight Gates]]
    I --> J[[Check Charter]]
    J --> K[[Assess Project]]
    K --> L[[Triage Assessment]]
    L --> M[[Create Plan]]
    M --> N[[Execute Plan]]
    N --> O[[Run Verify Gates]]
    O --> P[[Route Gate Result]]
    P -->|pass| Q([project_iteration_completed])
    P -->|fail, retries left| R[[Retry Execution]]
    Q -->|maintain=true| F
    F --> S[[Resolve Gates]]
    S --> C[[Classify Dependency Updates]]
    C -->|dependency_updates_classified, phase before| T[[Execute Maintain]]
    T --> U[[Run Verify Gates]]
    U --> V[[Route Gate Result]]
    V -->|pass| W([project_maintenance_completed])
    V -->|fail, retries left| X[[Retry Execution]]
    W --> C2[[Classify Dependency Updates]]
    C2 --> Y([dependency_updates_classified, phase after])

Classify Dependency Updates decides, in code, which dependency moves maintenance may make, under the project's update policy. Execute Maintain gets that list as its brief. The step runs again when maintenance completes, so the summary can report what was actually applied. See Dependency Update Policy.

Syncing the Checkout With Its Remote

The nightly chain works directly in each project's registered checkout, not in a disposable worktree. A checkout that lags origin — common on a second host whose clones are not in daily use — would otherwise be maintained as stale code, and its push would be rejected or, worse, land on a remote that has since moved. So Validate Project syncs the checkout before anything else touches it, on the registered branch:

  1. Dirty tree → refuse. If git status --porcelain is non-empty the project fails with sync_failure: "dirty_tree". Nothing is fetched, merged, or cleaned; the uncommitted work is left for a human.
  2. Fetch. git fetch origin <branch>. If the fetch fails, or origin/<branch> cannot be resolved, the project fails with sync_failure: "remote_unavailable".
  3. Fast-forward only. git merge --ff-only origin/<branch>. The number of commits applied is recorded as fast_forwarded on project_validation_completed (0 when already level, or when the local branch is only ahead).
  4. Diverged → refuse. If both the local branch and the remote have moved, no fast-forward is possible and the project fails with sync_failure: "diverged". The checkout is left exactly as found.

A failed sync sets status: "error", so Route Project Workflow stops the chain (and, inside a maintenance cycle, reports the project run as failed).

Commit and Push commits anything the run left uncommitted, then pushes every commit the branch has ahead of origin/<branch>. The push does not depend on whether this step made a commit: agents usually commit their own work, and those commits must reach the remote too. A run that reports changes: false still runs the step, so commits stranded by an earlier run are pushed on the next one. A run that reports success: false never pushes; its commits stay local (run_failed).

Before pushing, Commit and Push repeats the fetch and fast-forward in case the remote moved during the run. If it did, the local commits are replayed with git rebase origin/<branch> and the project's required gates are run again on the rebased commits. The push happens only when they pass. The refusals, all recorded as push_failure on project_changes_committed (when the step committed) and in the step's summary:

push_failureMeaning
push_rejected_divergedThe rebase conflicted. It was aborted; the commits stay on the local branch for a human.
gates_failed_after_rebaseThe rebased commits failed a required gate, or the project has no gates to verify them. Nothing was pushed.
remote_unavailableThe pre-push fetch failed, or origin/<branch> could not be resolved.
push_failedgit push itself was rejected (for example a non-fast-forward race).
run_failedThe run reported failure, so its commits were kept local.

Foundry never force-pushes.

Agents never push

Foundry pushes a run's commits only in Commit and Push, after the verify gates and the suppression check. The agent must not push first. Three layers hold that line:

  1. The prompt. The maintain, retry and remediation prompts say: commit locally if you want to, never push, force-push, tag or change remotes. The iterate and task prompts say Foundry owns Git entirely.
  2. Prevention. Every maintain, iterate, task and remediation agent session runs with GIT_CONFIG_COUNT=1, GIT_CONFIG_KEY_0=remote.origin.pushurl and GIT_CONFIG_VALUE_0=foundry://agent-push-disabled in its environment. Every process the agent starts inherits it, so a git push to origin fails. Nothing is written to the repository, and Commit and Push runs outside that environment, so Foundry's own push works as before.
  3. Detection. Every maintain, iterate, task, vulnerability-remediation and pipeline-remediation run records HEAD and origin/<branch> before its first agent session (run_base, carried through retries). After each attempt Foundry fetches origin/<branch> and checks whether it moved to commits the checkout holds. The fetch catches a push to an explicit URL, which bypasses the push URL. If the agent pushed, the run fails with "needs review: agent pushed directly to origin/ …", naming the pushed commits and the result of the suppression check, which still runs over them. The run is not retried (a retry cannot undo a push), and the maintenance summary lists it as failed with that reason. A remediation that trips a check reports needs_review on remediation_completed and is not pushed or released; a task gets a blocked_on_decision verdict without a review session and does not land.

An agent could still get around the prevention layer on purpose, for example by pushing to an explicit URL instead of origin. The detection layer reports that case. (Adding a push URL with git -c does not help it: Git pushes to every configured push URL, and the disabled one fails the push.)

After the maintain agent finishes, Foundry checks that the checkout is on the registry's configured branch. If the agent left it on its own branch (or a detached HEAD) that fast-forwards the configured branch, Foundry moves the configured branch forward, checks it out and deletes the agent's branch with git branch -d. If it does not fast-forward, the run fails with a reason that names the branch, and the checkout is left for a human.

After the whole run, the maintenance summary checks every push-enabled project again and lists any that still hold unpushed commits in an Unpushed commits section at the top of audits/runs/<date>/summary.md ("N commit(s) ahead of origin/main"). Three more sections sit beside it:

  • Scanner failures: projects whose dependency audit did not run, with the error. The post-push audit block is marked failed (not ok) when this happens, and the release-audit table says "scanner failed", never "clean".
  • Projects skipped: wrong branch: projects whose validation stopped because the checkout was on another branch (sync_failure: "wrong_branch").
  • Dependency drift: each project's update policy, the updates applied, the updates held back, the majors lane's decisions, and anything not classified. See Dependency Update Policy.

The summary phase runs Plan Major Upgrades first (maintenance_summary_requested → major_upgrades_planned → Generate Summary). After the summary is written, the daemon starts each dispatched major upgrade as its own foundry task, one after another.

The block's result line carries a WARNING: naming each of these that is not empty. Failed install steps are counted there too, and the Local Installs table gives the reason for each failure or skip.

When a project's chain fails, or foundryd stops

Each project's run ends in a project_run_completed event, and the cycle completes when all of them have arrived. If a block fails before a project reaches that event, nothing else can produce it. When the run has nothing left to execute, the engine closes the cycle anyway: maintenance_cycle_completed is recorded with a missing list naming each project that did not finish and the block that stopped it, and the summary is written as usual.

If foundryd itself stops during a cycle (killed, crashed, host restarted), nothing records the end. On its next start foundryd looks back seven days for system cycles with no maintenance_cycle_completed on their trace. For each one it records the completion, with the unfinished projects as missing, and runs the summary phase over the events the cycle did log. That summary opens with a Cycle interrupted section, lists the unfinished projects as failed, is filed under the date the cycle started (as summary-interrupted-<HHMM>.md if a summary for that day already exists), and starts no major-upgrade tasks and no triage.

The one-shot foundry task / campaign path is unaffected — it already builds its isolated worktree from the fetched remote tip.

Refusing to Start on a Full Disk

Validate Project checks free space on the filesystems holding the project and ~/.foundry before anything else runs, and a task checks the same (plus its worktree location) before it creates a worktree. A filesystem is low when its free space is under FOUNDRY_MIN_FREE_DISK_GB (default 15) and under FOUNDRY_MIN_FREE_DISK_PERCENT (default 10). A low filesystem fails the run at once with "insufficient disk: 4.0 GB free (2%) on the filesystem holding …", instead of leaving a half-written worktree or a build that dies part-way. The maintenance summary lists low filesystems in a Low disk section at the top, and the ops digest treats low disk as a P0 anomaly (host_disk_low). Set either limit to 0 to turn the check off.

Routing Logic

Route Project Workflow reads the actions flags forwarded in the project_validation_completed payload and makes a single decision:

ConditionEmits
status != "ok"nothing — chain stops
actions.iterate = trueiteration_requested
actions.iterate = false, actions.maintain = truemaintenance_requested
both falsenothing — no automation enabled

When iterate = true, the actions.maintain flag is forwarded inside the iteration_requested payload. After a successful iteration, the gate routing emits maintenance_requested automatically when that flag is true, so the maintain sub-workflow starts without an extra routing step.

Triggering a Maintenance Run

To run maintenance for a single project:

foundry emit project_validation_completed my-project \
  --payload '{"status":"ok","actions":{"iterate":true,"maintain":true}}'

To trigger the full nightly cycle:

foundry emit maintenance_run_started my-project

Throttle Behaviour

ThrottleEffect
fullAll blocks execute and emit events
dry_runObservers emit; mutators are skipped entirely

Under dry_run, only iteration_requested or maintenance_requested are emitted (by the Observer router). No execution blocks run.

Validate Project still checks for a dirty tree under dry_run, but it does not fetch or merge. It reports the fast-forward that would happen, measured against the last-fetched origin/<branch>, as fast_forwarded with dry_run: true on project_validation_completed.

Agent Capabilities

The maintenance workflow uses a single agent invocation in Execute Maintain:

PhaseCapabilityModelAccessPurpose
Execute MaintainCodingclaude-sonnet-5FullApply the dependency brief, resolve gate failures

The prompt carries the dependency brief: the exact updates to apply, and the updates held back or left for major-upgrade tasks. The agent must not go beyond the list. Gate definitions are passed as context so the agent knows what must pass after its changes. If the project has an agent file registered, it is supplied via --agent.

See the Iteration Workflow for the full model-to-capability mapping and CLI parameters used across all agent invocations.

Dependency Update Policy

Nightly maintenance moves a project's dependencies only as far as the project's update_policy allows. Foundry decides what to move in code, before the maintain agent starts. The agent gets a list of the exact updates to apply, and it must not go beyond that list.

The policy

Each registered project has an update_policy. The policy is a ceiling. Each level includes the levels below it.

PolicyWhat maintenance may do
patchMove the lockfile inside the existing manifest constraints. No manifest constraint changes.
minorAlso widen a constraint to the newest release that is not a breaking (major) release.
majorAlso take major releases. A major upgrade never happens inside the nightly maintain session. Each one becomes its own foundry task.

When a project has no policy, maintenance behaves as minor. The maintenance summary lists the project under "No update policy set", so that you choose one.

Set the policy with the registry commands:

foundry registry edit my-tool --update-policy major
foundry registry add --name my-tool ... --update-policy patch

foundry registry show my-tool shows the policy on the Updates: line.

What counts as a major

A move is a major when it changes the first version component. Semver treats 0.x versions differently, and so does Foundry:

  • In a 0.x version, a change to the second component is a major (0.7.4 to 0.8.0).
  • In a 0.0.x version, every change is a major (0.0.3 to 0.0.4).

Calendar versions follow the same rule, so 2025.2 to 2026.1 is a major.

Classification

The Classify Dependency Updates step runs after the gates resolve and before Execute Maintain. For each direct dependency it finds the locked version, the manifest constraint, and the published releases. Then it records three candidate versions:

FieldMeaning
in_rangeThe newest non-major release that the current constraint admits. A lockfile-only move.
non_majorThe newest release that is not a major from the current version.
majorThe newest release, when it is a major.

Foundry classifies every ecosystem in the repository, not only the registered stack. Manifests at the root are always classified. In subdirectories, a manifest is classified where its lockfile sits beside it (for example apps/cli/Cargo.lock); build output, installed dependencies, fixtures and vendored trees are skipped.

Foundry reads each ecosystem's own files and asks each ecosystem's own registry. It does not need the project's toolchain to be installed.

EcosystemDirect dependenciesLocked versionReleases
CargoCargo.toml and its workspace membersCargo.lockcrates.io sparse index
Hexmix.exs of each Mix project the audit coversmix.lockhex.pm API
npmpackage.jsonpackage-lock.json or bun.locknpm registry
PyPIpyproject.toml (project, optional, dependency groups)uv.lockPyPI JSON API
Mavengradle/libs.versions.tomlthe catalog itselfMaven Central, Google Maven, Gradle plugin portal
SwiftPMPackage.swiftPackage.resolvedthe package repository's git tags

Each ecosystem's constraint operators are read correctly: Cargo caret and tilde, Elixir ~>, npm ^, ~, x-ranges and hyphen ranges, PEP 440 specifiers, Gradle catalog versions and Maven ranges, and SwiftPM from:, upToNextMinor, exact: and ranges. Pre-releases are never candidates. A Maven qualifier is a flavor, not an upgrade: 33.1.0-jre follows 33.0.0-jre, and 0.8.0-0.6.x-compat is not an upgrade of 0.8.0.

Vendored code

A vendored project changes only through its own repository, so its dependencies are not classified and never appear in the brief. A scope is vendored when its path has a vendor/ or third_party/ directory, or when .gitattributes marks the path with the GitHub Linguist attribute:

libs/forked/** linguist-vendored

foundry deps and the summary list these scopes as "vendored, updated upstream". The security audit still scans them, because an advisory in vendored code still needs a person to see it.

The checkout

Foundry classifies the files in the registered checkout. The nightly run syncs the checkout with its remote first. foundry deps fetches and compares: when the checkout is behind origin/<branch>, the output starts with a warning, because the lockfiles it read are not the remote's. The output also names the commit it read.

A Gradle [versions] key that several libraries share (for example ktor) moves as one, so it is classified once, under the key's name.

Not classified

Anything Foundry cannot classify is reported as "not classified", with the reason. It is never reported as up to date. Examples:

  • a stack with no classifier (C++),
  • a missing or unsupported lockfile (bun.lockb, yarn.lock, poetry.lock),
  • a Kotlin project without a version catalog,
  • a dependency that is declared but not in the lockfile,
  • a registry that did not answer.

The maintain brief

From the classification and the policy, Foundry builds the brief. For each outdated dependency it decides one of these:

  • Apply the move. patch projects get the in_range version. minor and major projects get the non_major version. The brief says whether the move is lockfile-only or needs a constraint edit.
  • Held back by policy: a patch project's move that needs a constraint edit.
  • Held by a hold: see Holds.
  • Major: never applied in the maintain session. See The majors lane.

The maintain prompt lists the updates to apply, then the held and major updates for information. It tells the agent to use targeted commands (for example cargo update -p <pkg> --precise <version> or uv lock --upgrade-package <pkg>==<version>), never a blanket upgrade, and not to change any other dependency. A retry of a failed maintain run is told not to move any dependency beyond what the first attempt applied.

To see the brief for a project without running maintenance:

foundry deps my-tool
foundry deps my-tool --policy major    # preview another policy; the registry does not change

foundry deps emits dependency_review_requested. It prints the outdated dependencies, the brief, and what the majors lane would do. It changes nothing.

Security fixes override the ceiling

The brief uses the advisories from the most recent nightly supply-chain scan (at most eight days old). When the policy's target does not reach a fixed release:

  • A fix that is a patch or a minor move is applied anyway. The brief marks it "beyond the policy ceiling".
  • A fix that is a major move goes to the majors lane, also for minor and patch projects.
  • A vulnerable package that is not a direct dependency becomes a lockfile move to at least the fixed version, or a major task when that move is a major.

The supply-chain auto-fix engine follows the same rule. See Supply-chain scan.

Suppressions are not fixes

Maintenance has no reviewer, so the maintain prompt, its retry prompt, the vulnerability remediation prompt and the iterate and task prompt carry three rules: never suppress, ignore or allowlist an advisory that has a fixed release (upgrade instead, past the policy ceiling if need be); never edit .supply-chain-allow.json, ignore_advisories, Dependency-Check suppressions, pip-audit --ignore-vuln, cargo deny/audit ignore lists or equivalent files; and never claim that no fix exists without citing the registry or OSV entry.

Foundry also checks the result, for every agent whose commits it pushes: maintain, iterate, task, vulnerability remediation and pipeline remediation. Before the first agent session it records where the run started (HEAD and origin/<branch>), and after each attempt it diffs the checkout against the commit those two shared. The diff covers the agent's commits, pushed or not, anything left uncommitted, new files never added to Git, and commits an earlier failed run left unpushed; a retry is checked against the same start as the first attempt. A run that added an allowlist entry, an ignore_advisories entry, a Dependency-Check suppression, an ignore flag or an ignore-list advisory, or an npm override that pins a package to a version the latest supply-chain scan marks vulnerable, fails with "needs review: …". It is never green, and its commits are not pushed. Maintain and iterate retry it (the retry is told to remove the suppression); remediation and tasks stop for review.

The check reads added lines only in files where a suppression takes effect. These are the auditors' configuration files (.supply-chain-allow.json, mix.exs, a mix_audit skips file, a Dependency-Check suppressions XML, deny.toml, audit.toml). They are also the files that run commands, where an auditor invocation can carry --ignore-vuln or cargo audit --ignore, for example .hone-gates.json, the YAML files in the root .github/ and .circleci/, shell scripts, extensionless files in a bin/ or scripts/ directory, Makefiles, justfiles, package.json, pyproject.toml, setup.cfg, tox.ini, noxfile.py, .pre-commit-config.yaml, .gitlab-ci.yml and Dockerfile. A log, an evidence archive, a brief, a README or a CHANGELOG can quote a flag without applying it, so the check does not read them. On 2026-10-11 a campaign cycle stopped because an archived controller log quoted the guard's own earlier rejection.

The check does not compare against origin/<branch> because the agent can move it: on 2026-09-29 a maintain agent pushed its own commit, the diff against the remote came back empty, and a suppression in that commit would have passed unchecked. See Agents never push.

Holds

A hold keeps a package at or below a version. Commit a .dependency-holds.json file in the repository root:

{
  "version": 1,
  "holds": [
    {
      "package": "phoenix_live_view",
      "max": "1.1",
      "reason": "vendored Roost requires ~> 1.1",
      "expires": "2026-12-24"
    }
  ]
}
  • max is a version prefix. "1.1" admits every 1.1.x and lower. "1" admits every 1.x.
  • expires is a date. After that date the hold lapses: the held update comes back, and the summary lists the hold under "Lapsed holds — re-decide". A date that does not parse also lapses the hold.
  • ecosystem (optional) limits the hold to one ecosystem, for a package name that two ecosystems share.

An active hold caps the brief at the newest release inside the hold. A major that the hold blocks does not go to the majors lane.

A hold whose cap is below the version already locked is stale. Foundry reports it as "stale hold: locked X is above cap Y, re-decide" in the brief, foundry deps and the summary. It never tells the agent to downgrade. While the stale hold stays in the file, it still blocks further upgrades of that package. foundry deps never says "Everything classified is up to date" while a hold is holding something back or is stale.

A security fix respects a hold when a fixed release exists inside it. When no fixed release exists inside the hold, the fix overrides the hold, and the brief and the summary say so.

If the file is not valid JSON, no holds apply, and the classification carries a warning that names the file. This mirrors .supply-chain-allow.json.

The majors lane

After the nightly maintenance run, the Plan Major Upgrades step decides each major:

StatusWhen
dispatchThe project's policy is major (or the major is a security fix), and nothing below applies.
proposedThe project's policy is minor or patch. The summary prints the foundry task command to run it.
deferredMaintenance for the project did not succeed.
dedupedA task for the same project, package and target version is in flight (started in the last 24 hours), or preserved event history has no matching preserved ledger obligation and its branch or bundle still exists.
overflowOver the per-project or per-night cap. The summary prints the command.

Each dispatched major becomes one foundry task with the objective "Upgrade <pkg> from <a> to <b> in <project>: adapt call sites, keep all gates green". The task runs through the normal task formation: an isolated worktree, the project's gates, and a landing on trunk only when the task is complete and green. Otherwise its work is preserved.

The daemon starts the dispatched tasks after the maintenance summary is written, one after another, each as its own workflow. Under dry_run nothing is dispatched; the summary says "would dispatch".

When an otherwise eligible nightly major upgrade matches a preserved ledger item by exact registered project, package and target version, the majors lane resumes that obligation from its preservation ref and original objective. Its child records kind: major_upgrade, lane: maintenance, origin nightly majors lane and the exact resumes id; automation records no operator action. The parent retains its submission identity and evidence. Only an actual child landing settles the preserved parent landed; other outcomes leave it open, and owner cancellation remains terminal. A selected resume admission failure is reported and never falls back to a fresh task.

Continuations count against the existing nightly caps and run sequentially with fresh upgrades in the existing order. Update policies, holds, security eligibility, successful maintenance and in-flight suppression still apply. Reviews, dry runs and interrupted-cycle summaries dispatch nothing. Without a matching preserved ledger item, the existing fresh-task and history-suppression rules apply. Owner queue resume remains an interactive, asynchronous task.

Caps bound the cost:

VariableDefaultPurpose
FOUNDRY_MAJOR_TASKS_PER_PROJECT2Most major-upgrade tasks dispatched for one project in one night
FOUNDRY_MAJOR_TASKS_PER_NIGHT6Most major-upgrade tasks dispatched in one night, across every project

Projects are taken in name order, security majors first, so the caps fall the same way each night.

The summary

The maintenance summary has a "Dependency drift" table near the top, beside "Unpushed commits" and "Scanner failures". It has one row per project: the policy, the updates applied by class, the counts held by policy and by holds, the majors lane's decisions, and the scopes not classified. Below the table it lists:

  • Applied beyond the brief: moves the agent made that the brief did not list, and any major. Foundry finds these by comparing the classification before the agent ran with the one after maintenance completed.
  • No update policy set.
  • Lapsed holds — re-decide.

A "Dependencies" section after the status table gives each project's detail, with the foundry task command for every major that was not dispatched.

Inference costs

Foundry records token usage and a cost estimate on agent_session_ended. foundry campaign report <name> shows the known list estimate for each stage. JSON reports include unpriced models and pricing limitations. These figures are USD at published API list prices. Subscription fees and negotiated discounts require separate accounting.

Rates verified on 2026-10-04

The first six rows are the models configured on mojility-ops-01 at verification. The remaining rows cover Foundry's built-in defaults and earlier configured models. Prices are USD per million tokens, for standard processing.

ModelFresh inputCache readCache write (5m)Cache write (1h)Output
claude-fable-5-1100.2512.502050
claude-opus-5-540.205820
claude-sonnet-5-520.202.50410
gpt-6-astra10112.5012.5050
gpt-6.1-sol20.102.502.5010
gpt-6-luna0.100.010.1250.1250.50
claude-opus-5 / opus-4-8 / opus-4-7 / opus-4-6 / opus-4-550.506.251025
claude-fable-510112.502050
claude-sonnet-520.202.50410
claude-sonnet-4-6 / sonnet-4-530.303.75615
claude-haiku-4-5 (-20251001)10.101.2525
gpt-5.550.500030
gpt-5.42.500.250015
gpt-5.4-mini0.750.075004.50
gpt-6-sol20.202.502.5010

Sources: Fable 5.1, Opus 5.5, Sonnet 5.5, Anthropic pricing, OpenAI pricing, GPT-5.5, GPT-5.4, and GPT-5.4 Mini.

OpenCode uses IDs such as openai/gpt-5.4. Lookup accepts that prefix, the anthropic/ prefix, dated snapshots and bracketed context suffixes. An exact entry takes precedence over its normalised base model.

Billing rules

Claude 4.6 and later have no long-context premium. Cache-write retention affects Claude's price; the transcript's aggregate TTL split apportions writes across models. Fable 5.1 and Opus 5.5 have lower cache-read rates than earlier models. Anthropic's batch discount is 50%. Opus 5.5 fast mode doubles rates; inference geography can add 10%. The provider's reported cost takes precedence over Foundry's list calculation. Anthropic pricing

GPT-6 requests above 272,000 input tokens double fresh input, cache reads and cache writes, and multiply output by 1.5. Fast processing doubles the applicable rates. Batch and Flex halve standard rates. Regional processing can add 10%. These modifiers apply to individual requests, so a multi-million-token session does not by itself prove that long-context pricing applied. GPT-6.1 Sol, GPT-6 Astra, GPT-6 Luna

GPT-5.4 and GPT-5.5 also have a 272,000-input-token threshold with 2x input and 1.5x output pricing. Their model pages describe the premium as applying to the full session. Mini has no such premium documented on its model page. GPT-5.4, GPT-5.5

OpenAI cache writes use their own rate instead of the fresh-input rate. Foundry subtracts reported cache reads and writes from total input before pricing each category. Reasoning tokens are already included in output and are never added again. Cache retention does not change the GPT-6 write rate. OpenAI prompt caching

Measurement limits

Current Codex terminal summaries can report cache_write_input_tokens, including an explicit zero. Older summaries omit it. Foundry keeps that distinction and marks missing writes as a pricing limitation for models that charge for them. The retained terminal record is used once; earlier records are not added again.

Terminal summaries do not report individual request sizes, processing tier or regional premiums. Foundry uses standard short-context rates and records those limitations. Such a list estimate has basis: partially_priced. The campaign report prints the limitations and labels the figure partial. It does not apply a long-context multiplier to cumulative session input. Server-side tool fees are outside this token estimate unless included in a provider-reported cost.

A Claude provider-reported cost remains authoritative even if the list book lacks a model. unpriced_models still exposes the incomplete list comparison. Interrupted sessions without terminal usage remain unmeasured. An unknown model is never treated as proof of free inference. OpenCode transcripts still have no supported terminal-usage parser and remain unmeasured.

Runtime price book

~/.foundry/token-rates.json is editable without a rebuild. Each rate records its source URL and verification date. until is inclusive; then applies on the following day. Missing models are added from the seed at daemon startup and in memory when pricing a session.

The 2026-10-04 seed also refreshes entries that exactly match the 2026-08-04 seed, including prices, provenance and dating fields. Other entries survive. Sonnet 5 retains Foundry's historical $3/$15 accounting through 2026-08-31 and uses the vendor's current $2/$10 standard price from 2026-09-01. Existing recorded events are not rewritten.

Tasks and Campaigns

For a worked example, read Make a tool safe to adopt. For preserved work, read Finish a change that stopped.

Foundry has two engineering dispatch primitives:

  • A task executes one concrete objective immediately.
  • A campaign holds a broader mission and derives one task at a time from current repository state until evidence proves the mission complete.

Campaigns do not contain a pre-cut queue. The next objective is created only when the preceding task has a typed result, so stale downstream inventory and per-item retry loops are unnecessary.

For a shared outcome across campaigns, use an initiative. The initiative supplies the brief, shared findings, owner decisions and stage providers. Each member campaign still derives its own next objective. Once initiative advance enables steering, a settled member reports its result before the initiative chooses the next action. Passive initiatives retain the usual independent campaign loop.

Why campaigns reassess scope

Implementation is a source of new knowledge. It can expose hidden dependencies, invalid assumptions, partial solutions, and work that is already complete. Campaign formation reassesses the full remaining mission after each implementation cycle. It uses actual implementation experience, reviewer findings, preserved work, and current repository evidence to derive the next objective.

The reassessment can change scope, priority, or task decomposition within the mission and owner constraints. It can retain an earlier objective when the evidence still supports it. It does not merely select the next item from an initial plan or repeat the reviewer's gap list.

The final reassessment checks the whole mission. A complete task verdict proves its assigned objective, which can be narrower than campaign completion.

Evaluate formation by whether it uses new evidence to choose appropriate work and preserves useful learning. Assessment token volume and the ratio of formation to implementation sessions do not establish waste. When reducing cost, preserve the full reassessment and the evidence it needs. The charter records this as a design principle.

Choosing a Task or Campaign

Use a task when you can state one objective whose acceptance evidence is available now:

foundry task parite-cli \
  "Add a --quiet flag and prove it suppresses progress output"

Use a campaign when any of these are true:

  • the mission spans several independently reviewable changes;
  • later work depends on what the repository reveals after earlier work lands;
  • the work may require an explicit owner decision;
  • production or other external evidence is part of completion;
  • a bounded cycle budget is needed to limit autonomous work.

A campaign is deliberately not a substitute for a backlog. If the work is already a known sequence of unrelated tasks, dispatch those tasks directly. Campaign formation is valuable when each next objective should be derived from mission minus current evidence.

Lifecycle at a Glance

flowchart TD
    A["staged campaign"] --> B["advance"]
    B --> C{"formation decision"}
    C -->|"done"| D["completed"]
    C -->|"advance one objective"| E["isolated task"]
    C -->|"human judgement or budget"| F["escalated"]
    E --> G{"typed task result"}
    G -->|"complete or remainder"| B
    G -->|"defect"| B
    G -->|"blocked on decision"| F
    G -->|"provider unavailable"| H["paused"]
    H -->|"resume"| B
    F -->|"decide or extend budget"| B
    A -->|"cancel"| I["cancelled"]
    B -->|"cancel"| I
    F -->|"cancel"| I
    H -->|"cancel"| I

cancelled is terminal and has no edge back: it records that the mission was abandoned, not achieved. Use paused for a campaign meant to resume.

Each dispatched task consumes one cycle. Formation, retries caused by a transient decision-provider transport failure, and an automatic provider pause do not consume cycles. A landed final task still receives completion evaluation. An exhausted budget with an unlanded non-complete result escalates before gates or formation run.

Durable Ownership

The daemon owns the durable campaign inventory. Online foundry campaign add/list/show/advance/pause/resume/decide/complete/cancel all go through typed gRPC and do not read, create, or mutate FOUNDRY_CAMPAIGNS_PATH. Successful online reads and mutations render the daemon's typed response directly, so stale client-side campaign files cannot mask the live daemon-owned state. Pass --offline only for direct-file recovery while the daemon is stopped. If the daemon is unreachable, the online command fails and leaves any absent or pre-existing client-side FOUNDRY_CAMPAIGNS_PATH byte-identical.

The read-only inventory surface starts with two gRPC queries:

  • ListCampaigns — returns summary/status records sorted by campaign name, with an optional exact project filter. Missing or empty stores return an empty list; malformed or unreadable stores return a gRPC error rather than an implicit empty inventory.
  • GetCampaign — retrieves the complete definition of one campaign by exact name, including intent_refs, context_paths, all done_evidence entries with the Gate/Review type distinction preserved, and escalation rules. Returns NOT_FOUND (not an implicit empty) when the name is absent from the store.

One-Shot Tasks

foundry task parite-cli "Add a --quiet flag and prove it suppresses progress output"
foundry task parite-cli "Fix the parser regression" --agent codex

The task formation:

  1. Creates a disposable Git worktree under ~/.foundry/worktrees/.
  2. Runs the coding agent and quality gates inside that worktree.
  3. Performs a read-only skeptical review against the objective and evidence.
  4. Emits one typed verdict: complete, remainder, defect, blocked_on_decision, or runner_error.
  5. Commits all task work before returning a terminal result.

Two verdicts may fast-forward the registered trunk branch. A complete verdict with passing required gates lands, as does a remainder — the reviewer's term for a finite list of missing work on a converging implementation — provided at least one required gate ran and every required gate passed. Converging work integrates rather than accumulating a long-lived divergent branch, and the green required gates are what keep trunk from going red; a remainder with no required gate to vouch for it does not land. Its reviewer gaps travel forward in the typed result and become the campaign's next objective.

When trunk moves during a task

A task can run for an hour. On a shared repository, other people and sessions push to trunk in that time. The task branch then cannot fast-forward trunk, but that says nothing about the quality of the work. Foundry reconciles it:

  1. It fetches trunk and rebases the task branch onto it inside the task worktree.
  2. If the rebase is clean, it runs the project's required gates again on the rebased tree (fix commands do not run).
  3. If the gates pass, the rebased work lands.
  4. If trunk moved again while the gates ran, Foundry does steps 1–3 one more time. It makes no more than two rebase attempts in one task.

If the rebase conflicts, a required gate fails on the rebased tree, or trunk is still moving after the second attempt, Foundry puts the branch back on the reviewed commit and preserves it as usual. The reviewer's verdict does not change. A complete that could not land is still complete, with landed: false, success: false, and a typed land_blocked reason in the task_run_completed payload:

land_blockedMeaning
trunk_moved_conflictTrunk moved during the run and the rebase conflicted
trunk_moved_gates_failedTrunk moved during the run; the rebase was clean, but a required gate failed after it
stale_base_conflictThe run started from a base already behind trunk and the rebase conflicted
stale_base_gates_failedThe run started behind trunk; the rebase was clean, but a required gate failed
trunk_moved_repeatedlyTrunk moved again after the second rebase
checkout_not_readyThe registered checkout was dirty or on the wrong branch
git_failedAnother git operation needed to land failed

Foundry tells "moved during the run" from "started behind" by the start the executor records when it prepares the task worktree (workspace_base: the worktree's first commit and the trunk commit it observed). A run that recorded no start, because it began before Foundry recorded one, keeps the trunk_moved_* reason, and its summary says that the cause is unknown.

The payload also lists in trunk_arrivals the trunk commits that the task branch did not contain (commit and subject, oldest first). This list is present when the work landed after a rebase too. When trunk did not move, the payload has neither field and the result is the same as before.

When a continuation starts behind trunk

A campaign cycle that follows an unlanded cycle, a foundry queue resume, and a nightly majors continuation start from preserved work: a branch, a commit, or a bundle:<path>. Trunk can move between the cycle that preserved the work and the cycle that continues it. Before the agent session starts, Foundry brings the continuation onto current trunk:

  1. It fetches the registered trunk (origin/<branch>). If origin cannot be reached, it uses the local branch, which is where finalize lands.
  2. If the preserved commit already contains trunk, the worktree starts on the preserved commit, as before.
  3. If not, it rebases the preserved commits onto trunk in the new task worktree. The worker then starts from trunk plus the preserved work. The preservation ref is not rewritten; only the new task branch moves.
  4. If that rebase conflicts, Foundry refuses the continuation before any agent session and removes the worktree it made.

An agent cannot repair a conflicting continuation. It may not rewrite history, so finalize would replay the same preserved commits onto trunk and hit the same conflict after the full session. The refused run ends with runner_error, landed: false, no preservation_ref, and land_blocked: continuation_conflict. Its summary names the continuation ref, the preserved commit, how many commits it was behind and each conflicting file. Its work item settles failed with that summary as the reason. The preserved work stays at its ref, and the item that preserved it stays open.

A campaign does not escalate a refused continuation as a runner fault. Formation runs as usual and sees the refusal in the last run result. The next cycle starts from trunk, because the refused run carries no preservation ref, so formation cannot ask for the same refused base again. To keep the preserved work, formation cuts an objective that reapplies it onto trunk (the preserved commit is readable with git show). Otherwise it cuts the next objective from trunk, or escalates. The refused cycle counts against the cycle budget like any other dispatched cycle, but it spends no agent session.

When the refused cycle was the last one the budget allowed, the campaign escalates. The escalation reason says that the cycle was refused before any agent session ran, and it includes the refusal summary (the ref, the preserved commit, how far behind trunk it was and the conflicting files). It also says what to do next: add a cycle with foundry campaign resume <name> --add-cycles 1 and advance, and the next cycle starts from trunk with the refusal in front of formation. If you already reconciled that work onto trunk by hand, close its work item with foundry queue close. Foundry does not give the refused cycle back: a refund would reuse its cycle number, and the ledger only carries an open cycle forward when a later cycle has a higher number.

A complete with a land_blocked reason is preserved work to reconcile, not a defect. Its work item settles preserved and its reason names the block. The next campaign cycle starts from the preserved branch (brought onto current trunk first, see below), and the campaign history shows the cycle as complete, did not land (<reason>). The nightly majors lane does not dispatch the same upgrade again while that branch exists.

defect, blocked_on_decision, and runner_error never land. Every result that does not land is pushed to a named preservation branch; if no remote push is possible, Foundry writes a Git bundle under ~/.foundry/preserved/. Either way the next cycle resumes from the preserved work; a bundle also carries HEAD, so you can fetch or clone it by hand to recover the work yourself. Tasks do not retry. A campaign decides whether the preserved result should seed another objective.

When preserved work was settled outside the campaign

The next cycle continues from the last cycle's preserved ref only while that work is still owed. Before formation, Foundry reads the work-item ledger and finds the campaign's cycle item whose disposition records that ref (the latest one submitted, if there are several). If that item is settled cancelled or landed, the ref no longer carries forward:

  • cancelled: an owner ran foundry queue close on it, for example after cherry-picking the work onto trunk by hand.
  • landed: supersession detection found the work on trunk and settled the item with the reason superseded by <commit>.

The next cycle then starts from the registered trunk. Formation's ACCUMULATED UNMERGED WORK section names the ref, the work item, its state and its reason, and tells formation not to treat that work as pending. An item that is still preserved, needs_decision or failed keeps carrying its work forward, as does a ref that no item records. If the ledger cannot be read, the cycle continues from the ref, as before, and the daemon logs a warning.

Without this check, a manual advance after an owner reconciled the work by hand replays the old ref. The continuation guard then refuses the cycle because the old commits conflict with trunk, and the owner's cycle is spent with no agent session. To use this, close the item before you advance.

Campaign Definitions

Create a JSON definition file:

{
  "name": "parite-phase-2d",
  "project": "parite-cli",
  "mission": "Prove both retrieval entrypoints preserve raw response identity.",
  "intent_refs": ["parite.intent.raw-retrieval-evidence"],
  "context_paths": [".alloy/projections/AGENTS.generated.md"],
  "done_evidence": [
    {
      "kind": "gate",
      "command": "cargo test -p parite-core retrieval_parity",
      "required": true,
      "artifacts": ["crates/parite-core/tests/retrieval_parity.rs"]
    },
    {
      "kind": "review",
      "statement": "The parity suite compares unmasked response IDs across both real entrypoints."
    }
  ],
  "budget": { "max_cycles": 12 },
  "escalation": ["A behavior choice requires an owner decision."],
  "authorized_by": "Stacey",
  "agent_provider": "codex"
}

Definition fields

FieldRequiredMeaning
nameYesStable campaign identifier, unique in the campaign store
projectYesExact registered project name
missionYesDurable outcome the formation evaluates
intent_refsNoOpaque identifiers connecting the mission to source intent
context_pathsNoExisting repository-relative neutral artifacts
done_evidenceYesAt least one mechanical gate or review statement
budget.max_cyclesNoMaximum dispatched tasks; defaults to 20
escalationNoConditions that require the formation to stop for an owner
authorized_byOperationallyOwner identity required for decisions, completion, and resume
agent_providerNoCampaign-specific provider override

context_paths must be existing repository-relative files under the registered checkout. Absolute paths, parent traversal, missing files, and symlink escapes are rejected before the definition is saved. Foundry reads these neutral artifacts but never invokes the tool that produced them.

Formation uses the balanced model tier with medium reasoning effort. The independent task reviewer continues to use the deep tier with high effort. Context files are listed by path and size for selective reading. Their normative requirements still apply. Formation receives the mission, owner decisions, reviewer gaps, recent commits, changed paths, and compact gate results. It returns a decision; the daemon dispatches that decision. It must not probe localhost or start nested Foundry workflows.

Passing gate output is omitted. Failed output keeps at most 512 bytes per gate and 2 KiB across all gates. The shortening keeps the end of every section of the output (stdout, stderr, and each self-heal run), so noisy stderr cannot hide the stdout line that names the failure. Full results remain in CampaignAdvanceCompleted, and a failed gate's logs field names the files that hold its complete output (see Gate output and full logs). An oversized packet fails before an agent starts. Binding declarations are never silently truncated to fit the packet.

Stage limits and repository scope

Campaign definitions accept these limits. Older definitions use these defaults:

{
  "writable_repositories": ["my-project"],
  "budget": {
    "max_cycles": 4,
    "stages": {
      "formation_prompt_bytes": 32768
    }
  }
}

The prompt byte limit must be positive. Campaign formation, execution, and review have no clock deadline, including the project's agent timeout. Legacy stage time fields are ignored when reading existing definitions. The byte limit applies to the rendered Foundry prompt, not provider instructions or files read during a turn.

An empty writable_repositories means the campaign's project alone. Admission rejects any other declaration. Multi-repository isolation and landing are not supported. Sibling repositories remain read-only.

Campaign execution currently needs Codex to enforce this filesystem boundary. An omitted campaign provider selects Codex. Other campaign providers fail before formation starts. Codex uses workspace-write, clears inherited extra writable roots, and permits Foundry's command-log directory. Foundry owns Git finalization. Standalone tasks retain their existing provider and access behavior.

Capture command output

Use the following command for verbose builds and tests:

foundry capture -- cargo test --workspace

This command needs no daemon. It returns the child's exit code and stores both complete streams in ~/.foundry/tool-logs/. Passing commands print only status and log paths. Failures print at most 2 KiB from each stream. Use --log-dir to choose another directory. Logs remain until you remove them. Gate runs keep their own logs for failed gates under ~/.foundry/evidence/gate-logs/, which Foundry prunes automatically.

Coding prompts request this command for verbose output. Codex also applies a 2,000-token limit to tool output retained in conversation history. The native limit does not preserve a separate complete log; use capture for that.

Prove acceptance early

Before broad fixture or documentation changes, the executor must exercise the hardest acceptance behavior through the real boundary. It must record the rejecting case and the corrected passing case in .foundry/proof.json:

{
  "kind": "behavioral",
  "source_change": "Describe the changed source/input and its paths",
  "rejecting": {"command": "test command", "exit_code": 1, "log": "full-log-path"},
  "corrected": {"command": "test command", "exit_code": 0, "log": "full-log-path"}
}

Write source_change as a single non-empty string. For compatibility, a non-empty array of non-empty strings is interpreted as their joined text. rejecting and corrected are top-level objects. Validate the file's JSON, field types, actual exit codes, and log paths before finishing.

For a non-behavioral objective, use this direct shape:

{
  "kind": "direct",
  "reason": "Explain why this objective is non-behavioral",
  "corrected": {"command": "acceptance command", "exit_code": 0, "log": ".foundry/logs/corrected.log"}
}

Review reads the worktree proof before finalization. Before committing, Foundry copies the proof and named logs into ~/.foundry/evidence/<trace-id>/<unique-attempt>/ (event id when no trace is present). The task result's proof_evidence field records that directory for later audits. original-proof.json retains the exact submitted bytes; proof.json uses relative paths to copied rejecting.log and corrected.log, so the archive is readable after worktree removal. Malformed proofs and absent logs remain failed evidence; archival never invents results. An archival I/O failure stops finalization before committing or cleanup.

The proof and its logs are not project content. The rules for them are the rules for every file under .foundry/ in a task worktree, described in What lands from a task: nothing there is committed, and finalization copies all of it to ~/.foundry/evidence/<trace-id>/<unique-attempt>/worktree/ before cleanup. The task result's uncommitted_artifacts field names that directory. It holds the raw proof and logs at their worktree paths, beside the portable proof archive in the parent directory. Evidence a campaign must keep in the repository goes in a normal project path, such as docs/ or the test tree.

New campaign tasks fail with a typed defect if the record is missing, malformed, or refers to absent logs. This check spends no reviewer session. The independent reviewer checks the source, logs, ordering, and whether the probe proves the intended behavior. Schema checks alone cannot establish semantic correctness.

Report campaign efficiency

foundry campaign report my-campaign
foundry campaign report my-campaign --json

The online command reads daemon-owned events through GetCampaignReport. --offline reads local stores and logs explicitly. The report separates formation, execution, and review time and tokens. It also shows dispatched and landed cycles, running sessions, missing usage, unpriced models, stage limits, and external completion reasons. Digest sessions do not enter these totals.

The report also lists formation sessions and decision events separately. JSON includes session IDs, trace IDs, timestamps, transcript paths, terminal usage, exact Foundry prompt bytes when recorded, and command activity. Captured output bytes are not tokens and do not prove how much output the model saw. Exact command repeats exclude duplicate transcript events.

For Codex, native observations include the first request's input, the latest cumulative usage, and a native transcript path. These observations never add to terminal accounting. An interrupted session can have a partial observation while its final usage remains unmeasured. Missing or unsupported transcripts produce explicit audit errors, not zero counts. Native transcript lookup uses the daemon's CODEX_HOME, or ~/.codex, and reads only the matching thread's file.

See the context-mixer2 formation audit for a worked example and the limits of each measurement.

Older session records lack an explicit stage. Their role is inferred from access and model tier, and the report gives the count of inferred sessions. Missing usage means unmeasured spend. A partial list-price estimate is not total cost. The report shows known list USD and billing dimensions missing from the transcript. See Inference costs for rates and limits.

Formation runs before the first task and after each result that needs a new objective or completion evaluation. Thus one campaign with N tasks normally has N+1 formation sessions. Separate campaigns each have an initial call. Forced terminal decisions bypass the agent. Transport retries can add sessions within the formation time budget.

Designing done evidence

Use a gate for a deterministic command that can run against the delivered repository checkout:

{
  "kind": "gate",
  "command": "cargo test -p parite-core retrieval_parity",
  "required": true,
  "artifacts": ["crates/parite-core/tests/retrieval_parity.rs"]
}

Every declared artifact must exist before the command is eligible to pass. This prevents a test runner that silently ignores a missing path from producing false-green evidence.

Use a review for a semantic or externally verified claim:

{
  "kind": "review",
  "statement": "Production preserves raw identity across both entrypoints."
}

Required gates are re-run by formation against delivered trunk and block done. A required command that asserts another host's state through ssh, rsync, systemctl, launchctl, or similar tooling is rejected when the campaign is added: code in a disposable task worktree cannot make such a gate pass reliably. Express deployment evidence as a review statement that the owner verifies, or mark a remote probe as optional.

Campaign gates are not task acceptance criteria. A dispatched task runs the project gates resolved from its own checkout. Formation must state acceptance evidence the task can actually produce inside that worktree; it does not copy campaign gate commands into the objective.

Formation's agent runs in a read-only sandbox. Builds, tests, linters and anything else that writes target/, caches or lock files cannot run there, and the prompt tells formation not to try. It takes test and gate evidence from two places: the done-gate results the daemon ran against delivered trunk before formation, and the last run result. A run with landed: true reached trunk only after every required gate in the project's gate file passed in the task worktree, and Foundry re-runs those gates after any rebase onto trunk. A review statement such as "with passing unit tests" is judged from that evidence and from the code. Being unable to run a command in the sandbox is not a reason to escalate.

Managing a Campaign

foundry campaign add ./parite-phase-2d.json
foundry campaign list
foundry campaign show parite-phase-2d
foundry campaign advance parite-phase-2d
foundry campaign pause parite-phase-2d
foundry campaign decide parite-phase-2d --decision "Use the generated tonic client path."
# On an active or paused campaign, decide records the policy and changes nothing else:
foundry campaign decide parite-phase-2d --decision "Do not remove the v1 endpoint."
foundry campaign resume parite-phase-2d
# When the cycle budget was exhausted, explicitly authorize more work:
foundry campaign resume parite-phase-2d --add-cycles 1
# Or record a decision and authorize more work in one step:
foundry campaign decide parite-phase-2d --decision "Keep the current design." --add-cycles 1

foundry campaign show <name> prints the campaign, then its cycles as the work-item ledger records them under Cycles in the ledger:, one line per cycle with the cycle number, the item id, its state, its settlement or start time and its reason. The cycles come from the ledger through the ListWorkItems source filter (campaign:<name>), the same list foundry queue --source campaign:<name> prints, in the daemon's reading order (running first, then open, then settled newest first). Nothing is inferred from traces. A campaign whose cycles predate the typed source, or that never advanced, prints (none recorded).

--json prints the same data as one object: the campaign's fields, open_escalation, owner_options and cycles (the ledger records foundry queue --json would print).

What an escalated campaign shows its owner

An escalated campaign stops and waits for you. foundry queue open lists it, with its reason, beside the open ledger items (see The work queue), and campaign show says why and what you can do:

Escalation:
  Reason:     campaign cycle budget exhausted (6)
  Escalated:  2026-10-11T03:12:40+00:00 at 6 cycles (6 landed)
Owner options:
  foundry campaign decide mojentic-x --decision "<text>"  record a binding owner decision and return to active; advance continues it
  foundry campaign resume mojentic-x --add-cycles <n>     authorize n more cycles and return to active; advance continues it
  foundry campaign complete mojentic-x --reason "<text>"  close it: owner-verified evidence shows the mission shipped
  foundry campaign cancel mojentic-x --reason "<text>"    stop it permanently

When a task stopped with blocked_on_decision, the block also prints the agent's Question: and each Choice: as the agent wrote them. The options follow the status table below. resume, decide and complete need an authorized_by owner, so an unauthorized campaign is offered only cancel. When the cycle budget is spent, decide and resume are offered with --add-cycles, the only form the engine accepts. An active or paused campaign is offered decide without --add-cycles, the only form it accepts, to record a decision for the next formation. Every other status prints its own options too, and a completed or cancelled campaign prints none. foundry campaign report prints the same Escalation: block and options, and its --json carries open_escalation and owner_options.

The reason lives on the campaign record as open_escalation. Formation writes it in the same store save that moves the campaign to escalated, from the same reason the campaign_escalated event carries. It is present only while the campaign is escalated: an escalated decide, resume, complete, cancel and pause each clear it, because a reason for a question already answered is stale. The event log keeps every escalation. While the campaign stays escalated the first reason stands; a later result that reaches it only restates the status (campaign is escalated and requires owner resumption) and does not replace the question you have to answer.

A campaign that escalated before Foundry kept this record has no open_escalation in its store. Reads recover the reason from the newest campaign_escalated event naming the campaign, across every monthly event file, and print Recorded: recovered from the event log. Nothing is written back: the store changes only when the campaign does. An escalated campaign with no record and no event prints Reason: (not recorded) beside its options.

An earlier cycle the campaign has moved past does not stay in the open list. When the next cycle starts, each earlier cycle still preserved, needs_decision or failed settles cancelled with the reason carried forward by campaign <name> cycle <n> (<item id>). When the campaign completes or is cancelled, its remaining open cycles settle cancelled with the reason campaign <name> completed or campaign <name> cancelled. The preserved ref stays recorded on each item, and no operator_action is written. An escalated cycle stays open until the campaign advances again or ends: it is the live question. A formation run that ended failed settles the same way, when the next cycle starts or the campaign ends. See The work queue.

New definitions start as staged. An authorized staged campaign becomes active on its first advance. Each advance re-runs mechanical done-evidence, reviews the repository and context artifacts, then makes exactly one decision:

  • done — all required gate and review evidence is satisfied.
  • advance — dispatch exactly one next objective from mission minus current state.
  • escalate — stop because the budget, an escalation rule, runner failure, or owner judgment requires attention.
StatusMeaningValid next control
stagedDefinition exists; no cycle has startedadvance, pause, cancel
activeFormation or a task may advance the missionadvance, pause, decide, cancel
pausedAdvancement is intentionally stoppedresume, decide, complete, cancel
escalatedBudget, policy, or human judgement stopped the missiondecide, resume, complete, cancel
completedEvidence or owner authorization closed the missionNone
cancelledAn owner abandoned the mission before its evidenceNone

The status transition table

Every rule governing which control is legal from which status lives in exactly one place: foundry_sdk::campaign::transition. The daemon's gRPC handlers, the CLI's --offline recovery path, and the AdvanceCampaign formation block all delegate to the same Campaign methods (pause, resume, record_owner_decision, complete, cancel, check_advanceable) rather than re-deriving the rules independently — --offline differs from the online path only in transport and event emission, never in legality. The table below is generated from that module's exhaustive state-machine test and is the authoritative specification:

Statuspauseresumedecidecompletecanceladvance
stagedallowed †rejected: wrong statusrejected: wrong statusallowedallowedallowed
activeallowed †rejected: wrong statusallowed ‡allowedallowedallowed
pausedallowed †allowedallowed ‡allowedallowedrejected: wrong status
escalatedallowed †allowedallowedallowedallowedrejected: wrong status
completedallowed †rejected: wrong statusrejected: wrong statusno-op (already settled)rejected: already completerejected: wrong status
cancelledallowed †rejected: wrong statusrejected: wrong statusrejected: cancelled campaignno-op (already settled)rejected: wrong status

resume, decide and complete additionally require authorized_by to be set, regardless of status; cancel deliberately does not, so an unauthorized campaign is never stranded with no reachable terminal state.

† pause is unconditional today, including on a completed or cancelled campaign — see the TODO(campaign-status) on Campaign::pause in foundry-sdk. That this can resurrect a terminal status into paused is a known open question, not a decision this table is asserting is correct.

‡ On an active or paused campaign, decide records the decision and keeps the status. It refuses --add-cycles there, and refuses a decision whose text and owner repeat the campaign's latest decision.

A done decision made while a required done-evidence gate is red is rewritten into an advance. The synthesized objective carries the campaign mission and each failing gate's own output, not just the command that failed, and forbids reverting or shrinking landed mission work — or broadening a lint allowance — to turn the gate green.

Task results auto-request the next advance. A result that did not land carries its preserved branch into the next task, so the campaign resumes warm. A blocked_on_decision escalates immediately, and so does a runner_error that describes a fault in the run. cycles_completed counts dispatched tasks, while cycles_landed counts only task results whose work actually landed on trunk.

A provider failure is not treated as a campaign failure. If the decision agent cannot be reached, Foundry re-asks it up to three times with a widening backoff before giving up, and the resulting escalation names every attempt — a single transport blip no longer ends a healthy campaign. A malformed decision is not retried: the agent answered, so re-asking would only repeat it.

When the provider itself is unusable — an exhausted account, revoked authentication, or an open circuit breaker — the campaign moves to paused rather than escalated, whether that surfaces during formation or as the executor's runner_error verdict. Nothing about the campaign's own work is wrong, so no cycle is consumed and the pending run result is preserved. Once the provider is usable again, foundry campaign resume continues from exactly where it stopped.

The stop emits a campaign_paused event carrying the reason. It is deliberately not a terminal event — it neither ends the campaign nor requires resurrection — but it is emitted because an automatic pause is the one kind nobody is watching: an operator who runs foundry campaign pause already knows, whereas a campaign that quietly stops itself is otherwise indistinguishable from one still working. The reason also appears in the advance block's summary in the run trace.

Formation reasons about two trees, because they can differ. The live repository snapshot is the delivered trunk state and is what a done decision is judged against. Separately, when the previous cycle did not land, its preserved branch becomes the next execution's base ref (unless its work item was settled outside the campaign, see "When preserved work was settled outside the campaign" above) — so formation is also shown an ACCUMULATED UNMERGED WORK section listing the commits and changed files reachable from that ref but absent from trunk. An advance objective is cut from mission minus trunk-plus-accumulated. Without this the agent inspects only trunk and re-cuts objectives the preserved branch already satisfied. When the final budgeted task lands, Foundry still evaluates the repository for completion. Only a decision to dispatch another task is converted to a budget escalation.

Formation is also shown an OBJECTIVE HISTORY section: the objectives this campaign has already cut, oldest first, each with the typed verdict its execution returned and whether that work landed. The campaign retains the eight most recent — the full text of every objective is already durable in the campaign_advance_completed event stream, so the stored history is formation's working memory rather than an archive, and stays bounded on a long mission.

The prompt forbids restating an entry from that history. When the objective being cut substantially repeats an earlier one, exactly two readings are available. Either the earlier work exists on the preserved branch and the inspection missed it, in which case the accumulated section applies and the objective becomes reconcile-and-land. Or the earlier cycle returned remainder and its gaps are genuinely open, in which case re-dispatching the same request has already failed once: the agent must name the blocking sub-gap, change the approach, or escalate for an owner decision.

Observing and Reconstructing a Cycle

foundry campaign advance prints the root event ID, streams the block progress of its own advance (not other workflows on the project), and renders the completed trace. An advance never duplicates work: when the campaign already has an advance or a cycle submitted, queued, held or running, the command dispatches nothing, prints the existing item, and exits 0:

Campaign 'parite-phase-2d' already has a cycle in flight: work item wi_3f… (running). Nothing dispatched.
  next: foundry queue show wi_3f…

Two advances for one campaign issued at the same moment dispatch exactly one workflow. The same run remains available through:

foundry history --project parite-cli
foundry trace <campaign-advance-event-id> --verbose

A campaign run mints a trace ID, and every advance mints a fresh cycle span within it. Task-side events carry both campaign and campaign_cycle, so concurrent campaigns in the same project cannot make cycle boundaries ambiguous.

CampaignAdvanceCompleted records the formation inputs that matter for audit:

  • the exact prompt shown to the decision agent;
  • the selected agent provider;
  • the formation decision and reason;
  • the objective, when one was dispatched;
  • the gate results produced by done-evidence commands; and
  • success: true when the advance decided or stood down, false when a fault (an unreadable store, a failed save, a missing campaign) stopped it.

Forced decisions that do not consult an agent record no prompt or provider. This is intentional evidence that formation was bypassed, not missing telemetry.

An advance that finds its campaign stopped records decision: "stood_down" instead of a formation decision, with campaign_status (the status that stopped it) and a reason. It has no objective, dispatches nothing, changes no status and counts no cycle. It carries the prompt and gate results only when a formation ran and was then discarded, which accounts for that agent's spend.

The advance's work item settles from this event. A decision or a stand-down settles landed; success: false, or no campaign_advance_completed at all, settles failed and lists the item in foundry queue open.

cycles_completed counts dispatched tasks. cycles_landed counts task results whose changes reached trunk. The bounded objective_history stored with the campaign is working memory for formation; the append-only event stream is the complete historical record.

Pausing and Resuming

Pausing prevents automatic or manual advancement. If an already-running task finishes after the pause, Foundry records its result without changing the paused state, and the advance that carried the result stands down and settles landed. The typed result and preservation ref remain pending in the campaign store; the next manual advance after resume consumes them, so formation sees the exact reviewer gaps and the task continues from preserved work. If you closed the preserved work's item while the campaign waited, the task starts from trunk instead.

Resume is valid for both paused and escalated campaigns. When an escalation is budget-only (the engine stopped because the cycle limit was reached but no human judgment question was recorded), resume is the right command — it returns the campaign to active without requiring an owner-decision record:

foundry campaign resume parite-phase-2d

resume requires authorized_by to be set and will not silently reactivate an exhausted campaign. When cycles_completed >= max_cycles, pass --add-cycles N to explicitly authorize more work; the engine rejects resume without an extension on an exhausted budget:

foundry campaign resume parite-phase-2d --add-cycles 1

Recording an Owner Decision

Completion and escalation are terminal events and are forced into the next ops digest as an anomaly. Campaign-store mutations are serialized across the CLI and daemon by a file lock on the campaign store. Every holder keeps it only for a short read-and-write section. Online add, advance, pause, resume, decide, complete, and cancel acquire it on Tokio's blocking pool, so runtime workers stay free to process agent output and answer other RPCs. Once admitted to the blocking pool, a control operation finishes even if its client disconnects; inspect campaign state before retrying an interrupted command.

A formation does not hold the lock while its done gates and agent run. It reads the campaign and saves the arriving run result under the lock, releases the lock, forms, then takes the lock again and applies the outcome only if the campaign record is unchanged since it read it. Controls and advances for any campaign therefore return promptly while formations run, and different campaigns form at the same time. One campaign never forms twice at once: a second formation for the same campaign waits for the first and then reads the record the first one left.

A control never loses to a formation that was running when it was issued. When the campaign record changed while it formed, the formed outcome is discarded and the change stands:

  • The campaign was paused, cancelled, completed or escalated meanwhile. The advance stands down: it emits a stood_down completion, dispatches nothing and settles landed, because the stop is what the operator asked for. A paused campaign keeps its pending run result for the advance after resume.
  • The campaign can still advance but its record changed (a budget edit, a mission edit, an owner decision recorded on the active campaign, an --offline edit). The advance forms again from the current record, still holding the campaign's formation lock, and re-runs every check against it, so a budget cut is enforced. The arriving run result is recorded once, whatever the number of formations. An advance whose campaign changes during three formations in a row fails: it applies nothing, its work item settles failed with a summary that says so and shows in foundry queue open, and every change stands. Run foundry campaign advance <name> once the edits stop.

Controls validate the latest stored state after acquiring the lock; a transition that became invalid while waiting returns FAILED_PRECONDITION. cancel --now aborts its target workflow before it takes the lock. Offline commands retain their synchronous file-lock behaviour.

If a daemon-side save fails during pause, resume, decide, or complete, the RPC returns INTERNAL, leaves the persisted daemon-owned store byte-identical, and complete does not emit CampaignCompleted.

What decide changes in each status

decide records a binding owner policy. It appends an owner decision record with the decision text, the campaign's authorized_by value, and a timestamp. Every later formation prompt includes every recorded owner decision as binding context, so the next objective follows the policy instead of re-deriving the choice you ruled out.

You do not have to wait for an escalation. When you rule on a question while the campaign is active or paused, record it at once:

foundry campaign decide parite-phase-2d \
  --decision "Do not remove the v1 endpoint; clients still depend on it."

On an active or paused campaign, decide changes nothing else. The status stays as it was, the budget is unchanged, and a stored run result stays pending. A paused campaign stays paused until you resume it. A spent budget does not block the decision, because no status changes. If the campaign is forming when you decide, the formation sees the changed record and forms again from it (see above), so the next objective already follows the decision.

--add-cycles is refused on an active or paused campaign, and nothing is recorded. Extending the budget through decide answers an escalation. For a paused campaign, use foundry campaign resume <name> --add-cycles N.

When a task escalates with a human judgment question, decide answers it:

foundry campaign decide parite-phase-2d \
  --decision "Keep the generated tonic client boundary; do not add raw JSON shims."

On an escalated campaign, decide records the decision, then returns the campaign to active, so the next advance can continue under explicit policy instead of re-escalating on the same question.

When an escalated campaign has spent its cycle budget, decide needs --add-cycles N:

foundry campaign decide parite-phase-2d \
  --decision "Keep the current design." --add-cycles 1

The decision, the budget extension and the return to active happen in one transition. Without --add-cycles the command is refused and records nothing. A decision alone would leave an active campaign whose next advance escalates on budget again, and resume does not accept an active campaign.

Running the same command again after it succeeded is refused with "campaign '' already records this owner decision as its latest; nothing to apply": the decision is not recorded twice and the cycles are not added twice. Foundry refuses a decision on an active or paused campaign when its text and owner equal the campaign's latest decision. An escalated campaign accepts the same words, because it stopped on a new question after that decision. decide is refused on a staged, completed or cancelled campaign.

By default, foundry campaign decide is an online mutation: it requires a reachable foundryd daemon and sends the decision through the DecideCampaign RPC. If the daemon is unreachable, the command fails and does not touch the client-side FOUNDRY_CAMPAIGNS_PATH.

If you need to update the file while the daemon is stopped, opt into the direct store path explicitly:

foundry campaign decide parite-phase-2d \
  --decision "Keep the generated tonic client boundary; do not add raw JSON shims." \
  --offline

External Completion

When production or other owner-reviewed evidence proves the mission shipped without another formation cycle, close the campaign explicitly:

foundry campaign complete parite-phase-2d \
  --reason "Production verification confirms every required outcome shipped."

This is an owner-authorized terminal transition. Foundry retains the reason and timestamp, clears any stale pending result, and emits the same completion event used by an internally completed campaign. Use --offline only while the daemon is stopped; the direct-file path cannot emit the terminal event.

Cancelling a Campaign

When a mission is abandoned rather than achieved — superseded by a different approach, overtaken by events, or simply wrong — cancel it:

foundry campaign cancel parite-phase-2d \
  --reason "Superseded by the streaming rewrite."

Cancellation is a distinct cancelled status, not a flavour of completed. Completion in Foundry is an evidence claim, so recording an abandoned campaign as complete would put a false assertion into the audit trail and the ops digest. It is terminal and not resumable; if the campaign should come back later, use pause instead.

Unlike complete, cancellation does not require authorized_by — an unauthorized campaign cannot be advanced to completion either, so requiring an owner would leave it stranded with no reachable terminal state. The --reason is always mandatory and always reaches the campaign_cancelled event; it is additionally recorded as an owner decision when the campaign has an owner.

By default the cancellation is graceful: the in-flight cycle runs to completion, its work is committed and preserved exactly as usual, and no successor cycle is dispatched. The advance that carries the cycle's result records it, stands down with decision: "stood_down" and settles landed; the campaign stays cancelled. To stop immediately instead:

# Kill the running agent, keep its work (committed and pushed or bundled)
foundry campaign cancel parite-phase-2d --reason "Wrong approach." --now

# Kill the running agent and throw its uncommitted work away
foundry campaign cancel parite-phase-2d --reason "Wrong approach." --now --discard-work

A whole campaign runs inside a single daemon task, so --now aborts that task: the running agent process is killed, and the cycle's worktree is left orphaned because normal finalization never ran. Foundry then disposes of that worktree according to --discard-work — preserving the work to a branch or bundle by default, or deleting the worktree and its local branch when asked. A remote branch pushed by an earlier cycle is retained until its owning item lands; it is the audit trail for work that did reach a durable ref.

--discard-work requires --now, because a graceful cancellation has already committed and preserved the cycle's work by the time it stops — there would be nothing uncommitted left to discard.

A --now cancellation also closes the cycle's entry in the work-item ledger. The cycle was recorded running when it was dispatched, and killing it means the TaskRunCompleted that normally settles that entry never arrives — so Foundry settles it cancelled itself, with your --reason text as the reason and a disposition naming the cycle's worktree, whether that worktree is gone, and the ref any preserved work is recoverable from. A work_item_cancelled event records it, on the aborted cycle's own trace. foundry queue show <id> reads the result back. A graceful cancellation lets its cycle finish; when that cycle settles open, it settles cancelled with the reason campaign <name> cancelled: <reason>, because the campaign is already cancelled. See The work queue.

Settlement happens asynchronously after the cancellation RPC returns. The ledger is saved before the engine writes and broadcasts work_item_cancelled, so a queue read can show cancelled before a Watch subscriber receives the event. Consumers waiting for the event should await its arrival rather than treating a ledger read as proof that broadcast has finished.

Two limits worth knowing. --now kills the agent process itself, but not the tool subprocesses that agent spawned; those are reparented and run to their own completion. And the aborted run produces no trace file, so reconstruct it from the aborted_event_id recorded on the campaign_cancelled event rather than from foundry trace.

--offline cancellation is graceful-only and emits no terminal event, matching offline complete. --offline --now is refused rather than quietly downgraded: with no daemon there is no workflow to abort, and reporting a kill that never happened would be worse than failing. The legality of the cancellation itself — whether this status may become cancelled at all — is not a separate offline rule; it is the same Campaign::cancel the daemon calls, so a completed campaign is rejected and an already-cancelled one is a no-op on both paths identically. See "The status transition table" above.

Online and Offline Control

By default, every campaign control command is daemon-authoritative:

  • add sends the JSON definition through AddCampaign; the daemon validates the referenced project and context paths against daemon-owned registry state, persists atomically, and returns the durable CampaignDetail that the CLI renders directly.
  • list renders ListCampaigns directly.
  • show renders GetCampaign directly.
  • advance dispatches AdvanceCampaign, prints the returned root event ID, watches the workflow, and then renders the trace from that daemon-owned event.
  • pause, resume, decide, and complete render the typed CampaignDetail returned by their respective RPCs.

Without --offline, an unreachable daemon is always an error. The CLI does not warn and fall back to direct file mutation automatically.

If foundryd is not running, pass --offline to opt into direct-file recovery:

foundry campaign add ./parite-phase-2d.json --offline
foundry campaign list --offline
foundry campaign show parite-phase-2d --offline
foundry campaign pause parite-phase-2d --offline
foundry campaign resume parite-phase-2d --offline
foundry campaign resume parite-phase-2d --add-cycles 1 --offline
foundry campaign decide parite-phase-2d --decision "Keep the daemon boundary." --offline
foundry campaign complete parite-phase-2d --reason "Production verification confirms every required outcome shipped." --offline

The offline path reads or mutates the file directly and cannot emit workflow events. Restart foundryd afterward so its in-memory state is refreshed from disk before you resume normal online control.

advance has no offline execution path because formation requires the daemon's engine, registered blocks, live project state, and event persistence.

Recovery and Preservation

Task execution never retries inside a single formation. When work does not land, FinalizeTask commits it before returning:

  • it first pushes a named task branch to the project's remote;
  • if the push is unavailable, it writes a Git bundle under ~/.foundry/preserved/;
  • the next campaign cycle uses that preservation reference as its base;
  • bundle recovery discovers the branch ref directly, and new bundles also include HEAD for ordinary git clone or git fetch recovery.

Formation judges done only against delivered trunk. It uses the accumulated preserved branch to decide what the next task should do. This prevents a campaign from declaring success for unintegrated work without forgetting work that has not landed yet.

Dry Run

Campaign advancement honors event throttle. A dry-run campaign_advance_requested simulates the next objective without mutating the campaign store or repository. It executes exactly one simulated task through review and terminal result, then stops without recursively auto-advancing:

foundry emit campaign_advance_requested \
  --project parite-cli \
  --throttle dry_run \
  --payload '{"campaign":"parite-phase-2d"}' \
  --wait

Context-mixer2 formation audit

The full assessment after each implementation cycle is intentional. Implementation changes the repository and teaches the agent things that can change the remaining work. Formation earns its place when it revises the next objective using those changes, preserves accumulated work, or verifies the whole mission before closing. Command count and token volume alone do not establish waste.

This audit covers the local campaign context-mixer2-atlas-contract-v1-20260930, completed on October 1, 2026. It dispatched three implementation tasks and landed two. The first task was preserved after review found defects. The last task received a complete verdict.

What the six sessions did

Session prefixDecisionNew evidence and resulting action
a319731dAdvanceConfirmed both capabilities were absent. Chose atlas preflight first and left manifest compatibility for the next cycle.
880ebfddNo decisionInspected preserved first-cycle work, but an obsolete clock deadline killed the session during slow macOS Git probes. It supplied no usable objective.
7e5a347dAdvanceCombined the preserved implementation, reviewer defects, missing manifest compatibility, and a failing adoption gate into the second objective. The daemon continued from the preserved commit.
39d14536EscalateReassessed landed behavior, found remaining diagnostic gaps and the missing acceptance test target, and asked for authorization to extend the exhausted budget.
d802b977AdvanceAfter extension, distinguished an absent test target from absent behavior. Existing tests were named manifest_schema. Chose precise diagnostic repair and test-target reconciliation rather than reimplementing the schema guard.
5a323b74DoneChecked the delivered trunk against the whole mission, passing binary tests, CI wiring, docs, compatibility, and read-only behavior. Closed the campaign.

The six sessions were not six implementation plans. Three formed implementation objectives, two assessed whether the campaign could finish or continue, and one failed before returning a decision. A separate prompt-size refusal ran no agent and does not count as a formation session.

The useful change in scope was clearest in the third objective. Formation found that manifest compatibility existed under the wrong test-target name. It asked for reconciliation and preserved the implementation. That is the kind of learning an assessment must use. The final assessment also checked the whole campaign, while the task reviewer had checked its specific assigned objective.

How learning travels today

The implementation output is stored in the execution_completed event and its agent transcript. The reviewer inspects source, tests, and gates, then returns a typed verdict. The next formation receives that verdict, its diagnosis or gaps, landing state, preserved reference, recent objective history, live Git evidence, done-gate results, and paths to the mission's contract files.

It does not automatically receive the implementer's discoveries as a structured learning record. TaskRunCompleted.summary is a generic landing summary. Formation therefore reconstructs much of the learning by reading changed source and tests. That reconstruction is legitimate, but the audit cannot prove that every useful implementation discovery reached the next assessment.

During this campaign, escalation cleared pending_run_result and removed the typed handoff. Recovery required restoring the preserved result from its durable event. The subsequent release-readiness fix retains that result through escalation, extension, and resumption. A regression test verifies that reassessment receives the reviewer finding and dispatches from the preserved base.

The next contract improvement should be an evidence-linked implementation learning record: discoveries, invalidated assumptions, remaining work, and the source/test or event that supports each item. Formation should reassess those claims against the live repository, then record what changed in its assessment and why. A small structured record is a starting point for assessment, not a substitute for it.

Accounting and measurement limits

The five finished formation sessions reported 202,457 fresh input tokens, 1,748,992 cached input tokens, and 15,316 output tokens. The failed session has no terminal usage record. Its native transcript contains a last observed cumulative count of 355,009 input tokens including 322,688 cached tokens, plus 2,522 output tokens. That is a partial observation, not final usage. It remains separate from campaign totals.

Each session's first model request contained about 35,000 to 37,000 input tokens. The Foundry prompt was 12,347 to 15,700 bytes. Automatically loaded instructions, skills, and other provider context contribute to the difference. The evidence supports a large fixed context cost, but does not attribute its exact tokens to individual instruction sources.

The six sessions completed 153 commands and captured 1,276,634 output bytes. Native tool-output text includes wrappers and truncation. Neither byte count is a token count or a reliable measure of the text retained in every subsequent model request. The aggregate report has no price for the models in this run. Do not interpret its known-list estimate as total spend.

These quantities describe the work; they do not judge whether assessment was worthwhile. The stronger test is whether the assessment used new evidence to choose a better objective and retained the learning required by the next cycle.

Audit improvements

Campaign reports now include chronological formation sessions and decision events. Session records include trace and transcript links, terminal usage, exact request bytes for new sessions, completed and repeated commands, failed commands, and captured output bytes. Codex native observations include the first request's input, the last cumulative counters, and a matching native transcript path. Missing or unsupported transcripts remain explicit. Partial observations never inflate or replace terminal usage totals.

New decision events identify the latest task result known to the campaign. Full prompts and gate results remain in their original durable events. This makes it possible to inspect the assessment alongside the preceding implementation and review rather than judging it from a stage total.

These changes introduce no deadline, token threshold, command quota, or shortcut around the full reassessment.

Initiatives

For a concrete example, start with Deliver one outcome across repositories.

An initiative holds one outcome shared by several campaign missions. It keeps owner decisions, findings and a binding brief in one place. Each campaign still derives its own next objective from the repository and the last result.

Add existing campaigns as members, or derive staged candidates from Tracker. An explicit foundry initiative advance enables steering. The steering agent reads the current evidence and chooses one next action. Foundry enforces the shared cycle budget, active member limit and questions before admitting work.

Existing definitions remain passive until you enable steering. Passive members still advance with foundry campaign advance.

Add an initiative

Add the member campaigns first. Register each campaign's project. Include that project in the initiative's projects list. A campaign can belong to only one initiative.

Save this definition as initiative.json:

{
  "name": "shared-contract",
  "outcome": "The producer and consumer use the same contract",
  "intent_refs": ["et:goal:shared-contract"],
  "projects": ["producer", "consumer"],
  "members": [
    { "kind": "campaign", "name": "publish-contract" },
    {
      "kind": "campaign",
      "name": "adopt-contract",
      "after": ["publish-contract"]
    }
  ],
  "done_evidence": [
    {
      "kind": "review",
      "statement": "The consumer accepts the producer's contract"
    }
  ],
  "budget": {
    "max_cycles": 20,
    "max_active_members": 2,
    "max_open_questions": 3
  },
  "authorized_by": "Stacey",
  "agent_provider": "codex",
  "providers": {
    "formation": "claude",
    "execution": "codex",
    "review": "claude"
  }
}

Run these commands:

foundry initiative add initiative.json
foundry initiative list
foundry initiative show shared-contract
foundry initiative advance shared-contract

show prints the outcome, member status, cycles used and landed, the latest verdict and reviewer gaps. It lists each open question once, with its applicable members and an answer command. list and show accept --json. The response includes the latest steering record and a total history count. The store and typed event history retain all assessment prompts and decisions.

max_cycles counts dispatched cycles across all members. The initiative owns member budgets. max_active_members defaults to two. A member with an open owner question cannot advance. A member's after prerequisite must have landed a cycle or completed before that member can advance. Queued formation, reserved dispatches, running cycles and standalone initiative tasks count toward the active cap. A completed or cancelled member counts only while one of its cycles still runs, for example after a graceful cancellation. Its last cycle without a recorded result, a reservation left behind, or a queued or held item never counts. Cancelling a queued cycle or formation with queue cancel or campaign cancel releases the slot it reserved. The engine checks repository ownership before starting another mutation.

Initiatives and findings use the campaign store API and its existing lock. Online commands use typed gRPC and daemon-owned state. An unavailable daemon is an error. Use --offline only for recovery with the daemon stopped. The current storage file is ~/.foundry/campaigns.json. Integrations must use the store API rather than depend on its file shape.

Steer the initiative

Request one assessment, or pin it to an eligible member:

foundry initiative advance shared-contract
foundry initiative advance shared-contract --member adopt-contract

The command returns the submitted event identity. Inspect the initiative and queue while work proceeds. Once enabled, steering runs again after member settlement and repository cleanup. It can select another member or pause for an owner decision. A passive initiative keeps its existing campaign behaviour.

The read-only agent chooses exactly one typed action:

ActionEffect
advanceAdvance an eligible member, assigning a project to a candidate
dispatchStart one initiative-owned task in a registered project
holdWait for explicit member milestones
relayRecord a finding for the selected members
askOpen one shared owner question
stageAdd a campaign for work the existing members do not cover
donePropose completion after the outcome checks pass
escalateStop for owner attention with a reason
waitRecord why nothing should move until running work settles

A wait requires a reason. Foundry records it and changes nothing else: it opens no question and does not change the initiative status. Use ask only for a question the owner must answer. Waiting for workers to finish is a wait, not a question.

Some decisions are already in force. A hold on a member that has an owner hold keeps the owner hold. A relay to members that a finding already reaches adds no recipient. Foundry records these decisions as no-ops and changes nothing else.

The assessment receives the outcome, brief identity, member results, findings, questions, owner decisions, integration results and recent trunk evidence. Its provider is the initiative's formation provider, at balanced tier and medium effort. initiative_steered retains the exact prompt, provider and typed decision.

Check eligibility before the agent runs

Foundry computes the steering options in code before it asks the agent. The packet's eligibility section lists the members that can advance now. It lists every other member with its reason: owner hold, unmet milestone or prerequisite, open question, no free slot, spent budget or busy repository. It also lists the members that use the active-member slots, and it says whether a new task can start.

An automatic assessment does not start an agent when no action can apply:

  • No member can advance.
  • No task can start.
  • At least one member uses a slot, so its settlement requests the next assessment.
  • No finding that can reach more members, and no owner decision, arrived after the previous steering decision.

Foundry records initiative_steered with outcome skipped and the reason. An owner's initiative advance request always starts an assessment.

The agent can choose wait while members are eligible, when the mission requires it. Foundry records its reason beside the eligible list.

How each assessment settles

Each assessment is a work item. Its settled state shows whether a person must act:

OutcomeWork itemMeaning
appliedlandedFoundry recorded the decision and requested its action
waitedlandedThe agent chose wait; the reason is on the fact
no_oplandedThe decision was already in force
skippedlandedNo action could apply, so no agent started
rejectedcancelledA guard refused the decision
stalelandedEvidence changed; a fresh assessment was requested
failedfailedThe agent failed, its output was unusable, or Foundry state could not be read or written

A guard refusal is not work for a person. Foundry refuses a held member, a full active-member limit, a busy repository or a project outside the initiative. It also refuses a member other than the one the owner pinned, and invalid audit evidence. The refused decision has no effect.

The work item settles cancelled with the reason steering rejected: <guard message>. The fact keeps the guard's message in error. foundry queue open lists only failed assessments.

Only one assessment can run per initiative. Store changes or a changed trunk invalidate stale output. Foundry releases the store guard during repository reads and agent execution. Durable action receipts prevent replay from starting work or spending cycles twice. Pending actions recover after daemon restart. A wait does not wake the initiative again.

Wake steering after a member settles

A member cycle result and an initiative task request the next assessment when they settle. Repository cleanup and ledger accounting occur first.

A member advance can also settle without a cycle. The member can escalate, stand down or finish. An advance that a steer started can also start a cycle. Then Foundry calculates the eligibility again. The work item of the advance and its slot reservation are not in this calculation. Foundry requests an assessment when these two conditions are true:

  • At least one member can advance.
  • The member of the advance cannot advance at this time.

A member with a cycle in the queue or in progress uses a slot, so it cannot advance. Thus each advance from a steer requests the next assessment, until all slots are in use or no member can advance. Then the members advance in parallel, up to max_active_members. When another path starts a cycle, the member waits for the result of that cycle.

If the member of an advance can still advance, the advance changed nothing. Foundry does not request an assessment for it, because steering could choose the same advance again and again. A failed advance does not request an assessment. If an assessment of the initiative waits in the queue or runs, the request joins it.

The escalated status does not stop steering. It shows that the owner has an open question. A member that the question applies to cannot advance. Other members can advance. A full set of open questions does not stop steering either. Steering can advance a member that has no open question.

Follow up a step that dispatches nothing

A steering hold, relay, ask, escalate or stage changes state but starts no work. A hold or relay that is already in force (no_op) starts no work either. After one of these steps, Foundry calculates the eligibility again. If at least one member can advance, Foundry requests exactly one follow-up assessment. Without it, the eligible member waits until the next settled fact.

The follow-up is an ordinary initiative_advance_requested through the work ledger. Pacing, the precheck and the one-assessment rule apply to it. If an assessment of the initiative is already queued or running, Foundry does not add another. A follow-up requested while its own steer still runs starts after that steer ends.

The request carries follow_up_of, with the earlier steer's id and its step. The follow-up's steering record and its initiative_steered fact keep the same cause, so initiative show and the events show the chain. A follow-up pass never requests another follow-up, whatever it decides.

These outcomes never request a follow-up: waited, skipped, rejected, stale and failed. A stale assessment requests a fresh assessment, which keeps the follow-up cause. A done proposal waits for the owner and does not request one either. An owner's direct hold or relay records its own fact, and that fact already requests the next assessment.

An indefinite owner hold remains until an owner releases it. Pinning an initiative advance --member releases that member's owner hold. A steering agent cannot release it. Holds with milestones require explicit evidence; an arbitrary milestone label is not satisfied by any landed cycle.

Owner overrides use the same admission checks:

foundry initiative hold shared-contract --member adopt-contract \
  --reason "Review the contract first"
foundry initiative hold shared-contract --member adopt-contract \
  --until publish-contract:contract-landed --reason "Wait for the contract"
foundry initiative milestone shared-contract --member publish-contract \
  --milestone contract-landed --evidence producer@a1b2c3d --evidence contract.md
foundry initiative relay shared-contract --finding fnd_contract_port \
  --member adopt-contract
foundry initiative ask shared-contract --question "Which port is binding?" \
  --member publish-contract --member adopt-contract
foundry initiative dispatch shared-contract consumer "Verify the contract example"

Record custom milestones only after checking their evidence. Built-in settlement milestones retain actual landing or completion evidence. A meaningful owner override during a running assessment is refused with a retry instruction; it is never silently dropped.

Record an owner decision

Open the Sources page in ops-visualizer to review initiatives above campaigns. Each initiative shows its outcome, member results and integration evidence. Each shared question shows its age and applicable members. Use its decision form to answer those members, or use the CLI below.

The dashboard keeps your draft and member selection when live results arrive. If the daemon refuses a decision, the form shows its reason and retains your draft.

A decision applies to every member that can still act, unless --member selects a subset. It reaches staged, active, paused and escalated members. It skips completed and cancelled members, as campaign decide refuses them. If --member names a completed or cancelled member, Foundry refuses the whole decision and records nothing. The refusal names each such member. If no member can still act, Foundry records the decision on the initiative only. It then reaches no campaign and answers no member question.

foundry initiative decide shared-contract --decision "Keep port 5001"
foundry initiative decide shared-contract \
  --decision "Retain the legacy adapter" --member adopt-contract

Foundry records the decision on the initiative and each applicable campaign. It closes the questions that decision answers for those members. Formation that was running when the decision arrived discards its stale outcome and forms again from the current record. It does not overwrite the decision.

Use --add-cycles N with initiative decide to authorize more shared cycles. When the shared budget is spent, initiative decide without --add-cycles is refused and records nothing. A decision alone would leave a member that escalated on budget still escalated, and the same question would open again. campaign decide without cycles on an escalated, spent member is also refused, and its message names the initiative that owns the budget. An active or paused member records a campaign decide without cycles, as any campaign does. Member grants stay within that total. Raising an individual campaign budget cannot authorize an initiative to exceed its shared budget.

Keep owner attention bounded

max_open_questions limits the shared question list. The default is three. Matching questions appear once with the union of their applicable members. Their age starts at the earliest unanswered occurrence. Answering one member does not reset the age for the others.

Extra distinct member questions remain on their campaigns. The initiative reports how many questions are waiting for a slot. After an answer frees a slot, the next waiting question appears. A manual request for another distinct question is refused at the limit.

At the limit, steering can advance an eligible member that needs no owner answer. If no member can advance, it waits for the owner. The ops digest reports shared questions older than one day. It also reports an initiative with no member activity for thirty days. The inactivity check uses recorded creation and member activity times. An older initiative with no known time stays unclassified until member activity provides one.

foundry queue open shows an escalated initiative once and suppresses its members' escalation rows beneath it. Open work items retain their own rows. Use this source filter to see work the initiative caused:

foundry queue --source initiative:shared-contract

Store the binding brief

Foundry owns the authoritative brief.

Omit brief from the initiative definition. Foundry rejects an external brief object. Import its content with brief set after adding the initiative.

Run these commands:

foundry initiative brief set shared-contract BRIEF.md
foundry initiative brief show shared-contract

The stored path is ~/.foundry/initiatives/shared-contract/BRIEF.md. The record includes its SHA-256 hash. Formation, execution and continuation receive the path and hash. Formation reads the brief as binding context. brief show refuses content that differs from the recorded hash.

Relay findings

A finding records information a member must see before its next objective. Save this definition as finding.json:

{
  "id": "fnd_contract_port",
  "initiative": "shared-contract",
  "from": { "kind": "owner", "id": "Stacey" },
  "to": ["adopt-contract"],
  "kind": "contract_changed",
  "text": "The producer now listens on port 5001",
  "refs": ["producer@a1b2c3d", "contract.md"],
  "consumed_by": []
}

Record and inspect it:

foundry finding add finding.json
foundry finding list shared-contract
foundry finding list shared-contract --member adopt-contract --json

The kinds are contract_changed, regression, blocker, evidence and owner_note. An empty to list applies to every campaign member. Findings remain in the record after consumption so their origin remains visible.

A landed cycle's reviewer gaps create findings automatically. A defect verdict creates a finding with its diagnosis. Formation receives a bounded "Findings from the initiative" section, newest first. Its task payload carries the exact finding IDs and the bounded text. Campaign continuations and queue resume also receive current findings.

Foundry consumes a finding for a member only when a cycle that saw it lands. Formation alone, a preserved run and an unlanded verdict do not consume it. Other applicable members keep seeing it until their own cycle lands.

Choose providers by stage

Campaigns and initiatives accept this map:

"providers": { "formation": "claude", "execution": "codex", "review": "claude" }

A member's explicit stage takes precedence over the initiative's stage. An omitted stage uses agent_provider. Existing definitions keep working. The task chain carries the map. Each block selects its own stage provider. foundry campaign report keeps stage tokens separate for each provider.

Campaign execution accepts a provider only when it declares that it enforces a writable root. Codex and Claude implement that boundary. Worktrees remain under ~/.foundry/worktrees.

For Claude, each invocation uses a private settings file, dontAsk permission mode and the OS sandbox. The policy allows file edits only inside the selected worktree. The invocation excludes user and project settings, hooks and MCP servers. It disables permission bypass and unsandboxed subprocess retries. Read-only formation and review allow writes only to temporary scratch space. Sibling repositories and Foundry's state remain readable.

An initiative can explicitly alternate stage pairings:

"provider_pairings": [
  { "formation": "claude", "execution": "codex", "review": "claude" },
  { "formation": "codex", "execution": "claude", "review": "codex" }
]

Explicit member stages still take precedence. A continuation keeps its admitted pairing. Foundry does not alternate providers unless the definition requests it.

Derive from Epilogue Tracker

Run derivation in the repository that supplies the Tracker intent:

foundry initiative derive --from et:goal:shared-contract --projects producer,consumer
foundry initiative derive --from et:journey:adopt-contract --projects producer,consumer

Add --authorized-by <owner> to record the authorizing owner. If omitted, it defaults to the invoking user. Derivation records the initiative in Foundry with staged candidates. It does not start member campaigns. Add --integration-gates <file> to include shared checks during derivation. The file contains a JSON array of gate definitions, as shown in Check the outcome across repositories. Foundry adds these gates to the reviews supplied by Tracker. The file accepts only entries of kind gate.

Foundry reads a committed projection first. Supported paths are .et/projection.json, .alloy/projections/et.json and .alloy/projections/epilogue.json. Each projection has goals, interactions and journeys arrays. Without a projection, Foundry invokes the read-only et list ... --all --json commands from the repository root. The et CLI loads its own repository configuration. Foundry does not read credentials.

The goal description and success criteria become the outcome. Interactions in create, update or delete state become staged candidates in priority order. Journey steps supply after order. Success criteria and interaction descriptions become review statements. intent_refs retain the Tracker IDs.

Derivation retains the supplying repository and intent reference. The supplying repository need not be a registered execution target. Audit and dispatch can use only the initiative's registered projects.

The first advance audits each candidate against the registered codebases before starting work. Findings retain checked paths and classify intent as realised, partial, absent or misplaced. Realised intent produces a close proposal. It does not start redundant work or write Tracker state.

Each later steer reads intent again. New approved intent becomes a candidate. Withdrawn intent stops its owned work gracefully. Completed intent retains its evidence and history. Existing findings and cycle counts remain attached to their intent references. Tracker stays unchanged until the owner confirms completion.

Check the outcome across repositories

For a changed boundary, both sides must verify the same versioned executable contract set. Consumer tests state the behavior needed by the consumer. Provider verification checks that same contract against the implementation. Cover relevant errors, optional fields and supported old and new versions. Independent mocks or matching schemas alone do not establish compatibility.

Run contract verification in each project's required gates. Include the contract version or hash, tested commits and both verification results in the initiative's outcome evidence. Record missing verification as unfinished work within the authorized repositories. Use the brief, shared decisions and findings to coordinate the contract and missing work.

Contract tests establish compatibility for their declared cases and versions. Integration gates also check wiring and the user's full journey. Foundry repeats a short contract reminder in initiative agent prompts. This guidance does not add a contract verifier or a required evidence field. Declared gates and outcome reviews provide the completion checks.

Add integration gates to the initiative's done_evidence:

{
  "kind": "gate",
  "command": "python3 producer/scripts/check-contract.py consumer",
  "host": null,
  "required": true,
  "artifacts": ["producer/scripts/check-contract.py", "consumer/contract.json"]
}

Omit host, or set it to null, to use the daemon's host. A named remote host uses SSH and that host's registered checkouts. The daemon's host is ops-01 in production and the Mac during development. The gate host needs Git, Python 3 and the Foundry CLI on its command path. Named remote hosts also need a running Foundry daemon for registry and pacing checks.

Foundry fetches each registered trunk and permits only a fast-forward update. It creates disposable copies beneath a shared directory. Each copy uses its registered project name, such as producer or consumer. The command runs from that shared directory. Use paths relative to that directory for the command and required artifacts.

Foundry checks the artifacts before starting the command. A missing artifact, failed checkout update or failed command cannot pass the gate. Commands run through foundry capture, with stdout and stderr retained in Foundry evidence. The result records the host and exact trunk commits. Gate failures create findings for the next assessment.

Foundry integrates only when the assessment holds every initiative trunk. An assessment reserves every initiative repository through pacing, but only if all of them are free. It does not wait for these repositories. Pacing decides when the assessment starts. At that time, if all initiative repositories are free, the assessment holds all of them.

If other work uses one of these repositories when the assessment starts, the assessment holds no initiative repository. It starts next to that work, so another eligible member can start too. That assessment does not fetch trunks or run gates, and it records no gate result or finding. Its steering record gives the reason in integration_skipped, and foundry initiative show shows that reason. Older gate results stay, but they do not show the current trunks.

Steering receives the gate results and reviews each outcome statement against the trunks. Passing contract gates on both sides proves compatibility for the tested contract. Other outcome claims still need their declared evidence. Changed trunks, intent or owner policy invalidate an earlier completion proposal.

Add work uncovered by an integration check

An integration check can expose work that no member campaign covers. Steering can choose stage to add a campaign within the initiative's registered projects. Foundry checks the campaign definition, ownership, providers and shared budget. It records the new member without starting an execution cycle. A later eligible advance starts its work through normal pacing.

Inspect staged work with these commands:

foundry initiative show shared-contract
foundry campaign list
foundry queue --source initiative:shared-contract

Confirm completion

A done decision is a proposal for the owner. Every required integration gate must pass. Steering must affirm every review statement with supporting evidence. Open questions and unfinished intent prevent completion. The initiative remains open while it waits for the owner.

Read the proposal and its evidence:

foundry initiative show shared-contract
foundry initiative show shared-contract --json

Confirm the outcome with a reason:

foundry initiative complete shared-contract \
  --reason "Both sides passed the contract review"

Foundry checks that the evidence still matches the current trunks, intent and policy. If it changed, request another assessment with initiative advance. An explicit offline command cannot bypass outcome checks or change Tracker.

For a derived initiative, confirmation closes verified Tracker interactions through et close. It closes the goal after its children are in reality and its success criteria are affirmed. Foundry runs the Tracker CLI from the supplying repository. It does not read or copy Tracker credentials.

Closure receipts record successful Tracker changes. A failed closure leaves the initiative open and retains those receipts. Retry the same completion command after resolving the reported failure. A retry does not repeat a closure already confirmed by current Tracker state. After a restart, the daemon also retries completion that the owner has already confirmed. It checks the evidence again before each Tracker change. If evidence changes after confirmation, use initiative decide to record the new owner direction. Then request another assessment and confirm its proposal.

Cancel the initiative

Cancel an initiative when the owner stops the work:

foundry initiative cancel shared-contract --reason "The shared contract was withdrawn"

Cancellation stops its member campaigns gracefully. Running cycles can settle and preserve their work. It does not discard preserved branches or close Tracker entities.

See campaigns, the work queue and initiative events.

Sentinels (Scheduled Triggers)

A sentinel is a declarative, named, scheduled trigger that lives inside foundryd and emits an event into the engine when its schedule fires. They internalise the kinds of proactive workflows that previously required an external scheduler (a launchd plist, a cron entry, a systemd timer).

The shipping example is nightly-maintenance: at 02:00 every day it emits maintenance_cycle_started with project = "system", which is exactly the event the foundry run command used to emit from outside the daemon.

Why have them at all?

Before sentinels, the nightly maintenance run lived in launchd/com.mojility.foundry-maintenance.plist. That meant:

  • The trace store and event history had no record of why a cycle started. It just appeared, as if conjured from outside the daemon.
  • Adding a second proactive workflow meant editing a per-machine system file.
  • Pausing or rescheduling required editing that system file too.

Sentinels move all of that inside foundryd. The launchd plist's only job becomes "keep foundryd running"; everything proactive happens through the event bus, the scheduler, and the registry the daemon already owns.

How they fire

flowchart LR
    A[sentinels.json] -->|loaded at startup| B(Scheduler)
    B -->|tokio::time::sleep until next firing| C{deadline reached?}
    C -->|yes| D[build Event from EmitSpec]
    D --> E[engine.process]
    C -->|reload signal| B
    F[foundry sentinel<br/>enable/disable] -->|gRPC mutation| G[Notify::notify_one]
    G --> B

The scheduler always knows the soonest upcoming firing across every enabled sentinel. It blocks on tokio::select! between that deadline and a reload Notify; when the reload fires (because a CLI command toggled a sentinel's enabled state), the loop recomputes deadlines.

The default seed

On first start the daemon writes ~/.foundry/sentinels.json with the canonical seed set — currently five entries:

{
  "version": 1,
  "sentinels": [
    {
      "name": "nightly-maintenance",
      "schedule": { "cron": "0 2 * * *" },
      "emit": {
        "event_type": "maintenance_cycle_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    },
    {
      "name": "daily-commit-digest",
      "schedule": { "cron": "0 17 * * *" },
      "emit": {
        "event_type": "commit_digest_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    },
    {
      "name": "ops-digest",
      "schedule": { "cron": "0 */3 * * *" },
      "emit": {
        "event_type": "ops_digest_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    },
    {
      "name": "nightly-supply-chain",
      "schedule": { "cron": "0 6 * * *" },
      "emit": {
        "event_type": "supply_chain_scan_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    },
    {
      "name": "work-reconciler",
      "schedule": { "cron": "30 */3 * * *" },
      "emit": {
        "event_type": "work_reconcile_started",
        "project": "system",
        "throttle": "full",
        "payload": {}
      },
      "enabled": true
    }
  ]
}

The daily-commit-digest sentinel drives the Commit Digest formation. The ops-digest and nightly-supply-chain sentinels drive the Ops Digest and Supply Chain workflows.

Subsequent starts read the existing file and additively merge any seed entries whose names are not yet present — so new Foundry releases that ship additional canonical sentinels reach existing installs automatically on the next restart, without manual JSON edits and without overwriting user toggles or hand-edited cron on entries already in the file. The path is overridable via FOUNDRY_SENTINELS_PATH.

Schedule format

Slice 1 supports a single schedule kind, cron, taking a standard 5-field expression evaluated in local time:

minute hour day-of-month month day-of-week

Examples:

CronMeans
0 2 * * *Every day at 02:00 local
*/30 * * * *Every 30 minutes
0 9 * * 1Every Monday at 09:00 local
0 17 * * 1-517:00 on weekdays

Six-field expressions (second minute hour dom month dow) are also accepted verbatim; five-field expressions are auto-padded with a leading 0 for seconds. The schedule shape is an externally-tagged enum ({"cron": "..."}) so future kinds (interval, event_silence) can be added without breaking the existing wire format.

Defaults you should know about

  • Time zone is local. A cron expression of 0 2 * * * means 02:00 wherever the daemon is running, not 02:00 UTC. This matches how the launchd plist behaved.
  • Catch-up policy is "skip missed firings." If the daemon was down at 02:00 and starts at 05:00, the next firing is the next 02:00. Sentinels intentionally never play catch-up on miss — workflows like maintenance should not run twice in quick succession because the daemon was offline.
  • Auto-seeding only happens once. Subsequent restarts read the existing file even if you have deleted every entry. To restore the seed: remove ~/.foundry/sentinels.json and restart.

CLI

foundry sentinel list | show | enable | disable mirrors foundry registry exactly:

foundry sentinel list
foundry sentinel show nightly-maintenance

# Toggle through gRPC (preferred — the daemon's scheduler wakes immediately):
foundry sentinel disable nightly-maintenance
foundry sentinel enable nightly-maintenance

# Toggle the file directly when the daemon is not running:
foundry sentinel disable --offline nightly-maintenance

Without --offline, all four commands are daemon-authoritative:

  • list calls SentinelList
  • show calls SentinelShow
  • enable calls SentinelEnable
  • disable calls SentinelDisable

Online reads render the daemon response directly and never read the client-side FOUNDRY_SENTINELS_PATH. If foundryd is unreachable, the command fails with a stable actionable error and leaves any absent or pre-existing client-side sentinel file untouched byte-for-byte. Pass --offline only for explicit direct-file recovery while the daemon is stopped.

Online enable and disable are persistence-atomic at the daemon boundary: if the daemon cannot save the sentinel store, the RPC returns INTERNAL, the daemon-owned in-memory store stays unchanged, list and show continue to render the pre-mutation state, the scheduler does not reload, and the on-disk sentinels.json stays byte-identical. The save path writes a temporary file in the same directory and renames it into place only after the full JSON payload is ready, so a failed save does not expose destination truncation or partial bytes.

Adding a new sentinel

There are two paths:

  • Canonical sentinels (the ones every Foundry install should have) are added to SentinelStore::default_seed() in foundry-sdk. The additive seed-merge that runs on every daemon start will append them to existing sentinels.json files automatically — no JSON editing, no migration step. This is how daily-commit-digest reached installs that were already running Slice 1.
  • One-off, machine-local sentinels still need a manual edit. Open ~/.foundry/sentinels.json in your editor, append an entry following the schema shown above, and restart the daemon. foundry sentinel add | remove | edit is deferred to a later slice.

Future slices will also bring non-cron schedule kinds (interval, event_silence) so sentinels can be event-driven, not just timer-driven.

Relationship to launchd

After Slice 1, launchd/com.mojility.foundry-maintenance.plist is removed. Only com.mojility.foundryd.plist remains, and its job shrinks to "keep the daemon alive." If you are upgrading from an older Foundry install that had the maintenance plist loaded, unload and delete it:

launchctl unload ~/Library/LaunchAgents/com.mojility.foundry-maintenance.plist
rm ~/Library/LaunchAgents/com.mojility.foundry-maintenance.plist

After the next foundryd restart, foundry sentinel list should show nightly-maintenance as enabled. Without this cleanup step the daemon and the legacy plist would both emit maintenance_cycle_started each night, doubling the work.

Work reconciliation

The work-reconciler sentinel runs at 30 */3 * * *, or use foundry queue reconcile against the daemon to run it now and print that invocation’s report. It reports exact inventory identities and conservatively settles verified preserved obligations without deleting their evidence. Orphan, broken and unresolved findings, including inspection errors, reach the ops anomaly path even below the normal pressure threshold. See the work reconciler guide for proof and failure semantics.

The Work Queue

For a daily walkthrough, read A morning check.

Foundry runs engineering work continuously — one-shot tasks, campaign cycles, nightly maintenance, releases, remediations. The work-item ledger is the durable record of that work as units, so you can answer "what is running, and what still needs me?" without reading raw events, worktrees on disk and remote branches.

foundry queue is the operator's view of that ledger. The overview, show and open read it; close and cancel let an owner discharge specific items; hold, release and foundry pacing steer what starts next (see Pacing).

What a work item is

A work item is one unit of work Foundry dispatched. It is recorded at the root of its chain — the event that set the work in motion — so a dispatch that stops early is still in the ledger rather than invisible.

Each item carries a stable id (wi_ followed by 24 hex characters), the project it belongs to, the objective it serves, its kind, its lane, an opaque origin string Foundry never interprets, a typed source saying what dispatched it, the timestamps of its life, its state, a one-line reason for that state, the workflow trace_id it runs under, and — once it settles — its settlement fields.

The ledger lives in a single JSON file, ~/.foundry/work-items.json (override with FOUNDRY_WORK_ITEMS_PATH). It is owned by foundryd and authoritative: every mutation loads the file, applies the change, and saves it through a same-directory temp-file rename.

The seven kinds

KindWhat it isAdmitted rootSettlement
taskA one-shot taskTask ExecutionRequestedTyped TaskRunCompleted
campaign_cycleOne campaign cycleTask ExecutionRequestedTyped TaskRunCompleted
major_upgradeA nightly major upgradeTask ExecutionRequestedTyped TaskRunCompleted
maintenanceA project maintenance runProjectRunStarted, ProjectMaintenanceRequestedWhole project traversal
releaseA releaseReleaseRequestedRelease, pipeline and installation traversal
remediationPipeline or vulnerability workPipeline and vulnerability rootsWhole remediation traversal
workflowOther engineering work, including agentsIteration, validation, formation, digest, summary and scan rootsWhole traversal

Task settlement retains its typed verdict. Its execution slot stays occupied until task cleanup finishes. Other kinds settle after the whole traversal. A release reached during remediation uses that root's existing ownership.

The three lanes

LaneWho submitted the work
interactiveA person at the CLI
campaignA campaign derived it
maintenanceA scheduled maintenance run dispatched it

Origin: how the work reached Foundry

An item's origin says how the work arrived. For work an automation dispatched that is the lane itself:

Dispatched byOrigin
foundry taskfoundry task
A campaign cyclecampaign <name> cycle <n>
The nightly majors lanenightly majors lane

For work a person asked for by hand, the useful extra fact is who asked and from where. foundry task and foundry campaign advance therefore record the CLI client's hostname, and accept an optional --origin <text> note that is recorded verbatim beside it:

foundry task acme "Add a --quiet flag" --origin "asked by Stacey in standup"
foundry campaign advance tidy-cli --origin "kicked off by hand after the fix"

The recorded origins then read:

foundry task (host workbench: asked by Stacey in standup)
campaign tidy-cli cycle 4 (host workbench: kicked off by hand after the fix)

The operator context is appended to the dispatch origin rather than replacing it, so a campaign cycle still says which campaign and cycle it is. If the hostname cannot be read, the origin states host unknown host rather than going silent — "the lookup failed" is a more honest record than "no operator was involved".

Origin is opaque. Nothing parses, validates, filters or groups on it; foundry queue only displays it, and no dispatch is ever rejected, delayed or altered because of what its origin says — an empty --origin is accepted and simply adds nothing. Cycles dispatched by the automatic post-result advance carry no operator context, because no operator issued them.

Source: what dispatched the work

Origin is text for a person. source is the typed answer to the same question, recorded at submission and never changed, so the ledger alone can answer "everything this campaign dispatched" or "what did the nightly start" without reading the event log to recover a campaign from a trace.

A source is a kind from a closed set, the one ref that names the dispatcher, and, for a campaign, the cycle number:

KindrefRecorded when
campaignthe campaign namea campaign advance dispatches a cycle; cycle is the cycle number, whoever asked for the advance
sentinelthe sentinel namea sentinel-fired chain dispatches an item: the nightly's per-project maintenance runs, its majors-lane upgrades, a remediation or release it reaches
operatorthe hostname of the CLI that askedfoundry task, foundry iterate, foundry run, foundry release, foundry pipeline, and any item a chain they start reaches
work_itemthe parent item idan item created from another item: a queue resume child or a nightly continuation (its resumes link is unchanged)

The kinds are a closed enum; adding one is a code change, never a free string. The source travels on the event envelope: whatever emits a root event names it there, and every event below the root inherits it verbatim (see Tracing), which is how a per-project run three hops below the nightly's root still knows which sentinel fired it. A release that follows remediation retains the root's source and execution slot.

An item recorded before the source existed has none, and so does one whose root named none (a raw foundry emit). Every reader treats that as not recorded, exactly as worktree_removed is handled: queue show prints no Source: line, --json emits no source key, and a row shows -. The free-text origin is untouched by any of this; the two sit side by side.

To list one source's items:

foundry queue --source campaign:bedrock-gated-trials-v1   # every cycle of that campaign
foundry queue --source sentinel:nightly-maintenance       # what the nightly started
foundry queue open --source operator:workbench            # open work asked for from that host

The filter is an exact match on kind and ref, applied by the daemon's ListWorkItems (and by the same selection offline); an unknown kind, or a kind with no ref, is refused. --source goes before or after open. foundry campaign show <name> lists the campaign's cycles through this same filter. The filter also selects escalated campaigns: campaign:<name> shows that campaign's escalation, and every other kind shows none.

The states

An item moves through unsettled states, then settles. Settled does not mean finished: three of the settled states still hold an obligation for a person, which is why the ledger says an item settles rather than completes.

StateMeaningGroup
submittedAdmitted to the ledger, not yet queuedQueued
queuedWaiting for the scheduler to start it; reason says why it waitsQueued
heldTaken out of the scheduler's hands by queue hold until releasedQueued
runningAn agent is working on itRunning
preservedWork is held on a durable ref for a later cycleOpen
needs_decisionWork stopped on a question only a person can answerOpen
failedWork stopped on a faultOpen
landedThe work reached trunkTerminal
cancelledAn operator stopped the workTerminal

landed and cancelled are terminal — nothing more is owed. preserved, needs_decision and failed are open — settled, but still carrying something for you to discharge. foundry queue open is that set, plus every escalated campaign.

Escalated campaigns wait on you too

An escalated campaign has stopped until its owner decides, resumes, completes or cancels it. That is as much "waiting on you" as a preserved item, but it is campaign state, not a unit of executed work, so the ledger never records it as an item. On 2026-10-11 two of four escalated campaigns had no open item at all, because they escalated after their final cycle landed, and queue open did not show them.

So foundry queue open, and the open group of the foundry queue overview, list every escalated campaign after the open items, one row each:

Open — needs a person
  wi_ccc3  beta  major_upgrade  maintenance  sentinel:nightly-maintenance  preserved  2026-09-29T03:10:00+00:00  gates red after the bump
  campaign  mojentic-ex-v2  mojentic-ex  escalated  2026-10-11T03:12:40+00:00  campaign cycle budget exhausted (6)
            next: foundry campaign show mojentic-ex-v2 (decide, resume, complete, cancel)

The row carries the campaign, its project, when it escalated and why (see What an escalated campaign shows its owner), then the command that prints the full reason and the owner's command lines. The reason reads (reason not recorded) when neither the campaign nor the event log has one.

Every escalated campaign is listed, including one that also has an open cycle item. The two are not duplicates: the item is the work and its evidence (a preserved ref, a failure), the campaign row is the decision and the commands that answer it. The row names the item, as (open cycle wi_…), so they read as one obligation. Hiding the campaign row when an item exists would hide the reason and the commands again, which is the defect this fixes; hiding the item would make the ledger view incomplete.

Every kind enters queued and starts only when the shared scheduler permits. Its reason names what it waits on, or says ready. The submitted state exists in the model, but normal admission records the queued state directly.

Queued and held items survive a daemon restart as they are: the scheduler reads them back off the ledger and picks up where it left off.

Daemon restarts settle running items as failed

A running item means "an agent is working on it", and after a daemon restart no agent is. On every start foundryd settles each item still running as failed, with the reason daemon restarted. This is deliberate: reporting long-dead work as running is worse than reporting it as failed, and a failed item is visible in foundry queue open where you can decide what to do with it.

The six lifecycle events

The ledger emits six events. They are the owner-specified exception to Foundry's *Started/*Completed pairing rule.

EventMeaning
work_item_submittedA unit of work entered the ledger; for paced work, state is queued and reason says why it waits
work_item_startedAn agent started on a ledger item
work_item_settledA ledger item reached a settled state, with its disposition
work_item_cancelledAn operator, or the item's own campaign, stopped a ledger item
work_item_heldAn operator took a queued item out of the scheduler's hands
work_item_releasedAn operator returned a held or dependency-blocked item to the queue

work_item_started pairs with work_item_settled rather than a work_item_completed, because an item does not complete — it settles, into a state that may still hold an obligation.

work_item_cancelled is emitted by foundry queue close, foundry queue cancel, and foundry campaign cancel <name> --reason … --now, which stops the in-flight cycle outright and cancels the campaign's queued cycles with it. It is also emitted when a campaign moves past an open cycle (see "Campaign cycles the campaign has moved past" below). It carries the item's id, project, kind, lane, state, reason and origin, plus the settlement fields, and for the aborted cycle it rides the aborted cycle's trace rather than the cancellation's — so it sits with the rest of the events about that unit of work. Like the other five it reaches foundry watch and the durable JSONL event log.

Commands

foundry queue [--source <kind>:<ref>] [--json] [--offline]
foundry queue show <id> [--json] [--offline]
foundry queue open [--source <kind>:<ref>] [--json] [--offline]
foundry queue hold <id> [--origin <text>]
foundry queue release <id> [--origin <text>]

foundry queue

Prints four groups on one screen, in this order:

  1. Running — items an agent is working on, by start time.
  2. Queued — items in submitted, queued or held, by submission time.
  3. Open — needs a person — preserved, needs_decision and failed, newest settlement first.
  4. Settled (last 20) — the newest 20 terminal (landed, cancelled) items. Older terminal items are omitted; the terminal group grows forever, so the overview shows only its newest page. The cap applies to this group alone.

Each line shows the item's id, project, kind, lane, source (- when none was recorded), state, the timestamp that placed it in its group, and the one-line reason:

Running
  wi_aaa1  alpha  task  interactive  operator:workbench  running  2026-09-30T01:00:05+00:00  running

Queued
  (none)

Open — needs a person
  wi_ccc3  beta  major_upgrade  maintenance  sentinel:nightly-maintenance  preserved  2026-09-29T03:10:00+00:00  gates red after the bump

Settled (last 20)
  wi_ddd4  alpha  release  maintenance  -  landed  2026-09-28T02:40:00+00:00  released v1.2.0

The group order and the order within each group come from the daemon's ListWorkItems response. The CLI groups by state; it never re-sorts. With --source <kind>:<ref> the daemon returns only that source's items, in the same order, and the groups show those.

foundry queue show <id>

Prints one item's full durable record, one field per line, including every settlement field, then a blank line and the item's own work_item_* events. An optional field the ledger never recorded produces no line at all, so you never have to tell a recorded empty string from an unset field. worktree_removed is the sharpest case: a recorded false prints Worktree removed: no, while "no worktree recorded" prints nothing. Source: follows the same rule: an item recorded before the source existed prints no Source: line.

Id:               wi_ccc3
Project:          beta
Objective:        bump serde to 2.0
Kind:             major_upgrade
Lane:             maintenance
Origin:           nightly majors lane
Source:           sentinel:nightly-maintenance
State:            preserved
Reason:           gates red after the bump; work held on a branch
Submitted:        2026-09-29T02:00:00+00:00
Started:          2026-09-29T02:00:01+00:00
Settled:          2026-09-29T03:10:00+00:00
Trace:            abcdef0123456789abcdef0123456789
Verdict:          remainder
Preservation ref: foundry/majors/serde
Worktree:         /home/you/.foundry/worktrees/beta/x
Worktree removed: no

Events:
  2026-09-29T02:00:00+00:00  work_item_submitted  submitted       evt_1a2b…  submitted
  2026-09-29T02:00:01+00:00  work_item_started    running         evt_3c4d…  agent started
  2026-09-29T03:10:00+00:00  work_item_settled    preserved       evt_5e6f…  gates red after the bump; work held on a branch

Each event line shows when it occurred, its type, the state it left the item in, the event id and the one-line reason. The events are read from the durable event log (FOUNDRY_EVENTS_DIR, one YYYY-MM.jsonl file per month) through the daemon's ListWorkItemEvents RPC, and are:

  • selected by the item's id in the event payload — never by trace or project. Multiple independently queued roots can share a trace and project; each shows only its own events.
  • read from every monthly file, however old, so an item from last year still shows its history rather than a false "no events".
  • in chronological order — oldest first, ties in the order they were logged.

An item with no events in the log (or no event log at all) prints (no events) under the heading rather than nothing. A line in the log that is not valid JSON — or that holds two events run together — is skipped with a warning in the daemon log; the item's other events are still shown. A fault reading the log itself is an error, never an empty history.

foundry queue open

Prints only the open group — the work that still needs a person, then the escalated campaigns. This is the command to run when you want the shortest possible answer to "what is waiting on me?"

--json

All three commands take --json. The JSON form renders from the same fetched data as the human form, so the two can never disagree about which items the daemon returned.

  • foundry queue --json and foundry queue open --json emit one object with two arrays: items, the ledger records, and escalated_campaigns, one entry per escalated campaign the view lists. Through 0.44.4 both emitted a bare array of items; a script that read .[] now reads .items[]. A campaign entry carries name, project, status, cycles_completed, cycles_landed, max_cycles, open_escalation (absent when no reason was recorded), open_cycles (the ids of its open items, absent when none) and owner_options (control, command, effect for each). The two arrays are separate because a campaign escalation is not a ledger record: a consumer that counts items never counts a campaign.
  • foundry queue show <id> --json emits a single JSON object: the record's keys, unchanged, plus an events array. Each event carries id, event_type, occurred_at, state, reason and, when it has one, trace_id; an item with no events has "events": [].

Optional fields are absent when unset rather than emitted as null, "" or false, so the JSON round-trips the same facts the record carries:

{
  "items": [
  {
    "id": "wi_ccc3",
    "project": "beta",
    "objective": "bump serde to 2.0",
    "kind": "major_upgrade",
    "lane": "maintenance",
    "origin": "nightly majors lane",
    "source": { "kind": "sentinel", "ref": "nightly-maintenance" },
    "submitted_at": "2026-09-29T02:00:00+00:00",
    "started_at": "2026-09-29T02:00:01+00:00",
    "settled_at": "2026-09-29T03:10:00+00:00",
    "state": "preserved",
    "reason": "gates red after the bump; work held on a branch",
    "trace_id": "abcdef0123456789abcdef0123456789",
    "verdict": "remainder",
    "preservation_ref": "foundry/majors/serde",
    "worktree": "/home/you/.foundry/worktrees/beta/x",
    "worktree_removed": false
  }
  ],
  "escalated_campaigns": []
}

Note what is not there: landed_commit. The item did not land, so the field was never recorded, so it is absent. A source has the same keys the ledger writes (kind, ref, and cycle for a campaign); an item that records none has no source key. The items array is the fetched list unchanged — neither grouped nor capped at 20 — so a consumer can apply its own rules.

Online versus offline

CommandDaemon required?Notes
foundry queue [--source <kind>:<ref>]Yes (or --offline)Renders ListWorkItems; --source is its exact-match source filter. The open group adds the escalated campaigns from ListCampaigns
foundry queue show <id>Yes (or --offline)Renders GetWorkItem, then ListWorkItemEvents
foundry queue open [--source <kind>:<ref>]Yes (or --offline)Renders ListWorkItems, open group only, then the escalated campaigns from ListCampaigns

Online is the default and is daemon-authoritative. All three commands render the daemon's response directly and never read, create or mutate the client-side ledger or events files: if FOUNDRY_WORK_ITEMS_PATH or FOUNDRY_EVENTS_DIR is absent, the online path leaves it absent, and an existing file is left byte-for-byte untouched. If foundryd is not listening, the command fails with a stable actionable error naming the matching --offline recovery command. There is no silent fallback.

A NOT_FOUND from GetWorkItem surfaces as an id-not-found error and a non-zero exit, not as an empty record.

--offline is explicit recovery for when the daemon is stopped and you intentionally want direct file access. It reads FOUNDRY_WORK_ITEMS_PATH (and, for show, FOUNDRY_EVENTS_DIR) directly and never contacts the daemon, applying the same grouping order the ListWorkItems contract documents and the same event selection and order ListWorkItemEvents documents — --offline differs from the online path only in transport, never in reading order.

  • A missing ledger file renders four empty groups and exits zero. Absence is the normal starting state, not a fault, and a read never creates the file.
  • A malformed ledger file is an error: the command exits non-zero and names the parse failure.
  • --source applies the same exact-match selection to the file the daemon applies to its store, so a filtered --offline read lists the same items.
  • The overview and open read escalated campaigns from FOUNDRY_CAMPAIGNS_PATH, and recover a pre-record escalation reason from FOUNDRY_EVENTS_DIR, the same way ListCampaigns does. A missing campaign store has no campaigns, and a read never creates it.

Close or cancel an item

foundry queue close wi_0123456789abcdef01234567 --reason "Reviewed; no further work required" --origin "owner review"
foundry queue cancel wi_0123456789abcdef01234567 --origin "withdrawn request"

close discharges an open obligation in preserved, needs_decision or failed; it requires a nonblank --reason. cancel stops a submitted, queued or held item and records the reason cancelled by operator, without requiring a reason argument. Both settle exactly the requested id as cancelled.

Both require the daemon, reject --offline, and never fall back to local writes. They record this CLI's hostname and optional --origin in operator_action, separately from the original submission origin. The action retains the previous state, reason and settlement timestamp. Submission identity, objective, trace and known disposition remain intact, including preserved refs and worktree-removal observations. No agent starts, running workflow stops, or preserved work is disposed of by either command.

The daemon reloads the ledger under its shared write gate and saves by atomic replacement before emitting work_item_cancelled to Watch and the durable log. Earlier event bytes stay untouched. Unknown ids return NOT_FOUND, blank input returns INVALID_ARGUMENT, and other states return FAILED_PRECONDITION. Malformed ledgers also return FAILED_PRECONDITION; read or save failures return INTERNAL and a failed save emits no cancellation. Concurrent requests for one item can succeed only once.

Follow up on open work

foundry task <project> "Fix the review findings on wi_X's branch" --continues wi_0123456789abcdef01234567

A follow-up task often finishes work that another item left open: it builds on that item's branch, fixes its review findings, and lands. foundry task <project> "<description>" --continues <id> (repeatable) records that lineage. Each id must name an item in the ledger (otherwise INVALID_ARGUMENT, as for --after) that belongs to the same project and is open, that is preserved, needs_decision or failed (otherwise FAILED_PRECONDITION). A refused dispatch admits and runs nothing.

--continues is lineage only. It does not change the task's base or its objective, and it does not make the task wait: combine it with --after for that. Use foundry queue resume instead to re-run a preserved item's own objective from its preserved branch.

The new item records the ids in continues, shown by foundry queue show in human and JSON output, by the queue overview as (continues <id>, …), and in its lifecycle payloads beside resumes. When the new item settles landed with a landing commit, each continued item that is still open settles landed with landed_commit set and the reason superseded by <commit> via <new item id>. A work_item_settled event records it on that item's own trace, and the guarded branch cleanup runs as for any landed item. The decision is taken on the ledger as loaded under the shared write gate, so an item the owner already closed or cancelled stays as it is. A follow-up that does not land, including a complete that delivered nothing, leaves the continued items open and unchanged.

Resume preserved work

foundry queue resume wi_0123456789abcdef01234567 --origin "finish preserved work"
foundry queue resume wi_0123456789abcdef01234567 --after wi_89abcdef0123456789abcdef --not-before 30m

Owner-directed preserved-work continuation uses foundry queue resume <id> [--origin <text>] [--after <id>]... [--not-before <RFC3339|duration>]. It requires a live daemon and refuses --offline. Only a preserved item with a usable preservation ref and a registered project can be resumed. Foundry records a new task with the original objective, queued for the pacing scheduler, that starts from the preserved local branch, remote ref or bundle:<path> through the existing continuation path. When the preserved work does not contain current trunk, Foundry rebases it onto freshly fetched trunk before the agent starts (the preservation ref itself is not rewritten). If that rebase conflicts, the child is refused before any agent session: it settles failed with land_blocked: continuation_conflict and a reason that names the conflicting files, and the original stays preserved. --after and --not-before carry the same meaning as on foundry task. The new record and its lifecycle payloads expose resumes, the exact original id. Queue reads show this link in human and JSON output. Submission identity and the parent's prior evidence remain intact; the child records the hostname and optional origin in its resume operator action.

The original obligation stays preserved until the linked task actually lands. Then Foundry records its landing commit and appends work_item_settled for the original. Failed, blocked, preserved and no-landing results leave it open. An original cancelled by its owner stays cancelled even if its child lands. Unknown ids return NOT_FOUND, blank inputs INVALID_ARGUMENT, ineligible states or unusable evidence FAILED_PRECONDITION, and persistence failures INTERNAL. A rejected admission dispatches no execution and invokes no agent.

Admission first saves a child in failed with reason resume admission incomplete; execution not dispatched, no started_at, a settled_at, and the exact resumes and operator context. Only after both work_item_submitted and work_item_started roots have been appended successfully does Foundry save that child as running and dispatch it. If the initial ledger save fails, no child or lifecycle event is created. A later lifecycle or final ledger-save failure leaves the staged failed child; the parent's obligation and unrelated records remain unchanged. The failed child has no preservation disposition; continue using the preserved parent's id. An interruption during admission also leaves this non-running record.

Watch publishes each admission lifecycle root only after its write succeeds. A rejected admission may retain a successfully written submitted event, or both roots if the final ledger save fails. These are evidence of an admission attempt, not proof of execution: consult the ledger and RPC result. Earlier event bytes, including any partial failed append, remain intact; Foundry never truncates or rewrites history to undo admission. Ordinary engine roots, block outputs, scatter/gather, progress, close/cancel and restart settlement retain their existing best-effort event-persistence behaviour.

When an otherwise eligible nightly major upgrade matches a preserved ledger item by exact registered project, package and target version, the majors lane resumes that obligation from its preservation ref and original objective. Its child records kind: major_upgrade, lane: maintenance, origin nightly majors lane and the exact resumes id; automation records no operator action. The parent retains its submission identity and evidence. Only an actual child landing settles the preserved parent landed; other outcomes leave it open, and owner cancellation remains terminal. A selected resume admission failure is reported and never falls back to a fresh task.

Continuations count against the existing nightly caps and run sequentially with fresh upgrades in the existing order. Update policies, holds, security eligibility, successful maintenance and in-flight suppression still apply. Reviews, dry runs and interrupted-cycle summaries dispatch nothing. Without a matching preserved ledger item, the existing fresh-task and history-suppression rules apply. Owner queue resume remains an interactive, asynchronous task.

After a task actually lands, Foundry also checks that registered project's other open ledger items (preserved, needs_decision or failed) that recorded a preservation ref against the registered trunk branch, using the registered checkout path. Three proofs, tried in this order, can settle such an item landed:

  • Ancestry: the preserved commit is reachable from trunk.
  • Patch equivalence: git cherry matches every one of a nonempty set of preserved commits to a patch on trunk.
  • Content containment: git merge-tree --write-tree merges the preserved commit into trunk without a conflict, and the merged tree is trunk's own tree. Trunk then already holds every change the branch makes relative to their merge base. This is how work that a later cycle squashed, or rebased with the same conflict resolution, is recognised when its patches no longer match.

Its landed_commit is the verified trunk commit and its reason is exactly superseded by <commit>, whichever proof succeeded; the structured log names the proof. The appended work_item_settled event carries that item's exact id, project and original trace. Objective, submission origin, resumes and prior preservation evidence remain intact.

A needs_decision or failed run keeps a preservation ref even when it made no commits. Its branch then still points at the trunk commit it started from, which every later trunk contains. Such an item settles only when its preserved tip was committed after the item started, compared at whole seconds; otherwise it stays open with a diagnostic, because its obligation is the question or the fault, not work that can land. An open item without a preservation ref, such as one blocked on a dependency before it ran, is not checked. Content containment changes no ref, index or worktree; it writes the merged tree as unreferenced loose objects, which garbage collection removes. It needs Git 2.38 or later. Once an item settles landed, its recorded task branch locally and preservation branch locally and on origin are eligible for best-effort deletion. Each ref needs fresh proof by one of the three checks above against the registered trunk. Unowned refs, bundles, branches checked out in any worktree, and refs also owned by unlanded items in any registered project sharing the repository are kept. Ownership compares exact refs across repository slugs and shared Git common directories, including separate clones and linked worktrees. Deletion uses the proved commit as a guard against concurrent ref changes. The disposition and settlement event retain each ref name, observed commit, deletion result and failure reason. A failed deletion leaves the item landed. Preservation evidence for unlanded work is never deleted. Cleanup decides eligibility under the ledger write gate, releases it for all Git operations, then reloads under the gate to record outcomes. Concurrent ledger updates are retained; a changed state or ref ownership is never overwritten by stale cleanup evidence.

This check runs only after actual task landing. Failed, blocked, preserved and no-landing task results do not trigger it. It checks only open items with a preservation ref in the same registered project; owner-cancelled items remain cancelled. A repeated terminal does not append another settlement. The ledger is reloaded under the shared write gate before saving, so concurrent owner actions and unrelated updates are retained. A failed save emits no settlement.

Missing refs or objects, failed Git commands, preserved changes that trunk does not hold (unmatched patches whose merge into trunk conflicts or would still change trunk's tree), and ambiguous evidence leave the item open with an explicit supersession diagnostic in the settlement block's result and structured log. When patch equivalence and content containment both fail, the diagnostic gives each reason. Empty Git output is never proof. Local branch refs and commit ids are checked directly. Branch names are also checked by exact name on origin when configured, without fetching; conflicting local and remote heads remain unresolved. A remote-only head must already have locally available objects. A bundle must advertise exactly one head and that commit must already be locally available. Patch comparison and content containment require complete, shared history; patch comparison rejects merge commits that git cherry cannot account for. Queue show and JSON expose the persisted commit and reason through the daemon's read path. Scheduled invocation and the reporting reconciler are not delivered by this landing-triggered check.

What queue does not do

queue close, queue cancel, queue hold and queue release do not stop running work. To stop an in-flight campaign cycle use foundry campaign cancel <name> --reason … --now.

Pacing

Pacing is the stage between admission and execution. Every engineering workflow enters the same durable queue before any block runs. This includes manual and nightly maintenance, tasks, campaign formation and cycles, iteration, validation, releases, remediation, supply-chain scans, and agents that write digests. Raw emitted events pass through the same boundary.

The nightly records all project roots before it waits for their results. Queued or held maintenance remains outstanding in its cycle's gather. A restart rejoins these roots. Cancellation produces a failed project completion, so the cycle reports what stopped instead of reporting success.

One admitted root owns its downstream workflow through release installation and task cleanup. Those continuations use the existing slot. Independent tasks and campaign advances enter the queue before their parent releases its slot; they cannot deadlock by waiting inside that parent.

System digests and maintenance summaries consume host capacity without locking project repositories. They share a system resource lock so concurrent digests cannot overwrite shared output files or watermarks. A supply-chain scan with auto-fix enabled reserves every active repository it can change. The current scanner handles those projects sequentially and writes one aggregate digest. This conservative reservation can wait behind a busy repository; it never skips that repository. With auto-fix disabled, the scan consumes only a system slot.

Dry runs use the same queue because observers can run agents or checks. Mutators remain simulated, and the settlement reason says dry-run completed; mutations simulated. Lightweight status, work reconciliation and operator controls remain immediate. Submission and start lifecycle writes must succeed before execution begins. The scheduler validates the complete persisted root; a payload with an item id cannot grant execution ownership.

The rules

The scheduler evaluates every queued item on each tick, in this order, and starts the first items the rules let through:

  1. One mutating item per repository. A repository is the registered GitHub slug, or the registered checkout path when there is no slug, so two projects on one slug share a repository. An admitted workflow occupies it until all its side effects finish.
  2. Host running cap. At most max_running items (default 2) run on the host at once, whatever their lane.
  3. not_before has passed.
  4. Every depends_on item has settled landed.
  5. The item's lane is not paused.

Candidates are considered interactive lane first, then oldest submission first. A start earlier in a tick counts against the repository and host rules for the items after it.

An item can reserve the repositories of other projects. A supply-chain scan with auto-fix waits until all its reserved repositories are free. An initiative assessment reserves its repositories only if they are free.

For an initiative assessment, rule 1 applies to its own repository only. When the other rules let it start, it holds all reserved repositories if all are free. If one of them is busy, it starts with its own repository only. Its work item then shows no reserved repositories.

A dependency that can never land is not worth waiting for. When a dependency settles any way other than landed, or names an id that is not in the ledger, the dependent settles needs_decision with the reason waits on <id>, which settled <state> (or which is not in the ledger). foundry queue release <id> returns it to the queue: the settled dependencies are dropped from depends_on and the item runs regardless of them.

The reasons

A queued item's reason is the scheduler's current verdict, refreshed on every tick and visible on foundry queue rows, queue show, pacing show and in --json:

ReasonMeaning
repository busy: wi_…That running item occupies the repository
host at capacity N/NThe host running cap is reached
not before <time>Its not_before has not passed
waits on wi_…That item has not yet settled landed
lane pausedIts lane is paused
readyNothing holds it; the next tick starts it
held by operatorqueue hold took it out of the scheduler's hands

Scheduling a task

foundry task <project> "<description>" --after wi_a --after wi_b --not-before 2h
foundry queue resume wi_c --after wi_a --not-before 2026-10-11T09:00:00Z

--after <id> (repeatable) adds a dependency; every named id must already be in the ledger or the dispatch is refused with INVALID_ARGUMENT. --not-before takes an RFC 3339 timestamp or a duration from now: 90s, 30m, 2h, 1d. foundry task waits for its own run exactly as before; the wait now includes the time the item spends queued.

Hold and release

foundry queue hold wi_0123456789abcdef01234567 --origin "wait for the review"
foundry queue release wi_0123456789abcdef01234567

hold takes a queued item out of the scheduler's hands; it sits held with the reason held by operator until released. release returns a held item, or one in needs_decision over a dependency, to queued with the scheduler's current verdict as its reason. Both require the daemon, reject --offline, record this CLI's hostname and optional --origin in operator_action, and emit work_item_held / work_item_released on Watch and in the durable log. queue cancel also accepts a held item.

Pausing lanes

foundry pacing show [--json] [--offline]
foundry pacing pause [--lane interactive|campaign|maintenance|all] [--origin <text>]
foundry pacing resume [--lane interactive|campaign|maintenance|all] [--origin <text>]
foundry pacing drain [--timeout <duration>] [--origin <text>]

pacing show prints the limits in force, every running item with the repository it occupies, every waiting item with its reason in the scheduler's priority order, and the paused lanes. A waiting item that reserves repositories only if free shows them as (and <repositories> if free). --offline reads the ledger and the pacing files directly when the daemon is stopped.

pacing pause stops new starts in a lane (default all); running items are untouched. pacing resume allows starts again, and the next tick starts the next eligible item. Both emit pacing_paused / pacing_resumed for the project system, durable and on Watch. The pause state is persisted through an atomic rename and is in force after a daemon restart.

pacing drain pauses every lane, then waits until nothing is running, printing each item as it settles, and exits 0 when the daemon is idle. With --timeout it gives up after that long and exits 1 naming what still runs. It does not stop the daemon and does not resume the lanes afterwards: run pacing resume when you are ready for work to start again.

The two files

FileOwnerShape
~/.foundry/pacing.json (FOUNDRY_PACING_PATH)You{"max_running": 2}; a missing file means the defaults
~/.foundry/pacing-state.json (FOUNDRY_PACING_STATE_PATH)foundryd{"version": 1, "paused": ["campaign"]}; written by pause and resume

A malformed pacing.json falls back to the defaults with a warning. A malformed state file fails closed: every lane is treated as paused until the file is repaired or rewritten by a pacing resume.

What pacing does not do

Per-provider caps, starts-per-hour limits, provider-health gating and dashboard controls are out of scope. pacing drain does not refuse a daemon stop; it only tells you when a stop would interrupt nothing.

What campaign cancel --now does to the cycle's item

A --now cancellation kills the in-flight cycle, so the TaskRunCompleted that normally settles its item never arrives. Foundry therefore settles that item itself:

  • The running item carrying the aborted run's trace settles cancelled, with your --reason text as its reason and the settlement time recorded. It moves out of Running and into the Settled (last 20) group; cancelled is terminal, so it never shows up under queue open.
  • Its disposition records the cycle's worktree and whether that worktree is gone, observed after disposal ran — plus the branch (or bundle: path) the work was preserved on. With --discard-work there is no preservation ref, because the work was thrown away. A cycle that had built no worktree records no disposition at all.
  • Correlation is by trace alone, exactly as a normal settlement: if no running item carries the aborted run's trace, nothing is settled. Foundry never falls back to "the project's newest running item", because that would report an unrelated concurrent run as cancelled.
  • A graceful cancel (no --now) does not stop the in-flight cycle: it finishes on its own and then settles cancelled, as described in the next section. A --now cancel with nothing in flight, and cancelling an already-cancelled campaign, both change no item either.

The ledger is bookkeeping beside the cancellation, never a precondition for it: if the ledger cannot be read or written, the campaign is still cancelled and the CampaignCancelled event is still emitted — the fault is logged, and the item is left for the restart sweep.

Campaign cycles the campaign has moved past

A campaign forms each cycle from the previous cycle's preserved ref, or from trunk. An earlier cycle left preserved, needs_decision or failed is therefore not owed by a person: the campaign carried it, and the campaign's own escalation is what reaches a person. Foundry settles such cycles itself, as cancelled, without an operator_action, keeping their preservation ref and worktree fields:

  • When the scheduler starts a later cycle of the same campaign, every open cycle with a lower cycle number settles with the reason carried forward by campaign <name> cycle <n> (<item id>). This happens before the new cycle's agent runs. The newest cycle submitted carries; one that was itself cancelled carries nothing. An escalated (needs_decision) cycle is the live question, so it stays open until the campaign advances again or ends.
  • When the campaign completes (formation done or foundry campaign complete) or is cancelled, its remaining open cycles settle with the reason campaign <name> completed or campaign <name> cancelled, followed by : <reason> when one was recorded.
  • A graceful cancel lets the in-flight cycle finish after CampaignCancelled. When that cycle settles open, its own work_item_settled finds the campaign already cancelled in the campaign store, and the cycle settles cancelled then. It is not stranded open.
  • The campaign's formation runs (campaign_advance_requested), which durable admission records as workflow items with the campaign's source, follow the same rules. An open formation run settles with the carried-forward reason when a cycle submitted after it starts, and with the terminal reason when the campaign completes or is cancelled. A formation run never carries other items.

Each settlement emits work_item_cancelled on that item's own trace. Only campaign_cycle items whose typed source names the campaign and a cycle, and workflow items whose typed source names the campaign, are touched. An item recorded before the typed source existed, another campaign's item, a non-campaign item and a settled item never change. The decision is taken on the ledger as loaded under the shared write gate, in the same pass that writes it, so an owner action that landed first wins. The Settle Carried Campaign Work block runs on every campaign cycle's and formation run's work_item_submitted, work_item_started and work_item_settled, and on campaign_completed and campaign_cancelled; each trigger decides from the current ledger, so a repeated or reordered trigger changes nothing more. Durable admission persists and broadcasts work_item_submitted without routing it to blocks, so in the daemon the carry happens at work_item_started.

Work reconciliation

The work-reconciler sentinel runs at 30 */3 * * *, or use foundry queue reconcile against the daemon to run it now and print that invocation’s report. It reports exact inventory identities and conservatively settles verified preserved obligations without deleting their evidence. Orphan, broken and unresolved findings, including inspection errors, reach the ops anomaly path even below the normal pressure threshold. See the work reconciler guide for proof and failure semantics.

Worktree housekeeping after validation

Successful validation runs Cleanup Branches, including during nightly maintenance and foundry validate. It prunes Git metadata for directories that no longer exist. An existing secondary worktree is removed only when it is under that project's Foundry worktree root (FOUNDRY_WORKTREES_DIR, default ~/.foundry/worktrees), has no submitted, queued or running work-item owner or active campaign cycle, has no uncommitted files (including ignored files), and has no commits absent from every remote-tracking ref. Cleanup uses ordinary git worktree remove, without force.

Every retained worktree is named in the validation housekeeping summary and structured log, with its reason: outside Foundry ownership, owned by live work, or holding unpreserved work. A failed safety check also retains the worktree and reports the error. Cleanup reloads the daemon-owned ledger for each deletion and releases its write gate and campaign store lock before every Git await. Ownership is checked again immediately before each individual removal, after inspecting worktree contents. A task is admitted on ExecutionRequested, before workspace creation on PlanCompleted; the worktree path is recorded after git worktree add. Until that path is known, the live dispatch conservatively protects every candidate for its project. Creation refuses an existing path, so a later admission cannot adopt the existing worktree that cleanup has selected for removal.

Merged branches still use git branch -d. Branches used by live work or recorded preservation refs are retained. This housekeeping does not dispose of the workspace of a task undergoing review; task finalization remains responsible for its own workspace after landing or preservation.

Initiative work

An initiative groups campaigns under a shared outcome. Its cycles carry source: initiative:<name> while retaining the member's campaign and campaign_cycle in their task payload. Initiative steering and standalone dispatches use the same source filter. Their typed root and member identities distinguish them from campaign cycles.

foundry queue --source initiative:shared-contract
foundry queue open --source initiative:shared-contract

The open queue lists an escalated initiative once and suppresses its member campaign escalation rows. Open ledger items retain their own rows. foundry initiative show <name> lists each shared question, its applicable members and the command that answers it.

A resumed initiative member receives the current brief path and content hash, stage providers and bounded findings. Its diagnosis remains available until work that saw the finding lands.

Work reconciler

The reconciler answers what work remains and where its evidence lives. It compares open and running ledger records against each registered checkout, Git worktree registrations, Foundry worktree directories, and local and origin foundry-task/* branches. It uses the project's registered trunk, including projects whose trunk is not named main.

Run it now with:

foundry queue reconcile

The daemon runs the same work_reconcile_started workflow that the canonical work-reconciler sentinel starts at 30 */3 * * * (local time). The paired work_reconcile_completed records exact findings, settlement identities and counts. Sentinel seeding is additive; existing enabled settings and schedules are preserved.

The CLI prints that invocation's Markdown report returned by the daemon. There is no offline fallback, and --offline is rejected. An unreachable daemon is an error. Inspection, fetch, ledger or report-write failures return gRPC INTERNAL, with diagnostics; they never become a healthy empty inventory. Verified settlements may already be durable when a later report write fails. The completion still records the failure and reaches operational observation.

Reports are written atomically to {FOUNDRY_RECONCILE_DIR}/{YYYY-MM-DD}.md, defaulting to ~/.foundry/reconcile/. Each invocation replaces that day's report; the report is evidence, not a second authoritative store. Findings name exact item IDs, full paths and refs:

  • Orphan Foundry directories and Git worktree registrations, with their branch, registration and directory status.
  • Orphan local and origin task branches, their commit and supersession status (ancestor of, patch-equivalent to or content contained in) against either the local registered trunk or its freshly fetched origin tracking branch. Findings name the trunk that proves supersession and report differing trunk commits. A branch-only fetch refreshes origin tracking refs and retains stale origin tracking branches, even when Git configuration enables pruning.
  • Broken item workspace references and unresolved preservation or inspection evidence. Changes trunk does not hold and ambiguous heads keep obligations open.
  • Informational non-Foundry worktrees and dirty registered checkouts.

Running work is associated through recorded workspace fields or exact project and trace evidence in the durable event log, never by parsing item IDs out of workspace names. A running item without that evidence makes ownership unresolved; its possible workspace is not declared orphaned.

Every open item (preserved, needs_decision or failed) that recorded a preservation ref is checked. Owner-cancelled and other settled items are not. An open item without a preservation ref is not checked; only a preserved one is reported as a broken item. An item settles landed only when a conservative Git proof succeeds. The proofs run in this order:

  • Ancestry: the preserved commit is an ancestor of trunk.
  • Patch equivalence: all its non-merge commits have equivalent patches on trunk (git cherry).
  • Content containment: git merge-tree --write-tree merges the preserved commit into trunk without a conflict, and the merged tree is trunk's own tree. Trunk then already holds every change the branch makes since their merge base, as it does after a later cycle squashed the work or rebased it with the same conflict resolution. This needs Git 2.38 or later. It changes no ref, index or worktree, and writes the merged tree only as unreferenced loose objects.

The informational finding for a settled item names the proving trunk, its commit and the proof, for example superseded by origin trunk refs/remotes/origin/main at <commit> (content containment). When patch equivalence and content containment both fail, the unresolved finding gives each reason.

A needs_decision or failed run keeps a preservation ref even when it made no commits, and that ref then points at the trunk commit the run started from. Such an item settles only when its preserved tip was committed after the item started, compared at whole seconds. Otherwise it stays open with an unresolved finding: its obligation is the question or the fault, not work that can land.

After a successful origin fetch, either the local registered trunk (refs/heads/<branch>) or an origin trunk observed by that fetch (refs/remotes/origin/<branch>) can provide that proof: a stale checkout does not hide landed work, and local work ahead of origin still counts. With no origin or a failed fetch, existing behaviour is retained. Retained tracking refs absent from the successful fetch are never used as origin trunk proof. Shallow history, missing objects, divergent local and remote preservation heads, ambiguous bundles, merge conflicts and changes trunk does not hold remain open by exact ID. The settlement records the verified trunk commit and exact reason superseded by <commit>, retaining submission, continuation and preservation fields. Its work_item_settled event stays on the original trace.

Before saving, the reconciler reloads the ledger under the same write gate as owner controls and compares the entire inspected record. Owner cancellation and concurrent changes win over stale proof. Unrelated records remain intact and repeated reconciliation emits no second settlement. Historical event bytes are never rewritten.

Once an item settles landed, Foundry deletes only its recorded task branch locally and its recorded preservation branch locally and on origin, best-effort. Each deletion requires fresh ancestry, patch-equivalence or content-containment proof against the registered trunk and refuses branches checked out in any worktree. A changed ref fails the guarded deletion. Unowned refs, bundles and refs also owned by unlanded items in any registered project sharing the repository are retained. Ownership compares exact refs across repository slugs and shared Git common directories, including separate clones and linked worktrees. The disposition and settlement event retain ref names, observed commits and per-ref deletion results; deletion failure leaves the item landed. Preservation evidence for unlanded work is never deleted. Cleanup decides eligibility under the ledger write gate, releases it for all Git operations, then reloads under the gate to record outcomes. Concurrent ledger updates are retained; a changed state or ref ownership is never overwritten by stale cleanup evidence.

Reconciliation does not remove worktrees or bundles, run agents or manufacture task terminal events. Git fetch updates objects and tracking refs. The fetch leaves FETCH_HEAD byte-for-byte unchanged, so concurrent task startup and remote or bundle continuation retain their selected commit. It neither imports nor changes nor prunes local tags, including local-only and divergent annotated tags, even when global or origin-specific settings enable tag pruning or tag fetching. Orphan, broken, unresolved and error findings inject a Foundry anomaly through ObserveEvents, so the ops digest proceeds even below its normal 25-event pressure threshold.

Trusted-LAN Control Plane

Foundry's control plane is plaintext gRPC. It is intended only for loopback or for a trusted LAN or VPN segment that you already control with host firewalls and network policy. Foundry does not add TLS, authentication, clustering, or multi-writer coordination in this slice.

Address Configuration

foundryd still defaults to 127.0.0.1:50051.

Set FOUNDRYD_LISTEN_ADDR to bind somewhere else at daemon startup:

FOUNDRYD_LISTEN_ADDR=0.0.0.0:50051 foundryd
FOUNDRYD_LISTEN_ADDR=192.168.10.24:50051 foundryd

foundry resolves the daemon URL in this order:

  1. Explicit --addr
  2. FOUNDRY_DAEMON_ADDR
  3. http://127.0.0.1:50051

Examples:

foundry --addr http://mojility-ops-01:50051 status

export FOUNDRY_DAEMON_ADDR=http://mojility-ops-01:50051
foundry status

Only expose the daemon on a network you already trust. If the host is not on a trusted segment, keep the default loopback bind.

Authoritative State Inventory

Before moving daemon authority to another host, account for every daemon-owned or daemon-written path. Inventory the actual resolved value for every override below on the current Mac before you stop it, because any path moved outside ~/.foundry/ must be copied directly and is part of the single authority set.

| Env var | Default path | Purpose | | --- | --- | | FOUNDRY_REGISTRY_PATH | ~/.foundry/registry.json | Project registry authority | | FOUNDRY_CAMPAIGNS_PATH | ~/.foundry/campaigns.json | Durable campaign authority | | FOUNDRY_SENTINELS_PATH | ~/.foundry/sentinels.json | Sentinel authority | | FOUNDRY_AGENT_CONFIG_PATH | ~/.foundry/agents.json | Agent model/provider configuration | | FOUNDRY_WORKTREES_DIR | ~/.foundry/worktrees/ | Isolated task worktrees | | FOUNDRY_PRESERVED_DIR | ~/.foundry/preserved/ | Preserved bundles/refs for non-complete work | | FOUNDRY_EVENTS_DIR | ~/.foundry/events/ | Durable event log root | | FOUNDRY_TRACES_DIR | ~/.foundry/traces/ | Durable trace storage root | | FOUNDRY_AUDITS_DIR | ~/.foundry/audits/ | Audit artifact root | | FOUNDRY_DIGESTS_DIR | ~/.foundry/digests/ | Commit digest output root | | FOUNDRY_OPS_DIGESTS_DIR | ~/.foundry/ops-digests/ | Ops digest output root | | FOUNDRY_TRIAGE_DIR | ~/.foundry/triage/ | Maintenance triage digest root | | FOUNDRY_SUPPLY_CHAIN_DIR | ~/.foundry/supply-chain/ | Supply-chain digest output root | | FOUNDRY_OPS_EVENTS_DIR | ~/Work/Operations/Events/intake/ | MBOS JSONL intake root consumed by ops digests |

These non-overridable paths are still part of the authority copy set:

  • ~/.foundry/ops-digest.watermark
  • ~/.foundry/agent-sessions/

The environment source itself is also authoritative because it defines where the daemon reads and writes:

  • macOS launchd plist environment entries on the current Mac
  • Linux ~/.config/foundry/foundryd.env on mojility-ops-01

Mac to mojility-ops-01 Migration Runbook

This is a single-authority cutover. Do not allow both daemons to accept writes at the same time.

1. Prepare mojility-ops-01

Install the release binaries and service unit, but do not start the daemon yet:

mkdir -p ~/.config/foundry ~/.config/systemd/user
cp systemd/foundryd.service ~/.config/systemd/user/foundryd.service

Create ~/.config/foundry/foundryd.env on mojility-ops-01:

FOUNDRYD_LISTEN_ADDR=0.0.0.0:50051
FOUNDRY_DIGESTS_DIR=/home/svetzal/Work/Operations/Automation/commit-digests
FOUNDRY_OPS_DIGESTS_DIR=/home/svetzal/Work/Operations/Automation/ops-digests
FOUNDRY_OPS_EVENTS_DIR=/home/svetzal/Work/Operations/Events/intake
FOUNDRY_SUPPLY_CHAIN_DIR=/home/svetzal/Work/Operations/Automation/supply-chain-audits

Adjust host firewalls so only trusted LAN or VPN clients can reach TCP 50051.

Before cutover day, prepare the destination parents for any override that will live outside ~/.foundry/, especially FOUNDRY_OPS_EVENTS_DIR.

2. Confirm Authority Campaigns Are Complete

Before cutover, the following authority campaigns must all be complete:

  • Registry authority campaign
  • Campaign authority campaign
  • Sentinel authority campaign
  • Observability authority campaign

The point is to freeze topology and state semantics before the copy, not to migrate live concurrent writes.

3. Stop the Mac Daemon

Freeze the current writer before copying any state:

launchctl unload ~/Library/LaunchAgents/com.mojility.foundryd.plist

Do not start mojility-ops-01 yet. At this moment there should be zero active writable daemons.

4. Inventory the Mac's Actual Authoritative Paths

Record the real source path for every override, including paths that still happen to point inside ~/.foundry/. The migration copy must use these resolved locations, not assumptions:

plutil -p ~/Library/LaunchAgents/com.mojility.foundryd.plist

Build a cutover checklist that names the exact current Mac source and the exact mojility-ops-01 destination for each of:

  • FOUNDRY_REGISTRY_PATH
  • FOUNDRY_CAMPAIGNS_PATH
  • FOUNDRY_SENTINELS_PATH
  • FOUNDRY_AGENT_CONFIG_PATH
  • FOUNDRY_WORKTREES_DIR
  • FOUNDRY_PRESERVED_DIR
  • FOUNDRY_EVENTS_DIR
  • FOUNDRY_TRACES_DIR
  • FOUNDRY_AUDITS_DIR
  • FOUNDRY_DIGESTS_DIR
  • FOUNDRY_OPS_DIGESTS_DIR
  • FOUNDRY_TRIAGE_DIR
  • FOUNDRY_SUPPLY_CHAIN_DIR
  • FOUNDRY_OPS_EVENTS_DIR
  • ~/.foundry/ops-digest.watermark
  • ~/.foundry/agent-sessions/
  • ~/Library/LaunchAgents/com.mojility.foundryd.plist

5. Perform the One-Time Copy

Copy the default Foundry home once, then copy every actual override source from the checklist once while both daemons remain stopped:

rsync -a --delete ~/.foundry/ mojility-ops-01:~/.foundry/
rsync -a ~/.config/foundry/foundryd.env mojility-ops-01:~/.config/foundry/foundryd.env
rsync -a ~/Work/Operations/Events/intake/ mojility-ops-01:/home/svetzal/Work/Operations/Events/intake/

Then run one rsync -a per overridden path whose authoritative source is outside ~/.foundry/ or differs from the destination in ~/.config/foundry/foundryd.env. Do not skip directories that are only outputs; they are still part of the authoritative state expected by the new sole writer.

If the Mac launchd plist carries environment-only overrides that are not in a file, reproduce them in ~/.config/foundry/foundryd.env before starting mojility-ops-01.

6. Start mojility-ops-01 as the New Sole Authority

ssh mojility-ops-01 '
  systemctl --user daemon-reload &&
  systemctl --user enable --now foundryd &&
  systemctl --user status foundryd --no-pager
'

Point the Mac CLI at the new authority:

export FOUNDRY_DAEMON_ADDR=http://mojility-ops-01:50051

7. Validate Remote Command Parity

Run these from the Mac against mojility-ops-01:

foundry status
foundry registry list
foundry campaign list
foundry sentinel list
foundry history

Then validate a representative detail read from each authority surface:

foundry registry show <project>
foundry campaign show <campaign>
foundry sentinel show nightly-maintenance

Also confirm that each copied output root and the MBOS intake root exist at the authoritative destination paths declared in ~/.config/foundry/foundryd.env.

Do not retire the Mac daemon until these remote reads and path checks match expectations.

8. Roll Back if Parity Fails

If parity checks fail, stop the remote daemon before re-enabling the Mac:

ssh mojility-ops-01 'systemctl --user stop foundryd'
unset FOUNDRY_DAEMON_ADDR
launchctl load ~/Library/LaunchAgents/com.mojility.foundryd.plist

If mojility-ops-01 accepted any writes before failure, copy ~/.foundry/ back to the Mac while both daemons are stopped, then copy back every env-overridden authoritative path from the checklist before restarting only one daemon.

9. Retire the Mac Daemon Only After Success

Once remote parity is confirmed, leave the Mac daemon unloaded and keep the CLI pointed at http://mojility-ops-01:50051. From that point forward, mojility-ops-01 is the only writable Foundry authority.

Commit Digest

Every day at 17:00 local time, Foundry walks every active project in the registry, asks the agent to summarise the day's commits, and drops a markdown digest at a stable path — ready to read with your evening coffee. This is the second proactive formation Foundry ships, alongside the nightly maintenance run.

The digest exists to answer one question reliably each day: what shipped across my work today? It is intentionally lightweight — no code review, no opinion on quality, just a clear roll-up grouped by project with a heads-up section for anything that looks risky.

How it fires

flowchart TD
    A([daily-commit-digest @ 17:00<br/>Sentinel]) -->|emit| B([commit_digest_started<br/>project=system])
    B --> C[[ObserveCommits<br/>Observer block]]
    C -->|enumerate active projects<br/>run git log per project| D([commits_observed<br/>per-project commit data])
    D --> E[[SummarizeCommits<br/>Agent-invoking block]]
    E -->|Claude renders<br/>plain-English digest| F([commit_summary_composed<br/>markdown body])
    F --> G[[WriteCommitDigest]]
    G -->|writes file<br/>FOUNDRY_DIGESTS_DIR/YYYY-MM-DD.md| H([commit_digest_completed<br/>success, digest_path])

The chain is linear — no fan-out, no scatter/gather. Each block sinks on its trigger event and emits the next. CommitDigestStarted is a span opener, so every digest run gets its own trace_id.

Where the digest lands

{FOUNDRY_DIGESTS_DIR}/YYYY-MM-DD.md

The default digests_dir is ~/.foundry/digests/. Set FOUNDRY_DIGESTS_DIR in the daemon's launchd plist to put it wherever you prefer. The typical Operations-side override is the same pattern that already maps ~/.foundry/audits to ~/Work/Operations/Automation/maintenance-audits:

<key>EnvironmentVariables</key>
<dict>
  <key>FOUNDRY_DIGESTS_DIR</key>
  <string>/Users/svetzal/Work/Operations/Automation/commit-digests</string>
</dict>

The date in the filename is the firing date in local time. Rerunning the digest the same day overwrites — last one wins.

What's in the file

Each day's digest looks roughly like this:

# Commit Digest — 2026-05-28

_17 commits across 14 projects._

## ⚠ Heads-up

- `foundry` touched the release workflow — verify the changelog wiring.

## foundry

- `c80d68a` — feat(daemon): add and wire the commit-digest formation
- `b597878` — feat(core): seed-merge default sentinels on daemon startup
- `1503580` — feat(core): add CommitDigest event variants

Shipped Slice 2 of the Sentinel work end-to-end today.

## context-mixer2

- `4f2e891` — fix(parser): handle empty preludes

Single fix to the prelude parser; ready for the next release.

The agent renders this from the raw evidence the observer collected. It is explicitly told not to invent commits or facts not in the source data. If the agent is unavailable the digest is still written, but as a "raw evidence" fallback — a flat list of every commit grouped by project, with a warning line at the top noting the agent was unreachable.

Defaults you should know about

  • Time window is rolling 24 hours. git log --since="24 hours ago" evaluated at firing time. If the daemon was down at 17:00 yesterday, today's window misses that day's commits — same skip-missed catch-up policy as the nightly maintenance run.
  • Active projects only. Projects with a skip reason in the registry are excluded — their commits will not appear in the digest. Use foundry registry show <name> to see why.
  • Merge commits are excluded (--no-merges). The digest reports what was authored, not how it landed.
  • Empty days produce a file too. "No commits across N projects in the last 24 hours." Absence is a fact — the file is your daily proof the sentinel ran.
  • A failing git log for one project does not abort the digest. The error is captured inline under that project's section so you can fix the local repo without losing visibility on the others.
  • Throttle gating. A dry-run firing (foundry emit commit_digest_started --throttle dry_run) runs the full chain — observer queries, agent composes — but does not write the file. Useful for previewing the digest before letting it land.

Triggering it manually

The schedule is the primary path. To trigger immediately:

foundry emit commit_digest_started --project system
foundry watch         # observe the chain
cat ~/.foundry/digests/$(date +%Y-%m-%d).md

To pause the daily run:

foundry sentinel disable daily-commit-digest

And to re-enable:

foundry sentinel enable daily-commit-digest

Event taxonomy

EventPayloadNotes
CommitDigestStarted{ project_count }Span opener. Sentinel emits with an empty payload; observer fills the count once the active registry is known.
CommitsObserved{ window_hours, projects: [{ name, branch, commits, error? }] }Raw evidence. Carries the full SHA per commit so the agent can render the 7-char prefix without truncation ambiguity.
CommitSummaryCompleted{ markdown, project_count, total_commits }The agent-rendered body, before the file header is prepended.
CommitDigestCompleted{ success, digest_path?, project_count, total_commits }Terminal. digest_path is None on a dry-run firing and on persistence failure.

Adding more sentinels later

The daily-commit-digest entry was added to the canonical default seed when Slice 2 shipped. Existing installs from Slice 1 picked it up automatically on the next daemon restart via the additive seed-merge — see Sentinels for how the merge works.

Ops Digest

Every three hours, Foundry reads the MBOS event intake JSONL files, checks whether enough new operational activity has accumulated, asks the agent to summarise it, and drops a markdown digest at a stable path. This is the third proactive formation Foundry ships, alongside the nightly maintenance run and the daily commit digest.

The digest exists to answer one question reliably across the working day: what happened in my business and technical systems since the last check? It is intentionally lightweight — no deep triage, no automated response, just a clear roll-up grouped by domain with an anomaly section when something needs your attention.

How it fires

flowchart TD
    A([ops-digest @ 0 */3 * * *<br/>Sentinel]) -->|emit| B([ops_digest_started<br/>project=system])
    B --> C[[ObserveEvents<br/>Observer block]]
    C -->|reads intake JSONL<br/>applies pressure gate| D{gate<br/>satisfied?}
    D -->|no — skip| E([ops_digest_completed<br/>skipped=true])
    D -->|yes — proceed| F([ops_observed<br/>lean event digests])
    F --> G[[SummarizeEvents<br/>Agent-invoking block]]
    G -->|Claude renders<br/>domain-grouped digest| H([ops_summary_composed<br/>markdown body])
    H --> I[[WriteOpsDigest]]
    I -->|writes file + advances watermark| J([ops_digest_completed<br/>success, digest_path])

The chain is linear — no fan-out. OpsDigestStarted is a span opener, so every digest run gets its own trace_id.

Pressure gate

ObserveEvents applies a pressure gate before passing events downstream. The gate is satisfied when either condition holds:

  • At least 25 new events have arrived since the last watermark.
  • At least one event qualifies as an anomaly (see below).

When the gate is not satisfied ObserveEvents emits OpsDigestCompleted{skipped: true} directly and the chain terminates cleanly — no agent call, no file written.

Anomaly classification

An event is an anomaly if it matches any of these conditions:

ConditionDetail
Urgency P0Any MBOS event with "urgency": "P0"
ci_pipeline_failureAlways anomalous
maintenance_intervention_recordedWhen intervention.outcome == "unresolved"
dependency_vulnerability_detectedWhen vulnerability.severity is "high" or "critical"
maintenance_run_completedWhen maintenance.reposFailed > 0

Foundry also reads initiative attention from its campaign store API. An unanswered shared question older than one day is an anomaly. An open initiative with no member activity for thirty days is an anomaly. These anomalies open the pressure gate even with fewer than 25 intake events. The digest names the initiative and the question or inactive period. An overdue question also names its affected members. Older initiatives with no known creation or activity time are not classified as inactive. Answer questions in ops-visualizer's Sources page or with foundry initiative decide.

The summary agent receives the event facts in its prompt. It runs in an empty temporary directory. If the agent is unavailable, the digest retains a raw listing of those facts with the failure reason.

Where the digest lands

{FOUNDRY_OPS_DIGESTS_DIR}/YYYY-MM-DD.md

The default ops_digests_dir is ~/.foundry/ops-digests/. Set FOUNDRY_OPS_DIGESTS_DIR in the daemon's launchd plist to put it wherever you prefer. The typical Operations-side override pattern:

<key>EnvironmentVariables</key>
<dict>
  <key>FOUNDRY_OPS_DIGESTS_DIR</key>
  <string>/Users/svetzal/Work/Operations/Automation/ops-digests</string>
</dict>

Running the digest multiple times in one day appends to the same dated file — last one wins (atomic rename).

Where the intake comes from

ObserveEvents reads MBOS JSONL files from:

{FOUNDRY_OPS_EVENTS_DIR}/YYYY-MM.jsonl

The default ops_events_intake_dir is ~/Work/Operations/Events/intake. Override with FOUNDRY_OPS_EVENTS_DIR.

Each line in the file is a MBOS event object with at minimum an id, type, occurredAt, urgency, and summary field. Malformed lines are silently skipped so a bad event never aborts the chain.

Watermark-based incremental ingestion

After each successful digest write, WriteOpsDigest atomically advances ~/.foundry/ops-digest.watermark to the occurredAt timestamp of the newest event included in that digest. On the next run ObserveEvents reads the watermark and only considers events with occurredAt strictly after that timestamp — so you never re-process the same event in two digests.

On the very first run (no watermark file), the lookback window is 24 hours.

A dry-run firing (--throttle dry_run) runs the full chain — reads events, applies the gate, invokes the agent — but does not write the file or advance the watermark.

What's in the file

A typical digest looks roughly like this:

# Ops Digest — 2026-05-29

_47 operational events._

## ⚠ Anomalies

- `ci_pipeline_failure` on foundry — PR #42 gate failure, suspected flake.

## Infrastructure

- [P1] `maintenance_run_completed` | 3 repos processed, 1 failed (hone-cli)
- [P2] `dependency_vulnerability_detected` | moderate CVE in libc

## AI

- [P2] `ai_session_start` | 12 agent sessions this period
- [P2] `hone_iteration_started` | 8 iterations across 5 projects

## Clients

- [P1] `email_inbound_support` | Acme Corp — billing question
- [P2] `whatsapp_inbound_support_message` | Quick status check from BetaCo

## Summary

One CI failure on foundry that looks like a test flake — worth a quick check.
Maintenance ran cleanly on all other projects. Moderate client activity,
nothing urgent beyond the billing query.

Triggering it manually

foundry emit ops_digest_started --project system
foundry watch         # observe the chain
cat ~/.foundry/ops-digests/$(date +%Y-%m-%d).md

To pause the three-hourly run:

foundry sentinel disable ops-digest

And to re-enable:

foundry sentinel enable ops-digest

Event taxonomy

EventPayloadNotes
OpsDigestStarted{ event_count }Span opener. Sentinel emits with an empty payload.
OpsObserved{ proceed, new_event_count, anomaly_present, new_watermark?, events: [{id, event_type, occurred_at, domain, urgency?, summary?, client?}] }Lean per-event digests. proceed=false causes downstream self-filter.
OpsSummaryCompleted{ markdown, event_count, new_watermark? }The agent-rendered body, before the file header is prepended.
OpsDigestCompleted{ success, skipped, digest_path?, event_count }Terminal. skipped=true when the pressure gate was not satisfied. digest_path is None on dry-run, skip, or failure.

Environment variables

VariableDefaultPurpose
FOUNDRY_OPS_DIGESTS_DIR~/.foundry/ops-digestsWhere digest files land
FOUNDRY_OPS_EVENTS_DIR~/Work/Operations/Events/intakeMBOS JSONL intake directory

Work reconciliation

The work-reconciler sentinel runs at 30 */3 * * *, or use foundry queue reconcile against the daemon to run it now and print that invocation’s report. It reports exact inventory identities and conservatively settles verified preserved obligations without deleting their evidence. Orphan, broken and unresolved findings, including inspection errors, reach the ops anomaly path even below the normal pressure threshold. See the work reconciler guide for proof and failure semantics.

Post-Maintenance Failure Triage

After each nightly maintenance run, Foundry automatically classifies every gate failure and writes a dated triage digest. The formation is propose-only: it reads the Foundry event log, analyses the failures, and writes a markdown report. It applies nothing to any project.

How It Works

The triage formation consists of two task blocks:

  1. TriageMaintenance — sinks on MaintenanceSummaryRequested. Reads the Foundry JSONL event log for the maintenance run window, extracts PreflightCompleted failures, classifies each into one of twelve domain classes, correlates infra flakes, detects chronic deadlocks, and emits MaintenanceTriageCompleted with a typed payload.

  2. WriteTriageDigest — sinks on MaintenanceTriageCompleted. Renders the classified verdicts into a structured markdown file and writes it atomically to ~/.foundry/triage/YYYY-MM-DD.md.

Failure Classes

ClassMeaningDefault Decision
agent_runner_faultAgent runner crashed, timed out, or produced a silent no-opsuppress_infra (correlate first)
ci_infra_flakeCI/infra ephemeral failure: filesystem error, OOM, network hiccupsuppress_infra (correlate first)
format_and_lint_driftFormatting or lint drift — mechanically fixableauto_fixable
vuln_with_fixSecurity advisory with a known fix versionauto_fixable
routine_dependency_bumpPatch/minor dependency bump (no constraints broken)auto_fixable
vuln_no_fixSecurity advisory with no available fixpolicy_call
dependency_major_bump_or_constraint_relaxMajor version bump or constraint relaxation requiredpolicy_call
gate_infra_misconfigGate toolchain or configuration problempolicy_call
compile_and_static_analysis_code_errorCompile error or static-analysis failureneeds_investigation
test_breakageTest suite failureneeds_investigation
chronic_deadlockN≥3 consecutive failures on the same gateescalate
triage_rejected_noiseReclassified as benign (e.g. git push --dry-run exit 1)reclassify_benign

Infra Correlation

When N≥3 distinct projects share the same normalised failure signature and all fall into the infra class (agent_runner_fault or ci_infra_flake), their individual verdicts are collapsed into a single InfraIncident with decision suppress_infra. This avoids noise from widespread infrastructure events flooding the digest.

Streak Detection

If the same (project, gate) pair has failed in N≥3 consecutive PreflightCompleted events within the lookback window (default 14 days), the failure is reclassified as ChronicDeadlock with decision Escalate. These appear at the top of the digest as Deadlock Escalations.

Digest Format

The digest at ~/.foundry/triage/YYYY-MM-DD.md contains:

  • Summary table — counts by category (total, suppressed, auto-fixable, policy, investigation, escalation)
  • Deadlock Escalations — chronic failures needing immediate attention
  • Auto-fixable Proposals — failures with a suggested fix command
  • Infra-suppressed (Correlated) — collapsed infra incidents
  • Policy Calls — failures requiring a human judgement
  • Needs Investigation — failures without a clear mechanical fix
  • Reclassified as Benign — outcomes accepted as noise

Configuration

Override the triage output directory with the FOUNDRY_TRIAGE_DIR environment variable. The default is ~/.foundry/triage/.

VariableDefaultPurpose
FOUNDRY_TRIAGE_DIR~/.foundry/triageTriage digest output directory

Dry-run Behaviour

When triggered with Throttle::DryRun (e.g. foundry run --dry-run), the WriteTriageDigest block skips the file write but still runs the full render. The MaintenanceTriageCompleted event is re-emitted with digest_path: null.

Event Chain

MaintenanceSummaryRequested
  └─► TriageMaintenance
        └─► MaintenanceTriageCompleted (verdicts, infra_incidents, counts)
              └─► WriteTriageDigest
                    └─► MaintenanceTriageCompleted (with digest_path populated)

Supply-Chain Scan

The supply-chain formation is a nightly, working-tree dependency-advisory scan across every managed project. It is advisory and never fails a project run. Its remediation engine is gated off by default; when explicitly enabled, it may apply verified dependency fixes and commit them locally without pushing.

Why it is its own formation

A supply-chain advisory is an external, time-triggered fact. It appears because the world changed — a CVE was published against a dependency — not because the project's own code regressed. It can go red on a repo with zero diff landed.

That makes it categorically different from a quality gate, which answers "is my code correct?" and fails because of your change. Cramming a time-triggered, externally-owned advisory check into the change-triggered, blocking preflight gate set has three failure modes:

  1. it aborts preflight, killing the very maintain step that could bump the dep;
  2. it has no memory — it re-discovers and re-fails the same CVE every night;
  3. it conflates "my code regressed" with "an advisory dropped" under one red checkmark, so a project with perfect code reads as failed.

The release-tag audit action (ReleaseTagAudited) already handles supply-chain at release time. This formation is the missing nightly working-tree lane: it scans what is checked out now, on a schedule, independent of whether code changed.

The chain

nightly-supply-chain sentinel  →  SupplyChainScanStarted
        →  ScanSupplyChain        →  SupplyChainScanned
        →  RemediateSupplyChain    →  SupplyChainRemediated
        →  WriteSupplyChainDigest  →  SupplyChainScanCompleted
  • ScanSupplyChain iterates every active registry project, runs the stack's audit tool (cargo audit, npm audit, mix deps.audit, osv-scanner on Swift's Package.resolved) against the working-tree lockfile, classifies each advisory against that repo's committed allowlist, and emits SupplyChainScanned. Each finding carries a fix version when the audit tool reports one and, where npm supplies it, the direct fix package whose upgrade removes a vulnerable transitive package. Python is the exception to "global tool": pip-audit is a project dependency, so it is run from the project's own .venv/bin/pip-audit — a repo that hasn't installed it reports cleanly under "Not scanned" rather than relying on a global PATH. Kotlin is the other exception: Foundry runs the project's own ./gradlew dependencyCheckAggregate and reads the JSON report it writes, so the project's suppression file decides what counts. Dependency-Check names no fix version, so every Kotlin finding is a policy call.
  • Every ecosystem in the repository is audited, not only the registered stack. Foundry walks the repository (to a depth of four, skipping target/, deps/, _build/, node_modules/, build/, dist/, fixtures and vendored trees) and audits each lockfile it finds with that ecosystem's tool: Cargo.lock with cargo audit, package-lock.json with npm audit, a text bun.lock, yarn.lock or pnpm-lock.yaml with osv-scanner, uv.lock with the project's pip-audit, Package.resolved with osv-scanner, and gradlew with Dependency-Check. Mix projects are found as before. A binary bun.lockb is reported as not scanned. The results are merged: every finding is kept, and any part that could not be audited makes the project "not scanned", with the failing part named (for example apps/cli (rust): …).
  • npm findings name the real advisory. npm audit reports a package that is only vulnerable through a dependency with that dependency's name in via. Foundry reads the chain: each finding carries the advisory's GHSA ID (with npm's advisory number as an alias), the vulnerable package and range, and npm's fix availability. fixAvailable: true is a fix even without a version (the digest says npm audit fix), and a semver-major fix says so (npm audit fix --force). Only fixAvailable: false is a policy call.
  • A repository with no dependency manifest has nothing to audit. It is listed under "No dependency manifests", not "Not scanned". A manifest without its lockfile (a package.json alone) is still not scanned.
  • Mix projects fetch before they audit. Foundry runs mix deps.get in each audited Mix project before mix deps.audit, because a lockfile that moved ahead of deps/ makes the audit refuse to run. A failed fetch is reported with the project's path.
  • RemediateSupplyChain triages every live finding by fix availability and emits SupplyChainRemediated, carrying the scan through. A populated fix version means the advisory is mechanically auto-fixable; an empty one means a policy call — an exploitability judgement about our usage that stays human. When the auto-fix engine is enabled (see below) it also applies the fixable ones, behind a verify-and-rollback rail; otherwise it only classifies.
  • WriteSupplyChainDigest renders a deterministic markdown digest (no agent — CVE identifiers must never be paraphrased or hallucinated) and writes it atomically to {FOUNDRY_SUPPLY_CHAIN_DIR}/{YYYY-MM-DD}.md. Dry-run skips the write.

The schedule is 0 6 * * * (06:00 local), offset past the 02:00 maintenance run. The sentinel ships enabled in the canonical seed; disable it with foundry sentinel disable nightly-supply-chain.

The allowlist — committed per-repo memory

A gate is stateless: it re-fails the same advisory forever. A function remembers a decision. Each repo may carry a committed .supply-chain-allow.json at its root — a neutral artifact Foundry reads (it never writes it; acceptances are authored by a human and land through the repo's normal commit flow, so every decision lives in git history):

{
  "version": 1,
  "allowed": [
    {
      "cve": "GHSA-gv7w-rqvm-qjhr",
      "reason": "transitive dev-only dependency; not reachable in our runtime",
      "expires": "2026-09-01"
    }
  ]
}

Each entry classifies one advisory on the day of the scan:

StateConditionEffect
livenot in the allowlistreported as a finding
acceptedpresent, expires today-or-later (or absent)suppressed; noted under "Accepted"
lapsedpresent, expires has passedresurfaces as a live finding and is flagged under "Lapsed acceptances — re-decide"

An entry matches a finding when it names the finding's ID or any alias the scanner reports for the same advisory. pip-audit, osv-scanner and cargo-audit report aliases, so one entry for CVE-2026-45829 also accepts the pip-audit finding PYSEC-2026-311. An active acceptance under any alias wins over a lapsed one. The post-push auditor and the scan_requested scan read the same file with the same semantics.

The expiry is deliberate: an acceptance is a decision to revisit, not a permanent mute. A malformed expires string fails safe — the advisory resurfaces rather than hiding.

The digest

The digest opens with a triage line — N auto-fixable · M policy-call — splitting the live findings by fix availability. It then groups findings into sections: Live findings (a per-project CVE / package / severity / version / fix table, where the fix column shows the resolving version or policy call), Lapsed acceptances (now live, need a fresh decision), Accepted (active allowlist entries, for transparency), and Not scanned (projects whose audit tool was unavailable or had no lockfile — reported, never failed). A clean scan reads "No live supply-chain advisories."

The auto-fix engine (gated dark)

RemediateSupplyChain can do more than classify: it can apply a fixable advisory's fix. This is off by default and stays inert on every install until two conditions both hold — the env var FOUNDRY_SUPPLY_CHAIN_REMEDIATE is truthy and the run is at Full throttle (never under dry_run). With the gate off, the block is byte-for-byte the classifier.

When enabled, each project with fixable findings goes through a mandatory verify-and-rollback rail, and every change is reversible — committed locally, never pushed:

  1. Refuse a dirty tree, or one with no gates. A project whose working tree carries uncommitted changes is skipped, so a rollback can always return to a known-clean HEAD. A repo with no .hone-gates.json gates is skipped too — an unverifiable fix is never applied.

  2. Full compatible update first. Rather than pinning one package, the engine first moves every dependency to the newest version the manifest already allows. This clears the advisory the same way a routine dependency refresh would, and keeps the lockfile close to current instead of accumulating one-off pins:

    • Rust: cargo update refreshes Cargo.lock.
    • TypeScript: npm update --package-lock-only or bun update --lockfile-only refreshes the native lockfile (with package.json committed or restored alongside it).
    • Python: uv lock --upgrade refreshes uv.lock.
    • Swift: swift package update refreshes Package.resolved (never Package.swift).

    The engine then re-runs the same scanner that detected the findings to confirm which ones the update cleared, and re-runs the repo's gates. If at least one finding cleared and the required gates pass, the lockfile is committed as chore: update dependency lockfile to latest compatible versions (fixes <CVE>). If the update fails, clears nothing, cannot be re-scanned, or fails a required gate, its files are unstaged and restored from HEAD.

  3. Targeted fallback. Every finding the full update did not clear — or every finding, when the full update was reverted — falls back to a single-package fix that follows the project's stack and lockfile:

    • Rust: cargo update -p <pkg> --precise <fix> updates Cargo.lock.
    • TypeScript: Bun and npm projects update their native lockfile. A matching direct dependency or override pin is rewritten in package.json first; transitive advisories target npm's explicit fixAvailable.name package.
    • Python: uv projects rewrite a matching pyproject.toml requirement and run uv lock --upgrade-package <pkg>==<fix>.
    • Swift: no targeted pin. osv-scanner names packages by repository URL and SwiftPM pins by package identity, so Foundry does not guess the mapping; the finding is reported as apply_failed.
    • Kotlin, Elixir, C++: no fixer (no_fixer).

    The gates are re-run; on a pass only the files that fixer touched are committed (chore(deps): bump … (supply-chain auto-fix)), otherwise they are restored from HEAD. Unsupported stacks or projects without a supported lockfile report a visible apply_failed/no_fixer outcome rather than guessing.

The engine follows the dependency update policy rule for security fixes:

  • A fix that is a patch or a minor move is applied, whatever the project's policy.
  • A fix that is a major move is never applied by the engine. The outcome is task_lane, with the exact foundry task command, and the digest lists it under Major upgrades (run each as its own task).
  • If the full compatible update moved any direct dependency by a major (a refresh inside an open constraint such as >= can), it is reverted and the findings fall back to targeted pins.
  • A pinned fix above an active .dependency-holds.json hold says in its outcome that it overrides the hold, because no fixed release exists inside it.

Each applied fix commits immediately, so a later finding's rollback can never clobber an earlier success. Every outcome's detail starts with the path taken — full_update or targeted_pin — and a targeted outcome names why the full update was not enough (for example, targeted_pin (after gate verification failed after the full update): …).

The digest gains a Remediation section — Auto-fixed, Reverted, Major upgrades, and Not auto-fixed (needs attention) — only when the engine actually ran. Enable it by adding the env var to the daemon's launch environment; disable by removing it.

Release-tag audit scan errors

The ReleaseTagAudited event carries an optional scan_error field (Option<String> in the SDK; absent from the JSON wire format when None).

Invariant: a scan that could not run is reported as unknown, never as a clean result. When scan_error is set:

  • vulnerable reflects the upstream payload value (the last known state), not a fresh clean reading.
  • The cve field is likewise forwarded from upstream unchanged.
  • Downstream blocks that branch on vulnerable: false should check for a non-null scan_error before treating a result as authoritative.

scan_error is populated in three situations:

CauseExample message
git checkout <tag> returns non-zero"git checkout v1.2.3 failed: ..."
Scanner tool returned a tool-level error"cargo audit not found"
Scanner gateway itself returned Err"I/O error spawning audit tool"

Environment variables

VariableDefaultPurpose
FOUNDRY_SUPPLY_CHAIN_DIR~/.foundry/supply-chainDigest output directory
FOUNDRY_SUPPLY_CHAIN_REMEDIATE(unset → off)Set truthy (1/true/yes/on) to enable the auto-fix engine. Off by default.

Throttle Control

Throttle controls how far an event ripples through the task block chain. It's set at invocation time and propagated through every event in the chain.

Levels

LevelObserversMutatorsUse case
fullExecute + emitExecute + emitAutomated runs, nightly maintenance
dry_runExecute + emitSkip execution, simulate success via dry_run_events()Preview what would happen

full is the default.

How It Works

The throttle is a property of the event, not the task block. When a block emits downstream events, those events carry the same throttle as the triggering event. This means the throttle decision is made once (at invocation) and respected throughout the chain.

Under dry_run, Mutator blocks are not executed at all. Instead they simulate success via dry_run_events(). The simulated events carry dry_run: true and are still delivered downstream, so the full shape of the chain remains visible even though no Mutator actually ran.

foundry emit vulnerability_detected --project my-tool --throttle dry_run

  vulnerability_detected (throttle: dry_run)
    → Audit Main Branch (Observer) → executes, emits main_branch_audited
      → Cut Release (Mutator) → NOT executed, emits simulated
        release_completed (dry_run: true)
        → downstream blocks still see the chain

Observer vs Mutator

The key design question for every task block: is it an Observer or a Mutator?

  • Observer: reads state, runs scans, checks conditions. Never changes the world. Always runs, always emits, regardless of throttle.
  • Mutator: writes files, pushes commits, cuts releases, installs tools. Changes the world. Throttle controls whether it runs.

At dry_run, Mutators don't execute at all — they simulate success via dry_run_events(), and the simulated events carry dry_run: true.

Mutator blocks implement the SimulatedSuccess trait — dry_run_events() is generated by dry_run_via_simulation!() and must never be hand-written. The simulate() method produces a synthetic outcome, and success_events() is the single source of truth for the event shape.

CLI Usage

# Default: full
foundry emit greet_requested --project hello

# Explicit throttle
foundry emit greet_requested --project hello --throttle dry_run

Writing Task Blocks

To add a new task block to Foundry:

  1. Implement the TaskBlock trait
  2. Register it with the engine in main.rs

The TaskBlock Trait

#![allow(unused)]
fn main() {
use std::pin::Pin;
use foundry_sdk::event::{Event, EventType};
use foundry_sdk::task_block::{BlockKind, TaskBlock, TaskBlockResult};

pub struct MyBlock;

impl TaskBlock for MyBlock {
    fn name(&self) -> &'static str {
        "My Block"
    }

    fn kind(&self) -> BlockKind {
        BlockKind::Observer  // or BlockKind::Mutator
    }

    fn sinks_on(&self) -> &[EventType] {
        &[EventType::GreetRequested]  // which events trigger this block
    }

    fn execute(
        &self,
        trigger: &Event,
    ) -> Pin<Box<dyn std::future::Future<Output = anyhow::Result<TaskBlockResult>> + Send + '_>>
    {
        let project = trigger.project.clone();
        let throttle = trigger.throttle;

        Box::pin(async move {
            // Do your work here...

            Ok(TaskBlockResult {
                events: vec![
                    Event::new(
                        EventType::GreetingComposed,
                        project,
                        throttle,
                        serde_json::json!({"result": "done"}),
                    ),
                ],
                success: true,
                summary: "Did the thing".to_string(),
                raw_output: None,
                exit_code: None,
                audit_artifacts: vec![],
            })
        })
    }
}
}

Key Points

  • Propagate throttle: always pass trigger.throttle to emitted events
  • Clone what you need: extract data from trigger before the async move block
  • Return events: the engine handles routing them to downstream blocks
  • Observer vs Mutator: choose based on whether your block has side effects

TaskBlockResult Fields

FieldTypeDescription
eventsVec<Event>Events to emit downstream (subject to throttle)
successboolWhether the block's work succeeded
summaryStringHuman-readable one-line summary shown in traces
raw_outputOption<String>Combined stdout+stderr from any shell command — shown in foundry trace --verbose
exit_codeOption<i32>Exit code from any shell command — useful for observability
audit_artifactsVec<String>Paths to files produced by this block (e.g. audit logs). Listed under artifacts: in verbose trace output

Blocks that do not run external processes should set raw_output: None, exit_code: None, and audit_artifacts: vec![]. Blocks that shell out should populate raw_output with the combined output and exit_code with the process exit code so that traces provide full observability without needing to reproduce the command.

Registering

In foundryd/src/main.rs:

#![allow(unused)]
fn main() {
let mut engine = engine::Engine::new();
engine.register(Box::new(blocks::MyBlock));
}

RetryPolicy

Override retry_policy() to enable automatic retry of transient failures. The default is zero retries (execute exactly once).

#![allow(unused)]
fn main() {
use std::time::Duration;
use foundry_sdk::task_block::RetryPolicy;

fn retry_policy(&self) -> RetryPolicy {
    RetryPolicy {
        max_retries: 3,
        backoff: Duration::from_secs(5),
    }
}
}

With max_retries: N, the engine tries the block up to N + 1 times total (1 initial attempt plus up to N retries), sleeping backoff between each attempt. Both Err results and TaskBlockResult { success: false, .. } trigger a retry. The final attempt's outcome is what appears in the BlockExecution trace.

Use retries for operations that may fail transiently (network calls, shell commands that occasionally time out). Do not use retries for operations that are expected to fail deterministically (e.g. self-filtering by payload).

Gateway Pattern

Task blocks that execute external processes (shell commands, audit tools) receive those capabilities through gateway traits rather than calling the implementation directly. This isolates I/O at the block boundary and makes every block fully testable without spawning real processes.

The ShellGateway Trait

#![allow(unused)]
fn main() {
pub trait ShellGateway: Send + Sync {
    fn run<'a>(
        &'a self,
        working_dir: &'a Path,
        command: &'a str,
        args: &'a [&'a str],
        env: Option<&'a [(String, String)]>,
        timeout: Option<Duration>,
    ) -> Pin<Box<dyn Future<Output = anyhow::Result<CommandResult>> + Send + 'a>>;
}
}

In production, ProcessShellGateway delegates to crate::shell::run. Blocks accept the gateway through their constructor:

#![allow(unused)]
fn main() {
pub struct MyBlock {
    registry: Arc<Registry>,
    shell: Arc<dyn ShellGateway>,
}

impl MyBlock {
    pub fn new(registry: Arc<Registry>) -> Self {
        Self {
            registry,
            shell: Arc::new(ProcessShellGateway),
        }
    }

    #[cfg(test)]
    fn with_shell(registry: Arc<Registry>, shell: Arc<dyn ShellGateway>) -> Self {
        Self { registry, shell }
    }
}
}

Testing with Fakes

gateway::fakes (available only under #[cfg(test)]) provides pre-built fakes:

#![allow(unused)]
fn main() {
use crate::gateway::fakes::{FakeShellGateway, FakeScannerGateway};
use crate::shell::CommandResult;

// Always return a successful, empty result.
let shell = FakeShellGateway::success();

// Always return a failure with the given stderr.
let shell = FakeShellGateway::failure("not installed");

// Return a fixed result every time.
let shell = FakeShellGateway::always(CommandResult { ... });

// Return results in sequence (last one repeats).
let shell = FakeShellGateway::sequence(vec![first_result, second_result]);

// Inspect recorded invocations after the fact.
let invocations = shell.invocations();
assert_eq!(invocations[0].command, "git");
}

For scanner-based blocks:

#![allow(unused)]
fn main() {
let scanner = FakeScannerGateway::clean();
let scanner = FakeScannerGateway::with_vulnerabilities(vec![...]);
let scanner = FakeScannerGateway::with_error("cargo audit not installed");
}

This pattern allows testing every code path — including failure modes and edge cases — without any real I/O:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn detached_head_recovery_succeeds() {
    let dir = tempfile::tempdir().expect("tempdir");
    let registry = make_registry(/* ... */);

    // First call returns "HEAD" (detached); second call (checkout) succeeds.
    let shell = FakeShellGateway::sequence(vec![
        CommandResult { stdout: "HEAD\n".into(), exit_code: 0, success: true, .. },
        CommandResult { stdout: String::new(), exit_code: 0, success: true, .. },
    ]);
    let block = ValidateProject::with_shell(registry, shell);

    let result = block.execute(&trigger).await.unwrap();
    assert_eq!(result.events[0].payload["status"], "ok");
}
}

Stand-in scripts on disk

Some tests need a real program on disk, for example a fake mix on PATH or a Git hook. Write it with foundry_sdk::test_support::write_executable, not with std::fs::write and set_permissions:

#![allow(unused)]
fn main() {
let mix = bin.path().join("mix");
foundry_sdk::test_support::write_executable(&mix, "#!/bin/sh\nexit 1\n").unwrap();
}

Tests run on parallel threads. If another test forks while this test holds the script open for writing, the forked child keeps a write descriptor on the file. On Linux, the exec then fails with "Text file busy". The helper writes the file from a child shell, so the test process never holds that descriptor.

File Organisation

Place block implementations in foundryd/src/blocks/:

blocks/
├── mod.rs              # pub use declarations
├── greet.rs            # hello-world blocks (ComposeGreeting, DeliverGreeting)
├── validate.rs         # ValidateProject
├── resolve_gates.rs    # ResolveGates
├── run_preflight_gates.rs  # RunPreflightGates
├── run_verify_gates.rs     # RunVerifyGates
├── route_gate_result.rs    # RouteGateResult
├── route_validation_result.rs # RouteValidationResult
├── check_charter.rs    # CheckCharter
├── assess_project.rs   # AssessProject
├── triage_assessment.rs # TriageAssessment
├── create_plan.rs      # CreatePlan
├── execute_plan.rs     # ExecutePlan
├── execute_maintain.rs # ExecuteMaintain
├── retry_execution.rs  # RetryExecution
├── summarize_result.rs # SummarizeResult
├── git_ops.rs          # CommitAndPush
├── audit.rs            # AuditReleaseTag, AuditMainBranch
├── release.rs          # CutRelease, WatchPipeline
├── install.rs          # InstallLocally
├── remediate.rs        # RemediateVulnerability
└── scan.rs             # ScanDependencies

CLI Commands

The foundry CLI communicates with a running foundryd daemon over gRPC.

Global Options

OptionDefaultDescription
--addr <url>FOUNDRY_DAEMON_ADDR or http://127.0.0.1:50051Daemon address

foundry resolves the daemon URL in this order:

  1. Explicit --addr
  2. FOUNDRY_DAEMON_ADDR
  3. http://127.0.0.1:50051

foundry emit

Emit an event into the system. May trigger a workflow chain.

foundry emit <event_type> --project <project> [--throttle <level>] [--payload <json>] [--wait]
ArgumentRequiredDescription
event_typeYesEvent type name (positional)
--projectYesTarget project
--throttleNofull or dry_run (default: full)
--payloadNoJSON string with event-specific data
--waitNoBlock until processing completes, then display the trace

By default, emit returns immediately after the daemon accepts the event. Use --wait to block until the full event chain finishes and then display the trace output (equivalent to running foundry trace after completion).

Output (default):

Event emitted: evt_47fcb603e1b18c8435b8cc3b

Output (with --wait):

Event emitted: evt_47fcb603e1b18c8435b8cc3b
Waiting for processing to complete...
greet_requested (evt_47fcb603e1b18c8435b8cc3b) project=hello
  → Compose Greeting (1ms): ok — composed greeting for Stacey
    greeting_composed (evt_a1b2c3d4e5f6) project=hello
      → Deliver Greeting (0ms): ok — delivered greeting: Hello, Stacey!
---
Total: 2ms (blocks: 1ms)

foundry status

Show status of active workflows. Queries the daemon for workflows that are currently being processed in the background. The daemon tracks these via an in-memory WorkflowTracker that is populated when each Emit request spawns a background task and cleared on completion. This is a live view only; completed work moves to durable trace history and no longer appears here.

foundry status [workflow_id] [--span <span-id>]

Without an argument, shows all active workflows. With a workflow ID, shows details for that specific workflow. --span first resolves the span through the daemon's Span RPC and then keeps only active workflows in that span's trace. If the daemon is unreachable, the command fails; there is no offline status cache or trace-file fallback.

Output example:

evt_47fcb603e1b18c8435b8cc3b [iteration_requested] foundry — running

If no workflows are currently running:

No active workflows.

foundry watch

Stream live events as they are emitted in real time.

foundry watch [--project <project>]
OptionRequiredDescription
--projectNoFilter by project name; omit to see all projects

Server-side streaming — stays open until interrupted (Ctrl-C). Each line shows the event type, event ID, project, and payload (when non-empty).

Output example:

maintenance_run_started evt_abc project=my-tool
project_validation_completed evt_def project=my-tool
  payload: {"status":"ok","has_gates":true}
project_iteration_completed evt_ghi project=my-tool

foundry run

Trigger a maintenance run for all active projects or a single named project.

foundry run [--project <project>] [--throttle <level>]
OptionRequiredDescription
--projectNoLimit run to a single project by name; omit to run all projects
--throttleNofull or dry_run (default: full)

foundry run emits a maintenance_run_started event which triggers the maintenance workflow chain: validate → iterate (if enabled) → maintain (if enabled) → commit and push → post-push audit.

The command streams progress events in real time and exits automatically when the daemon broadcasts a maintenance_run_completed event at the end of the processing chain. This differs from foundry watch, which streams indefinitely.

When --project is omitted, the project name sent to the daemon is "system", which causes all active (non-skipped) projects to be processed.

Output:

Triggered maintenance run for my-tool
Event: evt_47fcb603e1b18c8435b8cc3b

[my-tool] maintenance_run_started
[my-tool] project_validation_completed (ok)
[my-tool] maintenance_run_completed (ok)

Use foundry trace <event_id> to inspect the full trace after the run completes.

foundry task

Run one concrete coding objective against a registered project.

foundry task <project> "<description>" [--agent <provider>] [--origin <text>] [--after <id>]... [--not-before <RFC3339|duration>]
ArgumentRequiredDescription
projectYesRegistered project name
descriptionYesOne concrete objective, supplied as a positional string
--agentNoOverride the registered agent provider for this task
--originNoFree-text note recorded with the work item, beside this machine's hostname
--afterNoStart only after this work item has settled landed; repeatable. An id not in the ledger is refused
--not-beforeNoEarliest start: an RFC 3339 timestamp, or a duration from now (90s, 30m, 2h, 1d)

The task is recorded queued and the pacing scheduler starts it when the rules allow: one mutating item per repository, the host running cap, --not-before, --after, and the lane not paused. The command waits through that queue time; work_item_submitted on its trace carries the reason it waits on. See Pacing.

The work item this dispatch records always carries the CLI client's hostname in its origin, plus any --origin text verbatim. Origin is opaque: it changes nothing about how the task runs, and an empty --origin is accepted. See The Work Queue.

The command waits for task_run_completed, streams block progress, and renders the full trace. Execution and verification occur in an isolated Git worktree. The terminal event contains one structural verdict:

VerdictMeaning
completeRequired gates and skeptical review passed; deliverable changes landed on trunk, or no landing was required
remainderThe objective is incomplete; gaps[] names what remains
defectThe implementation or evidence is wrong; diagnosis explains why
blocked_on_decisionA human choice is required; finding and options[] carry it
runner_errorThe agent, workspace, Git, or provider failed

Task execution has no retry route. All task work is committed before terminal state. Non-complete work is preserved on a remote branch or, if push fails, in a Git bundle. For landed work, preservation_ref carries the landed commit SHA; otherwise it identifies the preserved branch or bundle artifact.

foundry deps

Show a project's outdated dependencies, what maintenance would apply under its update policy, and what the majors lane would dispatch. Nothing is applied and nothing is dispatched.

foundry deps <project> [--policy patch|minor|major]
ArgumentRequiredDescription
projectYesRegistered project name
--policyNoPreview under this policy instead of the registered one; the registry does not change

The command emits dependency_review_requested and waits for major_upgrades_planned. It prints a table of outdated direct dependencies (current, constraint, in range, newest non-major, major), the updates maintenance would apply, the updates held back by the policy or by holds, lapsed holds, anything not classified, and each major with its status and foundry task command. See Dependency Update Policy.

foundry campaign

Manage durable, evidence-terminated engineering objectives.

foundry campaign add <definition.json>
foundry campaign list
foundry campaign show <name> [--json]
foundry campaign advance <name> [--origin <text>]
foundry campaign pause <name>
foundry campaign decide <name> --decision "Use the generated tonic client path."
foundry campaign decide <name> --decision "Keep the current design." --add-cycles 1
foundry campaign complete <name> --reason "Production evidence confirms the mission shipped."
foundry campaign cancel <name> --reason "Superseded by the new architecture."
foundry campaign cancel <name> --reason "Wrong approach." --now --discard-work
foundry campaign resume <name>
foundry campaign resume <name> --add-cycles 2
SubcommandDaemon required?Description
addYes unless --offlineValidate and atomically add one JSON definition
listYes unless --offlineShow campaign status and cycle counts
showYes unless --offlineShow the complete stored campaign record; for an escalated campaign, why it waits on its owner; the owner's options as command lines; then its ledger cycles. --json emits one object
advanceYesRe-evaluate done evidence and dispatch one next task, complete, or escalate. --origin <text> records a free-text note, beside this machine's hostname, on the work item of the cycle it dispatches
pauseYes unless --offlineHalt future automatic and manual advancement
decideYes unless --offlineRecord a binding owner decision on an active, paused or escalated campaign. An escalated campaign also returns to active, and --add-cycles <n> extends its budget
completeYes unless --offlineMark an authorized campaign complete with an auditable owner reason
cancelYes unless --offlineStop a campaign permanently, optionally killing the in-flight cycle
resumeYes unless --offlineReturn an authorized paused or escalated campaign to active state

The store defaults to ~/.foundry/campaigns.json and can be overridden with FOUNDRY_CAMPAIGNS_PATH. A definition requires non-empty name, project, and mission fields plus at least one done_evidence item. authorized_by is required before decide, complete, or resume. decide appends an owner decision record, and every later formation run treats it as binding. On an active or paused campaign it changes nothing else: the status, budget and any stored run result stay as they are. --add-cycles is refused there; use resume --add-cycles N for a paused campaign. On an escalated campaign, decide also returns it to active. On a spent budget, an escalated decide requires --add-cycles N, which it applies in the same transition; without it the command is refused and records nothing. decide is refused on a staged, completed or cancelled campaign, and when its text and owner repeat the campaign's latest decision, so a retry records nothing twice. resume --add-cycles N is required when an exhausted budget needs an explicit positive extension. Without --offline, all nine campaign subcommands are daemon-authoritative: they require a reachable foundryd daemon, fail without touching the client-side FOUNDRY_CAMPAIGNS_PATH if the daemon is unreachable, and render the daemon's typed response or workflow output directly rather than re-reading the client store. See Tasks and Campaigns for the definition schema and lifecycle.

advance has no offline formation path. It re-runs campaign done-evidence against delivered trunk, inspects recent objective history and accumulated preserved work, and chooses exactly one of done, one next objective, or escalation. An advance whose campaign is paused, cancelled, completed or escalated before its outcome applies stands down instead (stood_down), and its work item settles landed. A dispatched task runs the project's resolved gates in its isolated worktree; campaign gate commands are not copied into task acceptance evidence.

Each advance prints and watches a root event ID. Its events carry trace and span context, while task-side events also carry campaign_cycle. campaign_advance_completed retains the formation prompt, agent provider, and done-evidence gate results, allowing the cycle to be reconstructed through foundry trace <event-id> --verbose.

For online mutations, daemon-side persistence is atomic at the control-plane boundary: if a save fails, the command surfaces the typed gRPC INTERNAL error, the daemon-owned campaign store stays byte-identical on disk, and campaign complete does not emit a terminal completion event.

complete is the owner-authorized external terminal path. It accepts any non-completed campaign state, requires a non-empty --reason, clears any stale pending run result, records the reason with the authorizing owner and timestamp, and emits the normal campaign_completed terminal event. Repeating it for an already-completed campaign is idempotent.

cancel is the other external terminal path, and it means the opposite of complete: the campaign is being abandoned before its done evidence was met. It is a distinct cancelled status rather than a flavour of completed, because in Foundry completion is an evidence claim — recording an abandoned campaign as complete would put a false assertion into the audit trail and the ops digest. Cancellation is terminal and not resumable; use pause for a campaign you intend to come back to.

Unlike complete, cancel does not require authorized_by. An unauthorized campaign can never be advanced to completion either, so requiring an owner would strand it with no reachable terminal state. The --reason is still mandatory and always reaches the campaign_cancelled event; it is additionally recorded as an owner decision when the campaign has an owner. Repeating a cancellation is idempotent and emits no second event, and cancelling an already-completed campaign is refused.

By default the cancellation is graceful: the in-flight cycle runs to completion, Foundry commits and preserves its work exactly as it would normally, and no successor cycle is dispatched. Two flags change that:

FlagEffect
--nowAbort the in-flight workflow immediately, killing the running agent process
--discard-workThrow the terminated cycle's uncommitted work away instead of preserving it

With --now and no --discard-work, the orphaned worktree is committed and pushed to its task branch (falling back to a bundle under ~/.foundry/preserved), then removed. With --discard-work the worktree and its local branch are deleted outright; any remote branch an earlier cycle already pushed is deliberately left alone, since that is the audit trail for work which did reach a durable ref.

--discard-work requires --now. A graceful cancellation lets the cycle finish, and FinalizeTask has already committed and preserved its work by then, so there is nothing uncommitted left to discard — the flag would silently do nothing.

--now terminates the agent process, not every process descended from it. kill_on_drop reaches only the direct child, so tool subprocesses spawned by claude or codex are reparented and keep running until they exit on their own.

--offline cancellation is graceful-only and emits no event, matching offline complete. --offline --now is refused rather than silently downgraded: there is no daemon holding a workflow to abort, and reporting a kill that never happened would be worse than failing.

foundry queue

Inspect the daemon-owned work-item ledger — the durable record of every unit of work Foundry dispatched. The overview, show and open are read-only.

foundry queue [--source <kind>:<ref>] [--json] [--offline]
foundry queue show <id> [--json] [--offline]
foundry queue open [--source <kind>:<ref>] [--json] [--offline]
foundry queue hold <id> [--origin <text>]
foundry queue release <id> [--origin <text>]
SubcommandDaemon required?Description
(none)Yes unless --offlinePrint running, queued, open and the newest 20 settled items on one screen
showYes unless --offlinePrint one item's full durable record, then its work_item_* events
openYes unless --offlinePrint only the open group — preserved, needs_decision and failed — then every escalated campaign
close <id> --reason <text> [--origin <text>]YesDischarge an open obligation; --offline refused
cancel <id> [--origin <text>]YesCancel submitted/queued/held work; --offline refused
hold <id> [--origin <text>]YesTake a queued item out of the scheduler's hands; --offline refused
release <id> [--origin <text>]YesReturn a held item, or one in needs_decision over a dependency, to the queue; --offline refused
resume <id> [--origin <text>] [--after <id>]... [--not-before <RFC3339|duration>]YesResume preserved work through a queued continuation; --offline refused
ArgumentDescription
<id>Work-item id, e.g. wi_0123456789abcdef01234567 (show, close, cancel, hold, release, resume)
OptionDescription
--jsonEmit machine-readable JSON instead of human output
--sourceList only one source's items, as <kind>:<ref>; campaign:<name> also selects that campaign's escalation (overview and open)
--offlineRead FOUNDRY_WORK_ITEMS_PATH (and FOUNDRY_EVENTS_DIR; for the overview and open, also FOUNDRY_CAMPAIGNS_PATH) directly instead of calling the daemon

The four groups are running, queued (submitted, queued or held), open (preserved, needs_decision, failed — settled but still needing a person), and the newest 20 settled items (landed, cancelled). The 20-item cap applies to the settled group alone; older terminal items are omitted. Each line carries the item's id, project, kind, lane, state, the timestamp that placed it in its group, and its one-line reason. Group order and the order within each group come from the daemon's ListWorkItems response — the CLI groups by state and never re-sorts.

The open group ends with one row per escalated campaign, from ListCampaigns: campaign, its name, project, escalated, when and why it escalated, and a next: foundry campaign show <name> (<controls>) line. A campaign escalation is campaign state, not a ledger item, so --json for the overview and open emits {"items": [...], "escalated_campaigns": [...]}; through 0.44.4 it was a bare array of items. See The work queue.

show renders every durable field, and an optional field the ledger never recorded produces no line at all rather than an empty string or false. A recorded worktree_removed: false therefore prints Worktree removed: no, while an unrecorded one prints nothing. A Depends on: line lists the --after ids when there are any, and a Not before: line the earliest start when one was set; --json carries them as depends_on and not_before. After the record, show prints an Events: heading and the item's own work_item_* events, one line each (occurred at, event type, state, event id, reason), oldest first. They are selected from the durable event log by the item id in the event payload — never by trace or project — across every monthly file, however old. An item with no events prints (no events). A malformed line in the log is skipped with a daemon-side warning; a fault reading the log is an error, never an empty list.

With --json, the list forms emit a JSON array and show emits a single object: the record's keys, unchanged, plus an events array whose entries carry id, event_type, occurred_at, state, reason and an optional trace_id. Optional fields are absent when unset, so the JSON round-trips the same facts the record carries. The human and JSON forms of one command render from the same fetched data.

The ledger defaults to ~/.foundry/work-items.json and can be overridden with FOUNDRY_WORK_ITEMS_PATH. Without --offline, all three commands are daemon-authoritative: the list forms render ListWorkItems and show renders GetWorkItem followed by ListWorkItemEvents. The online path never reads, creates or mutates the client-side ledger or events files, so an absent FOUNDRY_WORK_ITEMS_PATH or FOUNDRY_EVENTS_DIR stays absent and an existing ledger is left byte-identical. A NOT_FOUND from GetWorkItem surfaces as an id-not-found error with a non-zero exit, not as an empty record.

If foundryd is unreachable, the command fails with a stable actionable error naming the matching offline recovery command. There is no silent fallback. Pass --offline only when the daemon is stopped and you intentionally want to read the ledger JSON file (and, for show, the events directory) directly; it applies the same grouping order and event selection the RPCs document. A missing ledger renders four empty groups and exits zero without creating the file; a malformed one exits non-zero and names the parse failure.

Close or cancel an item

foundry queue close wi_0123456789abcdef01234567 --reason "Reviewed; no further work required" --origin "owner review"
foundry queue cancel wi_0123456789abcdef01234567 --origin "withdrawn request"

close discharges an open obligation in preserved, needs_decision or failed; it requires a nonblank --reason. cancel stops a submitted, queued or held item and records the reason cancelled by operator, without requiring a reason argument. Both settle exactly the requested id as cancelled.

Both require the daemon, reject --offline, and never fall back to local writes. They record this CLI's hostname and optional --origin in operator_action, separately from the original submission origin. The action retains the previous state, reason and settlement timestamp. Submission identity, objective, trace and known disposition remain intact, including preserved refs and worktree-removal observations. No agent starts, running workflow stops, or preserved work is disposed of by either command.

The daemon reloads the ledger under its shared write gate and saves by atomic replacement before emitting work_item_cancelled to Watch and the durable log. Earlier event bytes stay untouched. Unknown ids return NOT_FOUND, blank input returns INVALID_ARGUMENT, and other states return FAILED_PRECONDITION. Malformed ledgers also return FAILED_PRECONDITION; read or save failures return INTERNAL and a failed save emits no cancellation. Concurrent requests for one item can succeed only once.

Hold or release an item

foundry queue hold wi_0123456789abcdef01234567 --origin "wait for the review"
foundry queue release wi_0123456789abcdef01234567

hold takes a queued item out of the scheduler's hands: it sits held with the reason held by operator until released, and nothing starts it. release returns a held item, or one the scheduler settled needs_decision because a dependency settled some way other than landed, to queued; the settled dependencies are dropped from depends_on, and the recorded reason is the scheduler's verdict for the item as it now stands. Any other state is FAILED_PRECONDITION. Both require the daemon, reject --offline, record operator_action as close and cancel do, and emit work_item_held or work_item_released on Watch and in the durable log after the ledger is saved.

Resume preserved work

foundry queue resume wi_0123456789abcdef01234567 --origin "finish preserved work"
foundry queue resume wi_0123456789abcdef01234567 --after wi_89abcdef0123456789abcdef --not-before 30m

Owner-directed preserved-work continuation uses foundry queue resume <id> [--origin <text>] [--after <id>]... [--not-before <RFC3339|duration>]. It requires a live daemon and refuses --offline. Only a preserved item with a usable preservation ref and a registered project can be resumed. Foundry records a new task with the original objective, queued for the pacing scheduler, that starts from the preserved local branch, remote ref or bundle:<path> through the existing continuation path. When the preserved work does not contain current trunk, Foundry rebases it onto freshly fetched trunk before the agent starts (the preservation ref itself is not rewritten). If that rebase conflicts, the child is refused before any agent session: it settles failed with land_blocked: continuation_conflict and a reason that names the conflicting files, and the original stays preserved. --after and --not-before mean what they mean on foundry task; an --after id not in the ledger, or an unparsable --not-before, is INVALID_ARGUMENT. The new record and its lifecycle payloads expose resumes, the exact original id. Queue reads show this link in human and JSON output. Submission identity and the parent's prior evidence remain intact; the child records the hostname and optional origin in its resume operator action.

The original obligation stays preserved until the linked task actually lands. Then Foundry records its landing commit and appends work_item_settled for the original. Failed, blocked, preserved and no-landing results leave it open. An original cancelled by its owner stays cancelled even if its child lands. Unknown ids return NOT_FOUND, blank inputs INVALID_ARGUMENT, ineligible states or unusable evidence FAILED_PRECONDITION, and persistence failures INTERNAL. A rejected admission dispatches no execution and invokes no agent. An initial ledger-save failure creates no child or lifecycle events. Otherwise, admission stages a failed child with reason resume admission incomplete; execution not dispatched, no started_at, a settled_at and no disposition. The child becomes queued only after the work_item_submitted root is persisted and the final ledger save succeeds; the scheduler then starts it and emits work_item_started. Later failures leave that failed child and preserve the parent and unrelated records. Successfully written roots remain in history and on Watch even if a later admission step fails; failed writes are never advertised on Watch. Earlier bytes, including partial appends, are never truncated or rewritten.

See The Work Queue for the full model.

foundry pacing

Inspect and steer the pacing stage: the scheduler that starts queued work.

foundry pacing show [--json] [--offline]
foundry pacing pause [--lane interactive|campaign|maintenance|all] [--origin <text>]
foundry pacing resume [--lane interactive|campaign|maintenance|all] [--origin <text>]
foundry pacing drain [--timeout <duration>] [--origin <text>]
SubcommandDaemon required?Description
showYes unless --offlinePrint the limits in force, every running item with the repository it occupies, every waiting item with its reason, and the paused lanes
pauseYesStop new starts in a lane (default all); running items are untouched; emits pacing_paused
resumeYesAllow starts in a lane again (default all); emits pacing_resumed
drainYesPause every lane, then wait until nothing is running, printing each item as it settles; exit 0 when idle
OptionDescription
--jsonshow only: emit the PacingStatus as JSON
--offlineshow only: read the ledger and the pacing files directly instead of calling the daemon
--lanepause/resume: interactive, campaign, maintenance or all
--timeoutdrain: give up after this duration (30m, 2h) and exit 1 naming what still runs
--originOperator context recorded beside this CLI's hostname

show lists running items oldest start first and waiting items in the scheduler's priority order (interactive lane first, then oldest submission), then held items. A waiting item's reason is the scheduler's current verdict: repository busy: wi_…, host at capacity N/N, not before <time>, waits on wi_…, lane paused, ready, or held by operator.

The pause state is persisted to ~/.foundry/pacing-state.json (FOUNDRY_PACING_STATE_PATH) through an atomic rename and is in force after a daemon restart. The host running cap is read from ~/.foundry/pacing.json (FOUNDRY_PACING_PATH, {"max_running": 2}); a missing file means the default.

drain does not stop the daemon and does not resume the lanes when it finishes; run pacing resume when work may start again. It refuses --offline.

foundry sentinel

Inspect or toggle the daemon-owned scheduled sentinels.

foundry sentinel list [--offline]
foundry sentinel show <name> [--offline]
foundry sentinel enable <name> [--offline]
foundry sentinel disable <name> [--offline]
SubcommandDaemon required?Description
listYes unless --offlineRender every daemon-owned sentinel entry
showYes unless --offlineRender one exact-name daemon-owned sentinel
enableYes unless --offlineMark one sentinel enabled and wake the in-process scheduler immediately
disableYes unless --offlineMark one sentinel disabled and wake the in-process scheduler immediately

The store defaults to ~/.foundry/sentinels.json and can be overridden with FOUNDRY_SENTINELS_PATH. Without --offline, all four commands are daemon-authoritative: list renders SentinelList, show renders SentinelShow, enable calls SentinelEnable, and disable calls SentinelDisable. The online path never reads the client-side sentinel file, so an absent FOUNDRY_SENTINELS_PATH stays absent and a malformed trap file is left byte-identical.

If foundryd is unreachable, the command fails with a stable actionable error that names the matching offline recovery command. There is no silent fallback. Pass --offline only when the daemon is stopped and you intentionally want to read or mutate the sentinel JSON file directly.

Online enable and disable are persistence-atomic at the daemon boundary: the daemon writes a same-directory temporary file and renames it into place only after the full JSON payload is ready. If that save fails, the RPC returns INTERNAL, the daemon-owned in-memory sentinel store remains unchanged, list and show continue to reflect the pre-mutation state, the scheduler is not notified, and the on-disk sentinels.json bytes remain unchanged.

foundry validate

Validate quality gates for one or more projects without running iterate or maintain workflows. This is a read-only operation — no code changes are made.

foundry validate <project>...
foundry validate --all
ArgumentRequiredDescription
projectYes (unless --all)One or more project names (positional)
--allNoValidate all active projects in the registry

For each project, emits a validation_requested event which triggers: Resolve Gates → Run Preflight Gates → Route Validation Result → validation_completed. No Mutator blocks are involved, so throttle level is irrelevant.

A failed gate shows the start of its output, followed by one full log: line for each file that holds its complete stdout or stderr under ~/.foundry/evidence/gate-logs/.

Output example:

Validating mojentic-ts...
  mojentic-ts: PASS
    lint: ok (required)
    format: ok (required)
    test: ok (required)
    build: ok (required)
    security: ok (optional)
validation_requested (evt_007572156d627d7b1211d76f) project=mojentic-ts
  → Resolve Gates (0ms): ok — mojentic-ts: resolved 5 gates for validate workflow
    gate_resolution_completed (evt_92531a666649d6464e569dc2) project=mojentic-ts
      → Run Preflight Gates (6931ms): ok — mojentic-ts: preflight gates passed
        preflight_completed (evt_08b0f626599a23ee8c648a8c) project=mojentic-ts
          → Route Validation Result (3ms): ok — mojentic-ts: validation passed
            validation_completed (evt_e60a246dfa9072414890fa24) project=mojentic-ts
---

Exits with code 0 if all projects pass, non-zero if any required gate fails. Optional gate failures are reported but do not affect the exit code.

foundry trace

View one completed event chain from the daemon-owned trace store.

foundry trace <event_id> [--verbose] [--flat]
ArgumentRequiredDescription
event_idYesRoot event ID returned by foundry emit (positional)
--verboseNoShow trigger payloads, emitted payloads, raw shell output, and audit artifact paths
--flatNoForce the legacy chronological event tree instead of the default span tree

By default this renders the daemon's span tree view. --flat forces the legacy chronological event tree. The CLI asks the daemon for the trace; it does not inspect FOUNDRY_TRACES_DIR directly unless you explicitly choose offline history browsing. Traces are persisted under ~/.foundry/traces/YYYY-MM-DD/, survive daemon restarts, and are available through the daemon even after the in-memory cache has expired.

Output (default):

greet_requested (evt_47fcb603e1b18c8435b8cc3b) project=hello
  → ComposeGreeting: ok — composed greeting for Stacey
    greeting_composed (evt_a1b2c3d4e5f6) project=hello
      → DeliverGreeting: ok — delivered greeting: Hello, Stacey!
        greeting_delivered (evt_f6e5d4c3b2a1) project=hello
---
Total: 2ms (blocks: 1ms)

Output (with --verbose):

greet_requested (evt_47fcb603e1b18c8435b8cc3b) project=hello
  → ComposeGreeting (1ms): ok — composed greeting for Stacey
    trigger: {"name":"Stacey"}
    emitted[0]: {"greeting":"Hello, Stacey!"}
    greeting_composed (evt_a1b2c3d4e5f6) project=hello
      → DeliverGreeting (0ms): ok — delivered greeting: Hello, Stacey!
---
Total: 2ms (blocks: 1ms)

If the trace is unknown:

No trace found for evt_unknown (expired or unknown).

foundry history

Browse durable completed traces from the daemon-owned trace store.

foundry history [<date>] [--project <project>] [--offline]
ArgumentRequiredDescription
dateNoDate in YYYY-MM-DD format; omit to show the last 7 days
--projectNoFilter results by project name
--offlineNoExplicitly read local trace files instead of the daemon

Without --offline, history is daemon-authoritative: it calls the daemon's typed History RPC, renders daemon-owned results directly, and never reads or creates a client-side FOUNDRY_TRACES_DIR. If the daemon is unreachable, the command fails and suggests rerunning with --offline. Use --offline deliberately when you want direct file diagnostics against ~/.foundry/traces/ (or FOUNDRY_TRACES_DIR).

Each row shows the event ID, trace ID, success status, duration, event type, and project. Dates with no traces are omitted. Within a day, rows are rendered in deterministic newest-first order.

Output example:

2026-03-22
┌──────────────────────────────┬────────┬──────────┬──────────────────────────┬───────────┐
│ Event ID                     │ Status │ Duration │ Type                     │ Project   │
╞══════════════════════════════╪════════╪══════════╪══════════════════════════╪═══════════╡
│ evt_47fcb603e1b18c8435b8cc3b │ ok     │ 312ms    │ maintenance_run_started  │ my-tool   │
│ evt_a1b2c3d4e5f6789012345678 │ ok     │ 48ms     │ greet_requested          │ hello     │
└──────────────────────────────┴────────┴──────────┴──────────────────────────┴───────────┘

If no traces are found:

No traces found in the last 7 days.

foundry registry

Manage the project registry without editing the JSON file directly.

foundry registry <subcommand>

foundry registry init

Create an empty registry file at the default path (~/.foundry/registry.json). This is an explicit offline recovery command and requires --offline. It rejects runs without --offline before contacting the daemon or touching the registry path. Does nothing if the file already exists. This command never uses the daemon, even when foundryd is running.

foundry --offline registry init

foundry registry list

List all projects in the daemon-owned registry as a table. By default this requires a reachable foundryd daemon. Use --offline to read the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. The online path renders the daemon response directly and does not create FOUNDRY_REGISTRY_PATH. Typed daemon failures render as daemon error: <Code> — <message>.

foundry registry list

Output example:

┌──────────┬────────────┬──────┬──────────────────────────┬───────┐
│ Name     │ Stack      │ Skip │ Actions                  │ Skill │
╞══════════╪════════════╪══════╪══════════════════════════╪═══════╡
│ my-tool  │ rust       │ no   │ iterate, maintain, push  │ auto  │
│ frontend │ typescript │ yes  │ maintain, push           │       │
└──────────┴────────────┴──────┴──────────────────────────┴───────┘

The Skill column shows auto (default derived command), cmd (custom command), off (explicitly disabled), or blank (not configured).

foundry registry show <name>

Show all details for a single project from the daemon-owned registry. By default this requires a reachable foundryd daemon. Use --offline to read the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. The online path renders the daemon response directly and does not create FOUNDRY_REGISTRY_PATH. Missing projects surface the daemon's typed NotFound status.

foundry registry show my-tool

Output example:

Name:      my-tool
Path:      /Users/alice/projects/my-tool
Stack:     rust
Agent:     claude
Repo:      alice/my-tool
Branch:    main
Skip:      no
Actions:   iterate, maintain, push
Install:   brew: my-tool
Installs skill: yes (default -- runs my-tool init --global --force)
Timeout:   3600s (default)

When installs_skill is true but the project has no install config, there is no binary to derive the command from. The line then reports the skip and the reason, for example Installs skill: skipped -- no install config, so there is no binary to derive .... Set a command with --installs-skill "<command>" to fix it.

foundry registry add

Add a new project to the daemon-owned registry. By default this requires a reachable foundryd daemon. Use --offline to write the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. In offline mode, if the registry file does not exist, it is created automatically. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the command surfaces the daemon's stable INTERNAL error and leaves the daemon-owned registry unchanged in memory and on disk. Duplicate names and invalid inputs surface the daemon's typed AlreadyExists and InvalidArgument statuses.

foundry registry add \
  --name my-tool \
  --path /Users/alice/projects/my-tool \
  --stack rust \
  --agent claude \
  --repo alice/my-tool \
  --branch main \
  [--iterate] [--maintain] [--push] [--audit] [--release] \
  [--install-command "cargo install --path ."] \
  [--install-brew my-formula] \
  [--notes "Human-readable notes about the project"] \
  [--timeout-secs 3600] \
  [--update-policy minor] \
  [--installs-skill true]
OptionRequiredDescription
--nameYesUnique project name
--pathYesAbsolute path to the project
--stackYesTechnology stack: rust, python, typescript, elixir, cpp, swift, kotlin
--agentYesAI agent name (e.g. claude)
--repoYesGitHub slug (owner/repo)
--branchNoDefault branch (default: main)
--iterateNoEnable iterate action
--maintainNoEnable maintain action
--pushNoEnable push action
--auditNoEnable audit action
--releaseNoEnable release action
--install-commandNoShell command to run for local install
--install-brewNoHomebrew formula name
--notesNoHuman-readable notes
--timeout-secsNoCommand timeout in seconds (default: 3600)
--update-policyNopatch, minor or major: how far maintenance may move dependencies. Unset behaves as minor and is flagged in the maintenance summary. See Dependency update policy
--installs-skillNotrue (run <binary> init --global --force, binary from the brew formula or the project name; skipped when there is no install config), false (no skill install), or a command to run verbatim. See Skill install

foundry registry remove <name>

Remove a project from the daemon-owned registry. By default this requires a reachable foundryd daemon. Use --offline to mutate the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the command surfaces the daemon's stable INTERNAL error and leaves the daemon-owned registry unchanged in memory and on disk. Missing projects surface the daemon's typed NotFound status.

foundry registry remove my-tool

foundry registry edit <name>

Update settings for an existing project. Only the fields you pass are changed; all others are left as-is. By default this requires a reachable foundryd daemon. Use --offline to mutate the local registry file directly for recovery. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file untouched. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the command surfaces the daemon's stable INTERNAL error and leaves the daemon-owned registry unchanged in memory and on disk. Missing projects surface the daemon's typed NotFound status.

foundry registry edit my-tool \
  --skip "Waiting for CI to stabilise" \
  --timeout-secs 3600
OptionDescription
--pathUpdate the project path
--stackUpdate the technology stack
--agentUpdate the agent name
--repoUpdate the GitHub slug
--branchUpdate the default branch
--skipSet a skip reason (pass empty string "" to clear the skip)
--iterateSet iterate action (true/false)
--maintainSet maintain action
--pushSet push action
--auditSet audit action
--releaseSet release action
--install-commandSet install command
--install-brewSet Homebrew formula
--clear-installRemove the install configuration (cannot be combined with --install-command or --install-brew)
--notesSet notes (pass empty string "" to clear)
--timeout-secsSet command timeout in seconds
--update-policySet the dependency update policy: patch, minor or major
--installs-skillSet the skill install: true, false, or a command to run verbatim. An empty value is rejected

foundry queue reconcile

Runs the canonical work reconciler through daemon-authoritative ReconcileWork gRPC and prints that invocation's Markdown digest. Requires the daemon and rejects --offline; it never reads client-side ledger or digest files. Unreachable transport is an error. Inspection, fetch, ledger-save and digest-write failures surface as INTERNAL with diagnostics. Verified settlements can already be durable when a later digest write fails. Reports default to ~/.foundry/reconcile/YYYY-MM-DD.md (FOUNDRY_RECONCILE_DIR overrides the directory). See Work reconciler.

gRPC API

The Foundry service is defined in proto/foundry.proto.

Service: Foundry

Emit(EmitRequest) → EmitResponse

Fire an event into the system. The engine spawns processing as a background task and returns the event ID immediately. Use Trace to check for completion, Status to see in-flight workflows, or Watch for real-time event streaming.

Request:

FieldTypeDescription
event_typestringEvent type name
projectstringTarget project
throttleThrottle enumTHROTTLE_FULL, THROTTLE_AUDIT_ONLY, THROTTLE_DRY_RUN
payload_jsonstringOptional JSON payload

Response:

FieldTypeDescription
event_idstringDeterministic ID of the created event
workflow_idstringID of the triggered workflow (if any)

Status(StatusRequest) → StatusResponse

Query active workflow states.

Request:

FieldTypeDescription
workflow_idstringSpecific workflow (empty for all active)

Response:

FieldTypeDescription
workflowsrepeated WorkflowStatusActive workflow states

Watch(WatchRequest) → stream WatchResponse

Server-side streaming of live events as they are processed by the engine. Optionally filtered by project name.

Request:

FieldTypeDescription
projectstringProject name to filter by; empty string for all projects

Response (stream):

FieldTypeDescription
event_idstringEvent identifier
event_typestringEvent type name
projectstringTarget project
payload_jsonstringEvent payload as JSON

RegistryAdd(RegistryAddRequest) → RegistryAddResponse

Add a project to the daemon's in-memory registry and persist the change to registry.json. The daemon is the single source of truth for registry state.

Request:

FieldTypeDescription
namestringUnique project name
pathstringAbsolute path on the local filesystem
stackstringTechnology stack: rust, python, typescript, elixir, cpp, swift, kotlin
agentstringAI agent name
repostringGitHub repo slug (owner/repo)
branchstringDefault branch (empty → main)
iterateboolEnable iterate action
maintainboolEnable maintain action
pushboolEnable push action
auditboolEnable audit action
releaseboolEnable release action
install_commandstringShell command for local install (mutually exclusive with install_brew)
install_brewstringHomebrew formula for local install (mutually exclusive with install_command)
notesstringHuman-readable notes (empty → none)
timeout_secsuint64Per-project timeout (0 → use default 3600 s)
update_policystringpatch, minor or major (empty → not set; maintenance behaves as minor)
installs_skillstringtrue, false, or a skill-install command (empty → not set; blank → INVALID_ARGUMENT)

Response:

FieldTypeDescription
projectProjectThe newly created project entry

Errors: ALREADY_EXISTS if the name is already in the registry; INVALID_ARGUMENT for an unknown stack, an unknown update policy, or conflicting install fields; INTERNAL with the stable message failed to persist registry state when saving fails.

CLI: foundry registry add ... routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL with a stable failed to persist registry state message and leaves both the daemon's in-memory registry and its on-disk registry bytes unchanged.

RegistryList(RegistryListRequest) → RegistryListResponse

List the daemon-owned registry inventory from the in-memory state held by foundryd. The online CLI path renders this response directly and does not read FOUNDRY_REGISTRY_PATH.

Request:

This message has no fields.

Response:

FieldTypeDescription
projectsrepeated ProjectEvery project currently loaded in the daemon-owned registry

Each Project carries the full registry data required by online clients: name, path, stack, agent, repo, branch, skip, action flags, install config, notes, timeout, installs-skill state, audit exceptions, and update_policy (empty when not set).

Errors: None at the RPC layer; the daemon answers from already-loaded registry state.

CLI: foundry registry list routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to read the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path renders the RPC response directly and does not create FOUNDRY_REGISTRY_PATH; if that path is absent, the online path leaves it absent. foundry registry init is not part of this RPC surface and remains an offline-only recovery command.

RegistryShow(RegistryShowRequest) → RegistryShowResponse

Retrieve one exact-name project from the daemon-owned registry state. The match is exact and does not perform prefix or substring lookup.

Request:

FieldTypeDescription
namestringExact project name to retrieve

Response:

FieldTypeDescription
projectProjectThe full daemon-owned project record for that exact name

Errors: NOT_FOUND if no exact-name project exists.

CLI: foundry registry show <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to read the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path renders the RPC response directly and does not create FOUNDRY_REGISTRY_PATH; if that path is absent, the online path leaves it absent.

RegistryRemove(RegistryRemoveRequest) → RegistryRemoveResponse

Remove a project from the registry by name.

Request:

FieldTypeDescription
namestringProject name to remove

Errors: NOT_FOUND if no project with that name exists; INTERNAL with the stable message failed to persist registry state when saving fails.

CLI: foundry registry remove <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL with a stable failed to persist registry state message and leaves both the daemon's in-memory registry and its on-disk registry bytes unchanged.

RegistryEdit(RegistryEditRequest) → RegistryEditResponse

Apply partial edits to an existing project. Only fields that are non-empty / non-zero are applied. Use clear_* booleans to explicitly clear optional fields (e.g. clear_skip = true to un-skip a project).

Request:

FieldTypeDescription
namestringProject to edit (required)
pathstringNew path (empty → no change)
stackstringNew stack (empty → no change)
agentstringNew agent (empty → no change)
repostringNew repo slug (empty → no change)
branchstringNew branch (empty → no change)
skipstringNew skip reason; non-empty sets it; empty → no change unless clear_skip
clear_skipboolRemove the skip flag
iterateboolSet iterate to true (use clear_iterate to set false)
clear_iterateboolSet iterate to false
maintainboolSet maintain to true
clear_maintainboolSet maintain to false
pushboolSet push to true
clear_pushboolSet push to false
auditboolSet audit to true
clear_auditboolSet audit to false
releaseboolSet release to true
clear_releaseboolSet release to false
install_commandstringSet a shell-command install
install_brewstringSet a Homebrew formula install
clear_installboolRemove the install config
notesstringSet notes (empty string + clear_notes = false → no change)
clear_notesboolRemove notes
timeout_secsuint64Set timeout (0 → no change unless clear_timeout)
clear_timeoutboolRevert timeout to the daemon default
update_policystringSet patch, minor or major (empty → no change)
installs_skillstringSet true, false, or a skill-install command (empty → no change; blank → INVALID_ARGUMENT)

Response:

FieldTypeDescription
projectProjectThe updated project entry

Errors: NOT_FOUND; INVALID_ARGUMENT for conflicting install fields or unknown stack; INTERNAL with the stable message failed to persist registry state when saving fails.

CLI: foundry registry edit <name> ... routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the registry file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side registry file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_REGISTRY_PATH. If daemon persistence fails, the RPC returns INTERNAL with a stable failed to persist registry state message and leaves both the daemon's in-memory registry and its on-disk registry bytes unchanged.

SentinelList(SentinelListRequest) → SentinelListResponse

List every daemon-owned sentinel from the in-memory scheduler control-plane state held by foundryd.

Request:

This message has no fields.

Response:

FieldTypeDescription
sentinelsrepeated SentinelEvery daemon-owned sentinel entry in scheduler evaluation order

Each Sentinel carries the exact scheduler contract the CLI needs to render: name, cron, emit_event_type, emit_project, emit_throttle, emit_payload_json, and enabled.

Errors: None at the RPC layer; the daemon answers from already-loaded sentinel state.

CLI: foundry sentinel list routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to read the sentinel file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side sentinel file unchanged. The online path renders the RPC response directly and does not create FOUNDRY_SENTINELS_PATH; if that path is absent, the online path leaves it absent.

SentinelShow(SentinelShowRequest) → SentinelShowResponse

Retrieve one exact-name sentinel from the daemon-owned scheduler control-plane state. The name match is exact and does not perform prefix or substring lookup.

Request:

FieldTypeDescription
namestringExact sentinel name to retrieve

Response:

FieldTypeDescription
sentinelSentinelThe full daemon-owned sentinel record for that name

Errors: NOT_FOUND if no exact-name sentinel exists.

CLI: foundry sentinel show <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to read the sentinel file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side sentinel file unchanged. The online path renders the RPC response directly and does not create FOUNDRY_SENTINELS_PATH; if that path is absent, the online path leaves it absent.

SentinelEnable(SentinelEnableRequest) → SentinelEnableResponse

Mark one daemon-owned sentinel as enabled, persist the updated sentinel store, and wake the in-process scheduler so the next firing is recomputed immediately.

Request:

FieldTypeDescription
namestringExact sentinel name to enable

Response:

FieldTypeDescription
sentinelSentinelThe committed daemon-owned sentinel record after enable

Errors: NOT_FOUND if no exact-name sentinel exists; INTERNAL if the daemon cannot persist the sentinel store.

CLI: foundry sentinel enable <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the sentinel file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side sentinel file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_SENTINELS_PATH. If daemon persistence fails, the RPC returns INTERNAL, leaves the daemon-owned in-memory sentinel store unchanged, does not wake the scheduler, and leaves the on-disk sentinel bytes unchanged.

SentinelDisable(SentinelDisableRequest) → SentinelDisableResponse

Mark one daemon-owned sentinel as disabled, persist the updated sentinel store, and wake the in-process scheduler so any pending firing is cancelled immediately.

Request:

FieldTypeDescription
namestringExact sentinel name to disable

Response:

FieldTypeDescription
sentinelSentinelThe committed daemon-owned sentinel record after disable

Errors: NOT_FOUND if no exact-name sentinel exists; INTERNAL if the daemon cannot persist the sentinel store.

CLI: foundry sentinel disable <name> routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the sentinel file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side sentinel file unchanged. The online path mutates daemon-owned state only and does not create FOUNDRY_SENTINELS_PATH. If daemon persistence fails, the RPC returns INTERNAL, leaves the daemon-owned in-memory sentinel store unchanged, does not wake the scheduler, and leaves the on-disk sentinel bytes unchanged.

AddCampaign(AddCampaignRequest) → AddCampaignResponse

Add one campaign definition to the daemon-owned campaign store. The daemon parses the JSON definition, validates the referenced project and context paths against daemon-owned registry state, acquires the exclusive campaign-store lock, and persists the new definition atomically.

Request:

FieldTypeDescription
definition_jsonstringFull campaign definition JSON exactly as accepted by foundry campaign add

Response:

FieldTypeDescription
campaignCampaignDetailFull durable definition that was persisted

Errors: INVALID_ARGUMENT when the JSON is invalid or the definition is structurally invalid; FAILED_PRECONDITION when the definition references an unknown registered project or invalid context artifact; ALREADY_EXISTS when a campaign with the same name already exists; INTERNAL when the store cannot be saved. On INTERNAL, the daemon leaves the on-disk campaign store unchanged.

CLI: foundry campaign add <definition.json> routes through this RPC by default. The online CLI renders the returned CampaignDetail directly and does not re-read FOUNDRY_CAMPAIGNS_PATH. Pass --offline only when you intentionally need direct-file recovery while the daemon is stopped.

ListCampaigns(ListCampaignsRequest) → ListCampaignsResponse

List the durable campaign inventory from the daemon's configured campaign store. This is a read-only query: it loads the store at request time, returns records in deterministic campaign-name order, and exposes summary/status fields only.

Request:

FieldTypeDescription
projectstringOptional exact project-name filter; empty string returns all campaigns

Response:

FieldTypeDescription
campaignsrepeated CampaignDurable inventory records sorted by campaign name

An escalated campaign carries open_escalation, read the same way as GetCampaign reads it.

Errors: FAILED_PRECONDITION when the campaign store is malformed; INTERNAL when the campaign store is unreadable, or when an escalated campaign needs the event-log fallback and the event log cannot be read. Missing or empty stores return an empty list.

PauseCampaign(PauseCampaignRequest) → PauseCampaignResponse

Pause a campaign. The operation is idempotent on the status field — pausing an already-paused campaign is not an error. The daemon holds an exclusive lock on the campaign store for the duration of the write, so a concurrent advance formation cannot interleave with a pause.

Any pending_run_result that was recorded before the pause is explicitly preserved: the operation never clears or overwrites it. The result remains available for the next manual advance after a subsequent resume.

Request:

FieldTypeDescription
namestringExact campaign name to pause

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after the pause is applied

Errors: NOT_FOUND when no campaign with the given name exists; FAILED_PRECONDITION when the campaign store is malformed; INTERNAL when the campaign store is unreadable or when persistence fails. On save failure, the daemon leaves the persisted campaign store unchanged.

CLI: foundry campaign pause <name> routes through this RPC when the daemon is reachable. Pass --offline to bypass the daemon and mutate the store file directly (useful when foundryd is not running). Without --offline, an unreachable daemon is an error and the client-side campaigns path is left untouched.

ResumeCampaign(ResumeCampaignRequest) → ResumeCampaignResponse

Resume a paused or escalated campaign, optionally extending its cycle budget. The daemon holds an exclusive lock on the campaign store for the duration of the write.

pending_run_result is explicitly preserved: the operation never clears or overwrites it. The result remains available for the next manual advance after resume.

Accepted statuses: paused and escalated. Budget-only escalations (where the engine stopped because the cycle limit was reached) may be resumed with this RPC without recording an owner-decision entry. Use DecideCampaign when the escalation contains a human judgment question that requires a policy record.

Exhausted budget guard: when add_cycles == 0 and cycles_completed >= max_cycles, the RPC returns FAILED_PRECONDITION. Pass a positive add_cycles to explicitly authorize more work; the engine never silently reactivates an exhausted campaign.

Request:

FieldTypeDescription
namestringExact campaign name to resume
add_cyclesuint64Additional cycles to add to max_cycles before resuming (0 = no extension)

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after the resume is applied

Errors: NOT_FOUND when no campaign with the given name exists; FAILED_PRECONDITION when the campaign is not paused or escalated, lacks authorized_by, the budget is exhausted and add_cycles == 0, add_cycles would overflow max_cycles, or the campaign store is malformed; INTERNAL when the campaign store is unreadable or persistence fails. On save failure, the daemon leaves the persisted campaign store unchanged.

CLI: foundry campaign resume <name> routes through this RPC when the daemon is reachable. Pass --offline to bypass the daemon and mutate the store file directly (useful when foundryd is not running). Without --offline, an unreachable daemon is an error and the client-side campaigns path is left untouched. The rendered output is built from the ResumeCampaignResponse.campaign detail — the CLI never re-reads the store file on the online path.

DecideCampaign(DecideCampaignRequest) → DecideCampaignResponse

Record a binding owner decision on an active, paused or escalated campaign. The daemon holds the exclusive campaign-store lock for the full mutation and appends one durable owner decision record. Every later formation prompt carries it as binding policy.

The persisted owner decision carries the decision text, the campaign's current authorized_by identity, and the daemon timestamp. Existing counters and any stored pending_run_result are preserved. No event is emitted; the response carries the resulting status.

Active or paused: the decision is recorded and nothing else changes. The status, max_cycles and open_escalation stay as they were. A spent budget does not block it. A positive add_cycles returns FAILED_PRECONDITION and records nothing: budget extension through this RPC answers an escalation, and a paused campaign takes more cycles through ResumeCampaign. A decision recorded while the campaign forms makes that formation form again from the changed record.

Escalated: the decision answers the escalation. The daemon adds add_cycles to max_cycles (checked addition), clears open_escalation and sets active, all in one transition. When cycles_completed >= max_cycles, the request must carry a positive add_cycles; with add_cycles == 0 the RPC returns FAILED_PRECONDITION and records nothing, because an active campaign with no cycle left would escalate on budget at its next advance, and ResumeCampaign does not accept an active campaign.

Retries: on an active or paused campaign, a decision whose trimmed text and owner equal the latest recorded decision returns FAILED_PRECONDITION ("campaign '' already records this owner decision as its latest; nothing to apply") and records nothing. A successful escalated decide leaves the campaign active, so its retry meets this check before the add_cycles check: the decision is not recorded twice and add_cycles is not applied twice. An escalated campaign accepts the same words, because it stopped on a new question after that decision.

Request:

FieldTypeDescription
namestringExact campaign name to update
decisionstringNon-empty owner decision text to record
add_cyclesuint64Cycles to add to max_cycles with the decision (0 = none; escalated campaigns only; required on a spent escalated budget)

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after the decision is applied

Errors: NOT_FOUND when no campaign with the given name exists; INVALID_ARGUMENT when decision is empty after trimming; FAILED_PRECONDITION when the campaign is staged, completed or cancelled, lacks authorized_by, repeats its latest decision, is active or paused and add_cycles > 0, is escalated with a spent budget and add_cycles == 0, add_cycles would overflow max_cycles, or the campaign store is malformed; INTERNAL when the campaign store is unreadable or persistence fails. On save failure, the daemon leaves the persisted campaign store unchanged.

CLI: foundry campaign decide <name> --decision "<text>" [--add-cycles <n>] routes through this RPC by default and therefore requires a reachable daemon. Pass --offline to bypass the daemon and mutate the store file directly. Without --offline, an unreachable daemon returns an error and leaves the client-side campaigns path unchanged.

CompleteCampaign(CompleteCampaignRequest) → CompleteCampaignResponse

Mark an authorized campaign complete from outside the formation loop. The request requires a non-empty reason and an existing authorized_by owner. The daemon stores the reason as an append-only owner record, clears any pending run result, changes the status to completed, and emits the normal CampaignCompleted event for terminal observers. Calling it on an already completed campaign is idempotent.

Request:

FieldTypeDescription
namestringExact campaign name to complete
reasonstringEvidence-backed owner reason for external completion

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after completion is applied

Errors: INVALID_ARGUMENT for a blank reason, NOT_FOUND for an unknown campaign, and FAILED_PRECONDITION when the campaign has no authorizing owner or the campaign store is malformed; INTERNAL when the campaign store is unreadable or persistence fails. On save failure, the daemon leaves the persisted campaign store unchanged and does not emit CampaignCompleted.

CLI: foundry campaign complete <name> --reason "<text>" routes through this RPC by default. The online CLI renders the returned CampaignDetail directly, never re-reads FOUNDRY_CAMPAIGNS_PATH, and leaves the client-side path untouched if the daemon is unreachable. Pass --offline only for direct-file recovery while the daemon is stopped.

CancelCampaign(CancelCampaignRequest) → CancelCampaignResponse

Stop a campaign permanently before its done evidence was met. Distinct from CompleteCampaign because completed is an evidence claim and cancellation records the opposite. The daemon changes the status to cancelled, clears any pending run result, stores the reason as an append-only owner record when the campaign has an owner, and emits CampaignCancelled for terminal observers.

Unlike CompleteCampaign, this does not require authorized_by — an unauthorized campaign has no other reachable terminal state.

With terminate_now, the daemon aborts the in-flight workflow before touching the store. Aborting it kills the running agent process. A formation holds the campaign store lock only in its short read and write sections, never while its agent runs; if the abort lands inside one, dropping the task releases the lock. The abort is awaited, so the subsequent store acquisition does not wait on the aborted task. discard_work then selects how the orphaned worktree is disposed of by the DisposeCampaignWork block.

Request:

FieldTypeDescription
namestringExact campaign name to cancel
reasonstringOwner reason for abandoning the mission
terminate_nowboolAbort the in-flight workflow, killing the running agent process
discard_workboolThrow the terminated cycle's uncommitted work away

Response:

FieldTypeDescription
campaignCampaignDetailFull campaign detail reflecting the state after cancellation
event_idstringThe emitted CampaignCancelled event; empty when the call was a no-op

Errors: INVALID_ARGUMENT for a blank reason, NOT_FOUND for an unknown campaign, FAILED_PRECONDITION when the campaign is already completed or the store is malformed, and INTERNAL when the store is unreadable or persistence fails. Cancelling an already-cancelled campaign is idempotent: it returns the current detail with an empty event_id and emits no second event.

CLI: foundry campaign cancel <name> --reason "<text>" [--now] [--discard-work] routes through this RPC by default. --discard-work requires --now. --offline uses a graceful-only direct-file path that emits no event; --offline --now is refused rather than downgraded.

AdvanceCampaign(AdvanceCampaignRequest) → AdvanceCampaignResponse

Dispatch one manual campaign-advance workflow for an active or staged campaign. Under the exclusive campaign-store lock the daemon validates the current status, checks for campaign work already in flight, emits CampaignAdvanceRequested through spawn_workflow (which records the workflow in the tracker before it returns), then releases the lock and returns. A formation never holds that lock while its agent runs, so the call returns promptly whatever other campaigns are doing.

An advance never duplicates work in flight. When the campaign already has, in the work-item ledger, an item submitted, queued, held or running (or still holding its slot) that is one of its cycles (source campaign:<name>) or an advance of it (a CampaignAdvanceRequested root naming it), or a tracked advance or cycle workflow not yet admitted to the ledger, the daemon dispatches nothing and returns in_flight instead of an event_id. Because the check and the dispatch happen under the same lock, two simultaneous advances for one campaign dispatch exactly one workflow. The automatic advance after a cycle completes is not an RPC and is unaffected.

Request:

FieldTypeDescription
namestringExact campaign name to advance

Response:

FieldTypeDescription
campaignCampaignDetailCurrent campaign detail at dispatch time
event_idstringRoot event ID of the dispatched CampaignAdvanceRequested workflow; empty with in_flight
in_flightCampaignWorkInFlightSet when the request was a no-op because campaign work was already in flight

CampaignWorkInFlight carries kind (advance or cycle), work_item_id (empty for an accepted advance not yet in the ledger), state (submitted, queued, held or running) and the root event_id.

Errors: NOT_FOUND when the name is absent; FAILED_PRECONDITION when the campaign is paused, escalated, completed or cancelled, or the work-item ledger is malformed; INTERNAL when the campaign store or the ledger is unreadable. Rejected advances and no-op advances do not mutate either store.

CLI: foundry campaign advance <name> routes through this RPC by default. The online CLI prints the returned root event_id, watches the daemon-owned workflow stream for that advance's own trace only, stops at its campaign_advance_completed (a decision, or stood_down when the campaign was stopped first; or its work item's settlement when the advance failed without one), and renders the daemon trace for that event. For a no-op it prints one line naming the in-flight work and the command to inspect it, and exits 0 without watching. It does not re-read FOUNDRY_CAMPAIGNS_PATH.

GetCampaign(GetCampaignRequest) → GetCampaignResponse

Retrieve the complete durable definition of one campaign by exact name. Unlike ListCampaigns, this RPC returns the full detail record including intent_refs, context_paths, done_evidence (with the Gate/Review type distinction preserved), escalation rules, and all runtime status fields.

Request:

FieldTypeDescription
namestringExact campaign name to look up

Response:

FieldTypeDescription
campaignCampaignDetailFull durable definition of the campaign

For an escalated campaign, open_escalation says why it waits on its owner. The daemon reads it from the campaign record, which formation writes when it escalates the campaign. A campaign that escalated before the record existed has none: the daemon then recovers the reason from the newest campaign_escalated event naming the campaign (every monthly file under FOUNDRY_EVENTS_DIR) and sets from_event_log. A recovered record is never written back to the store.

Errors: NOT_FOUND when no campaign with the given name exists in the store (even when other campaigns are present); FAILED_PRECONDITION when the campaign store is malformed; INTERNAL when the campaign store is unreadable, or when the event-log fallback cannot read the event log.

CLI: foundry campaign show <name> routes through this RPC by default and renders the returned CampaignDetail directly, without re-reading FOUNDRY_CAMPAIGNS_PATH.

ListWorkItems(ListWorkItemsRequest) → ListWorkItemsResponse

List records from the daemon-owned work-item ledger (FOUNDRY_WORK_ITEMS_PATH). The store is loaded from disk on every call and nothing is cached between requests, so a caller always sees the ledger as it stands. This RPC never writes and never takes the ledger write gate.

Request:

FieldTypeDescription
projectstringExact-match project filter. Empty means every project; alpha does not match alpha-2
statestringExact-match state filter, given as the item state's serialized tag. Empty means every state

Response:

FieldTypeDescription
itemsrepeated WorkItemThe selected records, in order

Ordering is deterministic and independent of the store's contents order and insertion order:

  1. running items, by started_at ascending
  2. submitted, queued and held items, by submitted_at ascending
  3. Open items (preserved, needs_decision, failed), by settled_at descending
  4. Terminal items (landed, cancelled), by settled_at descending

Ties within a group break by id ascending. A state filter returns the matching items in the same relative order as the unfiltered list.

Errors: INVALID_ARGUMENT when state is a non-empty tag that is not one of submitted, queued, held, running, landed, preserved, needs_decision, failed, cancelled; FAILED_PRECONDITION when the ledger contains malformed JSON; INTERNAL when the ledger path is unreadable. A missing ledger file, or one holding {"version":1,"items":[]}, is an empty list rather than an error.

CLI: online callers render this response directly and never read the client-side ledger file; if FOUNDRY_WORK_ITEMS_PATH is absent, the online path leaves it absent.

GetWorkItem(GetWorkItemRequest) → GetWorkItemResponse

Retrieve one work-item ledger record by exact id. The store is loaded from disk on every call and nothing is cached between requests. This RPC never writes and never takes the ledger write gate.

Request:

FieldTypeDescription
idstringExact work-item id to look up

Response:

FieldTypeDescription
itemWorkItemThe full durable ledger record

Errors: NOT_FOUND when no item with that id exists, including when the ledger holds other items and when the ledger file is missing or empty; FAILED_PRECONDITION when the ledger contains malformed JSON; INTERNAL when the ledger path is unreadable.

CLI: online callers render this response directly and never read the client-side ledger file; if FOUNDRY_WORK_ITEMS_PATH is absent, the online path leaves it absent.

CloseWorkItem and CancelWorkItem

Both return an item: WorkItem after owner-directed settlement to cancelled. CloseWorkItemRequest carries id, a nonblank reason, and nonblank operator_origin; it accepts only preserved, needs_decision, failed. CancelWorkItemRequest carries id and nonblank operator_origin; it accepts only submitted, queued, held and uses cancelled by operator as the reason.

Validation returns INVALID_ARGUMENT, unknown exact ids NOT_FOUND, refused states and malformed ledgers FAILED_PRECONDITION, and I/O faults INTERNAL. The daemon reloads and atomically replaces the ledger under the shared write gate before emitting work_item_cancelled on Watch and in the durable event log. Failed saves emit nothing. Neither RPC dispatches, aborts a workflow nor disposes of work. The original identity, origin, objective, trace and disposition remain intact. operator_action records command, operator origin, previous state, previous reason and optional previous settlement timestamp. Earlier lifecycle events remain unchanged. CLI mutations reject --offline.

HoldWorkItem and ReleaseWorkItem

Both carry id and nonblank operator_origin and return an item: WorkItem. HoldWorkItem accepts only a queued item and moves it to held with the reason held by operator; the scheduler ignores it until released. ReleaseWorkItem accepts a held item, or one in needs_decision because a dependency settled some way other than landed, and returns it to queued: the settled dependencies are dropped from depends_on, and the recorded reason is the scheduler's own verdict for the item as it now stands. Validation, error codes, write-gate discipline and operator_action follow CloseWorkItem; the emitted event is work_item_held or work_item_released, on Watch and in the durable log, after the ledger is saved. Neither dispatches or aborts work.

GetPacing(GetPacingRequest) → GetPacingResponse

Return the pacing stage as it stands: the host running cap, how many items run, the paused lanes, every running item and every waiting item with its reason. Loaded from the ledger and the pacing files on every call; never writes and never takes the ledger write gate. Running items are listed oldest start first; waiting items in the scheduler's priority order, then held items.

Response: pacing: PacingStatus.

PausePacing and ResumePacing

Both carry lanes (lane tags interactive, campaign, maintenance, or all; empty means every lane) and nonblank operator_origin, and return pacing: PacingStatus after the change. PausePacing stops new starts in the named lanes; running items are untouched. ResumePacing allows starts again and the next scheduler tick starts the next eligible item. The pause state is saved to FOUNDRY_PACING_STATE_PATH through an atomic rename and is in force after a restart. An unknown lane is INVALID_ARGUMENT; a failed save is INTERNAL and emits nothing. Each emits pacing_paused or pacing_resumed for the project system, durable and on Watch, even when the named lanes were already in that state.

ListWorkItemEvents(ListWorkItemEventsRequest) → ListWorkItemEventsResponse

List one work item's work_item_* events (work_item_submitted, work_item_started, work_item_settled, work_item_cancelled, work_item_held, work_item_released) from the durable event log under FOUNDRY_EVENTS_DIR (one YYYY-MM.jsonl file per month). Additive to GetWorkItem: a caller that never sends this request sees exactly the GetWorkItem and ListWorkItems behaviour it saw before. The ledger and the event log are loaded from disk on every call and nothing is cached between requests. This RPC never writes and never takes the ledger write gate.

Request:

FieldTypeDescription
idstringExact work-item id whose events to list

Response:

FieldTypeDescription
eventsrepeated WorkItemEventThe item's events, in chronological order

Selection and order: events are selected by exact payload item_id — never by trace id and never by project, since the items of one maintenance run share both — from every monthly file regardless of age, and returned by occurred_at ascending, ties in log order (file name, then line).

Errors: NOT_FOUND when no item with that id is in the ledger; FAILED_PRECONDITION when the ledger contains malformed JSON; INTERNAL when the ledger path is unreadable, or when an existing events directory or log file cannot be read — never an empty list. A known item with no events, or a missing events directory, is an empty list. A malformed line in the log (not JSON, or two events glued onto one line) is skipped with a warning in the daemon log, and the item's well-formed events on other lines are still returned.

CLI: foundry queue show <id> renders this after GetWorkItem. Online callers never read the client-side ledger or events files; if FOUNDRY_WORK_ITEMS_PATH or FOUNDRY_EVENTS_DIR is absent, the online path leaves it absent.

History(HistoryRequest) → HistoryResponse

List durable trace history from the daemon-owned trace store.

Request:

FieldTypeDescription
datestringExact day in YYYY-MM-DD format, or empty to request recent history
projectstringExact project filter, or empty for all projects
recent_daysuint32Number of recent days to return when date is empty; online CLI uses 7

Response:

FieldTypeDescription
daysrepeated HistoryDayMatching days, newest day first

Each HistoryDay carries a date plus repeated HistoryTrace. HistoryTrace contains event_id, event_type, project, success, total_duration_ms, and trace_id.

Completed traces are persisted to disk under ~/.foundry/traces/YYYY-MM-DD/ and survive daemon restarts. The daemon reads that store directly when serving history, preserving durable retention semantics even after in-memory cache entries expire.

CLI: foundry history routes through this RPC by default, renders the daemon response directly, and does not read or create a client-side FOUNDRY_TRACES_DIR unless --offline is explicit.

Trace(TraceRequest) → TraceResponse

Retrieve the trace of a completed event chain. Returns all events produced during processing and a record of each block execution.

Request:

FieldTypeDescription
event_idstringRoot event ID to look up

Response:

FieldTypeDescription
foundboolWhether a trace was found for the given event ID
eventsrepeated TraceEventAll events in the chain
block_executionsrepeated TraceBlockExecutionRecord of each block execution

Completed traces are persisted to disk under ~/.foundry/traces/YYYY-MM-DD/ and survive daemon restarts. The Trace RPC checks the in-memory store first (for recently completed chains) and falls back to disk for older traces.

CLI: foundry trace <event-id> routes through this RPC by default. When no trace is found, the CLI prints No trace found for <event-id> (expired or unknown).

Span(SpanRequest) → SpanResponse

Retrieve every event and block execution that belongs to one span.

Request:

FieldTypeDescription
span_idstringExact span ID to look up

Response:

FieldTypeDescription
foundboolWhether a span was found for the given span ID
eventsrepeated TraceEventEvents whose span_id matches the requested span
block_executionsrepeated TraceBlockExecutionBlocks whose own span or parent span matches
trace_idstringOwning trace ID, including for block-only spans
total_duration_msuint64Sum of returned block durations

Span is an in-memory lookup keyed by the daemon's span index. It is intended for live drill-down and status filtering rather than durable offline browsing. If the span is unknown, the response is found = false with empty collections and an empty trace_id.

Messages

WorkflowStatus

FieldTypeDescription
workflow_idstringWorkflow identifier
workflow_typestringWorkflow type name
projectstringTarget project
statestringpending, running, completed, failed
started_atstringISO 8601 timestamp
completed_atstringISO 8601 timestamp (empty if running)
task_blocksrepeated TaskBlockStatusPer-block status

TaskBlockStatus

FieldTypeDescription
namestringBlock name
statestringpending, running, completed, skipped, failed
started_atstringISO 8601 timestamp
completed_atstringISO 8601 timestamp
throttledboolTrue if emission was suppressed by throttle

TraceEvent

FieldTypeDescription
event_idstringDeterministic event identifier
event_typestringEvent type name
projectstringTarget project
occurred_atstringISO 8601 timestamp
throttleThrottle enumThrottle level for this event

WorkItem

One durable work-item ledger record, as returned by ListWorkItems and GetWorkItem. The fields only a settled item has are proto3 optional, so a caller can tell "not recorded" from a recorded empty string or false.

FieldTypeDescription
idstringStable identity minted at submission (wi_ plus 24 hex characters)
projectstringRegistry project name
objectivestringThe task description or campaign objective the work serves
kindstringtask, campaign_cycle, maintenance, major_upgrade, release, or remediation
lanestringinteractive, campaign, or maintenance
originstringOpaque submitter text; Foundry never interprets it
submitted_atstringISO 8601 timestamp the item entered the ledger
started_atoptional stringISO 8601 timestamp an agent started on it; absent when it never started
settled_atoptional stringISO 8601 timestamp it settled; absent while unsettled
statestringsubmitted, queued, held, running, landed, preserved, needs_decision, failed, or cancelled
reasonstringWhy it is in that state, in one line; for a queued item, the scheduler's current verdict
trace_idoptional stringThe workflow trace the item belongs to; absent when it carries none
verdictoptional stringSettlement: the reviewer's typed verdict tag
landed_commitoptional stringSettlement: the trunk commit the work landed as; absent unless it landed
preservation_refoptional stringSettlement: the durable ref (branch or bundle:<path>) holding unlanded work
worktreeoptional stringSettlement: the isolated worktree the work ran in
worktree_removedoptional boolSettlement: whether that worktree was gone by settlement time; absent when the item records no worktree
depends_onrepeated stringPacing: the items this one waits on; it starts only once every one has settled landed. Empty when none
not_beforeoptional stringPacing: the earliest time the scheduler may start it (ISO 8601); absent when unset

WorkItem.operator_action is an optional WorkItemOperatorAction message with command (close, cancel, resume, hold or release), origin, previous_state, previous_reason and optional previous_settled_at. Absent on items without an owner action.

ResumeWorkItemRequest also carries additive depends_on (repeated string; each must be in the ledger) and not_before (ISO 8601 string; empty means at once).

PacingStatus and PacingItem

FieldTypeDescription
max_runninguint64The host running cap from pacing.json (default 2)
runninguint64How many items run on this host now
paused_lanesrepeated stringThe paused lanes, as lane tags, in lane order
running_itemsrepeated PacingItemEvery running item, oldest start first
waiting_itemsrepeated PacingItemQueued items in the scheduler's priority order, then held

A PacingItem carries id, project, repository (the key the per-repository rule uses), kind, lane, state, reason and since (started_at for a running item, submitted_at for a waiting one).

WorkItemEvent

One work_item_* lifecycle event read back from the durable event log, as returned by ListWorkItemEvents.

FieldTypeDescription
idstringThe event's own id
event_typestringwork_item_submitted, work_item_started, work_item_settled, work_item_cancelled, work_item_held, or work_item_released
occurred_atstringISO 8601 timestamp the event occurred
statestringThe item's state as of this event (same vocabulary as WorkItem.state)
reasonstringWhy it was in that state, in one line
trace_idoptional stringThe workflow trace the event was emitted on; absent when it carries none

Campaign

Summary-only wire form returned by ListCampaigns. Intentionally omits intent_refs, context_paths, done_evidence, and escalation — use GetCampaign to retrieve those fields.

FieldTypeDescription
namestringCampaign name
projectstringRegistered project name
missionstringCampaign mission statement
statusstringDurable status: staged, active, paused, escalated, or completed
cycles_completeduint64Number of dispatched task cycles
cycles_landeduint64Number of task results whose work reached trunk
max_cyclesuint64Configured campaign cycle budget
authorized_bystringOwner authorization identity, or empty when absent
agent_providerstringPreferred agent provider, or empty when absent
last_run_event_idstringMost recent campaign-run event ID, or empty when absent
open_escalationCampaignEscalationWhy the campaign waits on its owner; set only while escalated, absent when no reason was ever recorded

CampaignEscalation

Why an escalated campaign waits on its owner.

FieldTypeDescription
reasonstringThe reason formation recorded, as the campaign_escalated event carries it
escalated_atstringRFC 3339 time of the escalation
cycles_completeduint64Dispatched cycles at the escalation
cycles_landeduint64Cycles whose work had landed at the escalation
questionoptional stringThe task agent's question, when a task stopped with blocked_on_decision
choicesrepeated stringThe choices the task agent offered with its question
from_event_logboolThe record was recovered from the event log; the escalation predates the record

CampaignDetail

Full durable definition of a campaign, as returned by GetCampaign. Carries all fields in Campaign plus the definition-time fields that the summary form omits.

FieldTypeDescription
namestringCampaign name
projectstringRegistered project name
missionstringCampaign mission statement
statusstringDurable status: staged, active, paused, escalated, or completed
cycles_completeduint64Number of dispatched task cycles
cycles_landeduint64Number of task results whose work reached trunk
max_cyclesuint64Configured campaign cycle budget
authorized_bystringOwner authorization identity, or empty when absent
agent_providerstringPreferred agent provider, or empty when absent
last_run_event_idstringMost recent campaign-run event ID, or empty when absent
intent_refsrepeated stringIntent reference labels that anchor the mission
context_pathsrepeated stringPaths to context documents for the campaign
done_evidencerepeated DoneEvidenceCompletion criteria with Gate/Review type distinction
escalationrepeated stringHuman-readable escalation instructions
owner_decisionsrepeated OwnerDecisionAppend-only owner policy decisions recorded after escalations
open_escalationCampaignEscalationWhy the campaign waits on its owner; see Campaign.open_escalation

OwnerDecision

One recorded owner decision attached to a campaign after an escalation. These records are append-only and are threaded back into future campaign formation prompts as binding context.

FieldTypeDescription
decisionstringOwner-authored decision text
authorized_bystringOwner identity copied from the campaign at record time
decided_atstringRFC 3339 timestamp recorded by the daemon

DoneEvidence

One completion-evidence item. The kind field distinguishes the two variants.

Gate variant (kind = "gate"):

FieldTypeDescription
kindstring"gate"
commandstringShell command to evaluate
requiredboolWhether this gate is required for completion
statementstringEmpty for Gate items
artifactsrepeated stringRepository-relative paths that must exist before the gate can pass

Review variant (kind = "review"):

FieldTypeDescription
kindstring"review"
commandstringEmpty for Review items
requiredboolfalse for Review items
statementstringHuman-readable completion statement
artifactsrepeated stringEmpty for Review items

TraceBlockExecution

FieldTypeDescription
block_namestringName of the block that executed
trigger_event_idstringEvent ID that triggered this block
successboolWhether the block succeeded
summarystringHuman-readable summary of the result
emitted_event_idsrepeated stringIDs of events emitted by this block
duration_msuint64Wall-clock milliseconds for this block execution (including retries)
raw_outputstringCombined stdout+stderr from any shell command run by this block
exit_codeint32Exit code from any shell command run by this block
trigger_payload_jsonstringJSON payload of the event that triggered this block
emitted_payload_jsonsrepeated stringJSON payloads of events emitted by this block
audit_artifactsrepeated stringPaths to audit artefact files produced by this block

Throttle (enum)

ValueNumberDescription
THROTTLE_FULL0All blocks emit
THROTTLE_AUDIT_ONLY1Observers emit, mutators suppress
THROTTLE_DRY_RUN2Read-only, no side effects

Event Types

All event types are defined in foundry-sdk/src/event.rs as the EventType enum. The string representation uses snake_case.

Every event carries these common fields:

FieldTypeDescription
idstringDeterministic SHA-256 derived ID, prefixed evt_
event_typestringSnake-case event type name
projectstringProject this event relates to
occurred_atRFC 3339 timestampWhen the event happened
recorded_atRFC 3339 timestampWhen the event was logged
throttlestringfull or dry_run
payloadJSON objectEvent-type-specific fields (see below)
trace_idstring or nullOTel trace identity
span_idstring or nullCurrent workflow span identity
parent_span_idstring or nullParent span identity
causation_idstring or nullEvent that caused this event
gather_idstring or nullScatter/gather group identity

Hello-World (engine validation)

TypeDescription
greet_requestedRequest to compose and deliver a greeting
greeting_composedGreeting message has been composed
greeting_deliveredGreeting has been delivered (side effect)

greet_requested payload

FieldTypeDescription
namestringName to greet

greeting_composed payload

FieldTypeDescription
greetingstringComposed greeting text

Vulnerability Remediation

TypeDescription
scan_requestedRequest to scan a project for known vulnerabilities
vulnerability_detectedA vulnerability was found (or injected externally)
release_tag_auditedLatest release tag scanned for the vulnerability
main_branch_auditedMain branch checked for the same vulnerability
remediation_startedAutomated fix attempt initiated
remediation_completedFix attempt finished (success or failure)

vulnerability_detected payload

FieldTypeDescription
cvestringCVE or advisory ID (e.g. "CVE-2026-1234")
vulnerableboolWhether the project is affected
dirtybool (optional)Whether the main branch still contains the vulnerability

release_tag_audited payload

FieldTypeDescription
cvestringCVE from the scan or forwarded from the trigger
vulnerableboolWhether the release tag is affected
dirtybool (optional)Forwarded from the upstream trigger for downstream routing

main_branch_audited payload

FieldTypeDescription
cvestringCVE identifier
dirtybooltrue if the vulnerability is still present on main

remediation_completed payload

FieldTypeDescription
cvestringCVE that was remediated
successboolWhether the fix was applied successfully

Release Lifecycle

TypeDescription
release_requestedDecision made to cut a patch release
release_completedRelease tag created and pushed
release_pipeline_completedGitHub Actions build/publish workflow finished

release_completed payload

FieldTypeDescription
cvestringCVE that prompted the release
releasestringRelease type (e.g. "patch")
new_tagstring or nullSemver tag extracted from Claude CLI output
successboolWhether the Claude CLI invocation succeeded

release_pipeline_completed payload

FieldTypeDescription
statusstring"success" or "failure"
conclusionstring (optional)GitHub Actions conclusion label

Project Lifecycle

TypeDescription
project_validation_completedPre-flight checks for a maintenance run
project_iteration_completedIterate workflow finished
project_maintenance_completedMaintain workflow finished
project_changes_committedGit commit created
project_changes_pushedChanges pushed to remote

project_validation_completed payload

FieldTypeDescription
statusstring"ok", "error", or "skipped"
reasonstring (optional)Human-readable explanation when status is not "ok"
has_gatesbool (optional)Whether .hone-gates.json is present (only on "ok")
fast_forwardedint (optional)Commits fast-forwarded from origin/<branch> before work began (only on "ok")
sync_failurestring (optional)Typed reason the checkout sync was refused: "dirty_tree", "diverged", or "remote_unavailable"
dry_runbool (optional)true when the sync was simulated; fast_forwarded is then the count that would be applied

project_iteration_completed payload

FieldTypeDescription
projectstringProject name
workflowstring"iterate"
successboolWhether the iterate workflow succeeded
summarystringHuman-readable summary of the result
changesbool (optional)Whether code changes were made

project_maintenance_completed payload

FieldTypeDescription
projectstringProject name
workflowstring"maintain"
successboolWhether the maintain workflow succeeded
summarystringHuman-readable summary of the result
changesbool (optional)Whether code changes were made

project_changes_committed payload

FieldTypeDescription
cvestringCVE or "unknown" (from remediation path)
messagestringGit commit message used
push_failurestring (optional)Why the commit was not pushed: "push_rejected_diverged" (rebase onto the moved remote conflicted) or "remote_unavailable"

project_changes_pushed payload

FieldTypeDescription
cvestringCVE or "unknown" (from remediation path)

Local Install

TypeDescription
local_install_completedLocal tool reinstallation finished

Maintenance Workflow

TypePayloadDescription
iteration_requested{ project }Triggers the iterate sub-workflow for a validated project
maintenance_requested{ project }Triggers the maintain sub-workflow for a validated project

Task Lifecycle

TypeDescription
task_run_startedIsolated one-shot task execution began
task_reviewedSkeptical review produced a structural verdict
task_run_completedTask work was landed, durably preserved, or completed with no landing required

task_run_completed payload

FieldTypeDescription
projectstringRegistered project name
successbooltrue only for a complete verdict whose landing was not blocked
landedboolWhether complete or safe converging remainder work reached trunk
summarystringHuman-readable terminal summary
verdictstringcomplete, remainder, defect, blocked_on_decision, or runner_error
preservation_refstring (optional)Continuation ref: landed commit SHA, or remote branch / bundle:<path> for preserved work
land_blockedstring (optional)Why work did not land: trunk_moved_conflict, trunk_moved_gates_failed, stale_base_conflict, stale_base_gates_failed, trunk_moved_repeatedly, checkout_not_ready, or git_failed for landing-eligible work; continuation_conflict for a continuation refused before its agent session (with runner_error). The verdict is unchanged
trunk_arrivalsarray (optional)Trunk commits (commit, subject) the task branch lacked at landing, oldest first; absent when trunk did not move
workspace_baseobject (optional)Where the task worktree started: commit (its first HEAD) and trunk (the trunk commit observed while preparing it). Absent for runs that failed before preparation and for runs that predate it
campaignstring (optional)Campaign that dispatched the task
campaign_cycleinteger (optional)Campaign cycle that dispatched the task

Verdict-specific fields are gaps[], diagnosis, finding plus options[], or detail.

Campaign Formation

TypeDescription
campaign_advance_requestedRequest to re-evaluate a durable campaign
campaign_advance_completedFormation chose done, one next objective, or escalation; or the advance stood down because the campaign was stopped
campaign_pausedProvider unavailability paused a non-terminal campaign
campaign_escalatedCampaign halted for budget, failure, rule, or owner judgment
campaign_completedRequired done evidence proved the mission complete
campaign_cancelledAn owner abandoned the mission before its evidence was met

campaign_cancelled payload

Flattens the shared terminal payload (campaign, project, reason, cycles_completed, cycles_landed) so generic terminal observers parse it unchanged, and adds the operator's disposition choices.

FieldTypeDescription
terminated_nowboolThe in-flight workflow was aborted rather than left to finish
discard_workboolThe terminated cycle's uncommitted work was thrown away
aborted_event_idstring?Root event of the aborted workflow; absent for a graceful stop
aborted_trace_idstring?Trace of the aborted workflow; absent for a graceful stop

An aborted run never reaches trace persistence, so aborted_event_id is the only handle onto its partial events in the JSONL log — foundry trace has nothing to show for it. aborted_trace_id is what correlates the cancellation with the work-item ledger: the killed cycle's item was recorded running under that trace, and a --now cancellation settles it cancelled (see The work queue).

Work-Item Ledger

The six events of the work-item ledger. They report the same durable record at different points in its life, so all six share one payload shape; the event type says which point, and state says where the item stands.

TypeDescription
work_item_submittedA unit of work entered the ledger; paced work arrives queued with the reason it waits
work_item_startedAn agent started on a ledger item
work_item_settledA ledger item reached a settled state, with its disposition
work_item_cancelledAn operator stopped a ledger item
work_item_heldAn operator took a queued item out of the scheduler's hands
work_item_releasedAn operator returned a held or dependency-blocked item to the queue

work_item_started pairs with work_item_settled rather than a work_item_completed: an item does not complete, it settles, into a state that may still hold an obligation. work_item_cancelled is emitted by foundry queue close and foundry queue cancel on the exact item's trace, and by foundry campaign cancel --now on the aborted cycle's trace.

Shared payload

FieldTypeDescription
item_idstringThe item's stable wi_… id
projectstringRegistered project name
objectivestringTask description or campaign objective the work serves
kindstringtask, campaign_cycle, maintenance, major_upgrade, release, remediation
lanestringinteractive, campaign, or maintenance
statestringsubmitted, queued, held, running, landed, preserved, needs_decision, failed, cancelled
reasonstringWhy the item is in that state, in one line
originstringOpaque submitter text; Foundry never interprets it
depends_onarray of stringThe items this one waits on (--after); omitted when empty
not_beforestring (optional)The earliest time the scheduler may start it
dispositionobject (optional)How the item ended; present only on a settlement
operator_actionobject (optional)Owner command, hostname/context in origin, previous_state, previous_reason, optional previous_settled_at; original submission origin and disposition retained

disposition fields

FieldTypeDescription
verdictstring (optional)The reviewer's typed verdict tag, for a task-shaped settlement
landed_commitstring (optional)The trunk commit the work landed as
preservation_refstring (optional)Branch or bundle:<path> holding unlanded work
worktreestring (optional)The isolated worktree the work ran in
worktree_removedbool (optional)Whether that worktree was gone by settlement time

Pacing

TypeDescription
pacing_pausedAn operator paused one or more lanes; nothing in them starts
pacing_resumedAn operator resumed one or more lanes

Both are emitted for the project system, durable and on Watch, after the pause state is saved.

Payload

FieldTypeDescription
lanesarray of stringThe lanes the request named, in lane order
pausedarray of stringEvery paused lane after the change, in lane order
operator_originstringThe CLI's hostname and any --origin text

campaign_advance_requested payload

FieldTypeDescription
campaignstringCampaign name
run_event_idstring (optional)Typed task result that triggered the advance
run_resultobject (optional)Full task_run_completed payload

campaign_advance_completed payload

FieldTypeDescription
campaignstringCampaign name
projectstringRegistered project name
cycles_completedintegerTasks dispatched by the campaign
cycles_landedintegerTask results whose task_run_completed.landed field was true
successbooleantrue for a decision or a stand-down; false for a fault. Absent on older events, read as true
decisionstringdone, advance, escalate, or stood_down
campaign_statusstring (stood_down only)Status that stopped the advance: paused, cancelled, completed, or escalated
objectivestring (advance only)Exactly one next task objective
reasonstringEvidence or gap supporting the decision, or why the advance stood down
promptstring (optional)Exact prompt shown to the formation agent
agent_providerstring (optional)Provider used for the formation decision
gate_resultsarrayResults from campaign done-evidence gates

stood_down means the advance applied nothing because the campaign was stopped before its cycle result arrived or while it formed. It dispatches nothing, changes no status and counts no cycle. Its work item settles landed.

Gate Orchestration

TypeDescription
gate_resolution_completedGate definitions loaded from .hone-gates.json
preflight_completedGates passed/failed on unmodified codebase
execution_completedCode changes applied (emitted by future execution blocks)
gate_verification_completedGates passed/failed after execution
retry_requestedGate failure triggers bounded retry

gate_resolution_completed payload

FieldTypeDescription
projectstringProject name
workflowstring"iterate", "maintain", or "validate"
gatesarrayGate definitions (name, command, required, timeout_secs)
actionsobject (optional)Forwarded actions from the trigger event

preflight_completed payload

FieldTypeDescription
projectstringProject name
workflowstringWorkflow that triggered the preflight
all_passedboolWhether every gate passed
required_passedboolWhether all required gates passed
resultsarrayPer-gate results (name, command, passed, required, output, exit_code, duration_ms?, fix_applied?, logs?)

Each results[] entry includes an optional duration_ms field (unsigned integer) recording how long the gate command took in milliseconds. This field is absent when loading results from events persisted before timing instrumentation was added.

A results[] entry also carries an optional fix_applied boolean: true when the gate initially failed but its fix_command repaired the working tree and the re-check then passed (a self-healed gate). The field is omitted when false, so it is absent for gates that passed clean and for events persisted before self-healing gates were added.

The output field is a bounded view: at most 200 lines per command run, with stdout and stderr tailed separately and a --- stderr --- line between them. A failed gate also carries an optional logs array: the absolute paths of the files under ~/.foundry/evidence/gate-logs/ that hold the complete stdout and stderr of each command the gate ran. The field is omitted when empty, so it is absent for passing gates and for events persisted before gate logs were added. See Gate output and full logs.

gate_verification_completed payload

FieldTypeDescription
projectstringProject name
workflowstringOriginating workflow
all_passedboolWhether every gate passed
required_passedboolWhether all required gates passed
retry_countnumberCurrent retry count (0 on first attempt)
resultsarrayPer-gate results (name, command, passed, required, output, exit_code, duration_ms?, fix_applied?, logs?)

Each results[] entry includes an optional duration_ms field (unsigned integer) recording how long the gate command took in milliseconds. This field is absent when loading results from events persisted before timing instrumentation was added.

A results[] entry also carries an optional fix_applied boolean: true when the gate initially failed but its fix_command repaired the working tree and the re-check then passed (a self-healed gate). The field is omitted when false, so it is absent for gates that passed clean and for events persisted before self-healing gates were added.

The output field is a bounded view: at most 200 lines per command run, with stdout and stderr tailed separately and a --- stderr --- line between them. A failed gate also carries an optional logs array: the absolute paths of the files under ~/.foundry/evidence/gate-logs/ that hold the complete stdout and stderr of each command the gate ran. The field is omitted when empty, so it is absent for passing gates and for events persisted before gate logs were added. See Gate output and full logs.

retry_requested payload

FieldTypeDescription
projectstringProject name
workflowstringOriginating workflow
retry_countnumberIncremented retry count
failure_contextstringGate output from the failed verification, with each failed gate's log paths
actionsobject (optional)Forwarded actions

Validation

TypeDescription
validation_requestedRequest to validate a project's gate health
validation_completedTerminal event with per-gate pass/fail results

validation_requested payload

FieldTypeDescription
projectstringProject name

validation_completed payload

FieldTypeDescription
projectstringProject name
successboolWhether all required gates passed
resultsarrayPer-gate results (name, passed, required, output snippet)

Maintenance Run Lifecycle

TypeDescription
maintenance_run_startedA maintenance run was triggered for a project
maintenance_run_completedAll projects processed, summary available

maintenance_run_started payload

FieldTypeDescription
projectstringProject name this run covers

maintenance_run_completed payload

FieldTypeDescription
totalnumberTotal number of projects processed
succeedednumberProjects that completed successfully
failednumberProjects that encountered an error
skippednumberProjects that were skipped (already active or skip=true)
projectsarrayPer-project result objects (name, status, duration_secs)

Dependency Update Policy

TypeDescription
dependency_review_requestedCommand: classify one project's dependency updates and plan its majors, applying nothing. Optional payload { "policy": "major" } previews another policy
dependency_updates_classifiedA project's direct dependencies were classified and the maintain brief decided
major_upgrades_plannedThe majors lane decided each major upgrade: dispatch, deduped, overflow, deferred or proposed

dependency_updates_classified payload

FieldTypeDescription
projectstringRegistered project name
phasestringbefore (the maintain brief), after (maintenance completed) or review
workflowstring (optional)maintain in the before and after phases
successbool (optional)In the after phase, whether maintenance succeeded
classificationobjectoutdated[] (per dependency: ecosystem, manifest, package, current, requirement, in_range, non_major, major, hold, advisories), classified[], unclassified[], lapsed_holds[], holds_warning, transitive_advisories[], advisory_source
briefobjectpolicy, policy_set, apply[], held_by_policy[], held_by_hold[], majors[]

In the before phase the payload also carries the gate-resolution fields (gates, actions and the rest of the chain context) forward to Execute Maintain.

major_upgrades_planned payload

FieldTypeDescription
upgradesarrayOne entry per major: project, ecosystem, manifest, package, from, to, security, objective, command, status, reason
per_project_capnumberFOUNDRY_MAJOR_TASKS_PER_PROJECT in force
per_night_capnumberFOUNDRY_MAJOR_TASKS_PER_NIGHT in force
dispatch_enabledbooltrue only for the nightly run at full throttle
reviewbooltrue for an on-demand review
history_warningstring (optional)Set when earlier tasks could not be read for dedupe

In the nightly run the payload also carries the maintenance_summary_requested fields (project_trace_ids, skipped_projects, total_duration_ms, root_event_id).

Release Tag Audit

TypeDescription
release_tag_auditedLatest release tag scanned (see payload above)

Agent Session Lifecycle

Emitted by foundryd whenever a Foundry-launched Claude Code agent session begins or ends. Used by visualisation tools (e.g. ops-visualizer) to show in-flight and historical agent activity, and to locate the per-session stream-json transcript on disk.

TypeDescription
agent_session_startedAn agent session has begun; transcript file path is included
agent_session_endedThe agent session has finished (success, failure, unavailable, or interrupted)

agent_session_started payload

FieldTypeDescription
session_idstringUUID identifying this session; matches the transcript file basename
agent_typestringAgent runtime (currently always claude-code)
projectstringProject name (may be empty in v1)
working_dirstringAbsolute path of the working directory the agent ran in
source_log_pathstringAbsolute path to the per-session JSONL transcript (~/.foundry/agent-sessions/<session_id>.jsonl)
capabilitystringCapability label: reasoning, coding, or quick
accessstringTool access level: read_only or full
started_atRFC 3339 timestampWhen the session was launched
trace_idstringCorrelating trace ID (may be empty in v1)

agent_session_ended payload

FieldTypeDescription
session_idstringUUID identifying this session (matches agent_session_started)
statusstringOutcome: ok, agent_failed, unavailable, or interrupted
exit_codenumberProcess exit code (omitted when the agent could not be invoked, and for interrupted)
ended_atRFC 3339 timestampWhen the session finished; for interrupted, when the daemon restarted
bytes_writtennumberTotal bytes streamed to the transcript file (0 for interrupted)
errorstringError message when status = unavailable; daemon restarted when status = interrupted (omitted otherwise)

interrupted sessions. An agent session is a child process of foundryd, so it dies when the daemon stops, and nothing records its end at that moment. On the next start, before it accepts work, foundryd reads the last 7 days of the event log (the same lookback as the interrupted maintenance-cycle recovery) for agent_session_started events with no agent_session_ended for the same session_id. Nothing can be running then, so each such session is dead. For each one, foundryd records an agent_session_ended with status interrupted, error daemon restarted, the original session's project and trace_id, and ended_at set to the daemon start time. The event is persisted to the event log and published on the Watch stream like any other. Because the end is in the log, a later start does not end the same session again. Unparseable lines in the log are skipped and do not stop the daemon from starting. usage and cost are absent: nothing measured the session's last moments.

Initiatives and findings

Lifecycle and finding facts belong to the existing span. Steering requests open an admitted workflow; the decision fact and action request retain its causation and initiative source.

TypeMeaning
initiative_advance_requestedRequest one read-only steering assessment
initiative_steeredRetain the assessment prompt, provider and one typed decision
initiative_action_requestedApply the durable admitted steering action
initiative_owner_action_recordedRecord a direct owner hold, milestone or member override
initiative_addedAn initiative and its member references entered the store
initiative_decidedAn owner decision was recorded for applicable members
initiative_completedThe owner affirmed the shared outcome
initiative_cancelledThe owner stopped the initiative and its members
finding_recordedA typed finding became available to initiative members
finding_consumedA member cycle landed after its formation saw a finding

Initiative lifecycle payloads include initiative, outcome, projects, intent_refs, members, status, authorized_by and budget. Owner actions also retain reason, decision or applicable_members as applicable. Readers can render the initiative and its member references directly.

finding_recorded retains id, finding_id, initiative, from, to, kind, text, refs, consumed_by and recorded_at. The source records its kind and ID. Automatic reviewer gaps and defect diagnoses identify the cycle and the result event. The source envelope names the initiative.

finding_consumed includes initiative, finding_id, member, work_item_id and the updated finding with its consumption records. Consumption follows an actual landing. Forming or preserving a cycle emits no consumption fact.

initiative_advance_requested carries initiative, an optional pinned member, operator_origin and trigger_id. A follow-up request also carries follow_up_of, with steer_id (the steer whose step caused it) and step (that steer's decision, such as relay). Foundry requests one only after an applied hold, relay, ask, escalate or stage while a member stays eligible, and never after a follow-up. Roots are paced on the campaign lane and deduplicated per initiative. A distinct trigger during assessment is retained for a subsequent assessment.

initiative_steered carries success, outcome, error, initiative, steer_id, prompt, decision, provider and the optional durable action. It also carries reason when nothing changed, eligible_members when the precheck found members that can advance, and follow_up_of when the assessment was a follow-up. Its decision is exactly one of advance, dispatch, hold, relay, ask, escalate, stage, done or wait. Only applied requests an action; every other outcome admits no work.

outcomesuccessDetailAssessment work item
appliedtruenonelanded
waitedtruereasonlanded, reason steering waited: <reason>
no_optruereasonlanded, reason steering decision already in force: <reason>
skippedtruereason; no agent ran, so prompt is emptylanded, reason steering skipped: <reason>
rejectedtrueerror holds the guard's messagecancelled, reason steering rejected: <message>
staletrueerrorlanded; a fresh initiative_advance_requested follows
failedfalseerrorfailed

initiative_action_requested carries the persisted action, including its identity, initiative, typed decision, provider pairing and context fingerprint. The admission boundary compares current state before dispatch. Replay uses that identity to acknowledge the same work rather than dispatch it again. Audit findings also retain audit with the intent reference, classification, registered project, checked paths and explanation. Tracker close proposals remain on the initiative; slice 1 does not write Tracker state.

initiative_owner_action_recorded carries initiative, action_id, operator_origin and a typed action (hold, milestone or member_override). It is emitted after persistence and does not activate steering. The event envelope records its timestamp and identity.