Foundry
Foundry is an event-driven workflow engine for engineering automation. It replaces imperative shell scripts with composable task blocks connected by events, with throttle controlling how far each event ripples through the system.
Foundry runs as a daemon (foundryd) with a CLI controller (foundry)
communicating over gRPC. Any emitter — a scheduled job, a webhook, a manual
command — can fire an event and trigger the same downstream workflow.
For day-to-day engineering work, the main entry points are:
foundry taskfor one concrete objective in an isolated, reviewed worktree;foundry campaignfor a durable mission that derives one task at a time from live evidence;foundry validate,iterate, andscoutfor project health and intent;foundry runand sentinels for maintenance and scheduled operations.
Start with Getting Started, then read Tasks and Campaigns before authorizing autonomous coding work. The remaining chapters explain the event engine, workflow formations, operations, and wire-level reference.
How We Got Here
Foundry has been evolving for roughly four months. It started as a patchwork of shell scripts and launchd jobs — nightly maintenance automation that 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
EventTypeenum with a well-defined payload structure. - Block library — the catalogue of reusable task blocks. Each block
implements the
TaskBlocktrait, 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) ordry_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
-
Events over orchestration. Work is triggered by events, not by position in a script. Any emitter can start any workflow.
-
Composable task blocks. Small, reusable units of work. The same "Cut Release" block serves the vulnerability workflow and the maintenance workflow.
-
Throttle controls depth. Every invocation declares how far the ripple should go. Audit without releasing. Release without installing. The same workflow, different throttle.
-
Observability is paramount. Every event, every task block execution, every throttle decision is logged and traceable. If it happened, you can see it.
-
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)
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) orMutator(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:
| Kind | Examples | Throttle behaviour |
|---|---|---|
| Observer | Audit tag, audit main, validate project | Always executes, always emits |
| Mutator | Cut release, install locally, commit+push | Execution 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.
| Level | Observers | Mutators |
|---|---|---|
full | Execute + emit | Execute + emit |
dry_run | Execute + emit | Skip 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:
- Receives an event
- Finds task blocks that sink on that event type
- Checks throttle to decide whether to execute and/or emit
- Executes matching blocks
- Collects emitted events and feeds them back into step 2
- 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
| Event | Emitter | Purpose |
|---|---|---|
maintenance_run_started | Orchestrator / manual | Begins a per-project maintenance chain |
maintenance_run_completed | Orchestrator (fan-in) | All projects finished; carries aggregate results |
Per-Project: Iterate / Maintain
| Event | Emitter | Purpose |
|---|---|---|
project_validation_completed | ValidateProject | Pre-flight check (dir, branch, gates) |
project_iteration_completed | RouteGateResult | One structural improvement attempted |
project_maintenance_completed | RouteGateResult | Dependencies updated, gates verified |
project_changes_committed | CommitAndPush | Git commit created |
project_changes_pushed | CommitAndPush | Pushed to remote |
Per-Project: Release Audit
| Event | Emitter | Purpose |
|---|---|---|
release_tag_audited | AuditReleaseTag | Latest tag scanned for vulnerabilities |
main_branch_audited | AuditMainBranch | Main branch checked for same vulnerability |
release_requested | Audit chain | Intent to cut a patch release |
release_completed | CutRelease | Tag pushed |
Vulnerability Remediation
| Event | Emitter | Purpose |
|---|---|---|
vulnerability_detected | External / nightly audit | Entry point for remediation workflow |
remediation_started | RemediateVulnerability | Fix attempt underway |
remediation_completed | RemediateVulnerability | Fix attempt finished (success or failure) |
Distribution Pipeline
| Event | Emitter | Purpose |
|---|---|---|
release_pipeline_completed | WatchPipeline | GitHub Actions finished building and publishing |
local_install_completed | InstallLocally | Tool reinstalled on local machine |
Event Structure
Every event has:
- id — Deterministic SHA256 hash of (type + project + occurred_at + payload)
- event_type — One of the
EventTypeenum 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.
| Field | Used By | Values |
|---|---|---|
vulnerable | AuditMainBranch | true/false — whether the tag has known CVEs |
dirty | RemediateVulnerability, CutRelease | true/false — whether main still has the vulnerability |
cve | All vulnerability blocks | CVE identifier string |
status | Downstream blocks | "ok"/"error" — validation and completion status |
has_changes | CommitAndPush | Whether 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.
| Level | Span opens at | Span closes at |
|---|---|---|
| Cycle | MaintenanceCycleStarted event | MaintenanceCycleCompleted event |
| Project run | ProjectRunStarted event | ProjectRunCompleted event |
| Workflow | A *Requested or *Started opener event | Matching completion event |
| Block | Engine dispatches a block | Block 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 sametrace_id.span_id— 16-character lowercase hex string. Identifies one specific span. Multiple events under the same workflow share aspan_id.parent_span_id— 16-character lowercase hex string, orNonefor the root span of a trace. Points at the span that contains this one.causation_id— theidof the event that triggered the block which emitted this one, orNonefor 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, orNonewhen the event is not part of a fan-out. Propagates verbatim liketrace_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
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 triggerspan_id= newly mintedparent_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.
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
MaintenanceCycleStartedProjectRunStarted
- Workflow request openers
ProjectIterationRequestedProjectMaintenanceRequestedExecutionRequestedCampaignAdvanceRequestedValidationRequestedDriftAssessmentRequestedReleaseRequestedPipelineCheckRequestedGreetingRequestedMaintenanceSummaryRequested
- Explicit lifecycle openers
RemediationStartedStrategicCycleStartedInnerIterationStartedCommitDigestStartedOpsDigestStartedSupplyChainScanStarted
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 ID01— trace flags (sampled)
Injection happens transparently:
foundryd::shell::runinjectsTRACEPARENTinto every spawned command if a span context is active.foundryd::agent_streaminjects 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 giventrace_id. The whole tree, in one response.Span— returns events and block executions belonging to a singlespan_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_ididentifies them, and both theTraceandSpanRPCs accept them. foundry tracedetects legacy traces — events that carry atrace_idbut nospan_id— and automatically falls back to the--flatchronological 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)
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Compose Greeting | Observer | greet_requested | greeting_composed |
| Deliver Greeting | Mutator | greeting_composed | greeting_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.
| Block | Kind | Sinks On | Emits | Self-filters |
|---|---|---|---|---|
| Scan Dependencies | Observer | scan_requested | vulnerability_detected | — |
| Audit Release Tag | Observer | vulnerability_detected, project_changes_pushed | release_tag_audited | Skips post-push when project not in registry |
| Audit Main Branch | Observer | release_tag_audited | main_branch_audited | Skips when vulnerable=false |
| Remediate Vulnerability | Mutator | main_branch_audited | remediation_completed | Only when dirty=true |
| Commit and Push | Mutator | remediation_completed, project_iteration_completed, project_maintenance_completed | project_changes_committed, project_changes_pushed | Skips when tree is clean or changes=false |
| Cut Release | Mutator | main_branch_audited | release_completed | Only when dirty=false |
| Watch Pipeline | Mutator | release_completed | release_pipeline_completed | — |
| Install Locally | Mutator | project_changes_pushed, release_pipeline_completed | local_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.
| Block | Kind | Sinks On | Emits | Self-filters |
|---|---|---|---|---|
| Validate Project | Observer | maintenance_run_started | project_validation_completed | Skips projects not in active registry |
| Route Project Workflow | Observer | project_validation_completed | iteration_requested or maintenance_requested | Stops when status != "ok" or no actions enabled |
| Commit and Push | Mutator | project_iteration_completed, project_maintenance_completed | project_changes_committed, project_changes_pushed | Skips when tree is clean |
| Audit Release Tag | Observer | project_changes_pushed | release_tag_audited | Skips 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.
| Block | Kind | Sinks On | Emits | Self-filters |
|---|---|---|---|---|
| Resolve Gates | Observer | iteration_requested, maintenance_requested, validation_requested | gate_resolution_completed | — |
| Run Preflight Gates | Observer | gate_resolution_completed | preflight_completed | Skips maintain workflow |
| Run Verify Gates | Observer | execution_completed | gate_verification_completed | — |
| Route Gate Result | Observer | gate_verification_completed | project_iteration_completed / project_maintenance_completed / retry_requested | Routes based on pass/fail and retry count |
| Route Validation Result | Observer | preflight_completed | validation_completed | Only handles validate workflow |
Iterate Workflow
These blocks form the native iterate chain, running inside the gate orchestration lifecycle.
| Block | Kind | Sinks On | Emits | Self-filters |
|---|---|---|---|---|
| Check Charter | Observer | preflight_completed | charter_validated | Only handles iterate workflow |
| Assess Project | Mutator | charter_validated | project_assessed | — |
| Triage Assessment | Mutator | project_assessed | assessment_triaged | — |
| Create Plan | Mutator | assessment_triaged | plan_created | — |
| Execute Plan | Mutator | plan_created | execution_completed | — |
Maintain Workflow
| Block | Kind | Sinks On | Emits | Self-filters |
|---|---|---|---|---|
| Execute Maintain | Mutator | gate_resolution_completed | execution_completed | Only handles maintain workflow |
| Retry Execution | Mutator | retry_requested | execution_completed | — |
| Summarize Result | Observer | project_iteration_completed, project_maintenance_completed | generate_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— wrapscrate::shell::runfor arbitrary command execution.ScannerGateway— wrapscrate::scanner::run_auditfor 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.rsandscanner.rsstay untouched; the gateway is a thin adapter.- No
async_traitmacro is required — the return type uses an explicitPin<Box<dyn Future + Send + '_>>(the same pattern asTaskBlock::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—Eventstruct,EventTypeenum, deterministic ID generationthrottle.rs—Throttleenum (Full,DryRun)task_block.rs—TaskBlocktrait,BlockKind,TaskBlockResult,RetryPolicyregistry.rs—Registry,ProjectEntry,ActionFlags,Stack,InstallConfigtrace.rs—TraceIndex,BlockExecution,ProcessResult— the structured types used to persist and display execution traces. Moved here fromfoundrydso 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.
Core engine
engine.rs— event router: matches events to task blocks, executes them with retry logic, propagates emitted events respecting the throttle level. ExposesBlockExecutionandProcessResultfor structured telemetry.service.rs— gRPC service implementation (Emit,Status,Watch,Trace)
Daemon support modules
orchestrator.rs— coordinates per-project maintenance runs with concurrency control. DispatchesMaintenanceRunStartedper project, enforcesmax_concurrentvia 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/(orFOUNDRY_EVENTS_DIR). Crash-safe: each write opens, flushes, and closes the file. AMutexserializes concurrent writes.trace_store.rs— in-memory store of recentProcessResultchains, keyed by root event ID. Used for fastTraceRPC lookups of workflows still in progress or recently completed.trace_writer.rs— persists completedProcessResultobjects 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 byfoundry historyandfoundry tracewhen the in-memory store has no match.workflow_tracker.rs— tracks workflows that are currently being processed by background tasks. Thread-safe viaRwLock. EachEmitRPC inserts anActiveWorkflowentry on start; a RAIIWorkflowGuardremoves it on completion or panic. TheStatusRPC 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 aCommandResult.scanner.rs— vulnerability scanner abstraction. Dispatches to the stack-appropriate tool (cargo audit,npm audit,pip-audit,mix deps.audit) and normalizes output into aVec<Vulnerability>.gateway.rs— I/O abstraction layer for task blocks. DefinesShellGatewayandScannerGatewaytraits withProcessShellGatewayandProcessScannerGatewayproduction implementations. Also providesFakeShellGatewayandFakeScannerGatewaytest 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 aMaintenanceRunSummaryas 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 runresolve_gates.rs—ResolveGates: reads.hone-gates.jsonand emits gate definitionsrun_preflight_gates.rs—RunPreflightGates: runs gates on unmodified codebaserun_verify_gates.rs—RunVerifyGates: runs gates after code changesroute_gate_result.rs—RouteGateResult: routes pass/fail to completion or retryroute_validation_result.rs—RouteValidationResult: routes validation-only resultscheck_charter.rs—CheckCharter: validates project charter before iterationassess_project.rs—AssessProject: AI-driven project assessmenttriage_assessment.rs—TriageAssessment: prioritises assessment findingscreate_plan.rs—CreatePlan: generates an execution plan from triaged findingsexecute_plan.rs—ExecutePlan: executes the generated planexecute_maintain.rs—ExecuteMaintain: runs maintenance tasksretry_execution.rs—RetryExecution: retries failed executions with contextsummarize_result.rs—SummarizeResult: generates workflow summary and tracesgit_ops.rs—CommitAndPush: stages, commits, and optionally pushes changesaudit.rs—AuditReleaseTag,AuditMainBranch: vulnerability scanningrelease.rs—CutRelease,WatchPipeline: tagging and CI monitoringinstall.rs—InstallLocally: reinstalls the project locally after a fixremediate.rs—RemediateVulnerability: invokes the AI agent to fix a CVEscan.rs—ScanDependencies: scans for known vulnerabilitiesgreet.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,registrycommands.rs— async implementations of each subcommand viatonicgRPC client; also contains thehistorycommand which reads on-disk traces directly from~/.foundry/traces/without a daemon connectionregistry_commands.rs— registry CLI handlers.initis explicit offline-only recovery;list,show,add,remove, andedituse typed gRPC againstfoundrydby default and touch~/.foundry/registry.jsononly when--offlineis set
proto/foundry.proto
The gRPC contract between CLI and daemon:
Emit— fire an event with type, project, throttle, and optional JSON payloadStatus— query active workflow states (all or by workflow ID)Watch— server-side streaming of live events, filterable by projectTrace— 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.
The repository also includes service definitions for unattended operation:
- macOS:
launchd/README.md - Linux:
systemd/README.md
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 campaignwhen the mission is broader than one task and the next objective should be derived from current evidence. - Use
foundry iteratefor charter-driven quality improvement. - Use
foundry scoutfor read-only intent-drift discovery. - Use
foundry runfor 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
| Argument | Required | Description |
|---|---|---|
event_type | Yes | Event type name (e.g., greet_requested, vulnerability_detected) |
--project | Yes | Target project name |
--throttle | No | full (default) or dry_run |
--payload | No | JSON string with event-specific data |
--addr | No | Daemon 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
- The CLI sends the event to
foundrydvia gRPC - The engine creates an
Eventwith a deterministic ID - The engine finds all task blocks that sink on the event type
- For each matching block:
- Check throttle: should this block execute? Should it emit?
- Execute the block's work
- Collect emitted events
- Feed emitted events back into step 3 (the chain continues)
- 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
| Field | Type | Description |
|---|---|---|
version | number | Must be 2 |
projects | array | List of ProjectEntry objects |
ProjectEntry fields
| Field | Required | Type | Description |
|---|---|---|---|
name | Yes | string | Unique human-readable identifier used in events and logs |
path | Yes | string | Absolute path to the project on your local filesystem |
stack | Yes | string | Technology stack — see Stack values |
agent | Yes | string | AI agent name used for automation (e.g. "claude") |
repo | Yes | string | GitHub repository slug (owner/repo) used by Watch Pipeline |
branch | Yes | string | Default branch (e.g. "main") — validation checks out this branch |
skip | No | string or null | Absent 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 |
actions | No | object | Which automation steps are enabled; all default to false |
install | No | object | How to reinstall locally after automation — see InstallConfig |
notes | No | string | Human-readable notes about the project (informational only) |
timeout_secs | No | number | Timeout in seconds for long-running commands. Defaults to 3600 (60 minutes) when absent |
audit_exceptions | No | string[] (default []) | Array of CVE/advisory IDs the project has formally accepted as not-applicable. Matching vulnerabilities are suppressed from the post-push auditor. This is Foundry's own copy — independent of hone's gate config and the supply-chain .supply-chain-allow.json. Record rationale in notes; remove entries once upstream patches. |
Accepted-risk CVEs (audit_exceptions)
When a project's supply-chain scan surfaces a CVE that your team has formally reviewed and accepted — for example, because the vulnerable code path is unreachable in your deployment — you can suppress it from Foundry's post-push auditor by listing it in audit_exceptions:
{
"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 suppressed CVE is logged at info level so suppression is never silent. The field is Foundry's own policy record — independent of hone's gate configuration and the supply-chain .supply-chain-allow.json allowlist. 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.
| Value | Audit tool | Notes |
|---|---|---|
"rust" | cargo audit --json | Requires cargo-audit to be installed |
"typescript" | npm audit --json | Exit code 1 = vulnerabilities found (not a tool failure) |
"python" | pip-audit --format=json | Requires pip-audit to be installed |
"elixir" | mix deps.audit --format=json | — |
"cpp" | — | Placeholder for C++ projects; audit tooling not yet wired |
ActionFlags
The actions object controls which steps run during a maintenance run. All
flags default to false when the actions key is absent.
| Flag | What it enables |
|---|---|
iterate | Runs the iterate workflow (assess, plan, execute) after project validation passes |
maintain | Runs the maintain workflow — either after iterate completes (when both are enabled) or directly after validation (when only maintain is enabled) |
push | Pushes 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" }
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
# 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:
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Resolve Gates | Observer | charter_check_completed, maintenance_requested, validation_requested | gate_resolution_completed |
| Run Preflight Gates | Observer | gate_resolution_completed | preflight_completed |
| Run Verify Gates | Observer | execution_completed | gate_verification_completed |
| Route Gate Result | Observer | gate_verification_completed | project_iteration_completed / project_maintenance_completed / retry_requested |
| Retry Execution | Mutator | retry_requested | execution_completed |
| Summarize Result | Observer | project_iteration_completed, project_maintenance_completed | summarize_completed |
| Commit and Push | Mutator | remediation_completed, project_iteration_completed, project_maintenance_completed | project_changes_committed, project_changes_pushed |
Iteration Blocks
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Check Charter | Observer | iteration_requested | charter_check_completed |
| Assess Project | Observer | preflight_completed | assessment_completed |
| Triage Assessment | Observer | assessment_triaged | triage_completed |
| Create Plan | Observer | triage_completed | plan_completed |
| Execute Plan | Mutator | plan_completed | execution_completed |
Maintenance Blocks
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Execute Maintain | Mutator | gate_resolution_completed | execution_completed |
Vulnerability Blocks
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Scan Dependencies | Observer | scan_requested | vulnerability_detected (per CVE) |
| Audit Release Tag | Observer | vulnerability_detected, project_changes_pushed | release_tag_audited |
| Audit Main Branch | Observer | release_tag_audited | main_branch_audited |
| Remediate Vulnerability | Mutator | main_branch_audited | remediation_completed |
| Cut Release | Mutator | main_branch_audited | release_completed |
| Watch Pipeline | Mutator | release_completed | release_pipeline_completed |
| Install Locally | Mutator | project_changes_pushed, release_pipeline_completed | local_install_completed |
Task/Prompt Workflow Blocks
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Direct Prompt | Observer | preflight_completed (task) | task_run_started, plan_completed |
| Execute Plan | Mutator | plan_completed (task) | execution_completed |
| Run Verify Gates | Observer | execution_completed (task) | gate_verification_completed |
| Review Task | Observer | gate_verification_completed (task) | task_reviewed |
| Finalize Task | Mutator | task_reviewed | task_run_completed |
| Request Campaign Advance | Observer | task_run_completed (campaign) | campaign_advance_requested |
Campaign Blocks
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Advance Campaign | Mutator | campaign_advance_requested | campaign_advance_completed, execution_requested, campaign_completed, campaign_escalated, or campaign_paused |
| Surface Campaign Terminal | Observer | campaign_completed, campaign_escalated | ops_digest_started |
Strategic Loop Blocks
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Strategic Assessor | Observer | iteration_requested (strategic=true) | strategic_assessment_completed |
| Strategic Loop Controller | Observer | strategic_assessment_completed, inner_iteration_completed | iteration_requested (loop) / project_iteration_completed (done) |
Orchestration Blocks
| Block | Kind | Sinks On | Emits |
|---|---|---|---|
| Validate Project | Observer | maintenance_run_started | project_validation_completed |
| Route Project Workflow | Observer | project_validation_completed | iteration_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.
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:
-
Entry events define scope. The deeper into a chain you emit, the narrower the formation. Emitting
maintenance_run_startedruns everything; emittingplan_completedskips assessment entirely and just executes a plan you provide. -
Payload values steer routing. Blocks self-filter on payload fields like
dirty,accepted,workflow, andactions. Changing a payload value changes which blocks fire without changing any code. -
Throttle controls depth. The same formation behaves differently under
fullanddry_run. This gives you two versions of every formation for free. -
Shared blocks multiply formations.
Commit and Pushsinks on three different event types.Install Locallysinks 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.
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
| Field | Used By | Values | Default |
|---|---|---|---|
cve | All blocks | CVE identifier string | "unknown" |
vulnerable | Audit Release Tag, Audit Main Branch | true / false | true |
dirty | Audit Main Branch, Remediate, Cut Release | true / false | true |
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) via theScannerGatewayabstraction - Audit Main Branch — checks out the main branch and runs the same scan
- Remediate Vulnerability — invokes the configured AI agent (e.g. via the
claudeCLI) to apply a fix - Commit and Push — runs
git add,git commit, and optionallygit 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), 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).
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:
| Condition | Action |
|---|---|
| All required gates pass | Emit project_iteration_completed (success) |
| Required gates fail, retry count < 3 | Emit retry_requested with failure context |
| Required gates fail, retry count ≥ 3 | Emit 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:
| Capability | Model | Use Case |
|---|---|---|
| Reasoning | claude-opus-5 | Deep analysis and planning |
| Coding | claude-sonnet-5 | Code generation and modification |
| Quick | claude-haiku-4-5-20251001 | Fast, lightweight decisions |
Access levels control which CLI tools the agent may use:
| Access | Allowed Tools |
|---|---|
| Read-only | Read, Glob, Grep, WebFetch, WebSearch |
| Full | All tools (no restrictions) |
Each phase in the iterate workflow maps to a specific capability and access level:
| Phase | Capability | Model | Access | Purpose |
|---|---|---|---|---|
| Assessment | Reasoning | Opus | Read-only | Deep analysis of codebase |
| Audit naming | Quick | Haiku | Read-only | Generate kebab-case filename |
| Triage | Quick | Haiku | Read-only | Accept/reject decision |
| Plan creation | Reasoning | Opus | Read-only | Step-by-step correction plan |
| Execution | Coding | Sonnet | Full | Apply code changes |
| Retry | Coding | Sonnet | Full | Fix gate failures |
| Summarisation | Quick | Haiku | Read-only | Generate headline and summary |
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
| Field | Carried By | Purpose |
|---|---|---|
actions | All events | {iterate, maintain} flags for workflow routing |
gates | Gates resolved through plan creation | Gate definitions for execution context |
audit_name | Assessment through plan creation | Kebab-case audit filename for traceability |
severity | Assessment through plan creation | Violation severity (1–10) |
principle | Assessment through plan creation | Name of the violated principle |
category | Assessment through plan creation | Category of the violation |
assessment | Assessment through plan creation | Detailed assessment text |
retry_count | Execution through gate routing | Current retry attempt (0-based) |
failure_context | Retry requested | Gate output from failed verification |
correction_needed | Plan completed through execution | false when plan agent concluded no changes are warranted; defaults to true |
correction_reason | Plan completed through execution | One-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:
- Outer loop (strategic) — AI identifies areas to improve, decides when to stop
- 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.jsondefining quality gates - The
foundryddaemon 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]]
-
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").
-
Strategic Loop Controller picks the top area and enters the inner iterate formation by emitting
iteration_requestedwith aloop_contextpayload. -
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 Resultemitsinner_iteration_completedinstead ofproject_iteration_completed. -
Commit and Push fires on
inner_iteration_completed, creating a per-iteration commit so each improvement is isolated in git history. -
Strategic Loop Controller receives
inner_iteration_completedand 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. -
When the loop completes, the controller emits
project_iteration_completedwithoutloop_context, which triggersSummarize Resultand a finalCommit and Push.
Step 4: Monitor Progress
Watch the live event stream:
foundry watch --project my-project
Key events to look for:
| Event | Meaning |
|---|---|
strategic_assessment_completed | AI identified areas; check areas in payload |
iteration_requested (with loop_context) | Inner loop entered for an area |
inner_iteration_completed | One cycle done; check success |
project_changes_committed | Per-iteration commit created |
project_iteration_completed | Strategic loop finished |
summarize_completed | Final 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
| Field | Type | Default | Purpose |
|---|---|---|---|
strategic | bool | false | Must be true to activate strategic mode |
max_iterations | integer | 5 | Maximum number of inner loop cycles |
strategic_prompt | string | (built-in) | Custom directive for the assessment and continue checks (see below) |
actions | object | — | 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:
- Assessment — the Strategic Assessor wraps your prompt in a
request to produce the
areasJSON. The AI reads the codebase with your directive as context and returns a ranked list of work items. - 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:
| Goal | Prompt |
|---|---|
| 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
| Throttle | Strategic Assessor | Inner Iterate | Commits |
|---|---|---|---|
full | Runs | Runs (modifies files) | Real commits |
dry_run | Runs | Simulated success | No 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:
| Phase | Capability | Model | Access | Purpose |
|---|---|---|---|---|
| Strategic Assessor | Reasoning | claude-opus-5 | Read-only | Holistic codebase analysis producing ranked improvement areas |
| Continue check | Quick | claude-haiku-4-5-20251001 | Read-only | Decide 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
| Aspect | Standard Iterate | Strategic Iterate |
|---|---|---|
| Entry payload | {} or {actions: ...} | {strategic: true, max_iterations: N} |
| Scope | One issue per run | Multiple issues per run |
| Commits | One at the end | One per inner cycle + one final |
| Continue logic | None (single pass) | AI re-assessment after each cycle |
| Inner retry | Up to 3 retries on gate failure | Same (unchanged) |
| Maintenance chaining | After single completion | After 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 --> 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]]
Routing Logic
Route Project Workflow reads the actions flags forwarded in the
project_validation_completed payload and makes a single decision:
| Condition | Emits |
|---|---|
status != "ok" | nothing — chain stops |
actions.iterate = true | iteration_requested |
actions.iterate = false, actions.maintain = true | maintenance_requested |
| both false | nothing — 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
| Throttle | Effect |
|---|---|
full | All blocks execute and emit events |
dry_run | Observers emit; mutators are skipped entirely |
Under dry_run, only iteration_requested or maintenance_requested are
emitted (by the Observer router). No execution blocks run.
Agent Capabilities
The maintenance workflow uses a single agent invocation in Execute Maintain:
| Phase | Capability | Model | Access | Purpose |
|---|---|---|---|---|
| Execute Maintain | Coding | claude-sonnet-5 | Full | Update dependencies, fix vulnerabilities, resolve gate failures |
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.
Tasks and Campaigns
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.
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 final budgeted task always receives one completion evaluation; only an attempted dispatch beyond the authorized budget escalates.
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 exactprojectfilter. 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, includingintent_refs,context_paths, alldone_evidenceentries with theGate/Reviewtype distinction preserved, and escalation rules. ReturnsNOT_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:
- Creates a disposable Git worktree under
~/.foundry/worktrees/. - Runs the coding agent and quality gates inside that worktree.
- Performs a read-only skeptical review against the objective and evidence.
- Emits one typed verdict:
complete,remainder,defect,blocked_on_decision, orrunner_error. - 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.
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.
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
| Field | Required | Meaning |
|---|---|---|
name | Yes | Stable campaign identifier, unique in the campaign store |
project | Yes | Exact registered project name |
mission | Yes | Durable outcome the formation evaluates |
intent_refs | No | Opaque identifiers connecting the mission to source intent |
context_paths | No | Existing repository-relative neutral artifacts |
done_evidence | Yes | At least one mechanical gate or review statement |
budget.max_cycles | No | Maximum dispatched tasks; defaults to 20 |
escalation | No | Conditions that require the formation to stop for an owner |
authorized_by | Operationally | Owner identity required for decisions, completion, and resume |
agent_provider | No | Campaign-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.
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.
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."
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
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.
| Status | Meaning | Valid next control |
|---|---|---|
staged | Definition exists; no cycle has started | advance, pause, cancel |
active | Formation or a task may advance the mission | advance, pause, cancel |
paused | Advancement is intentionally stopped | resume, complete, cancel |
escalated | Budget, policy, or human judgement stopped the mission | decide, resume, complete, cancel |
completed | Evidence or owner authorization closed the mission | None |
cancelled | An owner abandoned the mission before its evidence | None |
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:
| Status | pause | resume | decide | complete | cancel | advance |
|---|---|---|---|---|---|---|
staged | allowed † | rejected: wrong status | rejected: wrong status | allowed | allowed | allowed |
active | allowed † | rejected: wrong status | rejected: wrong status | allowed | allowed | allowed |
paused | allowed † | allowed | rejected: wrong status | allowed | allowed | rejected: wrong status |
escalated | allowed † | allowed | allowed | allowed | allowed | rejected: wrong status |
completed | allowed † | rejected: wrong status | rejected: wrong status | no-op (already settled) | rejected: already complete | rejected: wrong status |
cancelled | allowed † | rejected: wrong status | rejected: wrong status | rejected: cancelled campaign | no-op (already settled) | rejected: wrong status |
resume 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.
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 — 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 block progress, and
renders the completed trace. 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; and
- the gate results produced by done-evidence commands.
Forced decisions that do not consult an agent record no prompt or provider. This is intentional evidence that formation was bypassed, not missing telemetry.
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. 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.
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, so a control command cannot overwrite an in-flight formation decision.
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.
When a task escalates with a human judgment question, record the owner's policy before the next advance:
foundry campaign decide parite-phase-2d \
--decision "Keep the generated tonic client boundary; do not add raw JSON shims."
decide is valid only for an escalated campaign. It appends an owner decision
record with the decision text, the campaign's authorized_by value, and a
timestamp, then returns the campaign to active. Subsequent formation prompts
include every recorded owner decision as binding context, so the next advance
can continue under explicit policy instead of re-escalating on the same
question.
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. 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 never deleted; 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.
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:
addsends the JSON definition throughAddCampaign; the daemon validates the referenced project and context paths against daemon-owned registry state, persists atomically, and returns the durableCampaignDetailthat the CLI renders directly.listrendersListCampaignsdirectly.showrendersGetCampaigndirectly.advancedispatchesAdvanceCampaign, prints the returned root event ID, watches the workflow, and then renders the trace from that daemon-owned event.pause,resume,decide, andcompleterender the typedCampaignDetailreturned 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
HEADfor ordinarygit cloneorgit fetchrecovery.
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
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 four 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
}
]
}
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:
| Cron | Means |
|---|---|
0 2 * * * | Every day at 02:00 local |
*/30 * * * * | Every 30 minutes |
0 9 * * 1 | Every Monday at 09:00 local |
0 17 * * 1-5 | 17: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.jsonand 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:
listcallsSentinelListshowcallsSentinelShowenablecallsSentinelEnabledisablecallsSentinelDisable
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()infoundry-sdk. The additive seed-merge that runs on every daemon start will append them to existingsentinels.jsonfiles automatically — no JSON editing, no migration step. This is howdaily-commit-digestreached installs that were already running Slice 1. - One-off, machine-local sentinels still need a manual edit. Open
~/.foundry/sentinels.jsonin your editor, append an entry following the schema shown above, and restart the daemon.foundry sentinel add | remove | editis 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.
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:
- Explicit
--addr FOUNDRY_DAEMON_ADDRhttp://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.envonmojility-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_PATHFOUNDRY_CAMPAIGNS_PATHFOUNDRY_SENTINELS_PATHFOUNDRY_AGENT_CONFIG_PATHFOUNDRY_WORKTREES_DIRFOUNDRY_PRESERVED_DIRFOUNDRY_EVENTS_DIRFOUNDRY_TRACES_DIRFOUNDRY_AUDITS_DIRFOUNDRY_DIGESTS_DIRFOUNDRY_OPS_DIGESTS_DIRFOUNDRY_TRIAGE_DIRFOUNDRY_SUPPLY_CHAIN_DIRFOUNDRY_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
skipreason in the registry are excluded — their commits will not appear in the digest. Usefoundry 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 logfor 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
| Event | Payload | Notes |
|---|---|---|
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:
| Condition | Detail |
|---|---|
Urgency P0 | Any MBOS event with "urgency": "P0" |
ci_pipeline_failure | Always anomalous |
maintenance_intervention_recorded | When intervention.outcome == "unresolved" |
dependency_vulnerability_detected | When vulnerability.severity is "high" or "critical" |
maintenance_run_completed | When maintenance.reposFailed > 0 |
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
| Event | Payload | Notes |
|---|---|---|
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
| Variable | Default | Purpose |
|---|---|---|
FOUNDRY_OPS_DIGESTS_DIR | ~/.foundry/ops-digests | Where digest files land |
FOUNDRY_OPS_EVENTS_DIR | ~/Work/Operations/Events/intake | MBOS JSONL intake directory |
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:
-
TriageMaintenance— sinks onMaintenanceSummaryRequested. Reads the Foundry JSONL event log for the maintenance run window, extractsPreflightCompletedfailures, classifies each into one of twelve domain classes, correlates infra flakes, detects chronic deadlocks, and emitsMaintenanceTriageCompletedwith a typed payload. -
WriteTriageDigest— sinks onMaintenanceTriageCompleted. Renders the classified verdicts into a structured markdown file and writes it atomically to~/.foundry/triage/YYYY-MM-DD.md.
Failure Classes
| Class | Meaning | Default Decision |
|---|---|---|
agent_runner_fault | Agent runner crashed, timed out, or produced a silent no-op | suppress_infra (correlate first) |
ci_infra_flake | CI/infra ephemeral failure: filesystem error, OOM, network hiccup | suppress_infra (correlate first) |
format_and_lint_drift | Formatting or lint drift — mechanically fixable | auto_fixable |
vuln_with_fix | Security advisory with a known fix version | auto_fixable |
routine_dependency_bump | Patch/minor dependency bump (no constraints broken) | auto_fixable |
vuln_no_fix | Security advisory with no available fix | policy_call |
dependency_major_bump_or_constraint_relax | Major version bump or constraint relaxation required | policy_call |
gate_infra_misconfig | Gate toolchain or configuration problem | policy_call |
compile_and_static_analysis_code_error | Compile error or static-analysis failure | needs_investigation |
test_breakage | Test suite failure | needs_investigation |
chronic_deadlock | N≥3 consecutive failures on the same gate | escalate |
triage_rejected_noise | Reclassified 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/.
| Variable | Default | Purpose |
|---|---|---|
FOUNDRY_TRIAGE_DIR | ~/.foundry/triage | Triage 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:
- it aborts preflight, killing the very maintain step that could bump the dep;
- it has no memory — it re-discovers and re-fails the same CVE every night;
- 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
ScanSupplyChainiterates every active registry project, runs the stack's audit tool (cargo audit,npm audit,mix deps.audit) against the working-tree lockfile, classifies each advisory against that repo's committed allowlist, and emitsSupplyChainScanned. 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-auditis 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.RemediateSupplyChaintriages every live finding by fix availability and emitsSupplyChainRemediated, 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.WriteSupplyChainDigestrenders 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:
| State | Condition | Effect |
|---|---|---|
| live | not in the allowlist | reported as a finding |
| accepted | present, expires today-or-later (or absent) | suppressed; noted under "Accepted" |
| lapsed | present, expires has passed | resurfaces as a live finding and is flagged under "Lapsed acceptances — re-decide" |
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 fixable finding goes through a mandatory verify-and-rollback rail, and every change is reversible — committed locally, never pushed:
- Refuse a dirty tree. A project whose working tree carries uncommitted
changes is skipped, so a rollback can always return to a known-clean
HEAD. - Apply. The fixer follows the project's stack and lockfile:
- Rust:
cargo update -p <pkg> --precise <fix>updatesCargo.lock. - TypeScript: Bun and npm projects update their native lockfile. A matching
direct dependency or override pin is rewritten in
package.jsonfirst; transitive advisories target npm's explicitfixAvailable.namepackage. - Python: uv projects rewrite a matching
pyproject.tomlrequirement and runuv lock --upgrade-package <pkg>==<fix>. Unsupported stacks or projects without a supported lockfile report a visibleapply_failed/no_fixeroutcome rather than guessing.
- Rust:
- Verify. The repo's own
.hone-gates.jsongates are re-run. A repo with no gates is skipped — an unverifiable fix is never applied. - Commit or revert. If the required gates pass, only the dependency files
touched by that fixer are committed (
chore(deps): bump … (supply-chain auto-fix)); otherwise those files are unstaged and restored fromHEAD. Each applied fix commits immediately, so a later finding's rollback can never clobber an earlier success.
The digest gains a Remediation section — Auto-fixed, Reverted, 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:
vulnerablereflects the upstream payload value (the last known state), not a fresh clean reading.- The
cvefield is likewise forwarded from upstream unchanged. - Downstream blocks that branch on
vulnerable: falseshould check for a non-nullscan_errorbefore treating a result as authoritative.
scan_error is populated in three situations:
| Cause | Example 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
| Variable | Default | Purpose |
|---|---|---|
FOUNDRY_SUPPLY_CHAIN_DIR | ~/.foundry/supply-chain | Digest 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
| Level | Observers | Mutators | Use case |
|---|---|---|---|
full | Execute + emit | Execute + emit | Automated runs, nightly maintenance |
dry_run | Execute + emit | Skip 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:
- Implement the
TaskBlocktrait - 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.throttleto emitted events - Clone what you need: extract data from
triggerbefore theasync moveblock - Return events: the engine handles routing them to downstream blocks
- Observer vs Mutator: choose based on whether your block has side effects
TaskBlockResult Fields
| Field | Type | Description |
|---|---|---|
events | Vec<Event> | Events to emit downstream (subject to throttle) |
success | bool | Whether the block's work succeeded |
summary | String | Human-readable one-line summary shown in traces |
raw_output | Option<String> | Combined stdout+stderr from any shell command — shown in foundry trace --verbose |
exit_code | Option<i32> | Exit code from any shell command — useful for observability |
audit_artifacts | Vec<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"); } }
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
| Option | Default | Description |
|---|---|---|
--addr <url> | FOUNDRY_DAEMON_ADDR or http://127.0.0.1:50051 | Daemon address |
foundry resolves the daemon URL in this order:
- Explicit
--addr FOUNDRY_DAEMON_ADDRhttp://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]
| Argument | Required | Description |
|---|---|---|
event_type | Yes | Event type name (positional) |
--project | Yes | Target project |
--throttle | No | full or dry_run (default: full) |
--payload | No | JSON string with event-specific data |
--wait | No | Block 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>]
| Option | Required | Description |
|---|---|---|
--project | No | Filter 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>]
| Option | Required | Description |
|---|---|---|
--project | No | Limit run to a single project by name; omit to run all projects |
--throttle | No | full 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>]
| Argument | Required | Description |
|---|---|---|
project | Yes | Registered project name |
description | Yes | One concrete objective, supplied as a positional string |
--agent | No | Override the registered agent provider for this task |
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:
| Verdict | Meaning |
|---|---|
complete | Required gates and skeptical review passed; deliverable changes landed on trunk, or no landing was required |
remainder | The objective is incomplete; gaps[] names what remains |
defect | The implementation or evidence is wrong; diagnosis explains why |
blocked_on_decision | A human choice is required; finding and options[] carry it |
runner_error | The 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 campaign
Manage durable, evidence-terminated engineering objectives.
foundry campaign add <definition.json>
foundry campaign list
foundry campaign show <name>
foundry campaign advance <name>
foundry campaign pause <name>
foundry campaign decide <name> --decision "Use the generated tonic client path."
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
| Subcommand | Daemon required? | Description |
|---|---|---|
add | Yes unless --offline | Validate and atomically add one JSON definition |
list | Yes unless --offline | Show campaign status and cycle counts |
show | Yes unless --offline | Show the complete stored campaign record |
advance | Yes | Re-evaluate done evidence and dispatch one next task, complete, or escalate |
pause | Yes unless --offline | Halt future automatic and manual advancement |
decide | Yes unless --offline | Record an owner decision on an escalated campaign and return it to active |
complete | Yes unless --offline | Mark an authorized campaign complete with an auditable owner reason |
cancel | Yes unless --offline | Stop a campaign permanently, optionally killing the in-flight cycle |
resume | Yes unless --offline | Return 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 is valid only when
the campaign is currently escalated; it appends an owner decision record and
makes that policy available to the next formation run. 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. 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:
| Flag | Effect |
|---|---|
--now | Abort the in-flight workflow immediately, killing the running agent process |
--discard-work | Throw 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 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]
| Subcommand | Daemon required? | Description |
|---|---|---|
list | Yes unless --offline | Render every daemon-owned sentinel entry |
show | Yes unless --offline | Render one exact-name daemon-owned sentinel |
enable | Yes unless --offline | Mark one sentinel enabled and wake the in-process scheduler immediately |
disable | Yes unless --offline | Mark 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
| Argument | Required | Description |
|---|---|---|
project | Yes (unless --all) | One or more project names (positional) |
--all | No | Validate 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.
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]
| Argument | Required | Description |
|---|---|---|
event_id | Yes | Root event ID returned by foundry emit (positional) |
--verbose | No | Show trigger payloads, emitted payloads, raw shell output, and audit artifact paths |
--flat | No | Force 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]
| Argument | Required | Description |
|---|---|---|
date | No | Date in YYYY-MM-DD format; omit to show the last 7 days |
--project | No | Filter results by project name |
--offline | No | Explicitly 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)
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]
| Option | Required | Description |
|---|---|---|
--name | Yes | Unique project name |
--path | Yes | Absolute path to the project |
--stack | Yes | Technology stack: rust, python, typescript, elixir, cpp |
--agent | Yes | AI agent name (e.g. claude) |
--repo | Yes | GitHub slug (owner/repo) |
--branch | No | Default branch (default: main) |
--iterate | No | Enable iterate action |
--maintain | No | Enable maintain action |
--push | No | Enable push action |
--audit | No | Enable audit action |
--release | No | Enable release action |
--install-command | No | Shell command to run for local install |
--install-brew | No | Homebrew formula name |
--notes | No | Human-readable notes |
--timeout-secs | No | Command timeout in seconds (default: 3600) |
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
| Option | Description |
|---|---|
--path | Update the project path |
--stack | Update the technology stack |
--agent | Update the agent name |
--repo | Update the GitHub slug |
--branch | Update the default branch |
--skip | Set a skip reason (pass empty string "" to clear the skip) |
--iterate | Set iterate action (true/false) |
--maintain | Set maintain action |
--push | Set push action |
--audit | Set audit action |
--release | Set release action |
--install-command | Set install command |
--install-brew | Set Homebrew formula |
--notes | Set notes (pass empty string "" to clear) |
--timeout-secs | Set command timeout in seconds |
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:
| Field | Type | Description |
|---|---|---|
event_type | string | Event type name |
project | string | Target project |
throttle | Throttle enum | THROTTLE_FULL, THROTTLE_AUDIT_ONLY, THROTTLE_DRY_RUN |
payload_json | string | Optional JSON payload |
Response:
| Field | Type | Description |
|---|---|---|
event_id | string | Deterministic ID of the created event |
workflow_id | string | ID of the triggered workflow (if any) |
Status(StatusRequest) → StatusResponse
Query active workflow states.
Request:
| Field | Type | Description |
|---|---|---|
workflow_id | string | Specific workflow (empty for all active) |
Response:
| Field | Type | Description |
|---|---|---|
workflows | repeated WorkflowStatus | Active 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:
| Field | Type | Description |
|---|---|---|
project | string | Project name to filter by; empty string for all projects |
Response (stream):
| Field | Type | Description |
|---|---|---|
event_id | string | Event identifier |
event_type | string | Event type name |
project | string | Target project |
payload_json | string | Event 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:
| Field | Type | Description |
|---|---|---|
name | string | Unique project name |
path | string | Absolute path on the local filesystem |
stack | string | Technology stack: rust, python, typescript, elixir, cpp |
agent | string | AI agent name |
repo | string | GitHub repo slug (owner/repo) |
branch | string | Default branch (empty → main) |
iterate | bool | Enable iterate action |
maintain | bool | Enable maintain action |
push | bool | Enable push action |
audit | bool | Enable audit action |
release | bool | Enable release action |
install_command | string | Shell command for local install (mutually exclusive with install_brew) |
install_brew | string | Homebrew formula for local install (mutually exclusive with install_command) |
notes | string | Human-readable notes (empty → none) |
timeout_secs | uint64 | Per-project timeout (0 → use default 3600 s) |
Response:
| Field | Type | Description |
|---|---|---|
project | Project | The newly created project entry |
Errors: ALREADY_EXISTS if the name is already in the registry;
INVALID_ARGUMENT for an unknown stack 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:
| Field | Type | Description |
|---|---|---|
projects | repeated Project | Every 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, and audit exceptions.
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:
| Field | Type | Description |
|---|---|---|
name | string | Exact project name to retrieve |
Response:
| Field | Type | Description |
|---|---|---|
project | Project | The 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:
| Field | Type | Description |
|---|---|---|
name | string | Project 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:
| Field | Type | Description |
|---|---|---|
name | string | Project to edit (required) |
path | string | New path (empty → no change) |
stack | string | New stack (empty → no change) |
agent | string | New agent (empty → no change) |
repo | string | New repo slug (empty → no change) |
branch | string | New branch (empty → no change) |
skip | string | New skip reason; non-empty sets it; empty → no change unless clear_skip |
clear_skip | bool | Remove the skip flag |
iterate | bool | Set iterate to true (use clear_iterate to set false) |
clear_iterate | bool | Set iterate to false |
maintain | bool | Set maintain to true |
clear_maintain | bool | Set maintain to false |
push | bool | Set push to true |
clear_push | bool | Set push to false |
audit | bool | Set audit to true |
clear_audit | bool | Set audit to false |
release | bool | Set release to true |
clear_release | bool | Set release to false |
install_command | string | Set a shell-command install |
install_brew | string | Set a Homebrew formula install |
clear_install | bool | Remove the install config |
notes | string | Set notes (empty string + clear_notes = false → no change) |
clear_notes | bool | Remove notes |
timeout_secs | uint64 | Set timeout (0 → no change unless clear_timeout) |
clear_timeout | bool | Revert timeout to the daemon default |
Response:
| Field | Type | Description |
|---|---|---|
project | Project | The 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:
| Field | Type | Description |
|---|---|---|
sentinels | repeated Sentinel | Every 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:
| Field | Type | Description |
|---|---|---|
name | string | Exact sentinel name to retrieve |
Response:
| Field | Type | Description |
|---|---|---|
sentinel | Sentinel | The 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:
| Field | Type | Description |
|---|---|---|
name | string | Exact sentinel name to enable |
Response:
| Field | Type | Description |
|---|---|---|
sentinel | Sentinel | The 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:
| Field | Type | Description |
|---|---|---|
name | string | Exact sentinel name to disable |
Response:
| Field | Type | Description |
|---|---|---|
sentinel | Sentinel | The 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:
| Field | Type | Description |
|---|---|---|
definition_json | string | Full campaign definition JSON exactly as accepted by foundry campaign add |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full 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:
| Field | Type | Description |
|---|---|---|
project | string | Optional exact project-name filter; empty string returns all campaigns |
Response:
| Field | Type | Description |
|---|---|---|
campaigns | repeated Campaign | Durable inventory records sorted by campaign name |
Errors: FAILED_PRECONDITION when the campaign store is malformed;
INTERNAL when the campaign store is unreadable. 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:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to pause |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full 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:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to resume |
add_cycles | uint64 | Additional cycles to add to max_cycles before resuming (0 = no extension) |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full 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 an owner decision on an escalated campaign. The daemon holds the
exclusive campaign-store lock for the full mutation, appends one durable owner
decision record, and returns the campaign to active so the next advance can
proceed with that policy in context.
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.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to update |
decision | string | Non-empty owner decision text to record |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full 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 not escalated, lacks
authorized_by, 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>" 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:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to complete |
reason | string | Evidence-backed owner reason for external completion |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full 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. Because a whole campaign runs inside one task, aborting it kills the
running agent process and releases the campaign store lock that task was
holding. The abort is awaited, so the subsequent store acquisition is
uncontended. discard_work then selects how the orphaned worktree is disposed
of by the DisposeCampaignWork block.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to cancel |
reason | string | Owner reason for abandoning the mission |
terminate_now | bool | Abort the in-flight workflow, killing the running agent process |
discard_work | bool | Throw the terminated cycle's uncommitted work away |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full campaign detail reflecting the state after cancellation |
event_id | string | The 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. The daemon validates the current status under the exclusive lock,
releases the lock, emits CampaignAdvanceRequested, and returns immediately.
Request:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to advance |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Current campaign detail at dispatch time |
event_id | string | Root event ID of the dispatched CampaignAdvanceRequested workflow |
Errors: NOT_FOUND when the name is absent; FAILED_PRECONDITION when the
campaign is paused, escalated, or completed; INTERNAL when the store is
unreadable. Rejected advances do not mutate the 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, and renders the daemon trace for that event. 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:
| Field | Type | Description |
|---|---|---|
name | string | Exact campaign name to look up |
Response:
| Field | Type | Description |
|---|---|---|
campaign | CampaignDetail | Full durable definition of the campaign |
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.
CLI: foundry campaign show <name> routes through this RPC by default and
renders the returned CampaignDetail directly, without re-reading
FOUNDRY_CAMPAIGNS_PATH.
History(HistoryRequest) → HistoryResponse
List durable trace history from the daemon-owned trace store.
Request:
| Field | Type | Description |
|---|---|---|
date | string | Exact day in YYYY-MM-DD format, or empty to request recent history |
project | string | Exact project filter, or empty for all projects |
recent_days | uint32 | Number of recent days to return when date is empty; online CLI uses 7 |
Response:
| Field | Type | Description |
|---|---|---|
days | repeated HistoryDay | Matching 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:
| Field | Type | Description |
|---|---|---|
event_id | string | Root event ID to look up |
Response:
| Field | Type | Description |
|---|---|---|
found | bool | Whether a trace was found for the given event ID |
events | repeated TraceEvent | All events in the chain |
block_executions | repeated TraceBlockExecution | Record 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:
| Field | Type | Description |
|---|---|---|
span_id | string | Exact span ID to look up |
Response:
| Field | Type | Description |
|---|---|---|
found | bool | Whether a span was found for the given span ID |
events | repeated TraceEvent | Events whose span_id matches the requested span |
block_executions | repeated TraceBlockExecution | Blocks whose own span or parent span matches |
trace_id | string | Owning trace ID, including for block-only spans |
total_duration_ms | uint64 | Sum 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
| Field | Type | Description |
|---|---|---|
workflow_id | string | Workflow identifier |
workflow_type | string | Workflow type name |
project | string | Target project |
state | string | pending, running, completed, failed |
started_at | string | ISO 8601 timestamp |
completed_at | string | ISO 8601 timestamp (empty if running) |
task_blocks | repeated TaskBlockStatus | Per-block status |
TaskBlockStatus
| Field | Type | Description |
|---|---|---|
name | string | Block name |
state | string | pending, running, completed, skipped, failed |
started_at | string | ISO 8601 timestamp |
completed_at | string | ISO 8601 timestamp |
throttled | bool | True if emission was suppressed by throttle |
TraceEvent
| Field | Type | Description |
|---|---|---|
event_id | string | Deterministic event identifier |
event_type | string | Event type name |
project | string | Target project |
occurred_at | string | ISO 8601 timestamp |
throttle | Throttle enum | Throttle level for this event |
Campaign
Summary-only wire form returned by ListCampaigns. Intentionally omits
intent_refs, context_paths, done_evidence, and escalation — use
GetCampaign to retrieve those fields.
| Field | Type | Description |
|---|---|---|
name | string | Campaign name |
project | string | Registered project name |
mission | string | Campaign mission statement |
status | string | Durable status: staged, active, paused, escalated, or completed |
cycles_completed | uint64 | Number of dispatched task cycles |
cycles_landed | uint64 | Number of task results whose work reached trunk |
max_cycles | uint64 | Configured campaign cycle budget |
authorized_by | string | Owner authorization identity, or empty when absent |
agent_provider | string | Preferred agent provider, or empty when absent |
last_run_event_id | string | Most recent campaign-run event ID, or empty when absent |
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.
| Field | Type | Description |
|---|---|---|
name | string | Campaign name |
project | string | Registered project name |
mission | string | Campaign mission statement |
status | string | Durable status: staged, active, paused, escalated, or completed |
cycles_completed | uint64 | Number of dispatched task cycles |
cycles_landed | uint64 | Number of task results whose work reached trunk |
max_cycles | uint64 | Configured campaign cycle budget |
authorized_by | string | Owner authorization identity, or empty when absent |
agent_provider | string | Preferred agent provider, or empty when absent |
last_run_event_id | string | Most recent campaign-run event ID, or empty when absent |
intent_refs | repeated string | Intent reference labels that anchor the mission |
context_paths | repeated string | Paths to context documents for the campaign |
done_evidence | repeated DoneEvidence | Completion criteria with Gate/Review type distinction |
escalation | repeated string | Human-readable escalation instructions |
owner_decisions | repeated OwnerDecision | Append-only owner policy decisions recorded after escalations |
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.
| Field | Type | Description |
|---|---|---|
decision | string | Owner-authored decision text |
authorized_by | string | Owner identity copied from the campaign at record time |
decided_at | string | RFC 3339 timestamp recorded by the daemon |
DoneEvidence
One completion-evidence item. The kind field distinguishes the two variants.
Gate variant (kind = "gate"):
| Field | Type | Description |
|---|---|---|
kind | string | "gate" |
command | string | Shell command to evaluate |
required | bool | Whether this gate is required for completion |
statement | string | Empty for Gate items |
artifacts | repeated string | Repository-relative paths that must exist before the gate can pass |
Review variant (kind = "review"):
| Field | Type | Description |
|---|---|---|
kind | string | "review" |
command | string | Empty for Review items |
required | bool | false for Review items |
statement | string | Human-readable completion statement |
artifacts | repeated string | Empty for Review items |
TraceBlockExecution
| Field | Type | Description |
|---|---|---|
block_name | string | Name of the block that executed |
trigger_event_id | string | Event ID that triggered this block |
success | bool | Whether the block succeeded |
summary | string | Human-readable summary of the result |
emitted_event_ids | repeated string | IDs of events emitted by this block |
duration_ms | uint64 | Wall-clock milliseconds for this block execution (including retries) |
raw_output | string | Combined stdout+stderr from any shell command run by this block |
exit_code | int32 | Exit code from any shell command run by this block |
trigger_payload_json | string | JSON payload of the event that triggered this block |
emitted_payload_jsons | repeated string | JSON payloads of events emitted by this block |
audit_artifacts | repeated string | Paths to audit artefact files produced by this block |
Throttle (enum)
| Value | Number | Description |
|---|---|---|
THROTTLE_FULL | 0 | All blocks emit |
THROTTLE_AUDIT_ONLY | 1 | Observers emit, mutators suppress |
THROTTLE_DRY_RUN | 2 | Read-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:
| Field | Type | Description |
|---|---|---|
id | string | Deterministic SHA-256 derived ID, prefixed evt_ |
event_type | string | Snake-case event type name |
project | string | Project this event relates to |
occurred_at | RFC 3339 timestamp | When the event happened |
recorded_at | RFC 3339 timestamp | When the event was logged |
throttle | string | full or dry_run |
payload | JSON object | Event-type-specific fields (see below) |
trace_id | string or null | OTel trace identity |
span_id | string or null | Current workflow span identity |
parent_span_id | string or null | Parent span identity |
causation_id | string or null | Event that caused this event |
gather_id | string or null | Scatter/gather group identity |
Hello-World (engine validation)
| Type | Description |
|---|---|
greet_requested | Request to compose and deliver a greeting |
greeting_composed | Greeting message has been composed |
greeting_delivered | Greeting has been delivered (side effect) |
greet_requested payload
| Field | Type | Description |
|---|---|---|
name | string | Name to greet |
greeting_composed payload
| Field | Type | Description |
|---|---|---|
greeting | string | Composed greeting text |
Vulnerability Remediation
| Type | Description |
|---|---|
scan_requested | Request to scan a project for known vulnerabilities |
vulnerability_detected | A vulnerability was found (or injected externally) |
release_tag_audited | Latest release tag scanned for the vulnerability |
main_branch_audited | Main branch checked for the same vulnerability |
remediation_started | Automated fix attempt initiated |
remediation_completed | Fix attempt finished (success or failure) |
vulnerability_detected payload
| Field | Type | Description |
|---|---|---|
cve | string | CVE or advisory ID (e.g. "CVE-2026-1234") |
vulnerable | bool | Whether the project is affected |
dirty | bool (optional) | Whether the main branch still contains the vulnerability |
release_tag_audited payload
| Field | Type | Description |
|---|---|---|
cve | string | CVE from the scan or forwarded from the trigger |
vulnerable | bool | Whether the release tag is affected |
dirty | bool (optional) | Forwarded from the upstream trigger for downstream routing |
main_branch_audited payload
| Field | Type | Description |
|---|---|---|
cve | string | CVE identifier |
dirty | bool | true if the vulnerability is still present on main |
remediation_completed payload
| Field | Type | Description |
|---|---|---|
cve | string | CVE that was remediated |
success | bool | Whether the fix was applied successfully |
Release Lifecycle
| Type | Description |
|---|---|
release_requested | Decision made to cut a patch release |
release_completed | Release tag created and pushed |
release_pipeline_completed | GitHub Actions build/publish workflow finished |
release_completed payload
| Field | Type | Description |
|---|---|---|
cve | string | CVE that prompted the release |
release | string | Release type (e.g. "patch") |
new_tag | string or null | Semver tag extracted from Claude CLI output |
success | bool | Whether the Claude CLI invocation succeeded |
release_pipeline_completed payload
| Field | Type | Description |
|---|---|---|
status | string | "success" or "failure" |
conclusion | string (optional) | GitHub Actions conclusion label |
Project Lifecycle
| Type | Description |
|---|---|
project_validation_completed | Pre-flight checks for a maintenance run |
project_iteration_completed | Iterate workflow finished |
project_maintenance_completed | Maintain workflow finished |
project_changes_committed | Git commit created |
project_changes_pushed | Changes pushed to remote |
project_validation_completed payload
| Field | Type | Description |
|---|---|---|
status | string | "ok", "error", or "skipped" |
reason | string (optional) | Human-readable explanation when status is not "ok" |
has_gates | bool (optional) | Whether .hone-gates.json is present (only on "ok") |
project_iteration_completed payload
| Field | Type | Description |
|---|---|---|
project | string | Project name |
workflow | string | "iterate" |
success | bool | Whether the iterate workflow succeeded |
summary | string | Human-readable summary of the result |
changes | bool (optional) | Whether code changes were made |
project_maintenance_completed payload
| Field | Type | Description |
|---|---|---|
project | string | Project name |
workflow | string | "maintain" |
success | bool | Whether the maintain workflow succeeded |
summary | string | Human-readable summary of the result |
changes | bool (optional) | Whether code changes were made |
project_changes_committed payload
| Field | Type | Description |
|---|---|---|
cve | string | CVE or "unknown" (from remediation path) |
message | string | Git commit message used |
project_changes_pushed payload
| Field | Type | Description |
|---|---|---|
cve | string | CVE or "unknown" (from remediation path) |
Local Install
| Type | Description |
|---|---|
local_install_completed | Local tool reinstallation finished |
Maintenance Workflow
| Type | Payload | Description |
|---|---|---|
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
| Type | Description |
|---|---|
task_run_started | Isolated one-shot task execution began |
task_reviewed | Skeptical review produced a structural verdict |
task_run_completed | Task work was landed, durably preserved, or completed with no landing required |
task_run_completed payload
| Field | Type | Description |
|---|---|---|
project | string | Registered project name |
success | bool | true only for a complete verdict |
landed | bool | Whether complete or safe converging remainder work reached trunk |
summary | string | Human-readable terminal summary |
verdict | string | complete, remainder, defect, blocked_on_decision, or runner_error |
preservation_ref | string (optional) | Continuation ref: landed commit SHA, or remote branch / bundle:<path> for preserved work |
campaign | string (optional) | Campaign that dispatched the task |
campaign_cycle | integer (optional) | Campaign cycle that dispatched the task |
Verdict-specific fields are gaps[], diagnosis, finding plus options[],
or detail.
Campaign Formation
| Type | Description |
|---|---|
campaign_advance_requested | Request to re-evaluate a durable campaign |
campaign_advance_completed | Formation chose done, one next objective, or escalation |
campaign_paused | Provider unavailability paused a non-terminal campaign |
campaign_escalated | Campaign halted for budget, failure, rule, or owner judgment |
campaign_completed | Required done evidence proved the mission complete |
campaign_cancelled | An 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.
| Field | Type | Description |
|---|---|---|
terminated_now | bool | The in-flight workflow was aborted rather than left to finish |
discard_work | bool | The terminated cycle's uncommitted work was thrown away |
aborted_event_id | string? | Root event 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.
campaign_advance_requested payload
| Field | Type | Description |
|---|---|---|
campaign | string | Campaign name |
run_event_id | string (optional) | Typed task result that triggered the advance |
run_result | object (optional) | Full task_run_completed payload |
campaign_advance_completed payload
| Field | Type | Description |
|---|---|---|
campaign | string | Campaign name |
project | string | Registered project name |
cycles_completed | integer | Tasks dispatched by the campaign |
cycles_landed | integer | Task results whose task_run_completed.landed field was true |
decision | string | done, advance, or escalate |
objective | string (advance only) | Exactly one next task objective |
reason | string | Evidence or gap supporting the decision |
prompt | string (optional) | Exact prompt shown to the formation agent |
agent_provider | string (optional) | Provider used for the formation decision |
gate_results | array | Results from campaign done-evidence gates |
Gate Orchestration
| Type | Description |
|---|---|
gate_resolution_completed | Gate definitions loaded from .hone-gates.json |
preflight_completed | Gates passed/failed on unmodified codebase |
execution_completed | Code changes applied (emitted by future execution blocks) |
gate_verification_completed | Gates passed/failed after execution |
retry_requested | Gate failure triggers bounded retry |
gate_resolution_completed payload
| Field | Type | Description |
|---|---|---|
project | string | Project name |
workflow | string | "iterate", "maintain", or "validate" |
gates | array | Gate definitions (name, command, required, timeout_secs) |
actions | object (optional) | Forwarded actions from the trigger event |
preflight_completed payload
| Field | Type | Description |
|---|---|---|
project | string | Project name |
workflow | string | Workflow that triggered the preflight |
all_passed | bool | Whether every gate passed |
required_passed | bool | Whether all required gates passed |
results | array | Per-gate results (name, command, passed, required, output, exit_code, duration_ms?, fix_applied?) |
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.
gate_verification_completed payload
| Field | Type | Description |
|---|---|---|
project | string | Project name |
workflow | string | Originating workflow |
all_passed | bool | Whether every gate passed |
required_passed | bool | Whether all required gates passed |
retry_count | number | Current retry count (0 on first attempt) |
results | array | Per-gate results (name, command, passed, required, output, exit_code, duration_ms?, fix_applied?) |
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.
retry_requested payload
| Field | Type | Description |
|---|---|---|
project | string | Project name |
workflow | string | Originating workflow |
retry_count | number | Incremented retry count |
failure_context | string | Gate output from the failed verification |
actions | object (optional) | Forwarded actions |
Validation
| Type | Description |
|---|---|
validation_requested | Request to validate a project's gate health |
validation_completed | Terminal event with per-gate pass/fail results |
validation_requested payload
| Field | Type | Description |
|---|---|---|
project | string | Project name |
validation_completed payload
| Field | Type | Description |
|---|---|---|
project | string | Project name |
success | bool | Whether all required gates passed |
results | array | Per-gate results (name, passed, required, output snippet) |
Maintenance Run Lifecycle
| Type | Description |
|---|---|
maintenance_run_started | A maintenance run was triggered for a project |
maintenance_run_completed | All projects processed, summary available |
maintenance_run_started payload
| Field | Type | Description |
|---|---|---|
project | string | Project name this run covers |
maintenance_run_completed payload
| Field | Type | Description |
|---|---|---|
total | number | Total number of projects processed |
succeeded | number | Projects that completed successfully |
failed | number | Projects that encountered an error |
skipped | number | Projects that were skipped (already active or skip=true) |
projects | array | Per-project result objects (name, status, duration_secs) |
Release Tag Audit
| Type | Description |
|---|---|
release_tag_audited | Latest 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.
| Type | Description |
|---|---|
agent_session_started | An agent session has begun; transcript file path is included |
agent_session_ended | The agent session has finished (success, failure, or unavailable) |
agent_session_started payload
| Field | Type | Description |
|---|---|---|
session_id | string | UUID identifying this session; matches the transcript file basename |
agent_type | string | Agent runtime (currently always claude-code) |
project | string | Project name (may be empty in v1) |
working_dir | string | Absolute path of the working directory the agent ran in |
source_log_path | string | Absolute path to the per-session JSONL transcript (~/.foundry/agent-sessions/<session_id>.jsonl) |
capability | string | Capability label: reasoning, coding, or quick |
access | string | Tool access level: read_only or full |
started_at | RFC 3339 timestamp | When the session was launched |
trace_id | string | Correlating trace ID (may be empty in v1) |
agent_session_ended payload
| Field | Type | Description |
|---|---|---|
session_id | string | UUID identifying this session (matches agent_session_started) |
status | string | Outcome: ok, agent_failed, or unavailable |
exit_code | number | Process exit code (omitted when the agent could not be invoked) |
ended_at | RFC 3339 timestamp | When the session finished |
bytes_written | number | Total bytes streamed to the transcript file |
error | string | Error message when status = unavailable (omitted otherwise) |