Writing a constitution¶
A constitution specifies a desired state, not a plan. The worker decides the steps. You decide what "done" looks like.
Three moves: create the fiber, write the spec, install the dispatch contract.
1. Create the fiber¶
felt add pipeline/covariance-loader "Rewrite the covariance loader" -t constitution
The constitution tag serves browsing only (felt ls -t constitution). It
gates nothing — the daemon reads the shuttle: block, never tags.
Path the slug under the right parent so containment carries the relationship. See Organizing.
2. Write the spec¶
The body follows one fixed shape.
Open with a lede — a heading-less paragraph that orients both readers: the
human skimming a kanban card, and the worker landing cold at 3 a.m. Say what
this is, why now, where it sits. Weave in [[wikilinks]] to related fibers.
Then ## Desired State — the contract, and the one required heading. Write
done-conditions in checkable terms where the work allows. Name the quality bar
if it earns naming. Name anything you are specifically not asking for.
Then only sections the fiber has earned, named for what they hold: "Touch points", "Sharding rationale", "Considered alternatives". Resist a generic "Context" section — a section named for nothing in particular fills up like a junk drawer.
The loader rebuilds the full covariance on every call, which dominates runtime
in the [[pipeline/mcmc-driver]] inner loop. Caching was tried and reverted
because the cache key ignored the binning scheme.
## Desired State
`load_covariance()` returns in under 50 ms for a warm call on the fiducial
binning. The cache key includes the binning scheme and the survey mask hash.
`pytest tests/test_covariance.py` passes, including the two currently-skipped
cache tests.
Not in scope: the Gaussian-approximation path. That is [[pipeline/gauss-cov]].
Keep the body small and the network rich. Depth belongs in linked sub-fibers.
Do not write worker mechanics into the spec — verification cadence, exit discipline, when to spawn subagents. The agent-side skill carries those for every worker, and instructions dated to one model generation age badly.
The ## Status handoff¶
The worker owns ## Status. You do not write it when authoring; the first
worker creates it.
It gives the next reader a landing pad. Each session rewrites it — never appends. It holds the now: what landed, where the worker got stuck, what the next reader needs on arrival. Multi-paragraph prose is fine. Chronology that matters goes into commits, not here.
The kanban headline¶
Write outcome: as one or two sentences, rewritten every session: where the
work stands, and what the reader does next. A human reads it on the board
without opening anything.
When the work is blocked, lead the outcome with Blocked: … so the card reads
as "answer me," not "review me."
Optional: report.html¶
If the product is something a human reads — findings, figures, comparisons —
say so in the constitution. The worker then rewrites a report.html companion
file in the fiber directory each session, and embeds it in the body:
:::{embed} report.html
:::
Code-shaped work whose story is commits plus an outcome line needs none.
3. Install the dispatch contract¶
shuttle install pipeline/covariance-loader \
--project-dir "$PWD" \
--model claude-opus
This writes the shuttle: block, validates the agent against the registry, and
sets the fiber's status: active. The daemon picks it up on the next poll.
Add --disabled to stash it as a draft (status: open). It lands in the
board's Drafts column and waits. shuttle resume <fiber> arms it later.
install is create-only. Run it against a fiber that already has a block and
it refuses, naming the verb you actually wanted — shuttle status <fiber>
to inspect the block's state and the daemon's dispatch assessment, reshape
to change kind or schedule, set-model/set-agent to change the agent, or
uninstall to start over.
You can also write the block by hand while drafting. shuttle install adds
schema validation, which makes it the canonical path. felt preserves a
hand-written block without judging it; use shuttle check to validate the
schema and shuttle status to inspect dispatch eligibility. A block without
status: active does not dispatch.
The three kinds¶
The shuttle CLI validates shuttle.kind (internal/shuttle/schema.go); felt
preserves it as opaque frontmatter.
oneshot¶
The default, and the kind most work wants. Dispatches on the next poll while
status: active. Redispatches after every clean handoff until a human closes
it.
shuttle install <fiber> --project-dir "$PWD"
standing¶
Recurring, driven by a cron expression. Installed with the repeat verb:
shuttle repeat <fiber> \
--schedule "0 9 * * 1-5" --tz Europe/Paris --project-dir "$PWD"
shuttle computes due-ness from the cron expression against now, and stores
nothing. So no dispatch can silently consume a slot. The
active → closed → active document transition records that an occurrence ran.
A standing role carries at most one unaccepted work product. While a run waits
for review, scheduled runs do not fire and ad-hoc dispatch refuses. Accepting it
(shuttle accept) re-arms the role for the schedule's next tick and
keeps the outcome, which stays the card's headline until the next run writes
its own.
pinned¶
A schedule-less perennial interface — a status hub, a debug intake. A schedule
on a pinned block fails validation.
shuttle pin <fiber> --project-dir "$PWD"
It rests on the board's pinned strip until a human starts it. Once running it joins the ordinary lifecycle: a deliberate handoff relaunches a fresh successor, a dirty death or idle exit parks it back to the strip, a close-out lands in Awaiting review.
Oneshots alone make a coherent system. Ignore standing and pinned until you want them.
Changing kind¶
install, pin, and repeat refuse to clobber an existing block. To convert,
use shuttle reshape:
shuttle reshape <fiber> standing --schedule "0 9 * * 1" --tz UTC
kind is optional — omit it to leave the current kind alone and edit only the
schedule (shuttle reshape <fiber> --schedule "0 7 * * *"). Reshape
changes only kind and the schedule, and leaves the rest of the block —
including the daemon-owned runtime: keys — untouched. It never
touches felt's lifecycle fields (status, tempered, outcome), so a role
sitting in Awaiting review can be reshaped in place without being requeued. A
standing target needs a schedule, from --schedule or echoed from the
existing block; a oneshot or pinned target drops the schedule, and passing
--schedule/--tz against one is an error.
Agent selection¶
shuttle agents prints the effective registry — run it for your current
list. The maintained fleet ships built in:
| Family | Built-in ids | Notes |
|---|---|---|
| Claude | claude-opus (registry default), claude-sonnet, claude-fable, claude-haiku |
Browser access ("chrome") is an explicit axis on Claude agents, not a separate agent. |
| Codex | codex-astra, codex-sol, codex-luna |
Launched with --approve-for-me: Codex's automatic reviewer handles approval requests. |
| Pi | pi-luna, pi-grok, pi-gemini-flash, pi-kimi, pi-deepseek-flash, pi-glm-flash, pi-union-alpha |
pi-union-alpha uses OpenRouter's free Union Alpha model. |
Two orthogonal axes layer on the base id: effort (validated against the
resolved agent's allowed levels) and chrome (Claude only, for browser work).
shuttle set-agent <fiber> claude-opus --effort high
shuttle set-agent <fiber> claude-opus --chrome
shuttle set-model <fiber> codex-luna # agent-only shorthand
Add your own agents
Your registry file layers over the built-ins by default. Set
builtins: "restrict" when it should be the complete registry for one
host. Run shuttle agents init to seed
~/.config/shuttle/agents.json (or $SHUTTLE_AGENTS_FILE), then edit it — new
ids, aliases, or replacements for built-in ones. To change only an
agent's default effort, shuttle agents effort <id> <level> writes an
override instead of a copied record. See
Configuring agents.
Required gates¶
You set these three gates by hand. Install refuses, or the daemon ignores the fiber, unless all three hold. Check them first, but expect more — see Dispatch eligibility for the full ordered list the daemon evaluates.
project_dir— the worker's working directory on the target host.installandrepeatrequire it (install allows omitting it only with--disabled). The daemon also checks that the directory exists. It runs that gate last, because that gate alone touches the filesystem. On macOS a stat inside a synced folder can raise a permission prompt nobody can grant.host— the owning daemon's host id. A daemon dispatches a block if and only ifblock.hostequals its own id, which shuttle resolves (shuttle host):SHUTTLE_HOST, else the file~/.shuttle/host, else the system hostname — consulted once and then written to that file, so the name cannot drift. shuttle offers no"local"default and no wildcard. An absent host leaves the fiber unowned and ineligible on every daemon.install,repeat, andpinstamp it by default.status: active— the sole dispatch gate.openmarks a draft or a pause.closedmarks awaiting review or a terminus.
One more condition lives outside the block: the fiber must sit in a felt store
the daemon polls. depends_on is a board-only ordering annotation — a queued
card folds under the card it follows — and never gates dispatch.
Human in the loop¶
Every dispatch runs autonomously; shuttle offers no interactive mode. When work needs a human, that expectation rides one of two channels.
Per-dispatch. The card drawer's message box carries a free-text "From User" directive; a line asking the worker to talk first makes that run wait for you. The worker reads the directive at the top of its context. The directive applies to that moment only — the next dispatch starts clean.
Structural. Some work simply cannot one-shot: a 2FA step, a final send in your own voice, a draft-and-stage shape where the human commits. Write the gate into the constitution text: "I will be present; drive to the send and wait." The worker reads the spec as its contract and stays alive at that gate.
If a flow has a human-gated step, write the gate into the spec. No flag does this.