Skip to content

Latest commit

 

History

History
991 lines (861 loc) · 77.6 KB

cli.md

File metadata and controls

991 lines (861 loc) · 77.6 KB

oberth CLI contract

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.

Common rules

  • 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 --wait reports 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.state is 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_observation to 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 netstat and 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 with lsof and still reports observable. observation.source names the collector when it was not the primary one (lsof); the primary collector leaves it empty. The doctor detail and the daemon status observation describe the same round.

  • This fallback is narrower than netstat, and reading the result requires knowing two things: a non-root lsof cannot see other users' sockets, so a port held by a root process may be missing from the table; and a single lsof reports 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 read observation.source before concluding that a port is free. machine[].listening_known is false for a port missing from a narrow table, port: auto verifies each candidate by binding it instead of trusting absence, and the human status prints listening 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, status still 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/rpc fails 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.

Command surface

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.

Core loop

oberth status [path] [--json] [--no-mark] [--only services] [--brief]

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_context

oberth 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.

Agent-facing JSON fields

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.

oberth up [project] [--only name,...] [--no-deps] [--wait|--ready] [--wait-timeout duration]

[--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"]}

oberth restart [project] [--only name,...] [--force] [--allow-outside-home] [--json]

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.

oberth down [project] [--worktree path | --identity wt_…] [--force] [--json]

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.

oberth worktrees [--gone] [--json]

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.

oberth workplace

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.

oberth init

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.

oberth init draft [agent] [--dry-run] [--json] [--progress]

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".

oberth init adopt [draft|path] [--replace|--merge] [--dry-run] [--diff] [--json]

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.

Remaining commands by layer

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_observation binds a real local port and then uses the same collector as status to 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_matches compares 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_skill reads the skill already installed under HOME (~/.claude, ~/.codex, and ~/.config/opencode skill directories) and compares each copy's SKILL.md metadata.version: nothing installed is skip, a single copy is always ok, and only disagreeing versions or a symlink pointing at a checkout that no longer exists report warn. 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_ID includes the same repository's other worktrees along existing Repo relationships; --repository REPO_ID is a repeatable additive filter, and --workspace NAME joins 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; --once reads the snapshot only and registers no workspace membership. The RPC's filtered state.subscribe uses filter: {worktrees, repositories, workspace} and must set events to true; use state.snapshot to 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 run oberth status --json. A client should keep the seq from a snapshot or change, and on reconnect after a drop pass the last applied sequence number as after_seq to state.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 events reconnects automatically with the last seq after 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 with changed: ["workspace_changed"]. If you connect to an old daemon, run oberth daemon restart first to obtain the state.scope capability.
  • 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; when down stops 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 reads released <url>. --json's method field 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 --json does not truncate, and files_total is the complete count. oberth git graph [path] --json returns the full commit topology, parent IDs, refs and worktrees. --limit N (N > 0) can still truncate, and sets truncated to true and limit to N in the JSON; --limit 0 means the complete history just like omitting the parameter, in which case limit is 0 and truncated is false. Graph explicitly marks observed_head and stays read-only: no fetch, no index refresh. oberth git diff [path] --commit <hex> [--file <repo-relative-path>] --json reviews 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 carries commit and base fields.

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.

Infrastructure commands

  • oberth daemon status|path|stop|restart|log|schema: manages or inspects the background collector. daemon status --json carries observation (state, code, detail, hint, checked_at), saying whether the daemon itself can see listeners; daemon.hello carries 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 --json outputs version, commit, built and platform for the current build, for install check scripts to confirm the build actually running.
  • oberth version --check reads the latest release tag and reports whether this machine is behind; --check --json adds latest on 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, and BERTH_NO_UPDATE_CHECK=1 turns the automatic notices off. JSON output, --help and an explicit --check never 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, --check exits non-zero and explains why, while the terminal paths stay silent. The check result is cached in BERTH_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).

oberth feedback

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.

Manifest and state boundaries

  • The manifest is written by humans; an agent may only draft, and only oberth init adopt adopts 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.yaml is the current name. .oberth.yaml and oberth.yml are still read, nothing new is created under them, and oberth doctor reports 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 worktrees ledger 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.

Manifest references

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.

Shared environment files

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.

Cross-worktree address references

workspace: lists services in another checkout, providing addresses only and taking no part in start/stop:

workspace:
  - name: backend
    worktree: ../backend
    service: api

worktree 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.

Ambiguity rejection in manifest parsing

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.

Manifest schema and unknown-field strict mode

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.

oberth agent: external coding-agent handoff

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

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.

Explicit native continuation

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.