ADR-0009: Project-scoped harness.toml and Compose-style lifecycle commands
Context and Problem Statement
📝 ADR-0006 gave Harness 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 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." Without a project
concept, the project's harnesses have to be hand-added to the global config,
wired into a profile, and remembered for teardown.
docker compose solved exactly this shape: a per-project file in the repo root
plus a small verb vocabulary (up, down, ps, logs) that operates on
that project's services without polluting global configuration. How does
Harness get 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 lives in the repo,
like
docker-compose.yaml, versioned 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, and tearing it down must leave no residue. The global file stays the hand-authored, dotfiles-tracked artifact 📝 ADR-0006 protects. - Reuse the harness schema. Operators already know
[harness.*](harness,args,workdir,env_file, …). A project file speaks the same dialect, not a second one. - Collision-free by construction. Two repos each defining
claude-srccan be up at the same time. - Durable like Compose. A project that is up stays up across a daemon restart until someone tears it down, the way Compose containers with a restart policy come back under a restarted Docker daemon.
- The daemon stays the supervisor. PTYs, scrollback, restart policy and
remote attach are the daemon's job (📝 ADR-0002, 📝 ADR-0003, 📝 ADR-0005, 📝 ADR-0007).
harness upis a client gesture that pushes definitions and intent over the existing protocol, not a second execution engine. - Defuse the shared filename. The global config is already named
harness.toml. A project file with the same basename must be told apart by location and role, not by name.
Considered Options
- Option 1 — Daemon-managed project registration. A repo-root
harness.toml, discovered by walking up fromcwd, is parsed by the client and pushed to the daemon withproject_up/project_downcontrol ops. The daemon registers its harnesses under a project namespace (<project>/<harness>), supervises them like any other, persists the registration, and forgets it ondown. The global config is untouched. - Option 2 — A project is a profile. Treat the repo file as an alternate
on-disk form of an 📝 ADR-0006
[profile.*], discovered by directory;upmeans "hop into this profile". - Option 3 —
upmerges into global config.harness upappends the project's[harness.*]tables and a profile to the global file (📝 ADR-0006 write-back), starts them, anddowndeletes those lines again.
Decision Outcome
Chosen option: Option 1 — Daemon-managed project registration, because it
gives the Compose ergonomic while keeping two concerns cleanly apart: the
global file stays the dotfiles-tracked config of record (📝 ADR-0006 is
unchanged), and a project is a repo-owned set the daemon registers on up,
persists across restarts, and forgets only on an explicit down or rm.
Profiles remain the global "switchable view"; projects are the "repo-local,
daemon-managed set". They are siblings, not the same concept.
Project file: same schema, new location, project header
A project harness.toml is discovered by walking up from cwd, the way
git finds .git and 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
harness, args, workdir, env_file, restart_delay, backend and
description 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.
[harness.agent]
harness = "claude-code"
args = ["--remote-control"]
workdir = "." # relative paths resolve against the project root
[harness.reviewer]
harness = "crush"
args = ["--yolo"]
workdir = "."
A project file is told apart from the global file 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. The walk never treats
$XDG_CONFIG_HOME/harness/harness.toml as a project file. A project file MUST
NOT carry [server] or [profile.*] tables, or any other table or key the
daemon owns globally (trigger sources, schedules, operating hours); each is a
validation error.
Namespacing: <project>/<harness>
Every harness a project registers is exposed daemon-wide as
<project>/<harness>, for example reduit/agent and reduit/reviewer. The
project name defaults to the sanitized project-root directory basename
(My-Cool Project becomes my-cool-project) and is overridable with
[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 unprefixed identity, so
project harnesses never collide with global ones as long as a project name is
not itself a bare harness name, which up validates.
Registration persistence
The daemon persists each registered project's definitions and its harnesses'
runtime intent in state.json (📝 ADR-0007), keyed by project, and re-registers
them at daemon start. A member that was running comes back running; a member an
operator stopped stays stopped. The registration ends only on harness down
(whole project) or harness rm (one member). Project and global harnesses
restore through the same path.
Command surface
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. Viewing
is the harness TUI or harness attach, not up. The verbs map onto SPEC-0002
control operations, scoped to the project:
| Command | Behavior |
|---|---|
harness up | Discover and register the project, start all its harnesses (detached), print a status table. Idempotent: re-running reconciles (adds new, removes gone, and applies changed definitions on the next restart rather than bouncing a running process). |
harness down [PROJECT] | Stop and deregister every harness in the project; the daemon forgets them, persisted registration included. Destructive by design, unlike non-destructive profile hopping. With PROJECT, no discovery runs, so a project whose file was deleted can still be torn down. |
harness rm [NAME] | Stop and deregister one registered harness; removing the last member drops the empty project. |
harness ps | List 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 and rm. |
up, down and rm map to control ops (project_up, project_down,
remove); ps, logs, start, stop and restart reuse the existing
list, logs, start, stop and restart ops filtered by project namespace.
Every project verb requires a running daemon, exactly as every other client verb
does (📝 ADR-0002).
Consequences
- Good, because it delivers the
cd repo && harness upgesture with a file developers commit alongside their code. - Good, because 📝 ADR-0006's global config of record is untouched: no write-back, no residue, no merging of repo files into personal configuration.
- Good, because reusing the
[harness.*]schema means one parser, one mental model, and no second harness-definition dialect. - Good, because
<project>/namespacing makes simultaneous multi-repo use collision-free by construction. - Good, because registrations persist, so
upis a durable Compose gesture rather than an ad-hoc one: a daemon restart brings a project's running set back until an explicitdownorrm. - Bad, because the daemon now holds two sources of harness definitions (the
global file and N registered projects), and its registry and state model
(📝 ADR-0007) track provenance ("this harness came from project reduit") so
downremoves exactly the right set. - Bad, because a project registration lives in
state.json, so it outlives the project file: deleting the file does not tear the project down.harness down PROJECTcovers that case. - Neutral, because the shared
harness.tomlbasename needs a deliberate location-based rule rather than a filename one, and developers can still trip on it.
Confirmation
- SPEC-0004 formalizes discovery, the
[project]table,<project>/<harness>namespacing, theproject_up/project_down/removeops, registration persistence, and the verb semantics as testable requirements and 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 an up project and restores its running set while a stopped member stays stopped;rmremoves one member, drops an emptied project, and refuses a global harness; two projects defining the same bare name coexist; a project file carrying[server]or[profile.*]is rejected; discovery never adopts the global file as a project.
Pros and Cons of the Options
Option 1 — Daemon-managed project registration
A repo-root file, discovered by walking up, pushed to the daemon as a
namespaced, daemon-managed set with project_up / project_down / remove,
and persisted across daemon restarts until an explicit teardown.
- Good, because the global config of record (📝 ADR-0006) is never mutated.
- Good, because it is the closest analogue to the
docker composemental model. - Good, because
<project>/namespacing solves collisions structurally. - Good, because the registration is durable, like Compose.
- Neutral, because it introduces provenance tracking in the daemon registry.
Option 2 — A project is a profile
Treat the repo file as an on-disk [profile.*] discovered by directory; up
equals 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 contradicts
down's stop-and-forget semantics: one concept would carry two opposite lifecycles. - Bad, because profiles reference harnesses by name from the global config, so a repo-local profile would still need its members defined globally, reintroducing the pollution this ADR avoids.
- Bad, because it blurs "durable switchable view" and "repo-owned 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 the global file through 📝 ADR-0006
write-back; down deletes them.
- Good, because everything flows through one existing file and reload path.
- Bad, because it mutates the hand-authored, dotfiles-tracked config on every
upanddown, the churn 📝 ADR-0006 was written to prevent. - Bad, because a crash between
upanddownleaves orphaned project tables wedged in personal configuration. - 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, and adds project registration as a sibling to global profiles. Profiles stay the durable, switchable global view; projects are the repo-owned set. - Related 📝 ADR-0002 —
up,downandrmare thin-client gestures over the daemon control plane, not a second engine. - Related 📝 ADR-0005 — project harnesses are supervised identically, including restore on daemon restart.
- Related 📝 ADR-0007 — the registry tracks per-harness provenance (global or
project), and
state.jsoncarries project registrations. - Related 📝 ADR-0017 — scratchpads are the ephemeral sibling: never persisted, gone on daemon restart.
- Governs SPEC-0004 — the requirements and scenarios for discovery, namespacing, protocol ops, persistence and verb semantics.
Related Artifacts
Direct relationships declared in YAML frontmatter (per the SDD plugin's artifact-graph convention). Run /sdd:graph chain ADR-0009 for the transitive view.