ADR-0009: Project-scoped harness.toml and Compose-style lifecycle commands
Context and Problem Statement
📝 ADR-0006 gave us a single, global config — ~/.config/harness/harness.toml —
holding every harness, every [profile.*], and the [server] block, owned by
the daemon as the file-of-record. That model fits a personal, machine-wide set
of long-lived agents, but it has no answer for the most common developer
gesture: "I'm in a repo; bring up the agents this project needs." Today you'd
have to hand-add the project's harnesses to your global config, wire a profile,
and remember to tear it down later.
docker compose solved exactly this shape: a per-project file in the repo root
plus a small verb vocabulary (up, down, ps, logs) that operate on
that project's services without polluting global daemon config. How do we give
Harness the same "cd repo && harness up" ergonomic without breaking the
📝 ADR-0006 global-config-of-record model, and without letting two projects that
both define a claude harness collide?
Decision Drivers
- Repo-local, git-committable. A project's agent set should live in the
repo (like
docker-compose.yaml), version-controlled with the code, soharness upis reproducible for anyone who clones it. - Don't pollute global config. Bringing a project up must not rewrite
~/.config/harness/harness.toml; tearing it down must leave no residue. The global file stays the hand-authored, dotfiles-tracked artifact 📝 ADR-0006 protects. - Reuse the harness schema. People already know
[harness.*](cmd/args/workdir/env_file/…). A project file should speak the same dialect, not a second one. - Collision-free by construction. Two repos each defining
claude-srcmust be able to beupsimultaneously. - Daemon stays the supervisor. PTYs, scrollback, restart policy, and remote
attach are the daemon's job (📝 ADR-0002/0003/0005/0007).
harness upis a client gesture that pushes definitions + intent over the existing protocol — not a second execution engine. - Filename reuse is a trap to defuse. The global config is already named
harness.toml(config.DefaultPath()→~/.config/harness/harness.toml). A project file calledharness.tomlshares the basename; the design must make location + intent, not filename, the discriminator.
Considered Options
- Option 1 — Ephemeral project registration. A repo-root
harness.toml, discovered by walking up fromcwd, is parsed by the client and pushed to the daemon via newproject_up/project_downcontrol ops. The daemon registers its harnesses under a project namespace (<project>/<harness>), supervises them like any other, and forgets them ondown. Global config untouched. - Option 2 — Project is a profile. Treat the repo file as an alternate
on-disk form of an 📝 ADR-0006
[profile.*], discovered by directory.up== "hop into this profile." Reuses profile machinery wholesale. - Option 3 —
upmerges into global config.harness upappends the project's[harness.*]+ a profile into~/.config/harness/harness.toml(📝 ADR-0006 write-back), starts them, anddowndeletes those lines again.
Decision Outcome
Chosen option: Option 1 — daemon-managed project registration (revised
2026-08-19 from the original runtime-only cut: registrations are now durable
Compose-style state; see Registration Persistence below), because it gives
the Compose ergonomic while keeping the two concerns cleanly separated: the
global file stays the durable, dotfiles-tracked config-of-record (📝 ADR-0006
is unchanged), and a project is a transient, daemon-registered set that
lives and dies with up/down. Profiles remain the global "switchable view"
concept; projects are the "repo-local, daemon-managed" concept — siblings, not the
same thing.
Project file: same schema, new location, project header
A project harness.toml is discovered by walking up from cwd (like git
finds .git, like Compose finds its file) until a harness.toml is found; the
directory containing it is the project root. It reuses the 📝 ADR-0006
[harness.*] table schema verbatim (same parser, same core domain types), so
cmd/args/workdir/env_file/restart_delay/backend/description all
mean what they already mean. It adds one optional table:
# ./harness.toml (in the repo root, committed with the code)
[project]
name = "reduit" # optional; defaults to the project-root directory basename
# The agents this project runs — the whole point of `up`.
[harness.agent]
cmd = "claude"
args = ["--dangerously-skip-permissions"]
workdir = "." # relative paths resolve against the project root
[harness.reviewer]
cmd = "crush"
args = ["--yolo"]
workdir = "."
Discrimination between a project file and the global file is by location, not
filename: the daemon's own config is always config.DefaultPath()
(~/.config/harness/harness.toml); a project file is whatever harness up
discovers by walking up from cwd. To avoid a footgun, the discovery walk
stops before and never treats $XDG_CONFIG_HOME/harness/harness.toml as a
project file, and a project file MUST NOT carry [server] or [profile.*]
tables (validation error — those are global-only concerns).
Namespacing: <project>/<harness>
Every harness a project registers is exposed daemon-wide as
<project>/<harness> — e.g. reduit/agent, reduit/reviewer. The project name
defaults to the sanitized project-root directory basename and is overridable via
[project].name. This is Compose's container-naming trick: two repos can each
define agent and both be up at once as reduit/agent and spotter/agent.
Bare harness names from the global config keep their un-prefixed identity, so
project names never collide with global ones as long as a project name isn't
itself used as a bare harness name (validated at up).
Command surface (Compose verbs → existing control plane)
harness up is detached by default: it registers the project, starts every
harness in it, prints a one-shot status table, and returns to the shell — mirror
of Harness's daemon-centric model (viewing is harness TUI / harness attach,
not up). The verb set maps onto SPEC-0002 control operations, scoped to the
project:
| Command | Behavior |
|---|---|
harness up | Discover + register project, start all its harnesses (detached), print status table. Idempotent: re-running reconciles (adds new, restarts changed). |
harness down | Stop and deregister every harness in the project; the daemon forgets them, persisted registration included. Destructive by design (unlike non-destructive profile hopping). |
harness rm [NAME] | Stop and deregister ONE registered harness; removing the last member drops the empty project. The single-member tear-down. |
harness ps | List just this project's harnesses and states. |
harness logs [name] | Scrollback for the project (or one member). |
harness start/stop/restart [name] | Non-destructive lifecycle on project members (deregistration stays exclusive to down/rm). |
up/down/rm map to control ops (project_up, project_down, remove); ps/logs/
start/stop/restart reuse the existing list/logs/start/stop/
restart ops filtered by project namespace. harness up/down require a
running daemon exactly as every other client verb does (📝 ADR-0002).
Consequences
- Good, because it delivers the
cd repo && harness upgesture the pitch is missing, with a file developers can commit alongside their code. - Good, because 📝 ADR-0006's global config-of-record is untouched — no write-back, no residue, no black-box merging of repo files into personal config.
- Good, because reusing the
[harness.*]schema means one parser, one mental model, and zero new harness-definition dialect. - Good, because
<project>/namespacing makes simultaneous multi-repo use collision-free by construction. - Bad, because the daemon now holds two sources of harness definitions (global file + N ephemeral projects) and its registry/state model (📝 ADR-0007) must track provenance ("this harness came from project reduit, deregister on down") — more state than a single flat config.
Bad, because a project registration is runtime-only and does not survive a daemon restart— resolved 2026-08-19: registrations now persist to state.json (definitions + per-harness intent) and are re-registered on daemon start, soupstays up across restarts until an explicitdown/rm, exactly like Compose containers under a restarted dockerd with a restart policy. The 2026-07 original cut was runtime-only; real usage showed that madeupan ad-hoc gesture rather than a Compose one.- Neutral, because the shared
harness.tomlbasename requires a deliberate location-based discrimination rule rather than a filename one — documented above, but a real edge developers can trip on.
Confirmation
- SPEC-0004 formalizes discovery (up-walk from
cwd), the[project]table,<project>/<harness>namespacing, theproject_up/project_downprotocol ops, and theup/down/psverb semantics as testable requirements + scenarios. - Acceptance tests:
upin a repo registers<project>/*and starts them;downremoves them and leaves the global config file byte-identical; a daemon restart re-registers anupproject and restores its running set while a stopped member stays stopped;rmremoves one member and drops an emptied project, and refuses a global harness; two projects defining the same bare name coexist; a project file carrying[server]/[profile.*]is rejected; discovery never adopts the XDG global file as a project. - Resolved (was Deferred): the daemon persists which projects were
upand re-registers them on restart, restoring each member's intent like any global harness (📝 ADR-0007 state model; SPEC-0004 REQ "Registration Persistence"). The 2026-07 draft deferred this and shipped runtime-only; the deferral is over.
Pros and Cons of the Options
Option 1 — Ephemeral project registration
Repo-root file, discovered by up-walk, pushed to the daemon as a namespaced,
daemon-managed set via project_up/project_down/remove ops, persisted
across daemon restarts until an explicit tear-down.
- Good, because global config-of-record (📝 ADR-0006) is never mutated.
- Good, because it's the closest analogue to the
docker composemental model the request explicitly asks for. - Good, because
<project>/namespacing solves collisions structurally. - Neutral, because it introduces provenance tracking in the daemon registry.
Bad, because project registrations are runtime-only (lost on daemon restart)— resolved 2026-08-19; registrations persist (see Decision Outcome).
Option 2 — Project is a profile
Treat the repo file as an on-disk [profile.*] discovered by directory; up ==
use_profile.
- Good, because it reuses the existing profile machinery and
use_profileop. - Bad, because profiles are explicitly non-destructive in 📝 ADR-0006 ("hopping
profiles does not kill harnesses"), which directly contradicts
down's stop-and-forget semantics — we'd be overloading one concept with two opposite lifecycles. - Bad, because profiles reference harnesses by name from global config; a repo-local profile would still need its member harnesses to exist globally, reintroducing the pollution we're trying to avoid.
- Bad, because it blurs "durable switchable view" and "ephemeral repo set" into one word, making both harder to reason about.
Option 3 — up merges into global config
harness up appends the project's tables to ~/.config/harness/harness.toml
via 📝 ADR-0006 write-back; down deletes them.
- Good, because everything flows through one existing file + reload path.
- Bad, because it mutates the user's hand-authored, dotfiles-tracked config on
every
up/down— exactly the "config black box" and chezmoi-churn 📝 ADR-0006 fought to prevent. - Bad, because a crash between
upanddownleaves orphaned project tables wedged in personal config. - Bad, because collisions become file-level merge conflicts rather than clean namespaced coexistence.
Architecture Diagram
More Information
- Extends ADR-0006 — reuses the
[harness.*]schema and file-parsing path; adds project-scoped, ephemeral registration as a sibling to global profiles. Profiles stay the durable, switchable global view; projects are the repo-local, transient set. - Related ADR-0002 —
up/downare thin-client gestures over the daemon control plane, not a second engine. - Related ADR-0005 — project harnesses are supervised identically; project-autostart-on-restart is the deferred question.
- Related ADR-0007 — the daemon
registry must now track per-harness provenance (global vs. project) so
downremoves exactly the right set. - Governs SPEC-0004 — the formal requirements + scenarios for discovery, namespacing, protocol ops, and verb semantics.
Related Artifacts
Direct relationships declared in YAML frontmatter (per ADR-0023 / SPEC-0018). Run /sdd:graph chain ADR-0009 for the transitive view.