SPEC-0004: Project Compose (harness up / down)
Overview
A per-project, repo-root harness.toml plus a Compose-style verb vocabulary
(up, down, rm, ps, logs, start, stop, restart) that brings a
project's harnesses up under the running daemon without touching the global
config. See 📝 ADR-0009. The project file reuses the 📝 ADR-0006 [harness.*]
schema; the daemon registers a project's harnesses under a <project>/<harness>
namespace as a daemon-managed, durable set: up stays up — across daemon
restarts — until an explicit down (whole project) or rm (one member),
exactly like docker compose. Projects are deliberately NOT the ad-hoc
mechanism; ephemeral scratchpads are a separate concern. Lifecycle, PTYs, and
scrollback remain the daemon's job (SPEC-0003); the new work here is discovery,
the [project] header, namespacing, the control operations, and the verb
semantics.
Requirements
Requirement: Project File Discovery
harness up (and the other project verbs when invoked without an explicit path)
SHALL locate the project file by walking upward from the current working
directory, testing each ancestor directory for a harness.toml, and stopping at
the first one found. The directory containing that file is the project root.
If no harness.toml is found before reaching the filesystem root or the user's
home directory, the command SHALL fail with a clear "no harness.toml found"
error. The discovery walk MUST NOT adopt the daemon's own global config file
(config.DefaultPath(), i.e. $XDG_CONFIG_HOME/harness/harness.toml or the
~/.config fallback) as a project file, even if cwd is inside that directory.
Scenario: Found in an ancestor directory
- WHEN
harness upruns in~/src/reduit/internal/fooand aharness.tomlexists at~/src/reduit/harness.toml - THEN the project root resolves to
~/src/reduitand that file is used
Scenario: No project file present
- WHEN
harness upruns in a directory tree containing noharness.toml - THEN the command exits non-zero with a message telling the user no
harness.tomlwas found and nothing is sent to the daemon
Scenario: Global config is never treated as a project
- WHEN discovery walks through
$XDG_CONFIG_HOME/harness/ - THEN the global
harness.tomlthere is skipped and not registered as a project
Requirement: Project File Schema
A project harness.toml SHALL reuse the 📝 ADR-0006 [harness.*] table schema
verbatim (cmd, args, workdir, env_file, restart_delay, backend,
description, enabled) and MAY include an optional [project] table whose
name key sets the project name. Relative workdir values SHALL resolve
against the project root, not against the daemon's working directory. A project
file MUST NOT contain [server] or [profile.*] tables; the parser SHALL reject
such a file with a validation error identifying the offending table, because
those are global-only concerns.
Scenario: Reuses the harness schema
- WHEN a project file defines
[harness.agent]withcmdandargs - THEN it parses into the same
coreharness type a global[harness.*]table produces, with identical field meanings
Scenario: Relative workdir resolves against project root
- WHEN a project harness sets
workdir = "."and the project root is~/src/reduit - THEN the harness runs with working directory
~/src/reduit
Scenario: Server or profile table rejected
- WHEN a project
harness.tomlcontains a[server]or any[profile.*]table - THEN parsing fails with a validation error naming that table and the project is not registered
Requirement: Project Naming And Namespacing
Each project SHALL have a name: the value of [project].name if present,
otherwise the sanitized basename of the project-root directory. Every harness a
project registers SHALL be exposed daemon-wide under the name
<project>/<harness> (e.g. reduit/agent). Project names SHALL be validated to
not collide with an existing bare (global) harness name at registration time.
Two distinct projects that each define a harness of the same local name SHALL be
able to be registered simultaneously because their fully-qualified names differ
by project prefix.
Scenario: Default name from directory
- WHEN a project at
~/src/reduithas no[project].name - THEN its project name is
reduitand its harnesses register asreduit/<harness>
Scenario: Two projects, same local harness name
- WHEN project
reduitand projectspottereach define[harness.agent]and both are brought up - THEN the daemon supervises
reduit/agentandspotter/agentconcurrently without collision
Scenario: Name collides with a global harness
- WHEN a project name would shadow an existing bare global harness name
- THEN
upfails with a collision error and registers nothing
Requirement: Bring Up (harness up)
harness up SHALL require a running daemon, parse the discovered project file,
and send a project_up control request (SPEC-0002) carrying the project name and
its harness definitions. The daemon SHALL register the project's harnesses under
the project namespace and start each one (transitioning it to starting per
SPEC-0003). up SHALL run detached: after issuing the request it SHALL print
a one-shot status table of the project's harnesses and their states and return to
the shell. up SHALL be idempotent — re-running it on an already-registered
project SHALL reconcile: newly-added harnesses are registered and started,
removed harnesses are deregistered and stopped, and changed definitions are
flagged to apply on next restart per SPEC-0003 (never silently bounced).
Scenario: First up in a project
- WHEN
harness upruns in a project with agent harnessesagentandreviewer - THEN the daemon registers
reduit/agentandreduit/reviewer, starts both, and the command prints their states then exits
Scenario: Detached, not attached
- WHEN
harness upcompletes - THEN control returns to the shell with the harnesses running in the
background under the daemon (viewing is via
harnessTUI orharness attach)
Scenario: Re-up reconciles
- WHEN the project file gains a new harness and
harness upis run again - THEN the new harness is registered and started while the others are untouched, and no duplicate registration occurs
Scenario: Daemon not running
- WHEN
harness upruns and no daemon is reachable on the socket - THEN the command fails with the same daemon-unreachable error as other client verbs and registers nothing
Requirement: Tear Down (harness down)
harness down SHALL send a project_down control request for the discovered
project. The daemon SHALL stop every harness registered under that project's
namespace and then deregister them, so the daemon retains no record of the
project afterward — including its persisted registration (see Registration
Persistence). down SHALL be destructive by design — distinct from
non-destructive profile hopping (📝 ADR-0006) — and SHALL leave the global config
file byte-for-byte unchanged.
Scenario: Down stops and forgets
- WHEN
harness downruns for projectreduit - THEN
reduit/agentandreduit/reviewerare stopped and removed from the daemon's registry, and a subsequentharness psfor that project lists nothing
Scenario: Down ends persistence too
- WHEN
harness downruns for projectreduitand the daemon is then restarted - THEN
reduitis NOT re-registered: the persisted registration was deleted bydown, not just the live one
Scenario: Global config untouched
- WHEN
harness upthenharness downare run for a project - THEN
$XDG_CONFIG_HOME/harness/harness.tomlis byte-identical to its contents beforeup
Requirement: Project-Scoped Verbs
harness ps, harness logs [name], harness start [name], harness stop [name], and harness restart [name] SHALL, when run inside a project, operate
on that project's harnesses via the existing SPEC-0002 control operations
filtered to the project namespace. ps SHALL list only the project's harnesses
and their states. start, stop, and restart SHALL be non-destructive:
deregistration SHALL remain exclusive to down and rm. A bare name argument
to these verbs SHALL refer to the project-local name and be resolved to
<project>/<name>.
Scenario: ps is project-scoped
- WHEN
harness psruns in projectreduitwhile other global harnesses and other projects are also registered - THEN the output lists only
reduit/*harnesses
Scenario: stop does not deregister
- WHEN
harness stop agentruns in projectreduit - THEN
reduit/agenttransitions tostoppedbut remains registered, soharness start agentcan bring it back without re-runningup
Scenario: Local name resolution
- WHEN
harness restart agentruns in projectreduit - THEN the daemon restarts
reduit/agent
Requirement: Registration Persistence
A project registration SHALL be durable daemon state, not ad-hoc: the daemon
SHALL persist each registered project's definitions and its harnesses' runtime
intent to its state file, and on daemon start SHALL re-register every persisted
project and restore its harnesses' running set exactly as it does for
global-config harnesses (📝 ADR-0007). A registration SHALL survive any number of
daemon restarts and SHALL be ended only by an explicit tear-down (down for the
whole project, rm for one member). The global config file SHALL remain
byte-for-byte unchanged by persistence — state.json holds the registration, not
the TOML.
Scenario: Registration survives a daemon restart
- WHEN
harness upregistersreduit, the daemon is stopped and started again, and nodownwas issued - THEN
reduit/agentis registered and running again, with its restart counters and last-exit history intact
Scenario: Stopped member stays stopped across a restart
- WHEN
reduit/agentwas stopped (but not removed) when the daemon restarted - THEN it is registered but NOT started — the persisted intent is restored, Compose-style, not blanket-autostarted
Requirement: Remove (harness rm)
harness rm NAME SHALL send a remove control request (SPEC-0002) for one
registered harness: the daemon SHALL stop it, deregister it, release its attach
and log resources, and delete its persisted registration — the single-member
counterpart to down. Removing the last member of a project SHALL drop the
now-empty project record. rm SHALL refuse a global-config harness with a
structured not_removable error (those are authored in harness.toml and leave
via edit + reload) and SHALL refuse an unknown name with the same code. A bare
NAME inside a project SHALL resolve to <project>/NAME like every other
name-taking verb.
Scenario: Remove one member
- WHEN
harness rm agentruns in projectreduitwithagentandreviewerregistered - THEN
reduit/agentis stopped and deregistered whilereduit/reviewerkeeps running
Scenario: Remove the last member drops the project
- WHEN
harness rmremoves the only remaining member ofreduit - THEN the
reduitproject record is gone and a laterharness down reduitfails with unknown_project
Scenario: Global harness refused
- WHEN
harness rm sleepernames a harness from the global config - THEN the daemon replies with a structured
not_removableerror and changes no state
Requirement: Project Control Operations
The daemon control plane (SPEC-0002) SHALL add three operations: project_up \{ name, harnesses \}, which registers and starts a project's harnesses under the
project namespace; project_down { name }, which stops and deregisters them;
and remove { name }, which stops and deregisters ONE registered harness (the
wire form of harness rm). All SHALL return structured ERROR frames on
failure (unknown project for down, name collision or invalid definition for
up, not_removable for remove on a global or unknown harness) with a machine
code and a human message the CLI can surface verbatim. project_up SHALL be
idempotent in the reconcile sense defined by the Bring Up requirement.
Scenario: project_up round-trip
- WHEN a client sends
project_up { name: "reduit", harnesses: [...] } - THEN the daemon registers each harness as
reduit/<harness>, starts them, and replies success
Scenario: project_down on unknown project
- WHEN a client sends
project_down { name: "nope" }for a project the daemon has no record of - THEN the daemon replies with a structured
ERRORand changes no state
Requirement: Error Handling Standards
All error-producing operations (file discovery, TOML parsing, project registration, and control-plane exchange) MUST follow structured error handling:
- Errors MUST be wrapped with contextual information at each layer boundary (e.g., "harness up: parse ./harness.toml: unknown table [server] at line 12").
- Sentinel errors MUST be defined for domain-specific failure modes callers need
to distinguish programmatically (no project file found, project-name collision,
unknown project on
down, forbidden[server]/[profile.*]table). - Silent error swallowing MUST NOT occur — every error MUST be returned to the caller, logged with sufficient context, or explicitly handled with a documented reason.
- Structured logging MUST be used for error reporting (key-value pairs, not string interpolation).
- TOML validation errors SHALL carry a source line so the CLI/TUI can point the user at the offending table, consistent with the 📝 ADR-0006 reload path.
Scenario: Parse error names the table and line
- WHEN a project file contains a forbidden
[profile.default]table on line 12 - THEN the surfaced error identifies both the table and the source line, and no partial registration occurs
Scenario: No silent swallow on registration failure
- WHEN
project_upfails partway (e.g. a name collision on the third harness) - THEN the daemon reports a structured error and does not leave a partially registered project behind
Related Artifacts
Direct relationships declared in YAML frontmatter (per ADR-0023 / SPEC-0018). Run /sdd:graph chain SPEC-0004 for the transitive view.