English | 中文
This document describes the product mainline: one worktree's manifest, run records and runtime facts, start/stop and logs, the read-only event stream that parallel development needs, and the work record left for the next person or agent to take over. For every other command, oberth --help is authoritative; this document does not build a second contract here.
Running oberth with no arguments shows the first screen: the brand mark and wordmark, a one-line
positioning statement, the local version, the three most-used commands (status / up / down) and a
pointer to the full help; it is a signpost, not a manual — the grouped command list appears only in
oberth --help. The version line carries (latest) or (latest version vX.Y.Z) when the release
version check succeeds; when the check is unknown it prints only the local version and does not write
"not found" as "latest". When an update is found, the bottom of the first screen gives the install
command for the corresponding platform and oberth daemon stop. When no improvement-report decision
is recorded yet, the screen may also ask whether to send redacted reports about oberth itself; that
question and what a report carries are specified under oberth feedback in Infrastructure commands.
The mark is generated by
scripts/brand_export.py from the client geometry source into engine/internal/cmd/mark_gen.go,
never written by hand; the wordmark is an outline type rendered as bold text in the terminal.
The mark uses two colors per the brand's own division of labor: the two rails take ink (paper on dark
terminals) and the capsule takes terracotta. Painted in a single color, the capsule and the two rail
caps merge into three identical blocks that look like a face, so this division is part of the
semantics, not a color preference. Under truecolor it uses #23262B / #F8F6F2 and #A8543E /
#DB9378, taking the latter pair when COLORFGBG reports a dark background; 256-color terminals take
the nearest slots (235 / 255 / 131 / 174); on 8-color terminals the rails keep the terminal's own
foreground color and the capsule degrades to yellow. Nothing is colored under NO_COLOR.
-
Commands work on the current directory (or an explicit path / project name). With no manifest, run oberth init first.
-
Default output is for humans; with plain
--json, stdout holds exactly one JSON value. Explicit streaming commands emit one value per line, with errors written to stderr. -
Following logs and other streaming commands use NDJSON: one JSON value per line.
-
Exit codes: 0 success, 1 run failure, 2 argument or option error, 130 interrupted by Ctrl-C.
-
The JSON error shape is {"error":{"code":"...","message":"...","hint":"..."}}. The stable codes are invalid_params, not_found, invalid_config, ambiguous, permission_denied, daemon_busy, observation_empty, observation_unrecognized, observation_incomplete, observation_blind, observation_bind_denied and internal. Each one is either a refusal, a condition or a fault: a refusal means the request itself is wrong and repeating it cannot help, a condition means the world was not ready and a retry may succeed, and a fault means the engine is broken. A readiness timeout is not among them:
up --waitreports it as that service's state, so it travels as a runtime fact rather than as a transport error. None of the observation codes mean "nothing is listening":- observation_empty: the collection command exited 0 but produced no output at all; a self-proof probe decides whether this is a genuinely empty table or blindness.
- observation_unrecognized: there was output but no recognizable table header — a format problem.
- observation_incomplete: some output lines could not be decoded. Readable lines are published as usual, the ports on bad lines stay unknown, and
observation.stateis partial; if the command as a whole fails, it is still treated as a failure. - observation_blind: the probe can bind a local port, but the same collector cannot see its own listener.
- observation_bind_denied: the probe cannot even bind a local port, so services started from here cannot listen either.
The hint points at retrying outside the sandbox, upgrading option-berth, or running
oberth doctor --only listener_observationto check the current process.
-
Starting with macOS 27, a child process launched by a parent that Apple has not signed (Go, Homebrew Python and so on) runs
netstatand gets exit 0 with no TCP table. That "not one row" case is still treated as observation_empty and the self-proof probe decides it; when the probe can see its own listener, this round falls back to collecting withlsofand still reports observable.observation.sourcenames the collector when it was not the primary one (lsof); the primary collector leaves it empty. Thedoctordetail and thedaemon statusobservationdescribe the same round. -
This fallback is narrower than
netstat, and reading the result requires knowing two things: a non-rootlsofcannot see other users' sockets, so a port held by a root process may be missing from the table; and a singlelsofreports a dual-stack wildcard socket only as::, without adding the other address family. A row in the table is a fact, but absence is not: consumers readobservation.sourcebefore concluding that a port is free.machine[].listening_knownis false for a port missing from a narrow table,port: autoverifies each candidate by binding it instead of trusting absence, and the humanstatusprintslistening unknown. -
--no-daemon makes read commands collect locally and directly; commands that must record runs and start/stop still need the daemon. When the daemon is unavailable,
statusstill shows the most recent exit reason from local read-only history. -
No wire field is accepted without being read. The protocol schema is generated from the daemon's Go types, so a field declared there is a promise every client may rely on; a field no handler acts on is worse than a missing one, because the call succeeds and the caller cannot tell it was ignored. Every field a method's params can carry is therefore read by this repository's own code, and
internal/daemon/rpcfails the build otherwise. Withdrawing a published field is a protocol change; the names removed on purpose are recorded in that package so they cannot return silently.
The CLI is layered by what an agent wants to do, not by internal modules: facts are "what state is it in now", operations are "make it run or stop", code and declaration are "read the code and the manifest", handoff is "leave or retrieve work, hand the session to an external agent", and infrastructure is the runtime environment itself. The root help groups by these five layers; a new product capability may only land in an existing layer, and may no longer stuff one action into another command's options.
| Layer | Public commands | Boundary |
|---|---|---|
| Facts | status · logs · events · worktrees |
The current project's declarations, runtime facts and changes; checkout identities this machine has seen |
| Operations | up · restart · down · start · kill |
Start and stop by manifest, or explicitly name a low-level process entry point |
| Code and declaration | git · init |
Read-only code context, and this worktree's manifest |
| Handoff | workplace · agent · attn |
Work records, handing the session to an external coding agent, and the optional model assessment (off by default) |
| Infrastructure | daemon · serve · config · version · completion · doctor · feedback |
Background collector, install configuration, self-check and optional improvement reports |
oberth --help ends with a For agents section that gives a reading pattern, not commands: the
three reads a session opens with (status --json --brief, up --wait --json,
logs <service> --once --json --run current --first-error). It adds no command and does not change
the layer table above; the --help of status, up and logs carries the reasoning behind the
same reads.
Reads a worktree's complete current state: the services the manifest declares, run records, listener facts, the most recent exit reason and the git summary. When path is omitted the current directory is used. The command only leaves a "last look" marker in its own state file, and a second run lists the factual changes in changed; --no-mark reads without moving the marker.
--only api,worker keeps only those services and the transitive closure of their depends_on;
service/port changes in ports[], exits[] and changed narrow along with it, while machine: and
the git summary stay unchanged. Names must already be declared: an unknown name returns not_found
and lists the available services, and an empty name is treated as an argument error. Filtering
happens after the full snapshot and the "last look" marker are written, so it never writes a partial
state as the baseline for the next comparison. Code comparison also runs only for the selected
services, and sources outside the scope are not re-read.
--brief keeps only runtime facts: the state of services and dependencies, declared/actual ports,
health results, the current exit reason and next step, code changes (code_changed_since_start,
code_changed_files, code_hint), and listening ports plus observation. It does not output git,
drafts, changed, the attention handoff, or the per-service manifest fields; use the full status
when you need those.
A service's last_exit.reason distinguishes an ordinary crash (crashed) from a fixed port taken by
another process (port_occupied), and also marks services that failed before the command ever started
(start_failed), so "exited after starting", "port conflict" and "failed before starting" do not
collapse into one state.
Under port_occupied, last_exit.port_hint is the port this run was told to bind, and
last_exit.port_holder describes the process still holding that port at read time: port, pid,
command, kind, with group, name, run_id additionally for a managed run and cwd and
bind_address when readable. kind is oberth_run (a run started by option-berth, possibly from
another worktree), previous_run (an earlier generation of the same service), worktree_listener
(belonging to some worktree but with no managed run), or unknown (no worktree ownership). It checks
only the failing service's own port, not a machine-wide port table; when the holder has already
released it or is not currently observable the fields are absent, and absent means "not seen at read
time", not "the port is free".
Human-readable output is ordered by project, service, dependencies, code and failure reason, with
labels as fixed-width English columns (services · depends on · listeners · code · failed · next ·
drafts · last look) aligned by terminal display width. Each service gets its own line showing both the
declared port and the current runtime facts; the service state word is colored (running green, failed
red, ordinary stop dim), degrading to plain text under NO_COLOR. A portless worker is explicitly
marked no port, and port: auto that has not yet been assigned shows auto port unassigned. Only
listeners the manifest did not declare are listed separately. In the failure section each service
shows only its most recent exit record, and only while that record is still the service's current
last_exit: once the service has re-run, or the newest record is a normal exit or a down stop, the
service leaves the failure section and the old record stays in the exits[] history. Different
services each show one line. A port-conflict failure line writes the holder directly (port, pid,
command and ownership kind), so there is no need to run lsof/ps again. The
oberth logs <service> --once next step is given only when the failure record really left a log
file; that step also appears as hint in the JSON exits[], so a human and an agent read the same
guidance. Even if a forced stop left a non-zero signal exit code, services that exited normally or
were stopped do not enter the failure section.
worktree.services[] also carries health_status (status, code, reason, observed_at).
health is the declared canonical form: an HTTP path, or tcp:<port>, or command:<command line>;
prepare_inputs is the list of files prepare reads, an empty array when not declared. While a start
is in progress, worktree.services[].starting gives the instantaneous facts of that start attempt:
phase (waiting_dependency, prepare or spawning), started_at, the effective ready_timeout,
the elapsed_ms computed at read time, the waiting_on of this wait, and last_prepare_ms from the
previous prepare (null when unknown). It is not a run: it does not enter the run records, is not
persisted, does not count as running, does not affect worktree.status, and disappears when the
daemon restarts; it is readable during dependency waiting, prepare and spawn, and disappears once the
run is registered. --brief --json carries the same starting (same fields, copied at read time, not
sharing mutable data with the full document), so brief readers can also tell "starting" from "never
started". Human-readable output shows the service as
starting · <phase> · for <elapsed> · budget <ready_timeout> · waiting on <dependencies…>: the
duration is elapsed_ms rounded to seconds (under 1 second shows for <1s, never for 0s), budget
uses the published value directly, and the last segment is appended only when waiting_on is
non-empty.
Running services carry stats (cpu_percent, memory_rss_bytes, thread_count, uptime, state,
connections). A service with a listener samples from the listener row; a portless worker samples
from its running root process, so a worker no longer lacks resource data just because it holds no
port. When there is no sample the fields are absent, meaning unknown, not 0.
worktree.running_services is the number of declared services that are running; worktree.resources
is the worktree aggregate of those service samples (cpu_percent, memory_rss_bytes, services
being the number of contributing samples). The aggregate appears only when at least one service has a
sample and makes the difference between services and running_services explicit; with no samples it
is absent and never uses 0 to impersonate a measurement.
Running services also carry manifest_hash and runtime_spec_hash; when the two differ,
manifest_runtime_mismatch is true, meaning the current process comes from an old manifest and
should be re-applied with oberth restart --only <service>. The hash covers only executable
declarations such as command, cwd, ports, health and dependencies; it does not include environment
variable contents, and does not include sources: — changing the scope of the check itself does not
require restarting the process.
If a running service captured its code identity at start, status recomputes the content fingerprint
along the paths declared by sources: and publishes code_changed_since_start: false means it was
checked and did not change; for true, code_changed_files is given when files can be compared
exactly, along with a directly executable code_hint (oberth restart --only <service>). code_head
is the checkout's commit at start, absent when there is no commit or the checkout is not inside Git.
These fields being absent means unknown — a run started by an older version, a deleted snapshot,
or source that cannot be read right now — not "unchanged". When the per-file snapshot exceeds the
storage limit, it can still answer whether something changed, but the file count is absent. A client
that already holds the group rows reads the same comparison through the daemon's groups.code
method: the field names and the null-means-unknown rule are the same, and a service absent from the
reply is unknown, not unchanged.
When the listener table cannot be trusted (for example an agent sandbox refuses system introspection),
status still returns the manifest, services, run records, log paths and recent exits; the top level
carries observation (state, code, detail, hint, checked_at). When fully blind, ports is
empty and port_actual is null; when some lines are unreadable, ports contains only the readable
lines, ports that were not read are not in it, and observation.state is partial. up --wait returns
an observation-class error immediately when the observation is not observable and keeps the run it
just started, instead of waiting out the full timeout and then stopping it; logs <service> still
uses the log path registered by the run for managed services and does not depend on the listener
table. Write commands that need to auto-start the daemon run the self-proof probe first, and when
blind they refuse to create a daemon from the current process, suggesting that
oberth serve --detach be run in an ordinary terminal.
The JSON top level always contains:
{
"scope": {"kind": "worktree", "name": "demo", "root": "/work/demo"},
"worktree": {"name": "demo", "identity": "wt_0123456789ab", "services": []},
"ports": [],
"git": {"snapshot": {}},
"drafts": [],
"exits": [],
"changed": {"at": "..."}
}The full document's git.files holds at most 100 entries; files_total is the total before
truncation, and files_truncated being true means the list is only a prefix. Use
oberth git files --json for the complete file list; it does not truncate.
--brief --json is a separate, brief document:
{
"scope": {"kind": "worktree", "name": "demo", "root": "/work/demo"},
"worktree": {"name": "demo", "identity": "wt_0123456789ab", "branch": "main",
"running_services": 1,
"resources": {"cpu_percent": 1.2, "memory_rss_bytes": 104857600, "services": 1},
"services": [
{"name": "api", "running": true, "port": 3000, "port_actual": 3001,
"health": {"status": "ok", "code": 200},
"code_changed_since_start": true, "code_changed_files": 2,
"code_hint": "oberth restart --only api",
"stats": {"cpu_percent": 1.2, "memory_rss_bytes": 104857600}},
{"name": "worker", "running": false, "exit_reason": "crashed", "exit_code": 1,
"hint": "oberth logs worker --once"},
{"name": "cache", "running": false,
"starting": {"phase": "waiting_dependency", "started_at": "2026-10-31T10:00:00Z",
"elapsed_ms": 130000, "ready_timeout": "3m0s",
"waiting_on": ["db"], "last_prepare_ms": null}}
]},
"ports": [3000, 3001],
"machine": [{"name": "redis", "port": 6379, "listening": true, "listening_known": true}],
"exits": [{"run_id": "...", "name": "worker", "reason": "crashed", "code": 1,
"exited_at": "2026-10-31T09:00:00Z", "hint": "oberth logs worker --once"}]
}worktree.identity is exactly the value ${worktree} expands to in the manifest. It is derived from
the checkout's canonicalized root path, so the same checkout reads the same value every time; when a
human or an agent wants to connect to this worktree's database with psql, they read this field
directly to compose the database name instead of computing a hash themselves.
changed is an optional field that is absent on the first run. ports is still the raw listener fact for
scripts to verify against, not an independent product object and not a port list for the whole
machine. exits is the newest-to-oldest history of the last 5 finished runs, not the current failure
set; current facts are authoritative in worktree.services[].last_exit. exits[].ports describes
ports an exited run still holds, which is why exited runs keep an empty array; the failing port is in
port_hint and the holder in port_holder. Each failure record may carry hint (synonymous with
groups.start's per-service hint), the deterministic next step the CLI fills in from whether a log
file exists. Use logs for detailed output. worktree.services[]'s pid, run_id and started_at
are provided while the service is being run by option-berth, so even a portless worker can be
verified against the actual run record through these fields; log_path is always given by the engine.
A definite abnormal event in the state puts a fact-only handoff in the JSON, whether or not the
optional assessment is enabled. attn_path points at a recomputable, expirable agent handoff file
under BERTH_HOME/attention/; with no current abnormality the field is absent. Its presence means
"there is an anomaly", not "the person opted in" — publishing a local fact sends nothing anywhere,
so consent cannot decide whether it exists. The adjacent attn block says whether that handoff can
be assessed now: enabled is the consent bit, reason is the fixed code when nothing can run, and
assessment is assessed (the file already carries a validated result), pending (an enabled,
complete configuration could assess it), or unavailable. An agent may use the fact-only handoff
without a configured model; it must check attn.enabled before running oberth attn assess.
When there is an abnormality, attn_revision is provided as well and must equal the file's
state_revision, so the agent can check inside the same status response whether the file still
corresponds to current facts. An
undeclared listener is not an abnormality: status already lists the port with its owner, and a
process the agent did not start is not one it should stop. The core file is
oberth.attn/v1, containing event_id, state_revision, worktree_root, optional branch,
generated_at, fresh_until, first_seen, last_seen, optional recovered_at, event, an
optional full events array, evidence and fixed options, and containing no shell commands or
model invocation results; event is kept as a compatibility field for the first abnormality, and when
several are abnormal at once events is authoritative; an optional attention adapter may add
assessment to the file, but the agent must still re-read status --json first and verify the
worktree, attn_revision against state_revision, and fresh_until, before offering the user a
choice. assessment.context_id (if present) is only the adapter's hash cache key over the current task
and the authorized-option allow-list; it is not a runtime fact and grants no execution permission.
This file is a derived notification, not a new source of runtime truth. The model client is not on the daemon's or
status's control path: the deterministic part lives in internal/attn, the model client in
internal/attn/model, and internal/cmd/attention_boundary_test.go checks that dependency boundary.
The optional assessment runs as oberth attn in the Handoff layer. An agent should complete status
reading, abnormality verification, assessment and HIL output in a single invocation:
# for short tasks pass arguments directly, no need to write a file
oberth attn assess --project "$PWD" \
--task "keep the API healthy while the integration test runs" \
--authorize inspect_logs,inspect_port,restart_service,continue_without_action,ask_user_for_contextoberth attn config is the only verb that writes credentials: it takes an endpoint
and model (directly, or through a preset that fills both), reads the key from
--key-stdin, --key-env NAME, hidden terminal input, or keeps the stored key,
sends one synthetic redacted probe, and only writes attn.json when the probe
succeeds — a failed probe leaves the existing configuration untouched. config
changes credentials, not consent. oberth attn on and oberth attn off [--forget]
flip the single enabled boolean and never touch the network. oberth attn test
probes the current effective configuration once. oberth attn status reports the
resolved state, the fixed reason when it cannot be used, and a summary of the
local quality ledger; --json carries the same data plus the full quality
object. oberth doctor gains one offline row for the same state, and a human
status read mentions the feature once every two weeks when it is off and an
anomaly is present; BERTH_NO_TIPS=1 silences that hint.
oberth attn assess is the one-shot runner. --dry-run prints the exact redacted
request for the current event without sending it, and --follow keeps the same
loop alive for a task, emitting NDJSON (subscribed, only changed handoffs, and
stopped on cancellation). oberth attn feedback labels what a person actually
decided, and oberth attn preflight derives a bounded action request from a fresh
status snapshot. There is one oberth binary; none of these is a separate
executable.
The context file contains task and the required authorized_options, and is written by the agent
itself: nothing on the machine generates it for you, and the path is chosen by the agent too
(parallel tasks each use their own file; do not share one /tmp path). --task and --authorize
override the same-named fields in the file; when neither is given the runner errors out directly and
will not guess an authorization set on the agent's behalf. The adapter sends only a redacted fact
summary to the selected provider and does not read logs or start and stop services. The runner
outputs clear, continue, human_required, unavailable or stale; on success it atomically
writes the validated assessment back to the same file and re-reads the state before returning. When
there is no usable environment endpoint, model or key, or on network failure,
invalid response, expired file or changed state revision, the fact file is left unchanged. The
~/.option-berth/attn.json written by oberth attn config ($BERTH_HOME/attn.json for isolated
runs) is read automatically by oberth attn and is the only persistent consent: enabled: true
turns the model on, enabled: false keeps it off even when a key is present. An explicit
BERTH_ATTN_ENDPOINT / BERTH_ATTN_MODEL / BERTH_ATTN_KEY supplies those fields but does not
enable the model; BERTH_ATTN=1 enables it for one process. Each unavailable answer carries one fixed
reason (no_key, disabled, invalid_config, unauthorized, rate_limited, timeout, network,
provider_error, bad_response, stale) rather than a provider's error text. assess and
assess --follow must be given authorized_options explicitly; the artifact's options is only a
candidate set and does not automatically become the agent's authorization. Full arguments and the
privacy boundary are in
attn-adapter.md.
oberth attn assess / assess --follow leave redacted local quality observations in
$BERTH_HOME/quality/events.ndjson. After a real interaction, a development agent can write back
human labels and action outcomes with oberth attn feedback --file <json>, then use
oberth attn status --json [--since 168h] to view label coverage, assessment availability,
routing reasons and the model/option distribution, human-escalation precision/recall/F1, the model's
needs_human / task_relevant probability error, recommendation hit rate, action success rate,
resolution rate and detection latency under quality. Quality records are not status facts and
change no execution permission; the sessions object explains the number of sessions, polls, events,
the model calls, cache hits and no-event polls; with no real feedback the metrics stay null.
The fields below are the minimal set an agent needs to make decisions from the --json output of
status, up and restart. The full meaning of each field is in its command's description; this
table is also the anchor source for scripts/check_skill_contract.py, so update the skill when adding
or renaming one.
| Field | Command | Purpose |
|---|---|---|
last_exit.reason |
status |
The current failure reason; branch on it to logs, dependencies or port occupation. |
last_exit.port_hint |
status |
The port this start was told to bind. |
last_exit.port_holder |
status |
The process still holding the failing port at read time; absent means not observed, not that the port is free. |
exits[].hint |
status |
The next step in a historical exit record, decided by whether a log file exists. |
services[].ready |
up / restart |
The CLI's readiness annotation; false means unconfirmed, not failed. |
services[].port_holder |
up |
Holder evidence, same shape as in status, when the port conflicts. |
health_status |
status |
The result and reason of the declared health check. |
manifest_runtime_mismatch |
status |
Whether the running instance comes from an old manifest. |
worktree.identity |
status |
The expanded value of ${worktree}, used to name per-worktree data. |
observation.state |
status |
Whether the listener table can be trusted; read it before ports. |
observation.source |
status |
The collector that answered when it was not the platform's primary one; non-empty means a missing port is unknown, not free. |
machine[].listening_known |
status |
Whether listening was observed; false means the table cannot see that port's owner. |
attn_path |
status |
The derived handoff file for the current abnormality; absent when there is none. |
attn |
status |
Whether that handoff can be assessed now: enabled, fixed reason, assessment (assessed, pending or unavailable). |
services[].starting |
status |
Instantaneous facts of an in-progress start; not a run and not the same as running. |
starting.phase |
status |
waiting_dependency, prepare or spawning. |
starting.waiting_on |
status |
Dependencies still being waited on; keeps the last known set when observation fails. |
starting.elapsed_ms |
status |
Milliseconds elapsed in this start, computed at read time. |
starting.ready_timeout |
status |
The readiness budget in effect for this start. |
starting.last_prepare_ms |
status |
Duration of the previous prepare; null when unknown. |
services[].stats.* |
status |
Service resource samples; an absent field means unknown, not 0. |
worktree.resources.* |
status |
The worktree aggregate of available samples; absent when there are none. |
manifest_hash |
status |
The manifest fingerprint at process start. |
runtime_spec_hash |
status |
Fingerprint of the current runtime parameters; differing from manifest_hash means it comes from an old manifest. |
code_changed_since_start |
status |
Whether the service is running the current source; absent means unknown. |
code_changed_files |
status |
The number of changed files that can be compared exactly. |
code_hint |
status |
The deterministic next step once the code has changed. |
code_head |
status |
The commit at start; absent when not a Git checkout or when there is no commit. |
services[].exit_reason |
status --brief |
The failure reason in the brief view. |
services[].exit_code |
status --brief |
The exit code in the brief view. |
[--allow-outside-home] [--json]
Starts services in the order of oberth.yaml's depends_on. When --only names services, their
transitive depends_on closure is started along with them by default (for example --only web with
web → api → db starts db, api, web in that order); explicitly named services keep their named
identity, and services pulled in as dependencies are marked in the output with the named service that
pulled them in. --no-deps starts only the named services themselves and does not expand the
dependency closure; without --only it changes nothing (everything is started anyway). In
human-readable output the pulled-in lines carry a dim note (such as started (dependency of web),
skipped · required by web), and the summary adds a separate line
included dependencies: api, db when it is non-empty. In --json they remain ordinary services[]
entries carrying included_by (the sorted named root services); the summary carries included
(sorted). Capability negotiation: an old daemon does not know include_dependencies, and the new CLI
sends it only when --only names at least one service, in which case an old daemon produces a direct
error suggesting a daemon restart or --no-deps; without --only (or with --no-deps) it is not
sent, so ordinary up is unaffected. When a service has prepare:, that prerequisite command is run
directly in the same cwd first, and only on success is the long-running cmd: started; the
prerequisite's output goes into that service's log, and on failure the service is not started. When a
service has env_file:, both prepare and the long-running command read the variables from the file
first (see "Shared environment files"). prepare_inputs: declares the input files that command reads
(relative to the service cwd, for example [package.json, package-lock.json]); when the previous
prepare succeeded and those files' contents are unchanged, this up skips prepare outright and does
not reinstall dependencies. The fingerprint is recorded only after the previous success, and a
missing or unreadable input always forces a rerun; the cache lives in $BERTH_HOME/prepare, and
deleting it only makes the next start slower. sources: declares the files or directories used to
decide "is the process running the current code" (relative to the service cwd, that directory by
default); at start the content fingerprints of those paths and the HEAD at that time are recorded, and
status recomputes them and publishes code_changed_since_start. In a Git checkout only tracked
files and untracked files that are not ignored are included; node_modules, target, dist and the
like do not enter the fingerprint — even when they are explicitly written into sources: — because
they are usually artifacts produced from the source; non-Git directories only apply a fixed ignore
list. The sources: paths themselves must not pass through symbolic links (declare the link target
instead); deeper links inside a directory are recorded as the link itself and not followed to the
target. Multiple services with the same default source tree (for example all defaulting to the same
repository root) report changes together; to distinguish them, write the sources: each one actually
reads. Explicitly writing sources: [] turns this check off, which suits services that hot-reload
their own code. Dependencies with a port wait for a listener and portless workers wait for their run
record to become active; services already running are skipped; each service has its own log file and
run record. With no arguments the nearest manifest from the current directory is used, and a project
name may be given explicitly. ready_timeout: declares a service's readiness budget (a Go duration
string such as 90s, default 30s, must be positive): successors in depends_on use it when waiting
for that service, and up --wait uses it too. prepare: does not count against this budget. When the
manifest declares ready_timeout:, the CLI requires the daemon to announce the
groups.start.ready_timeout capability; an old daemon gets a suggestion to run
oberth daemon restart instead of silently waiting 30s. When starting by project name
(oberth up <project>, oberth restart <project>) the CLI has no manifest in hand, so this check
instead finds the project's manifest path through the daemon's state.snapshot and parses it locally,
then applies the same capability rules; when the manifest cannot be found or read, the start is not
blocked and the daemon reports its own error.
health: has three forms: an HTTP path (/healthz, the default form), a TCP connection
(health: {tcp: 5432}, whose port must match the service's declared port) and a command
(health: {command: pg_isready -h 127.0.0.1 -p ${port}}). The command runs in the service cwd, is
split into argv by the cmd: rules (not through a shell), supports ${port}, ${url},
${<service>.port} and ${worktree} references, and exit code 0 counts as healthy. HTTP and TCP only
say the port is reachable; services such as databases, whose port opens before the protocol is ready,
use a command check. Health results and dependency waiting use the same fact.
On success, human-readable output reports per service and then gives a summary; during --wait a
single dim hint line is printed first and results follow line by line. --json outputs one value
containing services and the summary fields. Each services[] has a structured state: started,
skipped or failed, and on failure also a machine-branchable reason and a directly executable
hint, with the original error kept for humans. hint only points at evidence that really exists:
oberth logs <service> --once only if the process actually came up, the dependency's logs when a
dependency was not ready, the rejection's own remedy when the process was refused before starting (for
example the worktree is outside home), and nothing when there is no executable next step. When the
failure reason is port_occupied, services[] also carries a port_holder with the same shape as in
status (port, pid, command, kind, plus group/name/run_id when managed), so there is no
need to look up the process table yourself. services[]'s ready is the CLI's annotation, not a
daemon fact: true after --wait confirms readiness; when not waiting by default, started services
carry false and the human-readable output shows ~ and started, readiness not confirmed;
skipped/failed do not carry the field. false is not a failure, and state still describes the
result of the start action. Wait determination goes by run identity first: this start chunk's
run_id, the running run a skipped chunk points at, or the same run observed for the first time. Only
old records with no run_id at all fall back to exit time, with the wait start aligned to the second,
so an exit recorded within the same second still counts as this round. Old exit records from other
runs are not read by up --wait as this round's start failure.
--wait (an alias of --ready) waits, before returning, for the services selected this time
(including services already running and not restarted) to actually listen; services that declared
health: must also pass the corresponding health check (a command check does not require the service
itself to listen). A portless worker is ready when its run record is active. There is no waiting by
default; without --wait-timeout each service waits by its own ready_timeout: (default 30s): a
service newly started this time counts from that run's start time in the state row, while an already
running skipped service, or one whose start time cannot be obtained, counts from the start of the
wait. When --wait-timeout is given explicitly it is the overall ceiling for the whole wait and only
tightens, never loosens, each service's ready_timeout:: each service's deadline is whichever of the
two comes first; it must be positive and must enable --wait or --ready at the same time, otherwise
it exits 2 without running the start; a service that times out has state failed with reason
ready_timeout, the message says whether the timeout came from that service's readiness budget or
from the caller's --wait-timeout ceiling and names the one that raises it (ready_timeout: for the
budget, --wait-timeout for the ceiling), and the command exits with code 1. Only runs newly started
this time that time out enter stop cleanup; existing runs are not stopped or restarted because this
wait failed. The cleanup names each run it started by run_id: a run another controller started for
the same service while the wait was still running is a different run and is left alone. A daemon that
does not announce groups.kill.expect_runs cannot scope a stop to named runs, so it is not sent a
name-only cleanup: the command fails and says the timed-out runs are still running. Readiness is
attributed by the same identity: when the service row's live run is not the run this wait is about, the
wait ends immediately with state failed and reason run_replaced, because the replacement's listener
and health are its own evidence, not the original run's; nothing is stopped then, since the replacement
is not this invocation's run. A row that names no run cannot confirm a named one: a listener not
attributed to the run keeps the wait going instead of counting as its readiness. A chunk with no
run_id at all carries no identity to compare, so the service row is read as before. The started /
skipped summary describes the start action, and readiness failures appear separately in errors
and in each service's state. For runs newly started this time that
were cleaned up, the engine writes the same ready_timeout into the service's last exit facts, so
status, the desktop service row, attention and the event stream do not show it as an ordinary manual
stop. Health facts carry status, code, reason and observed_at; when a dependency declared
health:, successors wait for that dependency's health status to be ok. The dependency wait budget
is the maximum of the depended-upon services' own ready_timeout: values in depends_on (undeclared
dependencies count as 30s), not the waiter's own; when the dependency wait itself fails, the service's
last exit reason is dependency_timeout or dependency_not_ready respectively, and like a start
failure it is kept in the status, attention and state changes. If a dependency was already attempted
by this groups.start and has already exited during the wait, the successor no longer waits out the
budget: it fails immediately with dependency_not_ready, the message carries the dependency's exit
code and reason, and hint points at the dependency's logs. When the dependency is still alive but
not yet listening, it still waits out the budget as dependency_timeout. Any service start failure
returns exit code 1, and the failed service keeps its exit reason for status to show. By default
services may only be started inside the user's home directory; only explicitly using
--allow-outside-home lifts this restriction.
When the daemon announces the groups.start.phases capability, up sends progress: true in the
request and receives progress chunks with state: "starting" as services enter the
waiting_dependency, prepare and spawning phases: phase, started_at, ready_timeout,
elapsed_ms, waiting_on, last_prepare_ms. waiting_on shrinks as dependencies are polled: each
time a declared dependency is observed ready it disappears from the set, and the set is empty when all
are ready; when observation itself fails the last known set is kept, so "cannot read" is not shown as
"not waiting". These chunks are diagnostics, not results: they do not enter services[], do not take
part in readiness waiting, and do not affect the summary. Human-readable mode prints them as dim
progress lines; with --json the progress lines go to stderr only, and stdout still holds exactly one
final JSON value with no diagnostics mixed in. An old daemon does not know progress, and an old CLI
does not send it, so behavior is as before.
For example, a partial success is still one JSON value:
{"services":[
{"service":"api","state":"started","ready":false,"pid":1234,"port":3000},
{"service":"worker","state":"failed","reason":"start_failed",
"error":"exec: \"nosuchcmd\": executable file not found in $PATH",
"hint":"check the command and its working directory"}
],"started":["api"],"skipped":[],"errors":["worker"]}Stops the project's services first and then starts them again from the manifest. Human-readable output
reports the stop result first, then an empty line, then the start result. --only restarts only the
named services while dependencies and other services keep running; this kind of restart preserves the
worktree's port: auto reservation so the service gets its original address back. --force skips the
graceful stop. --json returns one value in which stopped is the stop result and services plus the
summary fields are the restart result. restart does not wait for readiness, so started services
carry ready:false, and the human-readable output likewise marks readiness as unconfirmed.
Stops all services in the project's manifest and, once the action confirms they can be released,
returns the port: auto reservations. With no arguments the current directory's manifest is used.
--force uses a forced signal directly and does not skip the run-identity or reservation-release
gates. On success --json returns the stop result and the number actually released. A service or run
that could not be stopped is not success: --json still returns the envelope — ok false, with the
failed rows carrying the reason — and the command exits 1, so a caller that reads only the status
does not go on as if the project were down. machine:
dependencies declared in the project manifest are read-only as to their listener state; down does
not stop them.
--worktree PATH stops by checkout root path, --identity wt_… by ${worktree} identity. Neither can
be combined with a project name or a manifest path. They are the explicit entry point after a worktree
has been deleted or moved: while the directory still exists the daemon re-reads that manifest; once it
is gone, the daemon stops the listeners and runs still belonging to it under the group name recorded
in the identity ledger, and returns the port: auto reservations recorded for that worktree. An
identity that cannot be found returns not_found and does not guess a group. When the target already
has no runs or reservations, an explicit identity is still a successful empty stop. --identity's
value comes from status --json's worktree.identity or from oberth worktrees.
Stopping by project name is group-name scoped: different worktrees of the same repository are
published as <project> and <project>@<worktree>, and different names do not affect each other.
When two checkouts really do take the same group name (for example different repository directories
with the same name, or two same-named linked worktrees under different parent directories), the
daemon's groups are already merged and any group-scoped stop hits both; in that case down <project>
and an explicit identity both return ambiguous, suggesting kill by port or pid instead of picking
one for the caller.
On cancellation, an incomplete stop, an observation failure after the action, runs or listeners still
registered, or a reservation that has already been refreshed, no unconfirmed reservation is returned;
the existing RPC error is returned with a suggestion to check status and the logs and then retry.
Signals already sent are not undone by this error; not_found alone does not prove the port can be
released. A real stop of a portless run needs an accurate birth identity. Low-level dry-run and scoped
stops do not release reservations; restart --only therefore keeps the original allocation. This
promises no atomicity between OS observation and ledger writes, nor that arbitrary old versions
writing the same ledger at the same time will work.
Lists, read-only, the ${worktree} identities this machine's daemon has seen, the checkout root path
each was derived from, the most recently published group name, and whether that path still exists now.
It is an identity ledger, not runtime fact: rows remain after a checkout is deleted or moved, exists
is re-determined against the filesystem on every read, so an identity exists again when the same path
reappears; last_seen is only the time of the most recent ledger write (daemon start or group-name
change), not a heartbeat from every scan.
--gone keeps only identities whose checkout no longer exists, so a human or an agent can find the
databases and data directories left behind under an old identity (app_${worktree},
.data/${worktree}). Deletion is still done by a human: the command does not delete ledger rows,
databases, data directories or running processes; to stop a service that is still running use
oberth down --identity <wt_…>.
When a daemon answers, each row also carries running_services: the number of declared services
running under that identity. It is a single read-time connection to the group facts and is not written
back to the ledger. Without a daemon the field is absent — the ledger does not store run state, and
absent means not observed, not 0.
{"worktrees":[
{"identity":"wt_0123456789ab","root":"/work/demo","group":"demo","exists":true,"last_seen":"2026-10-30T09:00:00Z","running_services":2},
{"identity":"wt_abcdef012345","root":"/work/old","group":"demo@old","exists":false,"last_seen":"2026-10-28T18:00:00Z"}
]}With no daemon, or no ledger yet, it returns an empty list; it does not start a daemon or create a
database just to read this fact. The daemon is the ledger's only writer: it records identities when it
scans manifests and backfills by recorded root path after a restart; read commands such as status
and down do not write the ledger.
oberth logs <service|port> [--once] [--lines N] [--follow] [--ip address] [--json] [--run current|] [--first-error]
Views logs by the service name in the manifest; the port form continues to work. When the service has a listening port, the command reads that process's logs; a portless worker reads that run's service log file. By default it prints the most recent lines first and keeps following; --once reads once and exits. --ip disambiguates when the same port is bound on several addresses. --json --once outputs one document:
{"source":"/path/to/service.log","lines":["..."],"truncated":false,"service":"api","run_id":"...","pid":1234,"status":"running"}--run current --first-error's document:
{"source":"/path/to/service.log","lines":[],"truncated":false,"service":"api",
"run_id":"...","log_offset":1234,"first_error":{"line":42,"text":"ERROR could not connect"}}Service log files are appended across runs. --run reads only one run's own part of that file: from
log_offset to where the next run of that service started writing, so a later generation's lines —
including the error it died on — are not reported as this run's. A run that is still writing owns
everything after its start; two runs that overlap in time write interleaved bytes, and their parts
cannot be told apart. A run that has ended owns only the bytes already in the file: when no later
generation has recorded where it took over — the newest finished run — its part ends at the end of
the file as it is when read, so following it prints and returns instead of waiting for a generation
that has not started yet. --run current selects the service's current run, and any other value is an
exact historical run_id from status --json. current prefers a process that is still running and
only falls back to the most recently finished one when there is none. Following a run that has ended
prints its lines and stops: nothing arrives for it afterwards. --run accepts only service names,
cannot be combined with --ip, and needs the daemon; when the daemon is unavailable it returns
daemon_unavailable. Old run records without an offset return not_found with a suggestion to restart
the service once. When the file has been rotated and the recorded part is no longer in it, reading
falls back to the beginning of the file, and the JSON uses run_start_unknown: true to say that the
lines this time may include other runs.
--first-error is used together with --run: it locates the first matching line of that run from a
fixed vocabulary (case-insensitive error, exception, fatal, panic, traceback) and returns the line
number and the original text. It is a position, not a root-cause judgment; with no match the JSON
carries no_first_error: true. --first-error implies --once.
The --run JSON document adds log_offset, an optional run_start_unknown, and first_error
(line, text) or no_first_error on top of the original fields. Line numbers are counted from the
start of that run; when reading falls back to the beginning of the file, line numbers are counted
against the fallen-back content too, and run_start_unknown is the precondition for reading those
line numbers.
Follow mode uses NDJSON, each line carrying source, line and the available service and run
metadata; a one-shot read also gives exit_code and reason when the service has exited. A port that
cannot be found returns not_found; several bindings without --ip return ambiguous.
The handoff layer's work record: what was done under a worktree and how it turned out, for humans and agents to read back and take over. The full contract (subcommands, block fields, how records are produced and their boundaries) is in Work records.
init does one thing only: generate a reviewable starter oberth.yaml from the current worktree's
facts. Listening ports and declarations found in project files appear only as comments, left for a
human to decide. --dry-run only previews, --json is for machine reading.
oberth init [--dry-run] [--json]
A failed listener scan does not abort init: the project's own declarations and the shape of an entry
to copy are still there. Only one conclusion may not be written — "nothing was listening". The comment
says listening unknown instead, with the observation code and the next step, and stderr says so
once. This is the same rule status follows: cannot-look is not nothing-is-listening. A partially
readable scan still lists the ports it read and says the table is incomplete.
Has the local agent from the configuration draft a manifest. option-berth does not judge, does not go
online, and does not modify oberth.yaml directly: success only produces a draft (archived by default
under ~/.option-berth/drafts/), which a human reviews and then adopts. When several agents are
available, the positional argument can name claude or codex; with no argument the configured
agent, or the only one available on PATH, is used. --progress outputs NDJSON progress events,
suitable for a client to show the current stage while the agent works; the last line is the full
result with type: "done".
Writes the draft a human selected — or any manifest named by path — into the current worktree's
oberth.yaml. Without a path the project's latest draft is adopted; a path is how a new worktree
inherits a declaration from another checkout, and the two are independent afterwards. This is the
step that writes to disk. An existing manifest requires an explicit --replace or --merge.
--dry-run only previews the complete manifest that would be written; adding --diff outputs a
read-only unified diff of the current manifest against the adopted result (the JSON output puts it in
the diff field), writing no manifest and moving no draft.
Commands outside the core loop are listed here by layer, and this document is their contract too:
- oberth doctor: checks the manifest, daemon, CLI path, log directory and whether the current process can see listeners, and gives actionable repair hints.
--only listener_observationbinds a real local port and then uses the same collector asstatusto confirm it can be seen: it reports observable / blind / bind_denied; in CLI mode it also carries the daemon's self-proof result, and a blind daemon also fails.--only daemon_build_matchescompares the CLI's and the daemon's build commits; the same version number does not mean the same build. A missing commit identity or a failed status RPC reports warn, does not mark a match, and does not restart the daemon automatically.--only agent_skillreads the skill already installed underHOME(~/.claude,~/.codex, and~/.config/opencodeskill directories) and compares each copy'sSKILL.mdmetadata.version: nothing installed isskip, a single copy is alwaysok, and only disagreeing versions or a symlink pointing at a checkout that no longer exists reportwarn. It does not go online, so it does not know which version is current and never calls one copy outdated; it does not install, update, or repair the skill. - oberth events: for agents to subscribe to this machine's daemon state changes. During parallel integration work an agent should actively establish and consume subscriptions according to the relationships explicitly given in the task; a human running this command is mainly troubleshooting.
--worktree PATH_OR_IDincludes the same repository's other worktrees along existing Repo relationships;--repository REPO_IDis a repeatable additive filter, and--workspace NAMEjoins an explicit temporary integration relationship; the first NDJSON line is directly{"type":"state.snapshot",...}and each following line is directly{"type":"state.changed",...}. The first time a workspace establishes a live subscription it must supply a worktree or repository as well; later subscribers may carry only the workspace;--oncereads the snapshot only and registers no workspace membership. The RPC's filteredstate.subscribeusesfilter: {worktrees, repositories, workspace}and must seteventstotrue; usestate.snapshotto take a single snapshot. Snapshots and changes contain only the runtime fact summaries of worktrees, services and ports, not commands, working directories, logs or environment variables; when details are needed, go back to the relevant worktree and runoberth status --json. A client should keep theseqfrom a snapshot or change, and on reconnect after a drop pass the last applied sequence number asafter_seqtostate.subscribe; when the daemon can cover it, it first returns a baseline snapshot for that sequence and then replays increments in order, and when the window has expired or the state chain is discontinuous it returns the current full snapshot.oberth eventsreconnects automatically with the lastseqafter a transport drop or queue overflow; workspace membership changes still go by the new snapshot, and an expired queue window also falls back to a full snapshot; membership changes notify existing subscribers withchanged: ["workspace_changed"]. If you connect to an old daemon, runoberth daemon restartfirst to obtain thestate.scopecapability. - oberth kill <port|pid>...: stops listeners by name; --all is an explicit batch operation and still confirms.
Human-readable result lines use the reader's vocabulary:
stopped (SIGTERM)/stopped (SIGKILL)/stopped container, never printing the signal name directly; whendownstops a portless run it shows the service name (the name in the manifest), not the interpreter name from the process table. A released address line readsreleased <url>.--json'smethodfield is still the wire enum (sigterm/sigkill/docker_stop/none); human and machine readers each use their own vocabulary. - oberth git [path]: reads the code summary, file list or diff on demand outside status;
oberth git files --jsondoes not truncate, andfiles_totalis the complete count.oberth git graph [path] --jsonreturns the full commit topology, parent IDs, refs and worktrees.--limit N(N > 0) can still truncate, and setstruncatedto true andlimitto N in the JSON;--limit 0means the complete history just like omitting the parameter, in which caselimitis 0 andtruncatedis false. Graph explicitly marksobserved_headand stays read-only: no fetch, no index refresh.oberth git diff [path] --commit <hex> [--file <repo-relative-path>] --jsonreviews the patch of the given commit against its first parent; a root commit is against the empty tree, and merges do not use a combined diff. commit accepts only 7–64 hex-digit object IDs, and the response carriescommitandbasefields.
start runs only one low-level entry point, oberth start [flags] -- <command> [args...]; day-to-day
project services use up / down. ${<name>.port}, ${<name>.url} and ${worktree} in command
arguments are expanded before execution from the current worktree's address book: services in this
manifest, machine: references and workspace: references all work, and port: auto takes the
current actual port. So oberth start -- curl '${api.url}/health' reaches this checkout's service and
does not connect to the other one during two-worktree integration work. A declared reference that
currently has no address makes the command fail outright rather than executing ${...} literally;
bare ${port} / ${url} expand only when --port is given. Other arguments are passed through
unchanged. kill takes a port or pid as its subject, with --all as the batch exception that still
needs explicit confirmation; the old group/session selectors are kept only for migration and do not
appear in public help.
- oberth daemon status|path|stop|restart|log|schema: manages or inspects the background collector.
daemon status --jsoncarriesobservation(state,code,detail,hint,checked_at), saying whether the daemon itself can see listeners;daemon.hellocarries the same result, and clients must not take a blind daemon's empty port table as fact. oberth -v/oberth --version: prints the current CLI version concisely;oberth config init|path|edit,oberth serve,oberth version,oberth completion: configuration and runtime environment entry points.oberth version --jsonoutputsversion,commit,builtandplatformfor the current build, for install check scripts to confirm the build actually running.oberth version --checkreads the latest release tag and reports whether this machine is behind;--check --jsonaddslateston top of the original fields. It only reads release information: it does not download, does not install, and does not replace the running binary. Terminal commands reuse the same check result: the first screen shows the local version with a full upgrade block, and any other command that writes human output ends with a two-line upgrade footer when this build is behind. A release is announced once per build. The result is cached for 5 minutes on success and 30 minutes on failure, a command waits at most 1 second for it, andBERTH_NO_UPDATE_CHECK=1turns the automatic notices off. JSON output,--helpand an explicit--checknever add the footer. The check reads GitHub's release metadata and a CDN copy of the tag list, so a slow or blocked GitHub does not hide a release. When no source answers,--checkexits non-zero and explains why, while the terminal paths stay silent. The check result is cached inBERTH_HOME/update-check.json(default~/.option-berth/update-check.json), reused only for these notices and not a runtime fact.
oberth version --json's stdout is one JSON value:
{"version":"v0.1.0","commit":"<git-sha>","built":"<UTC timestamp>","platform":"darwin/arm64"}--check --json adds latest to this object:
{"version":"v0.1.0","commit":"<git-sha>","built":"<UTC timestamp>","platform":"darwin/arm64",
"latest":{"version":"v0.1.1","checked_at":"<UTC timestamp>","update_available":true}}latest.version is the latest tag reported by the release source; update_available is true only
when the local version is strictly older, and false both when the local version is newer (for example
a build from main) and when they cannot be compared (for example dev).
The improvement report is opt-in and about the engine itself. A release build asks once on the first
screen (oberth with no arguments) when both ends of the conversation are a terminal. The answer is
recorded owner-only at BERTH_HOME/feedback.json (default ~/.option-berth/feedback.json): a skip is
asked again only after an upgrade, a decision is not asked again, and BERTH_NO_FEEDBACK (presence
disables, the way NO_COLOR does) turns both the question and sending off.
| Command | What it does |
|---|---|
oberth feedback status [--json] |
The recorded decision, whether gh can file directly, and how many reports this installation filed in the last day |
oberth feedback on / oberth feedback off |
Record the decision without waiting for the first screen |
oberth feedback report [--json] [--dry-run] |
Compose one report and file it |
oberth feedback log [--json] |
The reports this installation filed |
report takes only the fixed vocabulary: --kind (slow, stalled, coarse, misleading,
observation, other), --command (an oberth command name), --error-code (a code the engine
publishes), and --observation (observable, partial, blind, bind_denied). --elapsed and
--services are filed as buckets, not as numbers; the build adds its own version, commit and platform.
--note is the reporter's own words: it is redacted, capped at 800 bytes, refused outright when it
looks like it carries a credential, and shown for confirmation unless --yes is passed.
The report goes out through gh issue create with the person's own GitHub account when gh is
installed and signed in, so that account is visible on the issue; otherwise the command prints a
prefilled issues/new URL for the person to submit, and --open opens it. The same complaint from the
same build is filed at most once in 24 hours, and one installation files at most five reports in a
rolling day. The local ledger at BERTH_HOME/feedback/reports.ndjson records what was filed; it is
advisory, and deleting it only makes a duplicate possible again.
A report never carries a path, a log line, a manifest field, a command line, a worktree identity or an
environment variable: it is about the engine, not about the project it was run in. --dry-run prints
the exact title and body and sends nothing.
- The manifest is written by humans; an agent may only draft, and only
oberth init adoptadopts it. - The manifest belongs to the worktree directory, not to the branch and not to the repository: the
engine finds it by walking the filesystem and never asks git whether it is tracked. Whether a
project commits it is the project's decision; the one behavior that follows from it: a tracked
manifest under a declared
sources:path is part of that fingerprint, an ignored one never is. - A new checkout starts without one.
oberth init adopt <path>copies a declaration from any existing checkout into this worktree; after that the two are independent. oberth.yamlis the current name..oberth.yamlandoberth.ymlare still read, nothing new is created under them, andoberth doctorreports a shadowed or legacy spelling.- option-berth only reads git; it does not commit, does not stage, does not switch branches.
- machine: is a read-only reference to a project dependency; up / down do not start or stop it on its behalf.
- workspace: is an address reference to a service in another checkout; up / down / restart do not start or stop the other side on its behalf, and do not establish a depends_on relationship.
- The
worktreesledger records only "which paths an identity has been seen at" and the most recent group name; it does not record whether services are running, and does not automatically delete data after a checkout disappears. - Listening ports, pids, exit codes and logs are a Run's runtime facts. The product does not provide a machine-wide port radar, a machine service registry, model judgment, an MCP service, or automatic skill installation.
prepare:, cmd:, env: values and command-form health: can reference ports and checkout
identity; the daemon expands them before starting services:
| Reference | Expands to |
|---|---|
${port} / ${url} |
This service's own port / http://localhost:<port> |
${<name>.port} / ${<name>.url} |
The port / URL of a service in this manifest, a machine: reference, or a workspace: reference |
${worktree} |
The current checkout's stable identity, of the form wt_0123456789ab |
<name> is looked up among this manifest's services: first; when there is no service of that name
it then checks machine: (fixed port) and workspace: (another checkout's address). Service, machine
and workspace names therefore may not duplicate one another. Command-form health: may reference
services and machines but not workspaces: the scanner does not go read another checkout's address on
every health check.
${worktree} is derived from the canonicalized root path of the checkout the manifest lives in: it is
the same on every start for the same worktree, and different for the main checkout and each linked
worktree. It is an identifier rather than a path, suitable for composing into database names
(app_${worktree}) or relative data directories (.data/${worktree}). Moving a checkout directory
yields a new identity, and old data is not moved along with it. Read the current checkout's value
directly from oberth status --json's worktree.identity; for which identities this machine has seen
and which checkouts are gone, use oberth worktrees. Deleting data is still done by a human; the
engine only reports the correspondence between identities and paths.
Only the five shapes in the table above are references; other ${...} (for example ${HOME},
${FOO:-x}) are kept as-is. ${worktree} does not point at a service, so it neither needs nor
produces depends_on; undeclared services and port references to a service with no port are still
rejected by manifest validation. For a runnable sample see
examples/stack-workshop: it puts ${worktree} into both a
SQLite path and a per-worktree Compose project, and its README shows port: auto reaching Compose as
PORT, the pg_isready health check and down letting Compose stop its containers.
A service may declare env_file: pointing at a dotenv-style file; relative paths resolve against the
directory containing oberth.yaml, so ../shared/dev.env can live outside the checkout and let
several worktrees share one configuration and set of secrets. The file is read only when that service
starts or runs its prepare:; option-berth does not create or modify it.
Blank lines and whole-line comments starting with # are ignored; the optional export prefix,
single-quoted literal values, and double quotes (escaping \n, \r, \t, \\, \") are supported.
Values are not interpolated, and ${...} is kept as-is. The precedence when writing into the
environment, from lowest to highest, is: the daemon's own environment, the caller's environment,
env_file, PORT, the service's own env:. A missing file, a non-regular file, or a malformed line
makes that service fail to start; the error names only the file and the line number and does not echo
the value. File contents do not enter status, logs or spec_hash; changing it does not require
restarting the daemon, but a started service must be restarted to read it.
workspace: lists services in another checkout, providing addresses only and taking no part in
start/stop:
workspace:
- name: backend
worktree: ../backend
service: apiworktree is the other checkout's root directory (relative to this manifest's directory, or an
absolute path), and may also point directly at that checkout's manifest file; service is a service
name in that manifest. Afterwards prepare:, cmd: and env: can use ${backend.port} /
${backend.url}. Resolution reads the other manifest and the daemon's runtime facts: if the other
service is running, its actual port is used (including port: auto); if it is not running but
declares a fixed port, the declared address is used; with port: auto and not running, the reference
cannot be resolved, and the service using it fails to start with an explanation. up / down /
restart do not start or stop the other side's services and do not establish depends_on
relationships. A workspace: name may not duplicate a service or machine: reference in this
manifest; health checks do not accept workspace references.
oberth.yaml accepts only one YAML document; a second document (including an empty one) is an error
rather than being ignored. The same service may not declare port twice, including mixing auto with
a number; the error carries the YAML line number. Single documents, comments, normal numeric ports and
port: auto remain compatible.
The manifest's JSON Schema is published at engine/docs/schema/manifest.schema.json, generated from
the declaration types in internal/groups; go generate ./... updates it together with the protocol
schema. The schema rejects unknown keys (except top-level x- extensions and the $schema key), and
ports accept only 1–65535 or auto. Files written by oberth init and oberth init draft carry a
# yaml-language-server: $schema=... modeline on the first line, so editors flag misspelled keys
before drafting and adopting; handwritten manifests can add the same line themselves.
Default parsing still ignores unknown keys for compatibility. With BERTH_STRICT_MANIFEST=1 set (it
also accepts true, yes, on), unknown keys become load errors with line and column positions,
covering every manifest read path in the CLI, the daemon and oberth init adopt; top-level x- and
$schema remain legal metadata.
doctor --only project_config reports ignored manifest keys with line and column positions as a warn
in default mode and does not echo field values; custom env keys and top-level x- extensions are
not falsely reported. It does not rewrite the manifest, does not run commands, and does not connect to
the daemon to supplement report metadata.
agent list --json lists OpenCode, Codex, Claude Code, DeepSeek Harness and Pi without executing them.
agent plan codex --worktree <path> --json returns an exact executable/argv/cwd plan; it does not launch,
log in, install hooks, modify configuration, or send a model request. --prompt-stdin is plan-only and
avoids putting the initial prompt in shell history. The resulting JSON can contain that prompt: keep it local.
agent run codex --worktree <path> explicitly replaces the CLI with the native agent on macOS/Linux.
The agent keeps its own permissions, authentication, tools and conversation history; no approval bypass is added.
Native prompts are supported for OpenCode, Codex, Claude Code and Pi. DeepSeek uses dsh --profile tui
for an already installed custom native profile; it is not a shipped-profile guarantee. Use --mode task
with a prompt to select its documented headless profile. That one-shot task is not a resumed native conversation.
No implicit “resume last” crosses worktree boundaries. The selected cwd is not an operating-system sandbox.
Windows currently supports plan/list, not native process handoff. Merely finding a binary does not validate login
or a particular installed version; a provider failure remains a provider failure, never a silent shell fallback.
oberth agent stream <provider> --worktree <path> [--resume-file <path>] starts the structured bridge
process: one command per line on stdin, one event per line on stdout, both NDJSON carrying "v":1;
stderr carries diagnostics only. The contract is in engine/docs/schema/agent-stream.schema.json and
console-plan.md section 4.6.
agent list --json's "stream": true|false says whether this build includes the streaming adapter for that provider (Codex, Claude Code, OpenCode ACP, Pi RPC).
Production requires the corresponding CLI to be installed; automated tests may set BERTH_AGENT_STREAM_FAKE=<provider> to use a pure-NDJSON fake driver, or inject a
LookPath pointing at the engine/internal/agentstream/testdata/fake* stand-ins for go test.
This build includes live bridges for Codex app-server, Claude stream-json, OpenCode acp and Pi --mode rpc; when the CLI is not installed, the first turn.start returns unsupported.
oberth agent plan <provider> --worktree /workspace/project --resume-file /absolute/session-file --json
produces a structured continuation plan; agent run accepts the same file option and hands the
terminal to the provider. There is no implicit --last, partial ID search, shell interpolation,
automatic login, conversation import, or approval bypass.
Supported metadata: Codex JSONL session_meta header; Claude Code JSONL records containing
sessionId, cwd, and a semantic version; Pi session-format v3 UUID headers; OpenCode JSON
exports with info.id, info.directory, and info.version. Metadata reads are bounded to 256 KiB;
transcript messages are not copied into plans or another conversation database. The recorded
worktree must resolve to the same directory as --worktree. Plan resume reports the exact ID,
source metadata version, source file, worktree, and approval_owner: provider_native.
The person must select an original provider session file or a trusted OpenCode export. This checks that file's metadata, not the authenticity of a user-edited export or the currently installed CLI's version. The external CLI remains responsible for finding the ID, enforcing permissions and reading its actual conversation. Unknown formats, task-mode continuation and DeepSeek file-based continuation fail closed. This is a structured launch contract, not a unified live event/approval protocol; those capabilities remain in backlog.
Provider contracts: OpenCode CLI, Codex CLI, Claude Code CLI, and Pi CLI.