CLAUDE.md@src/grove/core · git:20260917.fdcbb8c · 2026-09-17 · sha256 c135854676ddbfcd
CLAUDE.md@src/grove/core git:20260917.fdcbb8cC
Immutable. This exact content is served forever at /api/v1/blob/c135854676ddbfcd.
# grove.core — engine: lifecycle, config, side effects, status, registry, activity
> ↑ [root](../../../CLAUDE.md)
The engine: the manager sequences worktree/tmux lifecycle, the config cascade resolves user policy, status reconciliation promotes intents to displayed states, and the registry + activity service aggregate across repos. All side effects funnel through dedicated modules; everything else is pure.
Children: [contracts](contracts/CLAUDE.md) (wire shapes) · [agents](agents/CLAUDE.md) (agent adapters) · [tickets](tickets/CLAUDE.md) (branch-aware ticket providers) · [issueops](issueops/CLAUDE.md) (issue-comment → workspace action) · [telemetry](telemetry/CLAUDE.md) (the OpenTelemetry gateway) · [notifications](notifications/CLAUDE.md) (push on activity, question, and lifecycle edges) · [usage](usage/CLAUDE.md) (the historical audit — the past-tense sibling of `activity.py`).
## Lifecycle & manager
- **Init flows through four one-per-concern helpers in `manager.py`; no call site re-branches OK/FAILED/SKIPPED:** `_run_init_script` (invoke), `_init_outcome` (record), `_init_abort_detail` (decide fatality), `_init_enabled` (does it run at all). `create`/`resume`/`respawn` apply the outcome via `_replace(state, **_init_outcome(...))`.
- **`_run_init_script` normalizes BOTH failure shapes to an `rc` so `_init_abort_detail` is the only site deciding fatality.** `tmux.run_init_script` can fail by returning non-zero *or* by raising (mutually exclusive `inline`+`path`, a missing script, a timeout), and a policy flag wired to one shape silently does not apply to the other. **When one operation can fail two ways, normalize both at the seam so downstream policy has one input.**
- **`init_script.applies_to: all|host|container` gates on the PERSISTED runtime, never the requested one** — a workspace that asked for a container and fell back to the host is a HOST workspace here, so gating on the request skips exactly the setup a fallback needs. The predicate `InitScriptConfig.applies(is_container=)` takes a bare bool, not `Runtime`, because `workspace.py` imports `config.py` and the enum would close a cycle. Not-applicable is `InitStatus.SKIPPED`, not a new outcome.
- **`kill()` is the only op that deletes a branch, and it never touches a remote.** `pause` drops the worktree and keeps the branch (the worktree is Grove's, the branch is the user's); `resume` recreates the worktree from it; `respawn` touches only the agent runtime. `kill(id, *, delete_branch=None)` resolves an unspecified flag from `state.branch_provenance` (GROVE_CREATED → True, USER_ATTACHED → False), and the kill event emits `branch_deleted`.
- **Recovery is `respawn`, not workspace deletion.** Explicit respawn also accepts a LIVE host-native owner that is unresponsive; it preserves the worktree/branch and resumes materialized conversation state. Automatic message-triggered recovery still requires OFFLINE. Container-owned live sessions are not admitted by the host restart exception. Teardown failure must abort replacement, not start a second owner. Init re-runs only under `cfg.init_script.run_on_resume`, never for root placement.
- **The init log survives `_rollback_create`; only `kill()` drops it** — rollback fires exactly when the log is needed for diagnosis. Every fail_fast init raise routes through `_init_failure_detail`, which appends the log's path + tail; route any new one through it. **The invariant only holds from the moment the artifact exists:** `run_init_script` validates config and resolves the path *before* spawning and writes the log only after the subprocess returns, so bad config / missing script / timeout produced no artifact at all, and `_InitRun.from_exception` now writes the cause into that same log in the same section shape (best-effort — an unwritable log must never mask the failure it describes). **Audit the failure paths that precede the writer.**
- **Agent session ids are minted in one helper (`_mint_agent_session_id`), AFTER the worktree exists, with opposite directions per kind.** claude_code: client-minted UUID (`--session-id`). mewbo: SERVER-minted — `MewboClient.create_session(cwd=worktree, …)` validates the dir exists, hence the post-worktree ordering, and remote-create failure is loud and transactional (`_rollback_create`). codex mints nothing (no launch flag exists) and reaches Grove only through discovery. create/respawn mint fresh; resume keeps the persisted id. HTTP lives in the side-effect module `core/mewbo.py`; the manager takes an injectable `mewbo_client`.
- **`remap_session` and `resume_session_id` both resolve through `SessionExplorer.resolve` BEFORE any side effect, and both enforce adapter-kind equality** — a resolved session whose `adapter_kind` differs from the workspace's `effective_kind` is a permanent dead pointer its adapter can never read, rejected as `AgentSessionNotFound` naming both kinds. Manual remap and an explicit public-share pin are trusted references, so `_resolve_session_ref` gives them one unique-prefix, kind-checked, ungated resolution rather than two near-copies where one can lose the kind check; resume keeps its own create-time path. Manual remap otherwise mirrors `attach_ticket` (`ensure_can_update`, idempotent by resolved id, emits `updated`). `sessions.py` imports `manager.py`, so the `SessionExplorer` import must be call-time-local or it cycles, and the daemon runs `remap_session` in the executor (it full-parses transcripts). `resume_session_id` short-circuits the mint, flips `_compose_launch(resume=True)` and is gated to `_RESUMABLE_KINDS` at create()'s top.
- **Unpause continues what EXISTS and mints what doesn't.** `resume()` composes `resume=True` only if the pinned session actually MATERIALIZED (`adapter.locate_transcripts` non-empty across `scan_cwds`) and the kind is resumable — otherwise it keeps the mint form, so a resume-created codex workspace does not relaunch a bare `codex` against a stale pin. Respawn always mints fresh.
- **Public sharing is ONE optional field and no new verb: `WorkspaceState.share_token`, folded into `update()` as a third `share:` kwarg.** Presence of the token IS the "public" state, so there is no second boolean that can disagree with it, and revoking is clearing the field — which permanently kills the link rather than parking it, since a capability cannot be recalled from whoever it was sent to. `update()` already owned all four things a `share()` method would have needed (the ORPHANED gate, the read-modify-write against persisted state, the no-op short-circuit, the `updated` event), and a second copy of those is how two mutation paths drift. **Omission is what makes it safe**: `share=None` on the wire means "leave alone", so a client renaming a workspace can never revoke a link by not mentioning one. Enabling is IDEMPOTENT — `ShareToken.resolve` returns the existing token, which then falls through the equality check as a genuine no-op, so a re-share never invalidates a link in circulation. The `updated` event carries only the `share_changed` FLAG; an event bus fans out to subscribers with no business holding a credential.
- **A public capability pins the transcript it shows at issuance (`share_session_id`), never derives it when read.** Respawn, adoption and a shared scan cwd can each move a primary session, so a circulated URL otherwise changes content without any capability edge; `None` on an old record deliberately falls back to the current primary (unfrozen, not broken). `_share_changes` owns token, expiry and pin together: mint captures the resolved pin, revoke clears it, idempotent re-share does neither — clicking Share twice must not rewrite an already-circulated page.
- **`ShareToken` (in `workspace.py`) keeps minting and comparison together, because they are two halves of one security property and a call site that got either wrong would look fine.** The manager mints and the registry resolves, so splitting them is exactly how a 16-byte token or a `==` comparison ships unnoticed. **Resolution lives on `RepoRegistry`, not on a Manager** — a token is host-wide by construction (the reader is handed a bare string with no repo to scope it) — and it reads the STORE directly rather than walking `known_roots()`, since the walk would resolve every project's config cascade and mint a Manager per repo on a request that is unauthenticated by design. `share_expires_at` is fixed at issuance from `SharePolicyStore`'s project TTL; `resolve_share` treats an expired token exactly like an unknown one, with no policy-store read on this unauthenticated path.
- **Title is identity-seed-only at create; rename is metadata-only.** `create()` derives the worktree path and session name from `slug(title) + ts` once; `update()` changes only `title`/`description`/`updated_at`, because renaming breaks attached clients. An `_UNSET` sentinel separates "clear" (`None`, description only) from "leave alone"; title can never be cleared.
## Placement (worktree vs root)
- **Placement gates every worktree side effect and is safety-critical.** A workspace is WORKTREE (a dedicated dir under `${repo}/.worktrees`) or ROOT (runs in `repo_root`, adopts live HEAD, Grove creates nothing). Modeled as the `RootBranch` discriminated-union variant in [contracts](contracts/CLAUDE.md) — not a boolean — so illegal combos are unrepresentable. `RootBranch.resolve()` returns `placement=ROOT`, `provenance=USER_ATTACHED`, `name=""`; the empty name is the sentinel the manager fills from `git.current_branch()` (detached HEAD → `"HEAD"`).
- **The `Placement` enum lives in `workspace.py`, NOT `contracts/branch_plan.py`** — `branch_plan` imports from `workspace`, so the enum sits on the depended-upon side or the import cycles. `WorkspaceState.placement` defaults to WORKTREE so records written before the field existed load with no migration.
- **Agent cwd is orthogonal to placement: the worktree and branch always anchor at the repo root, the agent session only *starts* in a nested subdir.** `WorkspaceState.project_subpath` (a POSIX relative path under the worktree root, `""` = the root) is the durable fact; `agent_cwd` (= `worktree_path / project_subpath`) is the single derived "where the agent runs", reused by create/resume/respawn so they cannot drift. `create()` derives the subpath from `CreateWorkspaceRequest.project_cwd` (resolve + `relative_to(repo_root)`; out-of-repo → loud `GroveError` *before* any side effect) and threads `agent_cwd` into exactly the agent-session sites, **and a RELATIVE `project_cwd` anchors on the repo root, never the process cwd** — resolving it against the caller's own directory gives one request a different meaning per client, and the daemon is the case that proves it, since it serves repos it never stands in and would resolve `"webapp"` under its systemd unit's working directory and then refuse containment for a reason no user could see. The repo root is the one anchor every client already agrees on, so the rule lives here and the CLI/MCP surfaces stay thin shells that forward the string, while `git worktree add`, branch ops and the init script stay on `repo_root`/`worktree`. This is why several subdirs of one repo are distinct projects sharing one worktree family.
- **A repo's LABELLED working directories (`agent_cwds`) name paths; the engine resolves the DEFAULT and nothing else ever learns a label exists.** `entries` is label → repo-relative POSIX path and `default` names one of them; `default_path()` is the single consumer, returning the path, so the label stays a presentation concern that never reaches `project_subpath`, the store or the wire. Clients send the PATH, which is what makes a label safe to rename — a workspace already created from it is unaffected. **The default is applied at `_project_subpath`, in the ENGINE**, because `defaults.branch_mode` is the standing proof of what happens otherwise: nothing engine-side reads it, the TUI applies it client-side, and every other client silently discards the user's saved answer. Resolving here means the CLI, the TUI, the daemon, MCP and issue-ops inherit it for free, and a repo declaring nothing still resolves `""` — so this is opt-in per repo and no existing workspace or caller changes behaviour. Paths are validated where they are DEFINED (relative only, no `~`, no `..`), which is strictly earlier than the create-time containment refusal that still backs it up. **The catalog is read-only enrichment on `WorkspaceDefaultsView` and deliberately absent from the `PUT /defaults` shape** — that route replaces the `defaults` object, which can be saved at USER scope and applies to every project on the machine, where a repo-relative `webapp` means nothing.
- **The gate is one check (`placement is ROOT`) at each worktree side-effect site:** `create` skips `worktree_add` + branch creation; `kill` skips `worktree_remove` and **forces `delete_branch=False` even when the caller passes `True`**; `_rollback_create` reuses the gate so a failed root create cannot destroy the repo; `respawn` never re-runs init for root. Root lifecycle is create/kill/respawn only. **Tests must spy that `worktree_remove`/`branch_delete` are never *called* for root:** git refuses to remove the main worktree, so a survival-only assertion passes even with the gate missing.
## Config cascade
Six-layer cascade philosophy (mechanism, not policy) lives in [root](../../../CLAUDE.md).
- **Pydantic config = validated, frozen submodels; runtime state = plain dataclass.** Config loads once and is immutable; `WorkspaceState` is mutable and persisted to JSON. Mixing them tempts per-write validation and erodes the boundary.
- **A provider-scoped allowlist is a map, not a second profile object.** `usage.quota.profiles` maps each supported provider to profile-root strings, so ordinary deep-map cascading applies and a higher layer can clear one provider with `[]`. Empty means no subscription credential is inspected; transcript activity discovery is a separate, deliberately broader mechanism.
- **`${repo}` / `${repo_name}` placeholders expand at consume time, not validate time**, which is what lets one global config serve every repo without re-validation.
- **Mutually exclusive fields must resolve at MERGE time, not field time — the merge itself manufactures the invalid combination.** "Illegal states unrepresentable" holds only *within* one layer: a project config setting `init_script.path` and a machine-local one setting `inline` are each individually valid, and a field-by-field deep merge keeps BOTH, which `run_init_script` rejects. No per-field validator could catch it; nothing invalid was authored. **A field-by-field merge cannot express "my choice replaces yours",** so any pair of fields that are *alternatives* needs group-level resolution. `ExclusiveGroups.resolve` is a pure pre-pass over the layer list run immediately before `_deep_merge`: the highest-precedence layer that MENTIONS any group member wins the whole group, members are stripped below, and the warning fires only when a strip actually drops something. **"Mentions" is key *presence*, not truthiness** — an explicit `"path": null` in a higher layer deliberately CLEARS a lower layer's script. Group membership is declared on the model that owns it (`EXCLUSIVE_FIELDS`) so `_deep_merge` stays a policy-free dict merge.
- **`agents` lists merge by `name` field-by-field; other lists replace wholesale.** Codified in `_merge_agents`: an overlay entry refines the base (`{**base, **overlay}`), so overriding just the `claude` agent's `command` keeps its `kind="claude_code"`. Any new list-of-named-things: decide merge vs replace explicitly and document it at the field.
- **The built-in agent roster is literally layer 0 of `load_config`.** Pydantic's `default_factory` fires only when NO layer declares `agents`, so without a seed layer any config with an `agents` key replaced the built-ins wholesale — hiding codex/shell and degrading a redeclared `claude` to `kind="generic"`, silently killing dashboard tracking. The explicit disable knob is `builtin_agents: false`, gated POST-merge and never by skipping the seed layer, which would re-break both halves at once. `AgentRoster.allowed()` filters the merged roster to the names `AgentRoster.names_in()` finds, and **that scan reads only the NON-seed layers** — folding the seed in would make every built-in "declared" and the gate a permanent no-op.
- **`AgentSpec.models` is the per-agent model-catalog OVERRIDE, never a create-time allowlist** — `CreateWorkspaceRequest.model` is forwarded verbatim (provider boundary). Empty falls through to the adapter's live discovery; the catalog itself resolves in [agents](agents/CLAUDE.md).
- **`defaults` is the one user-first section in the cascade, and the inversion is field-by-field.** The normal project-last merge would let a repository a person cloned replace their machine-wide create-form preferences, so `DefaultsUserFirst` strips from committed and project-local `defaults` only the keys the user layer sets before `_deep_merge`. A project default still applies where the user leaves the field unset; env and CLI layers retain their ordinary precedence above user. `save_workspace_defaults` therefore read-modify-writes the RAW target layer under `paths.exclusive_lock` and validates the merged raw config before atomic publication: serializing a resolved `GroveConfig` would turn every inherited value into an accidental user choice, while merging an unlocked write would lose a concurrent save.
- **A SAVED DEFAULT IS ENGINE POLICY, NOT A FORM'S PRE-FILL — `GroveConfig.default_runtime` / `default_brief` / `default_skip_init` are the one resolution, and `create` is a reader of it.** `defaults` was client-side for a long time and the cost was invisible in the one direction that matters: `WorkspaceDefaultsView.from_config` honoured `defaults.runtime` while `RuntimeResolver.resolve` read `container.enabled` — which is **True by default** — so a user whose saved answer was `host` was shown Host by every create form and handed a **container** by every create whose client did not repeat the resolution itself. The TUI repeated it and was fine; the web composer trusts the contract (it sends only what the user *touched*, so an untouched field resolves at create time) and was wrong on every project that had not turned containers off. `model` and `skip_init` were silently discarded the same way. **The tell for this whole class: a field displayed by a "what will happen" view and consumed by an engine that reads a DIFFERENT source.** Assert them against each other, never each against a literal — a literal on both sides is exactly how the test agrees while production does not.
- **`skip_init` had to become `bool | None` for this to be expressible at all.** A bool cannot say "unspecified", so every omitted field arrived as an explicit `False` the engine could not distinguish from a caller that meant it. Same shape as `branch_plan`, which **still** cannot be resolved here for exactly that reason (`Field(default_factory=AutoBranch)`), which is why every client still applies `defaults.branch_mode` itself — the one remaining client-side default, and it is documented at both ends.
- **Making the engine forward a config value promotes that value to argv, so its validation has to move with it.** `WorkspaceDefaults.model` carried only a length cap while the wire carried a pattern; the moment `create` started forwarding it, a stored id beginning with `-` would have become a *flag* to the agent binary. `config.validate_model_id` is now the single rule both read — the value-becomes-syntax class the branch-name guard already covers, arriving through a new door.
- **A field's attribute docstring is published copy too — but ONLY because `use_attribute_docstrings` is on, and for a long time it was not.** Every field carried prose written as behaviour, `schema_to_md.py` prints a field's `description` verbatim, and Pydantic exported none of it: the public reference page rendered a Description column that was **empty for 84 of 94 fields**, `assign_bot` among them, so a shipped feature was undiscoverable by anyone reading the docs. The flag now sits on the shared `_FROZEN`/`_MUTABLE` `ConfigDict`s, which is the whole fix for every model at once. **The lesson is the detection story, not the flag:** nothing failed — not a test, not the docs build, not the schema dump — because an empty description is structurally valid, and the existing schema test asserted the *shape* of the dump rather than that any field said anything. A guard that only checks a key exists cannot see that its value is empty. **When prose is generated from code, assert on the CONTENT reaching the page, not on the pipeline running.** Rider: this makes every field docstring subject to the same no-bare-`#<n>` rule the class docstrings already have.
- **A config model's class docstring is PUBLISHED COPY, not a code comment.** `docs/hooks/schema_to_md.py` renders `configure-reference.md` from the exported JSON schema and prints every `description` verbatim onto the public site — so write these as behaviour and leave maintainer archaeology to this file, which is not published. **The guard is asserted over the SCHEMA, not the rendered markdown** (`tests/core/test_config_schema_dump.py`): the schema is the boundary, so it catches a `Field(description=…)` as readily as a class docstring and needs no docs build. Attribute docstrings under a field are NOT exported today (`use_attribute_docstrings` is off); flip that and the same test still covers it.
- **Anything the PRE-VALIDATION passes skip, validation can never report — so they raise instead of skipping.** `_merge_agents` and `AgentRoster.names_in` read raw layers before `model_validate`, so silently dropping a malformed entry is exactly what keeps it away from Pydantic: `extra="forbid"` has nothing to fire on, and a user who forgets `name` gets a roster silently missing that agent while mistyping a *field* raises loudly. `AgentRoster.entry_name` is the single definition of a usable entry, read by both passes. A non-list `agents` falls through untouched, because Pydantic names that field better than a raw pass could. **General test for any code that runs before a validator: if this drops something, who reports it? If the answer is "the validator", check that the validator can still see it.**
- **The `GROVE_*` env carve-out stands; its silence did not.** `_parse_env_overrides` consumes only vars whose first segment is a real `GroveConfig` field, because the namespace is shared (`GROVE_DEBUG`, `GROVE_GITEA_TOKEN`, installer knobs) and strict matching would hard-fail every load the moment one is exported. The cost is an asymmetry no user can predict: a field typo raises at validation while a *section* typo — one segment earlier — vanishes. The discriminator that keeps the carve-out and recovers the signal is the `__` nesting separator, which nothing else in the shared namespace uses: an unknown section *with* a separator warns, a foreign `GROVE_*` var stays quiet. (`docs/features-cascade.md` still claims "typos in any layer raise loudly" with no carve-out — still wrong.)
- **`${VAR}` in a string value is an EXPLICIT reference, which is why it fails loudly where the implicit `GROVE_*` layer stays quiet.** `EnvReferences.resolve` is a pure pass over the MERGED dict (`(mapping, env)` in, never `os.environ`) run immediately before `model_validate`, so a resolved value is rejected exactly like a literal. The two env mechanisms answer different questions and must not be unified: `GROVE_<SECTION>__<FIELD>` lets the environment override any field but the variable NAME is fixed by the schema path, while a reference lets the user pick the name. **Unset OR EMPTY is a `ConfigError` naming variable and dotted path,** deliberately breaking the implicit layer's "empty means absent" rule — writing the reference IS the opt-in, and an empty base URL yields a config that loads fine and sends telemetry nowhere. **It does NOT absorb the `*_env` fields, and the reason is the direction of the read:** those hold a variable *name* so a committed config never holds a credential and the consumer resolves it at the moment of use; folding a secret into the config object would put it in every dump, log and wire view. **`${repo}`/`${repo_name}` are reserved** for `expand_template`'s later per-repo expansion; `$${VAR}` is the one escape, and anything else carrying a `$` is left alone because this is a reference mechanism, not shell interpolation.
- **The declared per-field env var is the same feature pointed the other way, and it is a LAYER rather than a validator precisely because of precedence.** `DeclaredEnvVars` reads `json_schema_extra["x-env-var"]` off the model tree and builds a sparse cascade layer between the project-local file and `_parse_env_overrides`. A `model_validator(mode="before")` reading `os.environ` would run after the WHOLE merge and beat `GROVE_<SECTION>__<FIELD>`; the ladder we want is file literal < `${VAR}` reference in that file < declared var < schema-path var, because a schema-path name states the exact field it fills while a declared name is a convenience alias. **The unset/empty asymmetry against `EnvReferences` is deliberate:** a declaration is always-on and nobody opted in, so silence is the only honest answer and the file's value stands. **The name lives in the schema so the docs can enumerate it** — `schema_to_md.py` walks the schema rather than repeating a list, which it had to because the reference page never renders a NESTED submodel's fields at all. Every annotation is a published schema key we keep forever, so annotate sparingly — and **never annotate a `*_env` field or anything holding a credential.**
- **`skip_init` is a per-create override of `init_script.enabled` — create-scoped, never persisted.** Gating at the call site lets one create opt out without touching config; resume/respawn keep their own `run_on_resume` gate. Placement and skip-init are independent in the engine.
## Side effects & status
- **Side effects only in the dedicated modules — `git.py`, `tmux.py`, `mewbo.py`, `process.py`, `devcontainer.py` and the `container_*` edges.** A new I/O concern routes through one of those or a new dedicated module — never scatter `subprocess.run` or ad-hoc HTTP.
- **The agent launch is behind a `LaunchBackend` seam (`core/launch.py`) — the runtime abstraction, distinct from `AgentAdapter` (provider shape) and `Placement`.** `WorkspaceManager` takes an injected `launch_backend` (default `TmuxLaunchBackend`); create/resume/respawn compose an `AgentSpec` → a frozen `LaunchSpec` via one `_launch_spec` builder so they cannot drift. **The direction of the seam matters:** `launch.py` imports `tmux`, and `tmux.py` must NOT import `launch`. Because `build_workspace_layout` takes structured primitives (command/decoration/env/env_unset) rather than an `AgentSpec`, the tmux surface is decoupled from the config model — that is what lets another runtime reuse or replace it. A non-tmux runtime is a new `LaunchBackend`, not a manager change.
- **A `LaunchBackend` answers three capability questions, and each exists because only the backend can answer it.** `provides_pane: ClassVar[bool]` — does this runtime host a live tmux pane the manager may read and type into. `transcript_context(spec)` and `control_path(host_path, *, share_plan)` are the **namespace bridge**: only the backend knows how the namespace it launched into maps onto this host's filesystem. The bridge replaced an earlier `host_namespace` boolean, which could say "my paths are meaningless to you" but never *what they mean*. `HostNamespaceBackend` supplies the identity-map answers every on-this-host backend shares.
- **`control_path` returning `None` means OMIT the flag, and that is the load-bearing half of the return type.** Claude Code treats a missing `--settings` as FATAL, so emitting an untranslated host path is not graceful degradation — it is the agent exiting before it prints anything, *after* `create` already reported success. Losing the hook sidecar costs the status axis; an unopenable path costs the whole workspace. All three control flags (`--settings`, `--channels`, `--mcp-config`) route through the one helper.
- **A NATIVE workspace steers over a control channel while still HAVING a tmux pane, and `_steers_natively(state)` is the one predicate every pane-shaped verb reads.** `WorkspaceState.native` (decided at create by `AgentSpec.native_for(request.native)` — the request's per-launch choice over the entry's own default, both behind the one kind gate, so any profile runs either way and a shell entry can never be asked for a protocol it lacks; persisted like `brief`, degraded to the terminal for a container with no `GROVE_MAILBOX_SOCKET`) puts Grove's worker in the pane rather than the agent's TUI, so the pane shows the session's output and is not something to type into: `send_message`, `interrupt`, `switch_model` and `answer_question` take `_steer_native`, `send_keys` and plan approval refuse, and `pause`/resume-by-id refuse (the owned process holds the conversation; `respawn`/`kill` are the verbs). The paneless backend (`provides_pane=False`) is the OTHER runtime the same predicate covers, which is why the two cannot be gated differently by accident. `_steer_native` dispatches `message` / `interrupt` / `set_model` to the injected `NativeSteerClient`; messages stay best-effort, controls raise the client's typed refusal, because a control that silently did nothing reads as a working control. `_compose_launch(native=)` takes the PERSISTED flag from the three primary launch sites, never the roster, and an added container agent never passes it.
- **A native workspace's pane is the WIRE LOG, and its attach is a VIEWER — both follow from the pane holding no agent UI.** The worker prints one timestamped line per protocol frame in both directions (`NativeWorker.trace`, fed by the owners' `FrameTrace` hook, which fires BEFORE `_observe` decides anything so an unrecognised frame is the one that does get shown), bounded per line because the pane is a tmux scrollback every surface captures, not a store. `attach()` therefore hands out `read_only=True` for `state.native`: `tmux attach -r` on the host arm (measured: a Ctrl-C through a `-r` client leaves the pane's process running) and `attach-session -r` on the container arm — which is also the one `TmuxEntry` that must NOT `new-session -A`, since a viewer that creates on a miss hands the person a fresh shell to type into. **`switch-client -r` TOGGLES the client's flag rather than setting it**, so the inside-outer-tmux arm stays writable and the CLI/TUI say so instead. The tab is named for what it shows (`Stream` / `stream`) with the tab VALUE unchanged, so a bookmarked tab and the e2e census survive either mode.
- **A LIVE TMUX SESSION IS EVIDENCE ABOUT THE SESSION, NOT ABOUT THE WORKER INSIDE IT — and for a native workspace those are different processes.** When the owner exits, tmux keeps the session (the pane falls back to a shell), so `_reconcile_status` read ACTIVE and decayed to IDLE for a workspace whose control channel was gone. That hid the remedy: `respawn` is gated on OFFLINE, and `availableActions` offers it only for OFFLINE, so the one verb that rebuilds the session was unreachable from every surface while the Controls card offered Pause and Kill for a dead workspace. `_native_owner_exited` (gated on `native`, reading the SAME `agent_exit` seam the activity blend uses, so "the agent died" has one definition) demotes it to OFFLINE. **A zero exit counts here and does not on the activity axis** — `AgentExit.failed` separates "died" from "quit" for a state a human reads, while this asks whether any process still holds the control channel, and respawn is the remedy either way.
- **Provider-reader lifetime is independent of daemon connectivity.** The worker supervises reader termination alongside its reconnect and ordered input tasks; failed/closed provider output ends the unusable owner and records an exit instead of leaving a registered process nobody can steer. SIGHUP from tmux must reach the same cleanup as SIGTERM. Pending input receipts run separately from control intake so Interrupt/answers cannot wait behind a replay; queued text remains ordered and is never automatically replayed on connection loss. Coordinator cancellation has one reserved, coalesced priority slot, without evicting queued text.
- **A NATIVE WORKER OUTLIVES THE DAEMON, AND THE TWO BUGS THAT BROKE THAT WERE BOTH SHUTDOWN-SHAPED.** The worker is a process in a tmux pane holding the provider child; restarting or reinstalling the daemon must not restart that process. A host reboot is different: tmux and the provider die, and recovery continues saved conversation state rather than preserving execution. `MailboxRouter.aclose` REVOKED each owner's registration credential on shutdown, so the reconnect answered 401 — a daemon stop is not a workspace edge, and only a lifecycle event (`_INVALIDATING_EVENTS`) may revoke now. And the worker had no reconnect at all: one stream, then exit. It now loops with bounded backoff (1 s → 30 s) against the SAME child and token, ending only on 401/403, because the session lives in the child rather than in the daemon's memory of it. Measured: killing the daemon under a live worker prints `connection lost … reconnecting in 1s/2s/4s/8s` and the worker reattaches and keeps working when a daemon returns. **A second, independent killer shared the symptom:** the initial task's submission stage was treated as a GATE, so a replay that merely arrived late (`unknown` — a slow gateway, a session-start hook) raised `RuntimeError` and killed a session that had accepted the bytes. Only `rejected` ends it now; `unknown` is evidence, not a refusal. **Submission state belongs to the worker, not a connection's return value:** an SSE exception skips that return after the provider accepted the task, so a caller retaining the old flag replays the original task into the surviving conversation. Test clean EOF and transport failure AFTER submission separately, asserting both one provider start and one task submission.
- **`respawn` CONTINUES a native session rather than minting a fresh one, and that is what makes a dead workspace recoverable.** The old shape always minted, reasoning that "the old process vanished" — true of the PROCESS and false of the CONVERSATION, which lives in the transcript. Measured on 2.1.270 and 0.154.0: `claude -p --resume <id>` and Codex `thread/resume` both recall a codeword set before their owner exited, and Claude's resume keeps the same id and file. So respawn keeps the pinned id, gated on `_pinned_session_materialized` (the same predicate `resume()` uses — a pinned id with no transcript is a dead pointer, and asking a provider to resume one FAILS the launch where minting merely starts). `_compose_launch`'s native arm therefore no longer refuses `resume`: the create-time refusal stays in `create()`, where the choice is a caller's, while here it is the engine's own recovery. A resumed launch composes NO initial prompt, and the worker reads that empty prompt as its resume signal — no boot greeting (which would interrupt a mid-task agent to say "READY"), no workspace brief.
- **A message to a native workspace with no connected owner REVIVES it, because the remedy was mechanical and the refusal was a dead end.** `_revive_for_steer` respawns before delivering, scoped three ways: only a `message` (an `interrupt` or `set_model` names something the dead session was doing, so reviving to deliver them answers a question about a process that no longer exists), only when the reconciled workspace is OFFLINE and the coordinator reports no owner, and best-effort (a failed respawn leaves the original refusal, which is the better error). **Disconnection alone cannot authorize recovery:** a surviving worker reconnects after daemon startup, and explicit `respawn` also permits promotion of a LIVE fallback workspace. Auto-recovery must not inherit that broader permission; test a disconnected, live, promotable workspace or the inner respawn guard hides the defect. The probe is `owner_connected` read off the steer client with `getattr` and deliberately NOT a `NativeSteerClient` Protocol member: only the daemon's coordinator-backed client holds the registry that can answer, absent means "assume connected", and a default-bodied Protocol method would make every structural implementer's typing hinge on inheriting it.
- **Three backends ship.** `TmuxLaunchBackend` (default). `HeadlessLaunchBackend` (`provides_pane=False`) spawns detached via `core/process.py` with no session/window/pane. `DevcontainerLaunchBackend` runs the agent through `devcontainer exec`, env crossing via `--remote-env`. When `not provides_pane`, `_reconcile_status` keeps the workspace ACTIVE (never OFFLINE for a deliberately-absent session), the activity blend treats the pane as non-authoritative, `_capture_pane` short-circuits empty, and `send_message`/`answer_question`/`interrupt` route through the **native-steered arm** (`_steer_native`, after the remote-steered mewbo dispatch). `CapabilityUnavailable` stays for a runtime with genuinely no channel.
- **`TranscriptContext` is WRITTEN at every launch, not just carried for reads, and it is derived from the COMPOSED `LaunchSpec.env` rather than `agent.env`.** The bug it closes is a reader/agent env asymmetry: a filesystem adapter resolves its config-dir cascade from the READING process's ambient env (daemon/TUI) while the agent launches under a deliberately hermetic env (`env_unset` then `env`), so an operator pinning a profile per agent sent that agent's transcripts somewhere the reader never looked and the workspace sat at STARTING with a blank agent axis forever. Persisting on every launch means a pin added, changed or removed is picked up at the next relaunch, including clearing back to `None`. `TranscriptContext.CONFIG_DIR_ENV` is the single kind→env-var map, so the read scope and the write derivation cannot disagree. **`transcript_scan_cwds` is a UNION, never a replacement:** a nested project would otherwise narrow the scan to one entry and drop a root-recorded session.
- **A recorded environment value only means something inside the namespace that produced it, and a foreign-namespace one is worse than none, because the read side ACTS on it.** A container backend folds `spec.env` into its exec's env-passing flag, so a config-dir value there resolves inside the container while the field is contracted as a *host* directory — hence the backend answers `transcript_context` itself and returns `None` where the transcript is genuinely unreachable. The store decoder normalizes a blank/whitespace `config_dir` to `None` for the same reason: an empty string was not inert, it actively cleared a legitimate ambient pin.
- **One public `WorkspaceManager.transcript_scope(state)` owns EVERY transcript read site.** Nine reads needed the scope and it was missed four separate times, one site at a time: the blend, the pending-question resolution cross-check (unscoped, a pinned workspace's answered question can never see its own resolving `tool_result` and lingers on the SSE stream forever), the fleet drill-in, `latest_todo` (whose consumers include the issueops sticky comment, so an unscoped read posts an empty checklist into a real ticket), `session_controls` and `_current_model`. **A scope opened by a callee has already closed by the time you use its result** — scoping a loop header whose *body* re-reads changes nothing. **Four independent misses of one two-line convention is a design signal, not four bugs:** the fix is a single seam that resolves the kind itself. The failure mode is also diagnostically nasty — a partially-scoped workspace lights up PARTIALLY (status right, todo panel blank, wrong profile's slash commands, a 404 on the drill-in), which reads as several small unrelated bugs where uniform breakage would have pointed straight at the shared cause.
- **Instrumentation env is composed at the launch boundary (`_launch_spec`), not scattered:** launch env = `agent.env` over `telemetry.derive_env(os.environ)` (gated to `passthrough_kinds`) over `proxy.proxy_env(kind)`, agent `env` winning collisions and disabled knobs yielding `{}`. When the OTLP endpoint actually resolves it also folds in `adapter.telemetry_env()`, gated on the endpoint so a tool never enables an exporter with nowhere to send — **and on `not telemetry.owns_content(kind)`, below.**
- **Grove RESERVES the OTel exporter vocabulary for the agents it launches, and that is the one place `agent.env` does not get the last word.** `TelemetryConfig.reserved_env` names them; `reserve()` is pure and decides, `TelemetryReservation.apply` is the only thing that bites, and the manager owns the two side effects (reading `os.environ`, and one warning naming the displaced VARIABLES — never their values, since a telemetry value can be a credential). **The claim has two halves and one of them is not a merge:** a reserved name Grove does not set is *removed* from the composed env (a dict merge can only outrank, never delete), and separately `reserved_unset` feeds `LaunchSpec.env_unset` because a pane also inherits the tmux server's env — the `CLAUDE_CONFIG_DIR` road, where an endpoint leaking in points the agent just as effectively. Enumerated names, never a prefix: `OTEL_` would swallow `OTEL_RESOURCE_ATTRIBUTES`, which says who the agent IS rather than where its telemetry goes. `telemetry.env_file`/`env_command` stay authoritative — that IS Grove's tap being configured — which is what makes "your only supported way out is Grove" a redirection rather than a ban.
- **`content_owner` and the reservation are ONE policy, enforced through `owns_content(kind)` read by two sites.** `grove` means Grove's replay IS that runtime's trace, so the launch neither injects `adapter.telemetry_env()` nor lets any source turn the native trace exporter on (`OTEL_TRACES_EXPORTER=none` is FORCED — clearing it is wrong, the OTel SDK's default for unset is `otlp`). Two knobs that could disagree is exactly how one session arrives as two trees, the second of them content-free because a harness's tool bodies ride span *events* that a traces-only backend maps nowhere. **The cost is real and belongs in the open: `ttft_ms` exists ONLY on Claude Code's beta `llm_request` span and is in no transcript, so suppression loses TTFT** — the durable fix is ingesting that stream through `telemetry/receiver.py` instead, which is built and not yet mounted in the daemon.
- **Grove's own trace sink honors `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` / `_HEADERS` before the generic OTLP pair.** A configured collector is an intentional processing boundary and may be the only proven route to Langfuse; signal-specific endpoints are already complete (`.../v1/traces`) and must not have the suffix appended again. The generic derived Langfuse endpoint is only the fallback.
- **`TelemetryConfig.derive_env(env)` is the LangFuse consumption-boundary helper — pure over a caller-supplied mapping, never `os.environ`.** Config holds only the canonical env-var NAMES (`host_env`/`public_key_env`/`secret_key_env`, the `api_key_env` discipline — no secret literals); `derive_env` emits both the native `LANGFUSE_*` trio and the generic `OTEL_EXPORTER_OTLP_ENDPOINT`/`_HEADERS`, with the Basic-auth header assembled at call time and never stored.
- **`channel.py` and `permission.py` defer their MCP SDK imports through `grove._mcp_sdk.McpSdk`, never a bare `except ImportError`** — guarding a *deep submodule* import that way conflates "the distribution is absent" with "it is installed but incompatible" and emits an install hint for a package the operator already has. Mechanism in [mcp](../mcp/CLAUDE.md).
- **Launch-time channel composition and `tools_offline` both append to `_compose_launch`'s decoration, claude_code-only, additive, before the initial-prompt positional**, mirroring the hook `--settings` append. Every native launch channel is composed HERE because no attach-to-running exists.
- **Steering: the submitting Enter must not race the bracketed-paste window.** A TUI buffers everything inside that window as literal text, so a `\r` there is a newline, not a submit. `tmux.send_text`/`send_keys` take a `settle_ms` (`cfg.tmux.steer_settle_ms`, default 200): the payload and the submitting Enter are separate writes with a settle between, then a single verify-after-send that re-sends Enter exactly once if the composer still shows the text tail — never a loop, never raises on the race. A native input channel is the real fix where a runtime provides one.
- **STEER TEXT IS PASTED, NOT TYPED, AND A MODAL COMPOSER IS WHY.** `send-keys -l` writes bytes as if a human typed them; a composer with vim keybindings is modal, so the same bytes are *inserted* in insert mode and *executed as motions and commands* in normal mode. Grove cannot observe either fact — not the mode, not whether the user enabled vim mode at all — and the answer path deliberately presses Escape immediately beforehand, which is exactly what leaves such a composer in normal mode. Measured on Claude Code 2.1.263 with vim keybindings on: Escape then `send-keys -l` put the pane in VISUAL mode and ran the answer text as editor commands. `tmux.send_text` now pastes (`set-buffer` then `paste-buffer -p -d`), verified on the same pane to insert verbatim in normal mode with a following Enter still submitting. Three riders. **`set-buffer`/`paste-buffer` rather than `load-buffer -`**, because stdin does not survive the `docker exec` prefix a container's tmux is reached through, and the argv-only pair does. **The buffer name is per call and `-d` drops it**, so two concurrent steers cannot read each other's payload and none lands in the user's paste history. **`-p` is safe for a non-TUI target**: tmux emits the brackets only when the application asked for them, so a plain shell is unaffected. The cost is that a composer may collapse a large paste into its own placeholder (measured: six lines render as `[Pasted text #1 +5 lines]`), which makes the residual-Enter retry inert for long payloads — it fails toward a missed retry rather than a duplicate send, and recognising a provider's placeholder would be reading that provider's UI.
- **Answering a live question is DISMISS-AND-RESTATE, and it is the same steering path.** `answer_question` validates the plan against the captured questions, presses Escape, and delivers the whole batch as one Grove-fenced message; the keystroke driver that used to walk the provider's picker is deleted. Why, and what it cost, is in [agents](agents/CLAUDE.md) — the short form is that a widget-driving grammar made *what a human may answer* a function of *how one build of one tool paints a dialog*, and its worst failure was answering the wrong questions while reporting success. The paneless arm renders the identical message through `_steer_native`: **one renderer, two transports**, which is the whole point of rendering rather than driving.
- **Everything Grove appends to a prompt is fenced in one `<grove-instruction kind=…>` tag (`core/instructions.py`).** Grove speaks to an agent from three directions — the first-turn brief, a restated question answer, and a message's attachments — and all three arrive on the channel a *human* uses, so without a marker neither the agent nor the user reading their transcript back can tell the tool's sentence from the person's. One tag with a `kind` rather than three tags: the question a reader asks is "did a person write this", and that has to be answerable without knowing the vocabulary. Every renderer is pure and every body states only the fact the agent cannot observe (the prompt was dismissed; the file is at this path) — never a paraphrase of what the human chose.
- **Peer mail is fenced in `<grove-mailbox version="1">` — a SIBLING of `<grove-instruction>`, never a kind of it (`core/mailboxes.py`).** That tag means *Grove is talking* and a reader may trust it; this one means *another agent is talking and Grove only carried the bytes*, so folding peer mail into the instruction vocabulary would extend an authority marker's trust to text nobody vouches for. **`MailboxEnvelope` owns rendering AND parsing together**, the `ShareToken` argument applied again: they are two halves of one property — a peer's body can never forge its own fence — and a call site that got either wrong would look fine. Escaping (`<`/`>`/`&` → `\uXXXX`) is applied to the WHOLE serialized JSON document rather than to the body alone, so every string that crosses is covered by construction; that works precisely because JSON's own structural characters do not overlap the three, which is also why the payload is one line of JSON inside one tag rather than nested elements a later field could forget to escape.
- **Being unfenced cost three things at once, and only one of them was visible.** `_PEER_ENVELOPE_RE` knows Claude Code's `<teammate-message>`/`<cross-session-message>` and not Grove's own, so `DigestEntryView.mailbox` stayed `None` and the webapp's mailbox card — wire shape, data part and renderer all already built — never populated for a Grove peer message. The envelope also matched no `_NON_HUMAN_MARKERS` entry, so **every delivered peer message counted as a HUMAN TURN**: the exact inflation those markers exist to prevent, arriving through the one envelope Grove writes itself rather than merely reads. And an agent could only tell the block from a user turn by reading English. **When a tree has a fencing convention, the format its own code both writes and reads is the one nobody checks against it.**
- **`_NON_HUMAN_MARKERS` is necessary and NOT sufficient — `is_agent_notice` is what carries the payload.** `_spine_role` reads that predicate to reach `"notification"`, and only that role calls `mailbox_message`, so a marker added to the tuple alone stops the turn being miscounted and then drops the record on the floor.
- **`MailboxMessage.kind` (`peer` | `notice`) is the wire's answer to "is this a real agent-to-agent handoff", and it must never be inferred from which fields are populated.** A task notice carries a task id in `sender` and nothing in `recipient`, which is indistinguishable from a peer message whose recipient went unrecorded; only the daemon knows which protocol delivered it. It defaults to `notice` so an older payload decodes as the conservative case.
- **The reader had to be retroactive or the feature only ever worked for future mail.** 71 transcripts on the reference host carried the pre-fence banner (measured 2026-09-15; 23 real peer messages parse out of them), so `_parse_legacy` reads the shape Grove used to write and the adapter classifies both markers. Reading it is safe for the same reason reading the fence is — Grove published both formats at both ends, so this is not the provider-boundary trap of parsing a model's prose. It is deliberately the narrower parser: the old banner never named the recipient parseably, so that field stays absent rather than guessed. **Before shipping a format change to something already on disk, count what is already on disk.**
- **An attachment lives under the WORKTREE, and that is the whole design.** `.grove/attachments/<id>/<name>`, a sibling of the phase files, because the worktree is the only directory a host process and a containerized agent both see — a host temp directory would be an address half the fleet could not open, and the container's own path is the same `container_path` re-rooting `GROVE_PHASE_FILE` already gets. It inherits that placement's one obligation: an untracked file makes `git worktree remove` refuse, so `AttachmentStore.EXCLUDES` is re-asserted on every upload rather than only at create — the workspaces that need it most are the ones that existed before the feature did. **One file per id-named directory**, because two files called `shot.png` in one session is ordinary and a flat directory would silently serve the second for both. A client sends IDS, never paths: a caller-supplied path is a caller choosing which file the agent is told to open. An id that no longer resolves (a `pause` removed the worktree) is skipped rather than refused — dropping a message a human typed because one attachment expired is the worse failure. **The attachment row carries the byte count** (`- <name> — <path> (<n> bytes)`) because the size is known at store time and the webapp's transcript row can show one only if the engine publishes it — `Attachment.size` is read at store or resolve time so neither road re-reads the file, and the browser's parser treats the suffix as optional so pre-existing transcripts still render.
- **A detached tmux session has no client to size it, so it starts at tmux's 80x24 unless told otherwise.** That shape is what the agent lays its first screen out for, and what every `capture-pane` consumer reads FOREVER — the web dashboard's terminal pane and every `peek` never attach a client, so `window-size latest` never fires for them. `cfg.tmux.detached_size` is a starting size, not a pin: a human's own terminal still wins on attach. Applied at both creation sites through one parser (`tmux.parse_size`), and **only to a DETACHED start** — an attaching `-A` entry either finds a session whose size belongs to the attaching client or creates one it is immediately a client of, so passing a geometry there would be Grove overriding the terminal the person is sitting at.
- **Grove never runs `git commit` or `git push`, and `pause` refuses a dirty worktree from `ensure_can_pause`, before any side effect.** A precondition is a materially different contract from a wrapped downstream error: while the refusal was git's own, raised at step three of a teardown that had already killed the session and stopped the container, a user pausing with uncommitted work was told the pause failed — reasonably read as "nothing happened" — while the workspace sat sessionless with a stopped container and a record still RUNNING. **`GitRepo.is_clean` counts untracked files**, because it gates `git worktree remove`, which refuses on "modified **or** untracked": a worktree holding only new files (an agent's usual first act) read clean and git still refused. **A predicate that gates a command must be tested against that command's own criterion, not a plausible-sounding one.** `False` also means "could not inspect" — deliberately fail-closed, with `force` as the remedy.
- **`--no-optional-locks` is a GIT-WIDE option and must precede the subcommand — `git status --no-optional-locks` is a different, REJECTED command (`error: unknown option`, exit 129), not a stricter one.** `is_clean`/`dirty_file_count` ride the ~1 Hz activity fingerprint for every workspace on the host, so their `git status` read must not contend with a live `git add`/`commit` for the index lock; the fix is `["git", "--no-optional-locks", "status", "--porcelain"]`. **The trap is that the wrong placement fails SILENTLY**, not loudly: both call sites already use `check=False` and fold any nonzero exit into their fail-closed default (`is_clean` → `False`, `dirty_file_count` → `0`), so a misplaced flag reads as "everything is always dirty" rather than as a git error — exactly the shape a quick glance at the diff would wave through as correct. Mutation-tested: reverting the flag to trail the subcommand fails all three regression tests in `test_git_status_no_optional_locks.py`, confirming they are not vacuous.
- **Every git subprocess is bounded by `GitRepo.TIMEOUT_SECONDS`, and a timeout reports through the SAME channel a non-zero exit does.** A `git` that never returns — a credential prompt, a stale mount, an `index.lock` holder — otherwise takes its caller with it, and the daemon runs these on its loop, so one wedged child stops every repo's dashboard. `check=True` raises `GitError`, `check=False` returns a failed `CompletedProcess` (exit 124), because the `check=False` callers are exactly the peek/dashboard reads whose contract is "never raises". Not a config knob on purpose — `GitRepo` is built from a bare path at a dozen call sites. Testing it needs a REAL hanging child (a stub `git` earlier on `PATH`); a patched `subprocess.run` cannot tell a `timeout=` that is passed from one that is enforced.
- **`kill` is best-effort AND reported — the two are not alternatives.** Its stages deliberately continue past each other's failures (each leaves a different, separately fixable thing behind), so the `killed` event carries the *outcome* `branch_deleted` and a `residue` key naming the failed stages. **The record a user would use to find the leftovers is deleted on the next line, which makes the event the last surface able to say anything at all.**
- **"Since the workspace was created" is a RECORDED FACT (`WorkspaceState.base_commit`), never a range re-derived at read time.** Every such read used to start from `base_branch`, which is a *name*: it moves, so the answer silently changed under the user, and when the branch and the base branch are the same ref — every ROOT workspace, whose `base_branch` is the literal string `"HEAD"` — the range collapsed to empty however much work had been done. **A question about a moment in time can only be answered by something captured at that moment**, and create is the one point where it is knowable without guessing. Three consequences worth keeping: the anchor is resolved from the plan's own START POINT (base ref / upstream / the attached branch's own tip / live HEAD) *before* any side effect, so it needs no new git helper and no post-`worktree add` ordering; `diff_base` is one property so the log, the peek stats and the activity stream cannot drift on which anchor they used; and **`ahead_behind` deliberately keeps `base_branch`**, because "behind" asks how far the base BRANCH has moved and a frozen commit can only ever answer zero — the one read here whose question really is about the name.
- **Nullable, and the fallback is per-consumer rather than shared, because "degrade to what this call used to do" is a different revision for each.** A pre-anchor record falls back to `base_branch` for the commit log and to `HEAD` for the patch; routing both through `diff_base` would have quietly changed the patch's meaning for exactly the legacy records the nullability exists to protect. **A backfill was considered and refused**: a merge-base guess is indistinguishable on the wire from a recorded fact, so it would make every degraded answer read as a confident one — the same rule `available` vs an empty patch already encodes one bullet down. Absent must read as absent.
- **A ref range whose two ends resolve to ONE commit answers zero confidently, and zero is the one answer no reader questions.** The commit log's `base_branch` fallback is exactly that for a root record — measured on this repo's own root workspace, **0 commits from the range against 106 from a `--since` form** — and the card printed *"No commits on this branch yet"*, a wrong answer rather than a missing one. So `commits()` prefers `branch_commits_since(branch, created_at)`, and `CommitScope` on every row says which question was answered. **The trigger is `_range_is_degenerate` — a property of the REFS, not of the record's shape** — because "has no anchor" also demotes the legacy Grove-created records whose `base_branch` is a real, different ref and therefore exact; a window errs high, so keying on the record would trade precision away for a case that did not need it. **A timestamp is an admissible degradation where a merge-base guess is not**: `created_at` is a recorded fact answering a *different, well-posed* question, and it never claims to be the anchor. Generalizable: when a degraded path can only ever return the empty value, it is indistinguishable from a measurement, so the fix belongs at whatever makes it structurally empty.
- **A tree that was already dirty at create is credited to the workspace, on purpose.** Excluding it needs a per-file snapshot taken at create and consulted on every read, to shave a number in a case whose author is the user themselves. The count errs high; it never errs by hiding work, which is the direction that matters.
- **The working-tree patch is git's own output, carried whole — `working_diff` runs git and BOUNDS it, and parses nothing.** The clients vendor a diff renderer that reads unified format, so a structured files-and-hunks shape would be a second model of a format git already owns, maintained on both sides of the wire forever. The only structure imposed is finding `^diff --git ` to pick a safe cut point and to count what was returned; that is a cut, not a parse. Binary files consequently arrive as git's own `Binary files … differ` line rather than being filtered.
- **`git diff HEAD` does not show a new file at all, and creating files is an agent's FIRST act** — so a patch built from `git diff` alone is blank for exactly the work a reviewer wants. Untracked files are enumerated with `ls-files --others --exclude-standard` and appended as individual `diff --no-index -- /dev/null <path>` patches. **`--no-index` is the whole point: `git add --intent-to-add` would stage into the user's own worktree, and Grove does not mutate a user's git state to answer a READ** — the rule that keeps `commit`/`push` out of `git.py`, applied to the index. A test asserts `git diff --cached` stays empty afterwards. Rider: **`--no-index` exits 1 when the files differ**, which is the normal case here, so only a higher code is a failure and a zero exit means an empty new file.
- **`--no-color` and `--no-ext-diff` are correctness, not tidiness:** a user's `color.diff=always` hands back ANSI escapes and a configured `diff.external` hands back some other tool's output entirely, either of which reaches a parser expecting plain unified diff.
- **Truncation cuts at a whole-file boundary, and the split must be LOSSLESS.** `_split_file_patches` returns contiguous slices so `"".join(...)` reproduces the input byte for byte — a `patch.split("\ndiff --git ")` eats the newline separating two files and silently corrupts every multi-file patch, which is why it indexes instead. The header match is anchored to line start, or a body line quoting the string splits one file into two. A single file over the cap is kept WHOLE: the client's remedy is `path=`, and an unparseable fragment is worse than a large body.
- **Scope is the worktree against `state.base_commit` INCLUDING untracked — the creation anchor, not `HEAD`.** Anchoring on HEAD made the patch go blank at the first commit, which is exactly when a reviewer opens it. The consequence is that `dirty_files` no longer equals the patch's file count once anything is committed, and the two are now pinned as *deliberately different questions*: the patch answers "what has this workspace changed", the counter answers "what is uncommitted right now" and stays the cheap single `git status` the ~1 Hz activity fingerprint needs. **The earlier "they must agree" invariant was really "neither may silently answer a third question"** — it held them together on the axis that was wrong for both.
- **`available` is a separate field from an empty patch, and collapsing them is the bug.** "git cannot answer here" (no repo, a paused workspace whose worktree is gone) and "nothing changed" are different facts a reader acts on differently — helper text against an empty state — and one field answering both makes the degraded answer indistinguishable from the confident one. `reason` is set iff `available` is false.
- **`peek()` is best-effort by contract — it never raises.** Helpers can fail (git/tmux gone, branch deleted); peek returns zeros/empty rather than break the render loop. Lifecycle methods keep the loud, transactional failure surface.
- **The pane snapshot captures scrollback, not just the live viewport.** `tmux capture-pane` with no `-S` starts at the *top of the visible viewport*, so a long session previews as only its current screen; `capture_pane_snapshot` passes `-S -<cfg.tmux.peek_history_lines>` (default 500) and returns the whole grid with only trailing blank rows trimmed — **core never pre-crops to a fixed line count for everyone**, the client owns the viewport. `-J` is deliberately NOT used: rejoining wraps yields logical lines far wider than the pane, which renderers then clip, so the raw grid is the faithful snapshot.
- **Send Keys is terminal input, not provider intent.** One `SendKey` enum member reaches `_agent_pane` and its existing `TmuxPane.send_keys`, so the pane and server cannot drift from peek/message delivery. Never accept a caller's target, command prefix, raw bytes or repeat count. Remote and paneless runtimes refuse rather than typing into a decoy shell; the separate `interrupt` verb retains their native cancellation channel. A named Enter is sent once, with no text-residual retry, and acknowledgement says only delivered — the application owns its effect.
- **Steering is manager policy over reused seams.** The pane resolves via the same policy peek uses (None → typed `PaneNotFound`); injection is `tmux.send_text` (bracketed paste plus a separate Enter, loud `TmuxError`). Remote-steered kinds funnel through `_steer_remote` — THE single remote dispatch point — with no liveness gate on purpose, since `/message` re-engages idle/finished sessions by design. The `message_sent` event carries text *length*, never content.
- **Pane-target resolution is manager-side policy, never tmux-side.** `tmux.list_windows(session)` is mechanism — list whatever exists, never raise. `pane_target(id)` is policy — prefer the configured `agent_window_name`, fall back to the first non-`shell` window, then `shell`, return `None` only with no windows. Hard-coding `f"{session}:{agent}"` was the original peek-empty bug.
- **`WorkspaceStatus` is one enum, two domains.** Persisted: `RUNNING`/`PAUSED`/`ERROR` (written by the lifecycle verbs). Computed at read time and never persisted: `ACTIVE`/`IDLE`/`OFFLINE`/`ORPHANED`. `_reconcile_status` is the **single** policy site promoting persisted → displayed; `JsonWorkspaceStore.save` rejects non-`PERSISTED_STATUSES` as defense in depth. Adding a computed status means extending the enum + `STATUS_HEX`/`STATUS_GLYPH`/`STATUS_LABEL` + `_reconcile_status` + the TUI footer gate. Never branch on raw `RUNNING` outside `_reconcile_status` and the lifecycle writers.
- **Active vs Idle reads `tmux #{window_activity}`, NOT `#{pane_activity}`.** `pane_activity` needs tmux ≥3.4 and returns empty on 3.3 and below (Ubuntu 22.04 ships 3.2a) → every workspace reads IDLE. `window_activity` is broadly supported and, since Grove runs one pane per window, equivalent. The public helper stays `pane_activity_seconds_ago`; the implementation reads `window_activity`. Threshold is `cfg.tmux.activity_threshold_seconds`; unknown/future/non-numeric age → `None` → IDLE. No snapshot-diff cache — tmux is the source of truth.
- **A caller that already holds a reconciled `WorkspaceState` must resolve its pane via `pane_target_for(state)`, never `pane_target(workspace_id)`.** The id-only method re-fetches from the store and re-runs `_reconcile_status` from scratch, roughly doubling `has_session`/`pane_activity` and adding a third `list_windows` per workspace per poll for nothing.
- **The workspace axis has a CONTAINER dimension, and it deliberately maps onto the existing `OFFLINE` rather than a fifth computed status.** Worktree dir, `has_session` and pane activity all describe the *window*, not what is behind it: a `docker stop`/`rm` behind Grove's back leaves the session up with fresh activity, so the workspace read ACTIVE, decayed to IDLE, and **no verb could take it** (`resume` wants PAUSED, `respawn` wants OFFLINE). Reconcile asks `_container_state(state)` (gated on `state.runtime is CONTAINER`, so a host workspace never reaches for docker) and demotes anything not `ContainerState.RUNNING` to OFFLINE. **Reusing OFFLINE is why recovery works for free:** it already means *the runtime hosting the agent is gone, the worktree is intact, respawn is the remedy*. A distinct status would need every client taught, to offer the same single action. The knock-on is what finally gates `UNPROVISIONED`: OFFLINE is refused by `ensure_can_attach`/`ensure_can_steer`, which is exactly "an autonomous agent must not be let into a container whose lifecycle hooks never reported success".
- **A read on the reconcile path must be memoized at the READER, and "cannot tell" must not be spelled as an answer.** `ContainerLiveness` (in `container_runtime.py`, beside the identity that owns the argv) TTL-caches `ContainerLifecycle.status()` per container id: reconcile runs per workspace per poll AND is re-entered per hook event, while a `docker inspect` costs ~16 ms — one entry per identity per 5 s window decouples the fork rate from the caller's tick rate, and expired entries are swept on each miss so a long-lived daemon does not leak one per container ever seen. The subtler half: collapsing "exited non-zero" and "never started" into `None` reads as `ABSENT`, which is harmless until reconcile *acts* on the answer — then a `docker` missing from a systemd unit's bare PATH reports **every container workspace on the host as dead while all of them keep running**. Hence `DockerCli.read_result` (the `CompletedProcess`, or `None` only when no process started) and `status() -> ContainerState | None`. **A value nothing consumed was never forced to be honest about its own uncertainty, and the render path is where that bill comes due.**
- **The `XDG_*` cascade is deliberate and stays; what was missing is that nothing SAID it applied.** `platformdirs` reading `XDG_CONFIG_HOME`/`XDG_STATE_HOME` is load-bearing (the screenshot sandbox redirects all five on purpose), so the defect is never "Grove honoured the override" but "three surfaces disagreed in silence": a `grove` inheriting a leaked pair listed 1 workspace where the repo had 3, reported 3 fictional ones host-wide, and answered `no workspace matches` for the caller's own id, each surface internally consistent. Two reporting-only seams, neither of which changes resolution: `paths.dir_overrides()` (the ONE place that knows which variable drives which root) surfaces on `grove debug` as `path_overrides`, and `warn_if_store_disowns_caller` cross-checks the id in `GROVE_PHASE_FILE` against the resolved store. **The cross-check is cheap and has no false-positive class because it compares two facts Grove already holds** — there is no ordinary way to be launched by a Grove that then cannot find you — and it must ride BESIDE the result on stderr, never replace it, or `grove ls | jq` breaks. **When an override mechanism is correct, the bug report is about detectability; resist the instinct to disable the mechanism.**
- **Every whole-file rewrite goes through `paths.write_atomic`, and the property it buys is the STAGE NAME, not `os.replace`.** `os.replace` is atomic; a fixed `<file>.tmp` beside it is not. Grove is several processes over one state directory — daemon, a `grove` CLI verb, the TUI — so two writers truncated and filled the same stage file and one published the interleaved result, which is unrecoverable where a lost update is merely annoying. `mkstemp` in the target directory gives each writer a private name. The residual last-writer-wins needs a second mechanism: **`paths.exclusive_lock`, held by `JsonWorkspaceStore.save`/`delete` across the WHOLE read-modify-write** (`auth.json`'s identical half is still open). Two properties of that lock are forced rather than chosen. It locks a **sidecar** file, never the target, *because* of `write_atomic`: publication is a rename, so a second writer opening the target by name holds a different inode and the two locks exclude nothing — **atomic publication and file locking interact, and the obvious combination of them is a no-op.** And reads stay unlocked deliberately: the rename already gives them all-or-nothing. `flock` is per open file description, which is what lets two threads stand in for two processes in a test. The helper applies the mode to the STAGE file, so a 0600 file is never briefly 0644 at its final path — and the config dir is 0755, so that mode is the only protection, not defence in depth over a umask. `config.py`, `phase.py` and the hook sidecar keep the simpler shape deliberately: they are one-shot or per-workspace writers, and `phase.json` + the sidecar are read across a container boundary where a private-by-default mode is a real hazard.
- **A managed diagram is one existing regular worktree file, never a path capability.** `DiagramFiles` takes a per-file no-follow sidecar lock, re-checks containment and target type under it, hashes the raw source while locked, and atomically publishes validated uncompressed XML with the original mode. Its descriptor is the collaboration generation: stop fences updates by both session id and revision, and a later reopen mints a new id. That serializes Grove writers (including root-placement aliases); arbitrary shell writes remain outside the lock and are detected only by the raw-byte precondition.
- **`branch_provenance` defaults to `GROVE_CREATED` so records written before the field existed load without migration.** Same precedent as `placement` and `agent_session_id`.
## What a workspace was CALLED outlives the workspace (`workspace_history.py`)
`title`, `description`, the phase claims and the attached tickets live only in `state.json` and a phase file inside the worktree, and `kill` deletes both — the normal end of a task. So the usage audit kept every session row and lost every human-readable fact about them (**measured 2026-09-14: 2 of 10 stored workspace ids already named workspaces that no longer existed**). `WorkspaceHistoryStore` is the durable record; the split from the disposable usage cache, and the ATTACH that joins them, are argued in [usage](usage/CLAUDE.md).
- **`JsonWorkspaceStore.save`/`delete` are the ONE chokepoint every title assignment and every teardown passes through**, which is why the recorder lives there rather than in each manager verb — `create`, `update` and every other mutation are covered by one capture, and a future verb cannot forget what it never had to remember. Both run AFTER `paths.exclusive_lock` releases: a secondary record must never hold the lock three processes contend for, and must never be the reason a save fails. `delete` TOMBSTONES (`deleted_at`) rather than deleting, because the row surviving is the entire point.
- **The ~1 Hz tick is the only witness to a phase NOTE.** `_snapshot_row` already calls `phase_for`, so `ActivityService._record_history` adds no I/O to the poll path — the claim it just read is the sole existing copy, since the agent overwrites the file whole on its next transition.
- **A SQLite PRIMARY KEY treats NULL as DISTINCT, so ONE nullable key column defeats the whole dedupe.** The progress table keys on the CLAIM's content (never on `recorded_at`, which would append a row per second forever), and `note` is the column that proved it: `grove phase implementing` with no note is the ordinary case, and **500 identical note-less ticks wrote 500 rows** — ~86,400 per day per workspace. Every key column is `NOT NULL DEFAULT ''`, translated back to `None` at the boundary. This is the one place in the tree that deliberately gives up the absent-vs-empty distinction, because a note is prose for a human where `''` and NULL render identically, and an unbounded table is a real fault.
- **A whole-second timestamp is not a unique key for a human action, and the create-then-rename window is SUB-SECOND.** `PRIMARY KEY (workspace_id, recorded_at)` on the name history silently discarded a legitimate rename — the web composer and `grove edit` both do it inside one second. That table has no uniqueness constraint at all; the caller's exact `previous` read is the dedupe. Its read then needs `ORDER BY recorded_at DESC, rowid DESC`, because ordering by a tied timestamp alone returns insertion order — oldest-first, the inverse of the contract.
- **Reading it back needs a SHARED instance, not just a shared path.** The daemon built its own store beside the one `JsonWorkspaceStore` builds lazily: same file in production, so it works, but two caches of one truth — and a test injecting one and reading through the other saw a tombstone nobody wrote. `JsonWorkspaceStore.history` is public so `build_app` shares the writer's own.
- **`GET /workspaces/{id}/history` is the ONE per-workspace route with no workspace-exists gate**, and copying `/todo`'s `_manager_for` made a killed workspace's history unreachable through the only route that serves it. Reasoning in [daemon](../daemon/CLAUDE.md).
- **Recording is FORWARD-ONLY and a backfill is refused.** A workspace killed before this shipped has no source left but a guess, and a guessed title is indistinguishable on the wire from a recorded one — the same argument that declined the `base_commit` backfill. Absent reads as absent.
## Containerized workspaces
Grove provisions through the `@devcontainers/cli`; the project's committed `.devcontainer/` defines the container. Measurements are from the reference host (Docker 29.6.x, CLI 0.88.0) unless stated.
Four failure modes recur across this subsystem; each concrete instance below is one of them. **"Cannot tell" must never be spelled as an answer** — collapsing an unreadable boundary into a confident value is how a healthy fleet renders as dead. **Best-effort is honest only while the failure leaves a handle behind.** **A member no producer reaches reads as "handled" in review and in a green suite** — test the producer. **A fake boundary scripts its answers, so anything you learn from an external command's real output or exit status must be exercised against the real producer at least once.**
### The CLI boundary
- **The devcontainer config model must be LOSSLESS, not complete.** `--override-config` REPLACES the project config wholesale, so Grove's Pydantic model types only the fields it reasons about and carries the rest via `extra="allow"`. A typed field with no alias, or `extra="ignore"`, silently deletes a project's `features`/`postCreateCommand` on every launch. Symptom: "my postCreateCommand stopped running", never an error.
- **`mergedConfiguration` is A DIFFERENT SCHEMA from `devcontainer.json`, in three classes each taking a different remedy.** **RENAMED** — the five lifecycle hooks become plural arrays holding the project's hook alongside every Feature's → **translate on the way out**, since the override's reader knows only the singular name. **RESHAPED** — every `customizations` namespace becomes a list of per-layer objects → **tolerate on the way in, never translate**, because flattening re-implements the CLI's per-tool merge and getting it wrong corrupts a project's editor config rather than merely losing it. **MATERIALIZED DEFAULTS** — `init`, `privileged`, `portsAttributes` appear with values the raw config never mentioned → resolve against the RAW envelope on `ReadConfigurationResult.configuration`, because merged has destroyed the fact the consumer needs (did the project say anything at all). **Translate when what is lost is a NAME, tolerate when it is a SHAPE, reach for raw when it is the ABSENCE of a value.**
- **Un-materializing a default needs the raw config as WITNESS, and absence from the raw config does NOT mean nobody asked** — a Feature can contribute `init: true` without the project naming it. A key is dropped only when the merged value **also equals the CLI's own default**. Membership is a table (`MATERIALIZED_DEFAULTS`), not a branch; `portsAttributes`/`remoteEnv`/`containerEnv` are excluded despite also being invented, because an empty collection merges as identity and carries no intent. **Only a materialized SCALAR is mistakable for a pin.**
- **The CLI writes `.postCreateCommandMarker` and friends even when nothing ran**, so a later `up` on that container skips postCreate for good. Verify any fix on a FRESHLY CREATED workspace or a working fix looks broken; the remedy for an affected workspace is `kill` + recreate, never resume.
- **A failed `devcontainer up` can still have created a container** — the `outcome:error` event arrives WITH a `containerId`, which is why Grove's error type carries the id. **Any boundary whose failure can leave a resource behind must put the handle on the error type.**
- **The CLI's failure object names only the COMMAND, so the container's own refusal must be picked out of the progress STREAM — a log tail is the wrong seam**, being the CLI's Node stack trace while the sentence naming the cause sits above it. `_ProvisionLog` captures lines carrying Grove's own `grove:` marker as they stream. Two riders: dedupe (a `sudo -n … || …` fallback prints every refusal twice), and capture from the stream rather than by re-reading the file, so a log Grove could not open still yields a diagnosis.
- **A container-creation property is either a DEFAULT or an ASSERTION, never both.** `init` is a **default**: the CLI's entrypoint reaps but ends in `exec "$@"`, so under `overrideCommand: false` or a compose service a non-reaping project command becomes PID 1 and leaks a zombie per orphan (40 orphans → 40 permanent zombies; `init: true` → 0) — yet Grove yields to a project that pins `init` itself, because an image running systemd checks `getpid() == 1`. The **restart policy is an assertion**: `always` resurrects a PAUSED container when the docker daemon restarts, and a docker-level restart does not re-run `postStartCommand`, so the agent returns with **no egress firewall**; Grove wins by construction, since overlay `runArgs` append after the project's and docker takes the last occurrence of a non-repeatable flag. **`shutdownAction` is a verified non-issue — do not pin it:** the 0.88.0 bundle contains no `stopContainer`/`stopCompose` string at all. **Ask whether a supervision property protects the isolation contract (assert it) or is a courtesy to the unspecified case (default it).**
### Mounts, control files and blast radius
- **A linked worktree's `.git` is a pointer FILE naming the common dir by absolute path, so any container mount must bind that common dir at the SAME absolute path inside the container** or the worktree's git commands break the instant they run. `GitRepo.common_dir()` is the one seam that resolves it.
- **That identical-path mount makes Docker pre-create ROOT-OWNED directories inside the container mirroring arbitrary HOST path segments**, before anything the project controls runs. **Any tool inside the container that checks the OWNERSHIP of a path matching one of those segments will see root and may refuse to run**, with a symptom pointing nowhere near the mount that caused it. Deliberately not defended against: a per-tool `TMPDIR` default would be policy in code guarding one vendor's naming scheme.
- **Grove's control files cross as individual `:ro` file binds, never as their enclosing directory, and this is a security boundary.** `--settings`, `--channels` and `--mcp-config` live in `~/.config/grove/` — and so do `webapp-sessions.json` (LIVE plaintext daemon bearer tokens), `auth.json`, `hook-ingest.token`, `channel.token` and the `*.env` files. A `:ro` bind of that directory hands a relaxed-permissions agent full daemon API access across EVERY repo on the host; read-only does not help, because the value of a token is in reading it. **"These files share a directory" is never on its own a reason to mount the directory.** `container_control_path` answers off the plan's actual `mounts`, so "reachable" means *actually mounted* — recomputing it from the rule that built the table returns a container path for something not mounted.
- **A mount table names its sources at PROVISION; the control files are written at LAUNCH; on `create` provision runs first.** On a machine where no host launch had ever run, the first container create planned a mount for a file that did not exist yet, the plan correctly dropped it, the workspace came up with hooks dead — and it self-healed on the second create, so a fresh install or CI runner hit it and no developer machine did. **The drop rule must not be relaxed** (a missing bind source is materialized root-owned at the path Grove's own writer later needs); the ORDER is the bug, so `_ensure_control_files` renders all three before the provisioner runs. The launch still rewrites them safely, because `write_text` truncates in place and a container bound to that inode sees the new bytes. **A "drop what does not exist" rule turns every producer/consumer ordering into a silent feature loss: ask who writes this, and whether that runs first on EVERY path.** Test it from a path where the file genuinely does not exist yet.
- **Reachability of a control file is TWO questions — can the agent OPEN it, and can the agent RUN what it names.** `--channels` and `--mcp-config` are perfectly mountable files whose *content* registers a stdio server commanded by `sys.executable`, i.e. the HOST interpreter; a seeded `.claude.json` copying the host's `mcpServers` map has the same shape. **A registration pointing at an absent binary is strictly worse than no registration**, worst of all for `--permission-prompt-tool`, which would gate every tool call on a server that can never answer. Withhold the mount, `container_control_path` says `None`, the launch omits the flag. **Drop the whole `mcpServers` key rather than filtering by command shape** — the seed is written before `up`, so nothing there can know the container's `PATH`. Log the withholding when the file exists on disk, because a silently-inert *enabled* feature is the complaint.
- **Mounting host paths INTO a directory does not make that directory exist on the host — the config root has to be bound too.** `AgentSharePlan` binds `~/.claude/settings.json` &c. under `/grove/agent-config/<kind>/…`; until the plan also bound its own per-workspace host dir AT that root, the seeded `.claude.json` went to a host path nothing read and the agent's TRANSCRIPT lived and died inside the container, so every containerized workspace had no agent axis while the code looked wired. **A mount table that only ever mounts leaves gives you no host anchor for anything the container itself creates.** Nested binds under a bound parent do work (Docker mounts a target before its children). Two riders: a bind SOURCE must pre-exist or Docker materializes it root-owned (hence `seed()` creates the dir even when it seeds nothing), and Docker creates each leaf mountpoint inside the parent's host directory, so empty placeholder entries there are the mount machinery, not stray files.
- **The container backend answers `TranscriptContext` off the MOUNT TABLE, never off the share level or the launch env.** `config_dir` is the plan's `transcript_host_dir`; `agent_cwd` stays the container's own `remoteWorkspaceFolder`-derived string, matched opaquely against each record's `cwd`. Load-bearing because `share: projects` binds the real `~/.claude/projects` OVER the config root's own, so at that level transcripts land in the user's home and the workspace directory never receives one.
- **`kill` deliberately does NOT remove `paths.agent_workspace_config_dir` — that is what keeps "transcripts outlive worktrees" true for a container**, whose history lives under Grove's own per-workspace directory rather than the user's config dir. Pinned by test, because it is the obvious target for a future "tidy up after ourselves" pass.
- **A `:ro` share is only correct where the CONSUMER treats that path as read-only, and a vendor shipping an explicit read-only-share mechanism is telling you the plain directory is not one.** Binding the host `~/.claude/plugins` `:ro` at the tool's own WRITABLE plugin root made every marketplace refresh a `rename()` into a read-only bind (`EROFS`); those manifests also record absolute HOST paths that resolve to nothing inside a container. The fix is the vendor's own seed-dir env var, with the writable root left at its default inside the per-workspace `:rw` config-root bind. A per-workspace COPY was rejected on measurement: 543 MB per workspace, 417 MB of it a single marketplace. **Before sharing any directory, ask: does the tool WRITE here?**
- **Forcing one git setting inside a container, composably, means `GIT_CONFIG_COUNT`/`_KEY_n`/`_VALUE_n` env — never a config file or a lifecycle hook**, both of which collide with the project's own devcontainer config.
- **`workspace.py` cannot import `container_runtime` at runtime — the cycle runs through `git.py` → `workspace.CommitSummary`.** The `TYPE_CHECKING`-only import is load-bearing.
### Environment injection
- **Lifecycle hooks and the agent are TWO separate roads into a container, and feeding one does not feed the other.** `up --secrets-file` takes a **JSON object of string pairs (parsed as JSONC), NOT a dotenv file** — nothing validates the shape, so a dotenv there parses to nothing and injects silently-empty. Those secrets reach only the lifecycle-hook environment, are never folded into `containerEnv`, and **`exec` has no `--secrets-file` at all** (only `--remote-env`), so `EnvSource` is consumed at two boundaries. The CLI masks every secret VALUE in its own output, so the provision log is safe by construction.
- **`python-dotenv` is the wrong library for a SECRETS parser:** it performs POSIX `${VAR}` expansion by default, so any secret containing a `$` is silently corrupted into something that still looks valid. Grove's parser is deliberately literal.
- **A committed layer's trust rule has THREE shapes.** `CommittedShareFloor` is *tighten-only*. `CommittedEnvSource` adds *outright denial* for `env_command` (honouring an arbitrary command from a file that travels with the repo is RCE on clone) and *allowed-but-constrained* for `env_file` (it executes nothing, so denial would kill the team convention of committing `.grove/container.env`, but an ABSOLUTE path from a committed layer is an exfiltration primitive). **The question that picks the shape is "what does this let a committed layer REACH that a committed `.devcontainer/` could not already reach"** — a repo can already run arbitrary code inside its own container, so the delta for `env_file` is precisely the host filesystem. The containment predicate is a pure string check on the config model, because the cascade pre-pass runs before any repo root exists to resolve against.
- **A committed `panels` declaration is contained by a verified compose project, not stripped from the committed layer.** It may name only a service + port, never an origin, host, URL or Docker id. `PanelResolver` refuses unless the workspace's persisted `compose_owned` proof (the `ContainerRuntimeState.owns_project` evidence from the Grove-labelled container minted by `up`) holds, then resolves that exact service through Docker's compose labels. The service lookup is NOT a second ownership check; it narrows within the existing proof. The destination is re-read per request, never persisted/cached, because a recycled compose IP would turn yesterday's containment result into a claim about today's container. Unavailable means omission from the panel list and a clean direct-route refusal, never a synthetic empty panel.
- **A committed layer that WINS an exclusive group and is then stripped must leave the group EMPTY, never promote the loser** — `ExclusiveGroups` runs first and strips members from every lower layer, and re-promoting one would silently let a committed layer *choose which of the operator's own sources applies*. Pinned by test.
- **Resolving a secret twice per start beats caching it, and the argument is LIFETIME:** a daemon's `WorkspaceManager` is cached per repo for the process's whole life, so a cache would hold resolved secrets in memory for days and keep serving a value the store had already rotated. The contract instead requires the command to be idempotent and cheap.
- **The seam is section-shaped, not container-shaped.** `env_source.py::EnvSource` resolves the `env_file`/`env_command` pair of any `EnvSourceConfig`; `ContainerConfig` and `TicketsConfig` inherit the fields, `EXCLUSIVE_FIELDS`, the containment predicate and the both-set validator, declaring only a `SECTION` string. `ExclusiveGroups.GROUPS` and `CommittedEnvSource.SECTIONS` are keyed off `<Model>.SECTION`, so **registering a section in one guard and forgetting the other is the hole to watch.**
- **The residual exposure is the pane, and it is inherent to `--remote-env`:** an injected value lands in the pane's scrollback (which `peek` captures) and in `ps` for the same uid. Deliberately not closed; the two options are a launcher script outside the worktree (fixes the pane, not `ps`) or a bind-mounted env file sourced by the entry command (fixes both, costs a mount plus an entry-token change).
### Egress
- **An egress policy applied only to OUTPUT is not an egress policy — it misses everything the container ROUTES.** A docker-in-docker workspace *forwards* its nested containers' traffic rather than originating it, so an IPv4 OUTPUT allowlist misses it entirely and one `docker run` reaches the internet. The policy belongs in **`DOCKER-USER`**, which docker jumps to from the top of FORWARD ahead of its own ACCEPTs; rules placed there before `dockerd` first starts survive its startup, which is what makes the policy immune to the ordering race with `postStartCommand`. **Do NOT reach for `-P FORWARD DROP`:** nested container-to-container traffic is forwarded too and never leaves the container. The discriminator is the interface carrying the default route, and a pre-chain must `RETURN` (not `ACCEPT`) so it falls through to docker's own rules. Both chains are emitted from one helper, because a second copy of the rule is how the nested half came to be missing.
- **A self-check must be wrong in NEITHER direction.** A runtime canary proves only the namespace it runs in, so it stayed green across a live nested bypass indefinitely — and `iptables -S OUTPUT | head -1 | grep -q` **fails a CORRECT firewall**: `head`/`grep -q` exit at first match, `iptables` dies of SIGPIPE, and `set -o pipefail` promotes 141 to the pipeline status on any ruleset large enough to fill the pipe buffer, which is every real workspace. Assert with command substitution plus `case`. Where a probe cannot reach, **assert structurally and say in the script what the probe does not cover**.
- **An apply-time DNS pin is authoritative for one TTL, and `github.com`'s is 42 SECONDS.** A `/32` pinned at container start is right for under a minute and a coin flip thereafter — `gh`, `git fetch` and every GitHub download fail *as a timeout*, which reads as a network fault rather than a policy one, and **two-call `dig` checks disprove nothing** (the rotation is TTL-driven, not per-query). The fix is published CIDR ranges fetched host-side, expressed as config (`RangeSource{url, keys}`). **Dropping the v6 prefixes is load-bearing:** `iptables -d` rejects a v6 address under `set -e`, so one unfiltered v6 CIDR aborts the whole firewall and fails the container start.
- **The `0.0.0.0` guard has a blind twin: a name that resolves to NOTHING never reaches the guard at all.** `allow` is loud about an unusable address, but the `getent ahostsv4` loop simply iterated zero times and contributed no rule, in silence, on every start — measured live, `host.docker.internal` returns zero IPv4 addresses in a plain bridge-network container, so a Grove-plane allowlist entry was vanishing from the firewall with nothing said. **A guard placed on the value cannot fire when there is no value; check the empty case separately.** The skip message carries the `grove:` marker so it lands in the provision log and in any raised diagnosis. **And the check that reports the empty case must survive producing it:** the first fix rewrote the loop to `addrs=$(getent …)`, which under `set -euo pipefail` DIES on getent's exit 2 before the `[ -n "$addrs" ]` guard runs — aborting the entire firewall mid-loop, on every start, for every containerized workspace, because `host.docker.internal` is a built-in entry that never resolves. The old `for addr in $(…)` form was immune because a substitution inside a for-list is not a checked command. The assignment now carries `|| addrs=`, and the regression test EXECUTES the generated loop under a stubbed `getent` — asserting on the script's TEXT passed through the original breakage, because the guard's string was present the whole time.
- **A resolver that answers with `0.0.0.0` turns an allowlist entry into a rule that looks like an allow and is not** — a bare `iptables -A OUTPUT -d "$1"` becomes `-d 0.0.0.0/32 -j ACCEPT` on any host running Pi-hole or AdGuard. **Never emit a firewall rule for an unusable address, and say loudly when one is skipped.**
- **IPv6 is a separate hole** (`ip6tables -P OUTPUT ACCEPT`), latent only because the bridge has no v6 route.
- **Grove supplies the `iptables` binaries the standard base image does not, because a fail-closed control resourced from somebody else's image is not fail-closed.** `mcr.microsoft.com/devcontainers/base:ubuntu-24.04` ships no `iptables` at all and the allowlist runs as a `postStartCommand` under `set -e`, so `devcontainer up` failed outright under Grove's own defaults. A pre-`up` image preflight cannot honestly run (for a `build.dockerfile` or a compose service the image does not exist yet), and degrading to `egress.mode: open` is refused on principle, so Grove contributes the binaries through the static-build → per-version host cache → read-only bind mechanism (15.6 s cold build, 575 KB per copy, a real `up` then applying a 68-rule OUTPUT chain that genuinely enforces). **`ip` (iproute2) is deliberately NOT supplied**: replacing its two uses means hand-parsing `/proc/net/route`, clever code in the one script where clever code is least welcome.
- **libtool EATS a plain `-static` on the final link**, so `configure LDFLAGS=-static` yields a musl-DYNAMIC binary that runs in the builder and dies with `not found` in the target image — a failure that reads as a missing FILE, not a missing loader (`ldd`/`file` in the build stage disproves it). `-all-static` passed to *configure* breaks configure's own compiler test, so the working form is `make LDFLAGS="-all-static"`. `--disable-nftables` picks the legacy backend and drops libmnl/libnftnl — safe because this bundle is only reached in an image with no `iptables` at all, hence no nested `dockerd` with an opinion about the backend.
- **The firewall payload selects its architecture by EXECUTION, unlike the tmux bundle**, because it is consumed *during* `up` by the `postStartCommand`, so there is no "after" in which to probe. The script walks `bin/*` and takes the first candidate that answers `--version`; a wrong-arch binary is skipped rather than dying at `Exec format error`.
- **A payload gated only at the BUILD still gets MOUNTED** — `egress.mode: open` runs no script, yet an already-cached bundle still appeared in that workspace's override config. The producer helper returns `None` for that mode so build and mount cannot disagree.
### Teardown, ownership and orphans
- **Teardown safety is testable without spawning processes because the argv builders live on the runtime-state identity (pure methods), not on a driver.** Invariant: no Grove teardown may name a container it did not create, enforced with full 64-char ids only (`docker rm` resolves prefixes, so a truncated id could collide). Two fail-closed arms that must not be conflated: an unexpected `composeProjectName` degrades to label-filtered removal (still tears down), while an unreadable engine reports unavailable.
- **A conservative fallback whose trigger condition is ALWAYS TRUE has quietly become the primary path.** Compose teardown demanded a reserved `grove-` prefix on a project name **Grove does not mint** (the CLI derives it from the worktree basename), so the check failed for every real stack and the label-filtered fallback — which reaches the PRIMARY service alone — was the only arm that ever ran, leaving siblings, the network and the volumes. **Audit any fail-safe branch by asking how often its condition actually holds.** Ownership is now recorded at the mint and verified against the `com.docker.compose.project` label on the Grove-labelled container. **Mode and ownership had to become SEPARATE fields**: while one field meant both "is a stack" and "may be torn down as one", an unverifiable stack rendered as a plain container *and teardown reported success*. **One field answering two questions makes the degraded answer indistinguishable from a confident one.**
- **Volume ownership is decided by matching the container's real mount table against the PATTERNS in the config Grove itself wrote — never by attachment, never by absence of declaration.** `read-configuration --include-merged-configuration` hands feature-contributed mount sources over with `${devcontainerId}` **unsubstituted**, the only honest per-container-lifetime signal available. Both inverse rules are wrong: "everything attached" sweeps shared caches concurrent workspaces depend on, and "anything we didn't declare" sweeps a compose stack's database, since compose volumes never appear in `mounts`. **Anonymous volumes are deliberately NOT claimed** despite looking per-container: a compose service declaring a bare data path gets one holding the database, indistinguishable on the mount table from image scratch. Under-deleting is a disk leak, over-deleting destroys data; the rule fails toward the leak.
- **A compose devcontainer silently UN-SHARES every volume declared in `mounts`:** the CLI turns a `mounts` entry into a *compose* volume and compose namespaces by project, so a source declared shared becomes `<project>_<source>`, i.e. per workspace. Invisible without counting `docker volume ls`.
- **Per-workspace image reclamation does NOT belong in teardown:** the CLI builds two tags per workspace and the container runs the thin child, so untagging exactly what teardown can *prove* it owns reclaims **0 GB** — the parent tag pins every layer, and several workspaces' tags routinely point at ONE image id. Two hazards for anyone tempted anyway: a config with an explicit `image` and no features runs the user's OWN base image, and reaching the parent tag means deriving it from the child's name, exactly the guessing the ownership rule removes.
- **An enumeration must return the IDENTITY, not the name — and when a discriminator needs a naming convention to hold, check who does the naming.** A sweep reading `{{.Names}}` and parsing a `grove-ws-` prefix matches nothing, because the devcontainer CLI names the containers. Reading the `grove.workspace` label is also **smaller**: project scope becomes structurally excluded (a project label set cannot carry the workspace key) where a name rule needs a new prefix case per scoped object. Helpers claiming to name containers or volumes were deleted rather than corrected — Grove names neither, so both were fiction that read as authoritative. And **a label-anchored volume sweep reporting nothing is CORRECT, not a gap**: the volumes that leak are minted by the CLI's own features and carry no labels, so that class is answered by the record's `owned_volumes`, while "which volumes does nothing reference" is `dangling=true`, host-wide and mostly not Grove's. **Two different questions about leaked volumes need two different mechanisms, and an acceptance criterion written from the symptom will demand the wrong one.**
- **This subsystem's recurring defect is machinery that is defined, typed, documented and unreached. Refuse the third state: populate it or delete it, per MEMBER.** Grep for members whose only callers are tests BEFORE writing new code. Three checks, because it bites three ways:
- **Unreached code DRIFTS, so ask whether an orphan still WORKS, not just whether it is reachable.** An uncalled sweep whose identity paths parse a name Grove stopped producing fails in *opposite* directions: every Grove container reports as orphaned (running ones included, since `"" not in live` is True) while no real volume name is recognised at all. Wiring it would label a user's live workspaces as leaks, and acting on that report destroys running work.
- **"Built but unwired" and "built, unwired, and INCOMPLETE" are different diagnoses, and only the second is visible by RUNNING it.** A VS Code server volume mount looked like a wiring gap; applied for real, a fresh named volume mounts **root-owned** when its target does not exist in the image and the server installs as the remote user, so the mount does not save the download, it **prevents the install**. Same for `${containerEnv:HOME}` in a mount target (not substituted — it fails the whole `up`) and for a resource-limit knob filed as inert that had a live path which had never worked on any runtime (`docker update --memory` is rejected unless `--memory-swap` moves with it).
- **A dead field with a plausible DEFAULT is worse than a dead field, because the default is a claim.** Eight `ContainerConfig` knobs outlived the `docker run` backend that read them, keeping their docstrings, types and place in the published schema; one defaulted to a plausible mount target while the real value is chosen by the CLI, so a user reconciling the two would conclude Grove ignores its own config. **The orphan rule is about members with no CALLER; this is about members with no READER but a live SCHEMA.**
- **Deleting a persisted field is a MIGRATION, not a deletion.** `ContainerRuntimeState` is `extra="forbid"` and the store decoder re-validates loudly on purpose (a dropped container record orphans a real container), so removing a field fails every record on disk at the next daemon start. `RETIRED_FIELDS` names the retired keys and drops those, while an unknown key Grove never wrote still fails loudly. **That seam covers ONE model:** any other persisted `extra="forbid"` model has the same trap and no equivalent — grep before the next field removal.
- **Adding a persisted field is the SAFE direction — and it is still a WIRE change.** `ContainerRuntimeState` is shared verbatim with `contracts.views.WorkspaceView`, so `webapp/lib/grove/types.gen.ts` must be regenerated in the same change (in-process `build_app(...).openapi()` piped to `openapi-typescript`, no running daemon needed).
### Verification discipline
- **A correct reproduction does not imply a correct diagnosis, and verifying a BOUNDARY is not verifying the product path.** An audit reproduced its findings by execution and still pointed at the wrong lines twice — a blackout whose raise came from a *third* unguarded site, an injection with a *third* vector. **Re-reproduce a filed finding yourself, and keep going after the first reproduction succeeds.** Separately, containers shipped default-on verified only by hand-invoked `devcontainer build`/`up`; one real `grove create --runtime container` surfaced ten bugs, every one between the CLI boundary and Grove's own composition.
- **A test double that answers with a SIMPLER SHAPE than the real boundary does not under-test, it tests a different program, and it reports full confidence while doing so.** A fake returning a raw-only configuration result meant every container test exercised an arm production cannot reach (production always passes `include_merged=True`) — the mechanism that let four bugs ship green. **When a boundary has two response shapes and production only ever sees one, the fake must default to that one.** Two corollaries: **pin a double to a captured payload from the real boundary, NEVER to the production code's own model of it** (sharing the model makes production's belief the double's belief, so the two can never be caught disagreeing); and **prove a fixed double by MUTATION**, since closing a false-green one may produce zero new failures.
- **A new pervasive external dependency needs a pervasive autouse neutralizer, not a per-test opt-in** — containers-by-default made EVERY existing test a container test. **Reconciliation is a THIRD door after create and project registration:** merely LISTING a container workspace reaches `docker`, so the read is stubbed too, for determinism rather than speed. **The autouse neutralizer list is a map of doors, and read paths are the ones nobody thinks to add.**
- **A `ClassVar` does not satisfy a protocol member declared as a plain instance attribute (mypy)** — flagged at the first concrete backend that returns it that way, never at the protocol definition.
- **A capability a committed config layer may REQUEST but not GRANT cannot be expressed by layer precedence, only by direction** — hence a pre-pass over raw layers. "Grove inserts no permission gate" is pinned structurally: the suite scans for permission-flag constants.
- **Preflight is one definition with two consumers (`grove doctor` and the create probe), so "what doctor says" and "what create requires" cannot drift.** Never duplicate a check into the create path "just for creates".
- **`CheckResult.required_for` has an `optional` tier, because a container-scoped FAILURE is not a report, it is an action** — the runtime resolver turns the first failing `container` check into a fall back to the HOST runtime, so filing a missing convenience under that scope silently trades a workspace's isolation. `optional` renders as `warn`, never red, and is in neither readiness set. **Preflight reports and never provisions.**
- **A value that BECOMES a flag cannot be defended by quoting, and `--` does not help when the consumer re-parses.** A branch name reaches `git worktree add -b <value>`, so `shell=False` and list-form argv are irrelevant: **the value IS the flag**. `-m` renames the checked-out branch, `-D` deletes one the caller picks, and both exit `fatal:` so the caller is told the create failed *after* the branch is gone. **With the field omitted entirely the DERIVED default carries it** (git accepts a ref named `x/-D`), so patterning the field alone leaves the variant exploitable through the field being *absent*; and `git worktree add` hands the `-b` value to an internal `git branch` that re-parses it, so `--end-of-options` cannot fix it. One shared pattern across every name field plus the derived default, and a flag-shaped-argument refusal in `GitRepo`. Guard the MUTATING ref-takers only.
- **A slot or session name IS a tmux target, so it is validated where names enter, not quoted where they are used** — `:` and `.` are target syntax (`session:window.pane`), the same value-becomes-syntax class as the branch-name guard.
- **A convention applied per ITEM is not applied at all if the LOOP is unguarded — check the frame, and the setup call the frame runs first.** `ActivityService`'s per-repo loop isolated three per-workspace git reads, but the per-repo `registry.get()` — which resolves that repo's whole config cascade and can raise `ConfigError` — gated the entire iteration, so a stray comma in ONE repo's config blacked out the dashboard for EVERY repo plus every SSE connection and hook ingest. There were THREE unguarded sites, not the two an audit identified: `_ensure_bridged` resolves unguarded too, and both `snapshot()` and `poll_once()` call it on their first line. An unreadable repo is deliberately NOT added to `_bridged`, so a fixed config recovers the stream without a daemon restart.
- **A degraded item must be SURFACED, not skipped** — dropping the unreadable project leaves the fleet looking healthy with one repo quietly missing, harder to notice because nothing errors. `ProjectGroup.error` carries the parse failure and its file position, and the group is built per declared project (not per repo) so a client's grouping does not change shape under failure. Companion: the per-repo routes that resolve *before* their local `try` are covered by ONE app-level `GroveError` handler, so a caught and an uncaught `GroveError` cannot answer differently.
- **A destructive rule expressed at one call site is a rule the other call sites do not have — and the second site is usually a failure path, where nobody looks.** `kill()` read `branch_provenance` while `_rollback_create` gated on placement alone and force-deleted whatever branch the record named, so attaching your own branch and hitting a failing init script destroyed it. The predicate is now `WorkspaceState.grove_owns_branch` (placement + provenance, one definition, many readers). Its absence also kept a sibling leak unfixable: the `worktree_add` failure path deleted the store row by hand instead of rolling back, so `git worktree add -b`'s ref — created *before* the path is validated — survived every failed create and the retry died `BranchConflict`.
- **An exit status must answer "did this run", never "what did it find" — and a `;`-chained `sh -c` takes its LAST command's status.** A probe ending in `command -v tmux && echo tmux` exits POSIX **127** on precisely the images the feature exists to serve, and a caller gating on a zero exit discards the perfectly good output sitting right there. `if`/`fi` yields 0 when no branch is taken: absence is a legitimate ANSWER and must not travel through the channel reserved for "the exec did not happen".
- **`tmux new-session -A -d` exits 0 for a command that cannot run, and the session is already gone by the next command.** Its exit status answers *"did tmux create a session"*, never *"is the agent running"* — so adding an agent whose binary the image lacks reported success while nothing ran. One re-read of the server after the start is the fix, deterministic in the direction that matters. **This is the ordinary cross-kind case:** an image usually ships only the one agent binary it was built for.
### The agent's tmux runs INSIDE the container
- **A multiplexer on the far side of the namespace boundary from the process it multiplexes is the whole bug.** With the agent run as `devcontainer exec … -- claude` typed into a HOST pane, the host client owned the PTY: kill the client and the in-container process survives (PPID 0) with its terminal gone and no reattach path. `tmux new-session -A -s <session>` inside the container makes the container's tmux server own the agent's lifetime, and **`-A` needs ZERO manager change** — `resume`/`respawn` recompose the same line and tmux attaches instead of creating. The wart: on attach tmux **discards the trailing command entirely**, so a freshly-minted `--session-id` never reaches the running agent and becomes a dead pointer, recovered by the discovery/adoption path.
- **There is no host tmux session at all, because tmux sizes a window to its SMALLEST attached client** — a host pane running `tmux new-session -A` into an existing session stays on as an attached client, so a human asking for 200x50 got the shadow pane's 161x41 and could do nothing about it. `attach` execs into the container's own tmux and the launch starts the agent DETACHED (`-A -d`). It also fixed a second defect: `pane_activity_seconds_ago` is structurally host-only, so ACTIVE/IDLE for a container workspace had been judged from the shadow pane's repaints.
- **Two arms need ONE predicate, and it is the one the launch already branches on: `ContainerRuntimeState.tmux_command`.** Empty means the image ships no tmux and Grove has no bundle for its architecture, so the agent runs as a bare exec in a host pane. A second notion of "can this container run tmux" would be drift.
- **A tmux binary with no terminfo passes every test you would write first and fails the only thing that matters — and there are TWO terminfo consumers, not one.** `new-session -d` and `capture-pane` work with NO terminfo database at all, so a payload without one looks healthy until a CLIENT attaches. The pane process needs an entry for what tmux tells it (`screen*`/`tmux*`); the **client needs an entry for the human's own `TERM`, and without one tmux refuses outright** — an unusable workspace, not degraded colour. An 11-entry bundle chosen for the pane was measured refusing `alacritty`, `xterm-kitty`, `foot`, `xterm-ghostty` and `screen.xterm-256color`.
- **Ship the WHOLE terminfo database, because the smaller artifact is the larger program:** ~2-3 MiB against a 1.3 MB binary on a read-only mount shared by every workspace, where the ~1.5 MiB a curated set saves buys a maintained list of terminal-name patterns (policy in code) plus a standing supply of "we forgot terminal X" hard attach refusals. Three riders, each of which silently corrupts the bundle: **Debian ships THREE terminfo trees and they must be unioned** (`screen.xterm-256color` and `rxvt-unicode-256color` live only in `/lib/terminfo`); **alpine's ncurses lacks entries Debian's has**, so the recipe needs a second stage on a distro that packages them; and **`xterm-kitty`/`xterm-ghostty` are announced by their terminals but packaged under `kitty`/`ghostty`**, closed by copying the compiled file under the announced name (ncurses resolves by FILENAME and does not re-verify the name inside). **`TERMINFO_DIRS` must end in a colon** — ncurses reads an empty entry as "then the compiled-in default", so without it Grove's bundle *hides* an image's own database instead of supplementing it. It rides the overlay's `remoteEnv`, not the launch spec's env, because the tmux server is started by one exec and every later attach is another one.
- **The tmux BINARY is per-architecture; the terminfo bundle is not — so the cache is one root holding `bin/<arch>/tmux` beside a shared `terminfo/`.** The mount must be in the override config BEFORE `up` while the container's architecture is only knowable AFTER, so Grove mounts the whole cache root and defers only the SELECTION — a binary built for a foreign arch *after* `up` appears inside the running container immediately, because a bind mount is a live view. **Do not test wrong-arch rejection by execution:** the kernel checks an ELF's machine type against the REAL host CPU, not the container's declared platform, so the experiment passes for the wrong reason. Assert the SELECTION instead.
- **Grove BUILDS the static tmux with Docker rather than vendoring or downloading it.** A container workspace already hard-requires a container runtime, so a build adds nothing and turns the per-architecture problem into a `--platform` flag rather than a matrix of artifacts to publish and sign (an emulated cross-build takes ~4½ minutes against ~2 native, worth a log line). **The recipe is CODE (a reproducibility statement, like a pinned dependency); the things an operator might legitimately vary — whether to use it, whose bundle, the fallback TERM — are config.** One recipe fact: alpine's `configure` hard-fails `"yacc not found"` before it looks at tmux's shipped parser, so `bison` is mandatory.
- **Plan before `up`, probe after — which is why the bundle is mounted unconditionally.** "Does this image ship tmux" and "what architecture is it" both need a container that does not exist yet, so Grove mounts its cache root whenever it has one and records only the CHOICE as `ContainerRuntimeState.tmux_command` (`""` = no tmux reachable → the launch composes a bare exec). **The probe goes through `devcontainer exec`, never `docker exec`:** the agent is launched through the CLI too, so only that road answers for the REMOTE user's `PATH`.
- **`tmux new-session` with more than ONE command argument execvp's directly instead of re-parsing** (measured on 3.4 and in a real container). That is what keeps the adapter decoration working: `build_workspace_layout` appends it as shell-quoted argv after whatever the backend composed, so the tokens land as further argv of the `sh -c '<script>' grove` tmux spawns and `"$@"` hands them to the agent with spaces intact.
- **An unknown client `TERM` needs a fallback no bundle can replace, and its safety comes from `-A` plus one gate.** `container.tmux.term_fallback` retries the attach once with a known-good `TERM` and **says so on stderr**. Two measured pieces: the retry is gated on the session NOT existing, because `-A` *creates* when nothing is there and a bare retry after a normal session end would silently relaunch the agent; and the retry needs a `sleep 1`, because the dying server is still tearing its socket down and a retry inside that window fails deterministically. Config carries the TERM *and* the enable in ONE field (empty disables), since a bool beside a string can express "enabled with no TERM", which means nothing.
### Reading and steering the in-container tmux
- **Read the agent's own pane, not the host pane holding a tmux client** — a client repaints a viewport and keeps no scrollback of its own, so a `-S` capture returned 22 lines of an agent's 120 plus the in-container status bar, where the in-container pane returns all 120 and no chrome. `_agent_pane(state)` is the one policy site (`_pane_target` is its string projection).
- **Which ROAD into the container is a cost decision, and the two roads are not interchangeable.** Measured, 10 runs each: `devcontainer exec … capture-pane` **484 ms**, `docker exec -u <user> … capture-pane` **60.4 ms**, host `tmux capture-pane` **3.5 ms**, `docker inspect` **14.4 ms**. So the provision-time probe keeps the CLI road and every read/steer takes the docker road. **`-u <remote_user>` is load-bearing, not hygiene:** a tmux server binds a uid-keyed socket, so an exec landing as the image's default user reports a connection error against a perfectly healthy server.
- **The generic seam is "the argv that reaches a tmux server", threaded as `command` through the tmux mechanisms.** `tmux.py` knows nothing about docker; it takes a prefix (`("tmux",)` by default), so the host path is byte-for-byte what it was and the container path reuses the hardened `send_text`. `TmuxPane` (target + command + label) is what a caller carries so it cannot pair a target with the wrong server. The label matters: an in-container session name means nothing to a human's own tmux, so it renders as `container:<short id>:<session>` rather than being passed off as attachable.
- **"No host tmux session" does not mean "no agent", and status is what gates VERBS.** `_status_without_a_viewport` asks the in-container tmux (memoized) and keeps OFFLINE for **two** of three answers — a DEAD pane (respawn's job, and respawn requires OFFLINE) and an unreadable docker. Only a live pane promotes, with ACTIVE/IDLE from the same read's `#{window_activity}`.
- **A status change is a VERB change, and the knock-ons are where the work is.** Promoting a viewport-less workspace out of OFFLINE broke the two verbs OFFLINE was standing in for: `attach` handed the client a session name that no longer existed, and `respawn` — the thing that rebuilds the viewport — refused because the status was no longer OFFLINE. Hence a typed attach error naming respawn, and `ensure_can_respawn(sessionless=)` with the caller establishing the fact via one `has_session` so the predicate stays pure. **`sessionless` admits a LIVE status only** — PAUSED/ERROR/ORPHANED have no session either and none is a viewport to rebuild, so a blanket bypass turns respawn into a verb that accepts every workspace on the host.
- **`remain-on-exit` is set from INSIDE the pane** (`$TMUX` is set, so a bare `set-option -w` needs no target) as a prefix on the launch script. The dead pane then carries `#{pane_dead_status}` (1 for a config error, **127 for a missing binary**) and keeps the output the agent printed before dying — except for an agent that dies within milliseconds of pane creation. It lives on `ContainerAgentEntry`, the class composing every in-container session start.
- **Keeping a corpse changes what an idempotent command means — `tmux new-session -A` against a session whose only pane is DEAD exits 1 and starts nothing.** So `remain-on-exit` silently turns `resume`/`respawn` into no-ops unless the launch first clears a session whose agent already exited, conditional on the pane being dead (killing a LIVE session there destroys the very agent `-A` exists to reattach to). **Making a failure inspectable also makes it persistent, and every idempotent verb downstream has to be re-read against the new persistent state.** That is also why the option rides the agent's own script rather than the shared entry composition: a `grove shell` that kept its dead pane would leave a session no later `grove shell` could enter. **Keeping a corpse is only safe where something owns the clearing.**
- **One memoized read serves reconcile and the dead-agent signal, at **10 s, not `ContainerLiveness`'s 5**: the read costs 4× an inspect for an answer that ages far more slowly, and the ACTIVE/IDLE edge it feeds is judged against a 30 s threshold. At 24 container workspaces that is ~14% of a core rather than ~29%. Keyed by the argv itself, because that argv IS the identity of the thing being read. Nothing on the poll path pays it while a host session exists.
- **The tmux doubles are pinned to captured payloads, and the shape a hand-written one would "tidy away" is real:** a LIVE pane prints `0||1785471784` — tmux emits `pane_dead_status` and leaves it EMPTY — where an invented payload would have omitted the field. `FakeTmux` also records WHICH server each read/steer addressed, because a manager that steers the host tmux instead of the container's is otherwise exactly as green.
### The minutes BEFORE a container exists
- **The bar for a new computed status is "does it change the REMEDY", and folding the pre-existence window onto `OFFLINE` failed that bar in the one direction that matters.** The container-dead case reuses `OFFLINE` correctly (§`_reconcile_status`) — same remedy, no new vocabulary. A container that has not been BUILT yet is the opposite case wearing the same clothes: `create` persists the record on its first side effect and `devcontainer up` returns 49 s (warm) to 6.5 min (cold, measured) later, so for that whole window the record said RUNNING, `_container_state` honestly said ABSENT, and reconciliation published *the runtime is gone, respawn is the remedy* about a workspace that was building normally — while `ensure_can_attach`/`ensure_can_steer` refused every non-destructive verb. **Nothing was wrong, and the only action offered destroyed the build.** `PROVISIONING` is the one status whose remedy is to WAIT and the only one that ends on its own. **Reuse a status when the remedy is the same; take a new one when the remedy inverts.**
- **The check must precede the container read, not follow it** — during a provision the container legitimately does not exist, so the container branch demotes to `OFFLINE` anyway and the new status is unreachable. It also saves a `docker inspect` with nothing to inspect.
- **A duration written at the END is absent for the entire window it would have been useful in.** `provision_duration_ms` answers "how long did it take"; a surface deciding whether to tell a user to keep waiting needs the elapsed time of a provision that has NOT finished. That is a second field (`provision_started_at`), not a cleverer reading of the first, and exactly one of the two is meaningful at a time. It crosses the wire RAW: a server-computed elapsed is stale the instant it leaves the daemon and sits visibly frozen between polls, which reads as the very "nothing is happening" the axis exists to remove.
- **Publishing an in-flight fact must graft onto the STORED record, never save the in-hand state.** `respawn`/`resume` arrive at `_provision_container` holding a *reconciled* status (OFFLINE / PAUSED) that `JsonWorkspaceStore.save` refuses by design, so saving the in-hand state fails every re-provision while `create` — which alone holds a raw persisted intent — passes. The identical shape `_record_provision_failure` already uses, for the identical reason. The suite catches it (`test_respawn_re_provisions_a_restarted_container_and_the_workspace_recovers`); a fix tested only through `create` looks complete.
- **The transparency was already on disk and nothing read it.** `<workspace-id>-provision.log` has been written line-by-line as the provision runs since containers shipped, with its path already persisted on the record. The work was a reader, not a mechanism. **Before building a progress channel, check whether the slow thing already narrates itself.**
- **Do NOT derive a step, a percentage or an ETA from that log.** Its lines are the devcontainer CLI's and BuildKit's own output, a format with no contract, so parsing it is provider-boundary *semantics* — it goes stale silently on their next release and then reports confidently wrong progress, which is worse than none. The last line written is the honest headline. Its one known wart is accepted deliberately: on a FAILED provision the tail is the CLI's Node stack trace (what `_ProvisionLog.diagnose` exists to look past), but the failure has already raised the marker-selected diagnosis and the live case — the one this serves — has the build itself there. Reading a real 1.1 MB cold-build log costs 20 ms, so it is a per-request seam and never joins the ~1 Hz tick.
### A provisioning success is a fact about a START, not about a container
- **`postStartCommand` runs per START, so anything derived from it — and in the hardened config that hook IS the egress firewall — is a per-start property.** Reproduced end to end: a provisioned workspace refuses `1.1.1.1:443` and reaches `pypi.org:443` under `-P OUTPUT DROP`; after a plain `docker stop` + `docker start` the *same container* answers `-P OUTPUT ACCEPT` and reaches both, while Grove still read it active, attachable and steerable. `--restart no` closes only the docker-INITIATED path. **A record of "setup succeeded" must name the EVENT it succeeded for, or it keeps vouching for a state that has been torn down.**
- **Record the boundary's OWN value for the event, never your own clock reading — an identity, not an ordering.** A `provisioned_at` host timestamp compared against `.State.StartedAt` is correct only while Grove and the docker daemon share a clock, which stops the moment `DOCKER_HOST` names a remote engine, and it fails in the *unsafe* direction. `ContainerRuntimeState.provisioned_start` stores docker's own `.State.StartedAt` for the start provisioning ran on; verified that a stop keeps the value and a start writes a new one.
- **Durable evidence wants a comparison, transient evidence wants a listener.** A `docker events` listener only sees what happens while subscribed, so it needs a reconcile-on-startup pass — i.e. exactly the read-time comparison, after which the listener adds nothing. Two more reasons it was rejected: **"re-apply the firewall" is not a small repair** (the hook is composed into a generated override in the worktree, so the only honest re-application is `devcontainer up` = `respawn`, which relaunches the agent and cannot run for a PAUSED workspace whose worktree is gone), and a background repairer racing the user's own verbs amplifies the reconcile cost.
- **Repair was already built; only detection was missing.** OFFLINE already means *the runtime hosting the agent is gone, respawn is the remedy*, and respawn already re-provisions. Verified rather than assumed, because the naive reading says an idempotent `up` on a running container does nothing: the CLI re-runs `postStartCommand` whenever its marker disagrees with the container's current `StartedAt`, so respawning a bare-started container **re-applies the firewall without restarting it**. `ContainerState.UNPROVISIONED` therefore generalizes from "hooks never succeeded" to "hooks did not succeed for the start this container is on" — one state, because both take the identical remedy.
- **An empty witness fails CLOSED**, so a record with no `provisioned_start` reads as UNPROVISIONED; the alternative ships the detection and leaves every pre-existing workspace exposed. The cost is one `respawn` per such workspace, and a genuinely unreadable docker returns `None` and changes nothing.
- **The detection costs zero new processes: one more template field on a read the poll path already pays.** The one thing needing care was the log: a standing condition on a memoized read would be one warning every window (~17k lines/day for a workspace left overnight), so the warning is edge-triggered off the expired cache entry.
- **Provisioning failure stays FATAL — only the raise moved.** `DevcontainerCli.invoke_up` raises before any identity is minted, so the provisioner catches that raise, mints the identity from the id the failure carries, and re-raises `ProvisionFailed` **carrying the minted state**. The hole underneath: a failed RE-provision used to leave the record naming the PREVIOUS, successful container, so the workspace reconciled ACTIVE and attach/steer were allowed into a container whose lifecycle hooks had just failed. `_record_provision_failure` persists the unprovisioned identity onto the STORED record, never the in-hand one, which carries a *reconciled* status the store refuses by design. The launch-time re-check was **deleted rather than revived**: every launch verb passes through provisioning first, so a second copy of a fatality decision at a layer that cannot name the container protects nothing. Two riders: the raise must happen OUTSIDE the provision log's context manager (it re-wraps to append its own diagnosis, so re-entering doubles it and drops the identity), and `ProvisionFailed` lives in `runtime.py` rather than `errors.py` because it carries a `ContainerRuntimeState`.
### Pausing and killing a container
- **A grace period on `docker stop -t` is not a grace period for the AGENT, and it failed for two INDEPENDENT reasons — so fixing either alone changed nothing.** Against the devcontainer CLI's own entrypoint, `docker stop -t 30` returns in **0.14 s**, because the timeout only bounds waiting for PID 1 and PID 1 traps SIGTERM and exits at once. Independently, `docker stop` signals **PID 1 only**, and the agent is not in PID 1's tree (an exec'd process reports `PPID 0`) — so it was never sent anything and died of namespace teardown. No timeout tuning could have reached it.
- **The fix cannot live in any docker verb, which is why it only became possible once tmux moved inside the container.** `ContainerRuntimeState.SHUTDOWN_SCRIPT` runs through `docker exec`, signals the agent session's pane process **group**, then waits for the *session* to disappear — tmux destroying the session is the event meaning the pane process is gone, whereas a `kill -0` poll answers "alive" for a zombie awaiting its reaper and burns the whole budget. Three constraints: `-u <remote_user>` (the tmux socket is per-uid, so any other user cannot even SEE the session); scoped to the AGENT session and not the shell one (interactive bash **ignores** SIGTERM, so including it spends the full budget every pause); and the session name comes from the cascade, never a constant here. `tmux kill-session` is not the answer either — it sends SIGHUP and returns immediately.
- **`kill` is best-effort at every stage except one: a container teardown that did not happen.** A container Grove could not remove is nameable ONLY through the workspace record, so an unconditional `_store.delete` strands a live container no verb can ever find again. The record survives as ERROR carrying the reason and `kill` raises; `kill` accepts every status, so it is its own retry.
### `grove shell` and the shell window
- **A shell window that is a HOST shell beside a container is worse than no shell window at all** — not a missing feature but a lie the user acts on, standing on the host looking at a worktree the agent may not see the same way. So it is a *runtime* question: the container backend passes `shell_command` and `TmuxLaunchBackend` passes nothing, keeping the host case byte-identical and absence the default. `tmux.py` stays mechanism — it types whatever string it is given and never learns what a container is.
- **One composition, two consumers, and the quoting belongs at exactly ONE of them.** `grove shell` `execvp`s the argv; the shell window needs the same thing shell-quoted to type into a pane. `TmuxEntry.tokens` returns RAW tokens and each consumer quotes for itself — a helper that quoted for its caller would be silently wrong for one of the two, since a quoted token passed to `execvp` is a filename with quotes in it.
- **The shell and the agent are two sessions on ONE in-container server, and the shell's name is a reattach identity like the agent's.** `container.tmux.shell_session` exists separately from `.session` because sharing the name would drop a user into the agent's own pane; renaming either orphans a live session behind a newly-created empty one, which is why both are config rather than constants. **Which shell to run resolves INSIDE the container (`command -v` over the `container.shell` chain), never on the host** — presence is a fact about somebody else's image. The chain has NO baked-in final fallback: exhausting it says so and exits 127, because a hard-coded last resort would hide the misconfiguration behind a working-looking prompt.
- **`grove shell` is a CLI verb over an engine seam, not a `grove.client` transport, and the discriminator is whether anything is bridged.** `client/` holds a PTY↔xterm.js bridge and a launch-and-forget editor; this hands the terminal over with `os.execvp` and the CLI process *becomes* the exec, the shape `grove attach` already has. The composition lives in `core`, so a remote surface that ever needs it reaches it without anything moving.
- **The shell session is pre-started detached** — `-A` would create it on demand anyway, but without it the affordance disappears with the window it used to live in. Best-effort: a shell that fails to start must not fail a launch.
### Several agents in one container
- **The honest answer was a SELECTOR, because the two things a plural data model would have added already existed.** The container's tmux server was *already* hosting two sessions (agent and shell), so "several agents" is which session an existing reader addresses — `ContainerTmux.for_container(..., session=)`, empty meaning the workspace's own, keeping every pre-existing caller byte-identical. And the agent AXIS was already plural: `ActivityService.sessions_for` adopts any session of the workspace's kind born in its cwd after `created_at`. Total persisted state added: none.
- **Two shapes were rejected.** *A second workspace record sharing the container*: a record IS a worktree plus a branch, so two records over one worktree make `kill` on either destroy the other's working tree, and the dirty check, branch ownership and reconciliation all become co-tenancy questions with no honest answer. *Plural `agent_session_id`*: a persisted-field change rippling through the status blend, the activity fingerprint, adoption, remap and the wire, buying nothing discovery did not already deliver. **The enumeration is a READ of the container, never a stored list** — the tmux server is the only thing that knows an agent ended on its own.
- **An additional agent deliberately gets NO `remain-on-exit`**, because the launch backend owns the clearing for the primary and nothing does for an extra agent; so an extra agent that exits leaves no session and the roster stops listing it.
- **An added agent must launch exactly the way the primary launched or it is a different agent** — `devcontainer exec`, the expensive road, because it is the only one that answers for the remote user's `PATH` and applies the container's own `remoteEnv`, including the `TERMINFO_DIRS` the mounted tmux bundle needs. Reads and steers stay on `docker exec` because they run per poll.
- **`grove agent` is its own noun rather than an `--agent` flag on every verb, but the ENGINE seam is the flag** — `peek_pane(agent=)` / `send_message(agent=)` are the same verbs addressed at a named agent, so a daemon route or MCP tool adopts them without anything moving. Naming an agent on a workspace that cannot host several is a typed refusal, never a silent fall-through to the primary, which is why `peek_pane` breaks its own best-effort contract for that one case: "that agent printed nothing" and "there is no such agent" are answers a user acts on differently.
- **Deliberately NOT built:** no wire/daemon/MCP/TUI surface, and no host-workspace equivalent (one host workspace hosts one agent). An added agent of a DIFFERENT kind runs, lists, attaches, peeks and steers but does **not** appear on the workspace's agent axis, because `_scan_workspace` is kind-scoped on purpose.
### Status hooks and decor across the boundary
- **A hook has TWO arms and both were namespace-bound, so fixing either alone delivers nothing — and the second only becomes visible once the first is fixed.** The `command` arm names a console script of a Python package a project's image never installed; the `http` arm POSTs to `DEFAULT_DAEMON_LOOPBACK_URL` (`127.0.0.1:7421`), which inside a container is the container's own loopback. Both fail in the agent's OWN UI, once per event — no sidecar, no push status, no ask-time question capture, and an error at every session start.
- **The container side must carry NO policy, which rules out a shell reimplementation and rules IN spool-and-fold**: the event→state map and the pending-question state machine are real logic a `sh` shim cannot run without a JSON parser, and a generated `case` statement is a second copy that drifts. Ordering and each record's `ts` come from the spool file's **mtime** — folding out of order leaves a question standing that a later event cleared, and stamping with the drain's clock ages every event by however long the reader took to notice it.
- **The drain belongs in `ClaudeHook.read`, not at the four call sites** (the activity blend, its question cross-check, session adoption, `answer_question`) — this tree has already paid for the alternative, where the same two-line convention was missed one site at a time. Cost on a host, where nothing spools, is one directory listing. The spool is a CHILD of the sidecar dir precisely so `read`'s existing argument locates it.
- **The daemon push moved INTO the hook entry point, and that placement is the fix rather than a refactor:** a registered `http` handler cannot ask whether the address it names is reachable, so that arm was structurally unfixable from inside the file. Two consequences: the settings file bind-mounted into every container **no longer carries a live daemon bearer token**, and the URL rides as an **argv** rather than a config read — this is the hottest process in the system, and the flag reaching the entry point is also what makes the push host-only by construction. `urllib`, not `httpx`, for the same import-cost reason.
- **The spool mount is bound at its own HOST path, the only Grove mount not under `/grove`** — one rendered command names one directory, so that string must resolve identically on both sides (the git-common-dir mount is the same idiom). `:rw` widens nothing: every agent on a host already shares one unauthenticated sidecar plane under the user's uid. Deliberately **no `mkdir -p` in the shim**: an absent mount must fail loudly in the agent's hook output rather than write to a container-local directory nothing reads. **A container workspace created before the spool mount existed has none, and its hooks fail loudly — `kill` + recreate, or `respawn`, is the remedy.**
- **One rendered settings file serves both namespaces only if every VALUE in it means the same thing in both.** A capability probe (`command -v … && exec … || <fallback>`) lets a hook command find its own entry point, so nothing in the file asks whether it is in a container — but a `statusLine` naming a path under `/grove` is meaningless on the host and would silently replace whatever statusline the user configured. So container decor needs a per-runtime second file **merged into the one file the launch passes** (`--settings` is single-valued; the CLI keeps the last occurrence), never a second layer beside it. **The fork discriminates on the SHARE PLAN, not a new capability sentinel** — `None` on the host, a plan for a container, gated on the *persisted* runtime, so a fallback-to-host workspace answers correctly for free. The container file is written under `hooks.enabled` and not under the decor switches, because provision names its mount sources before launch renders them. And **a new path resolved through raw `platformdirs` is a new escape from the test sandbox**: `tests/conftest.py`'s autouse redirect must gain each sibling or a containerized create writes into the user's real config dir.
- **`/proc/loadavg` is not namespaced, so a container status bar that reads it reports the HOST's run queue** — confidently wrong about the machine it claims to measure. The honest source is the cgroup: v2 `cpu.max` for the denominator plus a `cpu.stat` `usage_usec` delta, and `memory.current`/`memory.max`. Two riders: **CPU needs a DELTA, so the FIRST sample has no honest percentage** and prints memory only; and **an uncapped cgroup (`cpu.max` = `max`) has no denominator**, so the segment is omitted rather than fabricated against the host's core count. Both assets ship as package data and are invoked as `sh <path>`, never on their execute bit — a file mode is not reliably preserved through a wheel build or an operator's `cp`, and a lost bit fails as chrome that looks configured and is inert.
- **Whether the decor bundle actually mounted is a PROVISION-time fact on the record (`ContainerRuntimeState.tmux_conf`), never re-derived from config at launch** — same argument as `tmux_command`: `tmux -f` on a missing file fails the start outright, so a config-derived path would let a cosmetic feature take the whole workspace with it. The field is gated on `tmux_command` as well as on the mount.
- **A font is not a property of the image, so "does this container have a Nerd Font" has no honest answer from inside it — the defence is a switch, never a probe.** The glyph is rasterized by the terminal emulator the human attached FROM; nothing reachable through `devcontainer exec` can see it, and every plausible proxy (`TERM`, a fontconfig query, the presence of a font package) answers about the wrong machine. So `statusline.sh` carries TWO complete vocabularies selected by `GROVE_STATUSLINE_GLYPHS`, and the ASCII one **puts the words back** rather than degrading the icon one — an icon that replaces a word (`ctx`, `effort`, `usage` are gone from the icon vocabulary entirely, which is where the horizontal room for `user@host` came from) has no readable fallback except that word. The badge is the exception that proves it: it stays `⬢ DEV CONTAINER`, a plain geometric codepoint rather than a private-use one, because it is the one segment that must survive a font rendering nothing else, so it is the one segment that asks least of one. Rider: the ASCII switch is asserted against the Nerd Font's own private-use ranges, not `str.isascii()` — `·` and `…` are punctuation every font ships, and giving them up would cost legibility to defend a problem nobody has.
- **Neither the username nor the hostname is guaranteed by anything in a devcontainer exec, and hostname is the one worth chaining for.** The exec env is whatever the CLI hands over, not a login shell's, so `$USER` is routinely empty; a slim image ships neither `whoami` nor `hostname`. Username chains `$USER` → `$LOGNAME` → `$USERNAME` → `id -un` (**`id`, not `whoami`** — same answer, and `id` is in busybox's applet set where `whoami` frequently is not) → `${HOME##*/}`; hostname chains `$HOSTNAME` → **`/etc/hostname` read with the `read` builtin** (no fork, no PATH, and the container runtime populates it in every OCI container by construction) → `hostname` → `uname -n`. Both end in NOTHING rather than in a fallback string: half an identity renders as the half that exists, because `@host` or `user@` reads as a bug in Grove where an absent segment reads as an absent fact. The identity segment needs no interpreter at all, which is why it is the one thing besides the badge that survives the no-`python3` degrade — and it is the segment a person most needs there, since a container makes "which machine am I on" genuinely ambiguous.
- **A statusline competes with the agent's own output for terminal ROWS, so it elides and must never wrap — but a hard clamp is not implementable here.** Truncating a rendered line to N columns means walking it ANSI-aware, and `dash` is byte-oriented (`${s#?}` takes a BYTE), so every 4-byte private-use glyph would count as four columns and the clamp would cut the line to a third of its width on exactly the vocabulary it exists to protect. The workable property is instead **a per-tier cap on every variable-length field** (directory, branch, model, hostname), which makes the worst-case width a known number rather than whatever a repository happened to name its branch. Tiers come from `GROVE_STATUSLINE_COLUMNS` → `COLUMNS` → `tput cols` → 100; the `GROVE_*` override is also the test seam, since `tput` answers off whatever `TERM` a developer's shell exported and an unpinned test would assert against a layout tier that varies by machine.
### Authoring a project's own `.devcontainer/`
- **A project config cannot inherit `FROM` a locally-built tag.** Grove resolves the config with `read-configuration --include-merged-configuration` BEFORE it brings anything up, and that resolution inspects the `FROM` image to merge the image's own devcontainer metadata. A tag built by a hook does not exist yet at that moment and nothing runs early enough to create it, **`initializeCommand` included**, despite it genuinely running before `up`. Cold resolution fails and the workspace never starts — so mirror that Dockerfile's toolchain from the same *public* parent rather than inherit it, and pin the `FROM` line in lockstep by comment.
- **`iptables` lives in `/usr/sbin`, which Debian omits from a NON-root user's PATH** — an ADJACENT problem to the missing-binary one, about an image that HAS the binary. The egress script runs as the container user, so a bare `iptables` call dies with "command not found" on a box that demonstrably has it, and `grove doctor` cannot catch it (it inspects the host). `mcr.microsoft.com/devcontainers/base:ubuntu-24.04` already puts `/usr/sbin` + `/sbin` on the non-root PATH; `python:*-trixie` does not and needs an explicit `ENV PATH="${PATH}:/usr/sbin:/sbin"`. Test it in a NON-login shell (`docker run --user <u> IMG sh -c 'command -v iptables'`) — a `bash -lc` probe reads profile scripts and hides the failure.
- **A baked browser and a `^`-ranged package silently drift, and an existence check is what makes it silent.** Playwright keys its browser directory off the EXACT installed `playwright-core`, so a Dockerfile ARG pinned to one version against a lockfile resolving another bakes a revision the runner never looks for ("Executable doesn't exist") while the browsers path looks populated. Do not short-circuit the init step on "some `chromium-*` dir exists"; `playwright install` is idempotent and sub-second on a hit. **A cache-hit check coarser than the thing it is caching converts a loud failure into a silent one.**
- **A baked path and a volume mount at the same target are mutually exclusive: the volume MASKS the image layer.** So a browsers path is either baked (and *not* in `mounts`) or volume-backed (and not baked). Only genuinely concurrency-safe, content-addressed caches (uv, npm) belong in shared volumes at all: several workspaces of one project run concurrent containers against the same volume, and a non-atomic writer (`npm ci` into a shared `node_modules`) corrupts it for its neighbours. `node_modules` and `.venv` stay in the worktree, already a per-workspace bind mount.
- **A `features` version pin can mean "compile it", and the packaged default is where that bill is largest** — it is the config every repo with no `.devcontainer/` of its own gets, so it IS the first-run experience. `ghcr.io/devcontainers/features/python:1` at `"3.12"` builds CPython from source (**249.7 s of a 384 s cold build, measured**) where `"os-provided"` takes Ubuntu 24.04's own 3.12.3 — the same minor — in 44.5 s. **Check what the base image already ships before declaring a feature for it:** `mcr.microsoft.com/devcontainers/base:ubuntu-24.04` is built with `common-utils` already applied and carries the `vscode` user, `sudo`, `zsh`, `jq`, `curl`, `ip` and a source-built git, so re-declaring `common-utils` and `git` cost **77.5 s per cold build** and changed only which shell is the login shell. Whole default: 384 s → ~89 s.
- **Grove must pin `cwd` on every devcontainer-CLI spawn, and the failure when it does not names the wrong thing entirely.** The CLI resolves each `docker` it spawns against `opts.cwd || <its own cwd>`, and it inherits Grove's — so an unpinned spawn binds a multi-minute provision to whatever directory the caller stood in. **Node reports a spawn whose `cwd` does not exist as `spawn <command> ENOENT`**, naming the COMMAND (verified: `spawn('docker', …, {cwd: '/gone'})` yields exactly that), so a sibling worktree being torn down mid-build kills a six-minute provision with a message that reads as "docker is not installed" and sends the reader to `PATH`, where everything is fine. The CLI never needs the caller's directory — every verb names its target with `--workspace-folder`.
- **A `uv tool install` baked into an image as root needs THREE path overrides, and getting two right still fails — as a different exit code each time.** The agent pane runs `sh -c`, a non-login shell, so the failures land on the agent rather than on the build: `UV_TOOL_BIN_DIR` alone leaves the shim in `~/.local/bin`, off that PATH (**exit 127**); adding it puts a shim in `/usr/local/bin` that symlinks INTO a tool venv under `/root`, unreadable by the remote user (**exit 126**); and `UV_TOOL_DIR` alone still resolves to an interpreter uv fetched under `/root`, so `UV_PYTHON_INSTALL_DIR` is the third. Finish with `a+rX` (traverse/read, never marking data files executable) — and **skip the python dir when it does not exist**, because an image that already ships a usable interpreter makes uv fetch none and a bare `chmod` on the absent path fails the build. Verify by running the real image as the REMOTE USER under `sh -c`, never `bash -lc`: a login shell reads profile scripts and hides every one of these.
- **The tier split is what keeps image rebuilds rare: `Dockerfile` → `features` → `postCreateCommand` (once) → `postStartCommand` (every start).** Anything changing more often than the toolchain belongs in the last two; dispatching both hooks into a flat `.devcontainer/init.d/NN-*.sh` run by a shared `bootstrap.sh` makes adding a setup step a new file rather than a config edit.
- **Those scripts run as SUBPROCESSES, not sourced, so `export` in one does not reach the shell a developer later attaches to** (a failing step must not poison the others). Env that must outlive the script goes in `/etc/environment` (PAM-read by the login shells VS Code and `docker exec -it … bash -l` use) or in `containerEnv`/`remoteEnv` — never a bare `export`.
## Admission
`admission.py::BoundedInbox` reserves items and bytes before scheduling a thread-to-loop wake. Reservations include in-flight deliveries until the consumer explicitly completes them. A key opts into replacing pending state only. Taking that state releases its coalescing key so changes during processing remain as trailing work. Commands use no key and retain FIFO order. The owning runtime must handle refusal and the undelivered items returned by close. An admission acknowledgement is not evidence of processing or durable persistence.
## How a model catalog READS (`model_catalog.py`)
`registry.resolve_models` answers WHICH models an agent offers and stays the one
composer of that list. `model_catalog.model_options` is the display layer over
it: the same ids, each joined to the name an operator declared
(`models.display_names`) and the context window a pricing source published. It
serves `GET /models`, behind `auth_dep` like every other listing.
- **`resolve_models` folds a redundant `[1m]` PAIR to one row, keeping the MARKED id.** A gateway publishes `x` and `x[1m]` because Claude Code needs them distinct — the marked one is how a caller asks for the extended window — so neither may be renamed or refused. To a reader they are the same choice twice: measured on the reference gateway, all 7 pairs report identical input/output rates and an identical `max_input_tokens`, and the 21-model catalog becomes 14. The marked half survives because it names the capability; dropping it would silently take the extended window away from anyone picking off a list. **It runs BEFORE `MODEL_CATALOG_CAP`**, or a catalog of pairs spends half its allowance showing each model twice. Structural, like the clients' namespace fold — the catalog says whether it has pairs, so there is no vendor table and an unpaired id (either half) is untouched. **This narrows what is OFFERED, never what is accepted**: `create` still forwards any id verbatim, so the plain variant stays launchable and every workspace already pinned to one keeps working.
- **It is a JOIN over the catalog, never a second source of membership.** A
model priced or windowed but absent from `resolve_models` does not appear, and
the order is the catalog's — a curated `AgentSpec.models` is an operator's
stated preference, so sorting it would discard the answer they gave.
- **The name is DECLARED rather than derived, because no catalog publishes one.**
Measured across the reference gateway's 92 models: `/v1/models` carries an id
and nothing else, and `/v1/model/info` adds rates, windows and a provider key
but no display name. So a name is config (`ModelsConfig.display_name` →
`None` when undeclared) and the clients' one pure labelling adapter supplies
the fallback. **Do not add a second spelling rule here** — two of them is how
one model comes to be printed two ways on two surfaces.
- **The window is READ from the usage package's existing snapshot** (see
[usage](usage/CLAUDE.md) for why that costs no request and why the snapshot
needed a version bump). The dependency runs core → usage and not back.
- **`AgentSummaryView.models` is UNCHANGED** — a published tuple of ids with
five consumers (client SDK, MCP, TUI, CLI completion, two webapp surfaces).
A picker wanting a second line per row is not a reason to change what "the
catalog" is on a contract other clients already read.
## Registry & activity
- **Invalidations name dependencies, not permission to rebuild the whole workspace.** `RefreshDomain` unions transcript/worktree/phase/runtime hints; lifecycle/recovery defaults to full. Coalescing must retain both domains and content-change provenance across producer threads. Runtime edges reblend retained transcript facts without dropping sidecar/native/exit evidence; comparing reconciled status as persisted identity defeats this partition.
- **An event-local optimization depends on complete native source coverage.** Watch linked-worktree HEAD/index separately from shared refs/packed-refs; watchfiles ignores `.git` unless explicitly opted in. New worker subtrees need directory watches, not only the file set that existed at bootstrap. Directory creation can race population, so its edge must refresh the owning session too. Test through the native watcher, not just by handing a fabricated batch to a callback.
- **A filesystem membership query scales with path depth, not the number of roots.** Exact/parent membership is a hash lookup; recursive admission walks the event path's ancestors. Preserve canonical symlink handling before membership, and admit a previously missing root's own creation event.
- **`RepoRegistry` (`registry.py`) caches one `WorkspaceManager` per `Path.resolve()`d repo root** (symlinks collapse to one). `known_roots()` is fresh-from-store every call so new repos appear without a restart. Unbounded by design (loopback-only, small N).
- **Two resolvers on the registry share one shape, and it generalizes: a host-wide identity resolves against the STORE, then materializes exactly one Manager.** `resolve_share(token)` and `resolve_workspace(ref)` both refuse to walk `known_roots()`, because the walk resolves every project's config cascade and mints a Manager per repo to answer a question one record already answers. Anything else keyed by a host-unique identity belongs here in the same form, not on a Manager that would have to be chosen before the identity is read.
- **`known_roots()` is THE single "which repos exist" seam — a union of store-derived roots and config-declared `cfg.projects`.** Derived-from-workspaces alone made a repo with zero workspaces vanish from every cross-project surface. Declared roots are best-effort: expanded, resolved, then dropped silently unless `(root/".git").exists()` — a bad path never fails a config load or a request. **That check is deliberately a stat, not a `git rev-parse` subprocess:** it is on the hot per-request path.
- **`known_projects()` is the *listing* seam; `known_roots()` stays the *repo* seam.** A `cfg.projects` entry may be a subdirectory of a repo, so a declared subdir resolves to its **enclosing** root (paying one `git rev-parse` only when the entry is not itself a root); `known_projects()` returns `Project(repo_root, cwd)` so a nested subdir lists distinctly while anchoring its worktrees at the true root. `ActivityService.snapshot()` splits one `mgr.list()` per repo into a `ProjectGroup` per `cwd` by `project_subpath`; an unmatched subpath still gets its own implied group, so no row is dropped and every declared project appears even when empty.
- **Each Manager gets its OWN repo cascade via the registry's injectable `config_loader`.** Sharing the daemon's global `load_config(repo_root=None)` meant `create()` validated agents and read `init_script` against config that never saw `<repo>/.grove/config.json` — a project-scoped agent read as "unknown agent", a project-enabled init script read as disabled. `config_loader=None` keeps the shared cfg, which is the seam tests use for an in-memory config. **Why per-repo config is safe:** `cfg.auth`/`cfg.daemon` are consumed ONLY from the global cfg `build_app` holds, so a project's config cannot widen daemon auth. **A `ConfigError` from one repo's cascade is deliberately left unguarded:** every daemon route calls `RepoRegistry.get` *outside* the route's `GroveError` handler, so an invalid project config costs that one repo a generic 500, nothing is cached on failure, and every other repo keeps serving. The only unwrapped path is daemon STARTUP, reachable from user config or `GROVE_*` env but never from a project file. The per-repo cfg is cached with its Manager, so a mid-session `.grove/config.json` edit is picked up only on the next fresh repo access.
- **`ActivityService` (`activity.py`) is the cross-project aggregation hub: one engine source, two renderers** — it enumerates every workspace via `RepoRegistry`, resolves each session through the adapters, and exposes `snapshot()` + `subscribe()`/`poll_once()`. Per-tick transcript cost is O(appended bytes), not O(history).
- **Every reduction over a session's message spine shares ONE read and ONE memo (`_session_spine_facts` → `_SpineFacts`).** Three of them — the two clocks, the token classes, the model-wait average — arrived one at a time, each as its own method with its own memo, and each re-called `adapter.read_messages`. That call is memoized to a `stat` on an unchanged transcript, so each addition looked free; it is not. Measured on this host's largest transcript (9,568 messages, 33 MB) a warm `read_messages` costs **3.5 ms** against `parse_activity`'s 3.4 ms, so the tick was paying ~10.5 ms per session where 3.5 ms would do — **three times the yardstick this file already names** ("measure it against `parse_activity` rather than against zero"). Pinned by a read-COUNT assertion rather than a timing one, because a fourth reduction re-introducing the cost is a counting question and the host's noise floor swamps the milliseconds. **The shape to copy for a fourth: add a member to `_SpineFacts`, never a method beside it.** Rider paid immediately: the test double had returned bare ints, which sufficed while each reduction was exercised alone with the others patched out — one read feeding all three means the fake must return real `AgentMessage`s.
- **`_blend` is the single status-blend policy site** and reuses the manager's already-reconciled `WorkspaceStatus` for the tmux dimension, never a second tmux call. Transcript WAITING/ERROR/UNKNOWN/**BLOCKED** pass through — BLOCKED once missing from the definitive set got erased to IDLE on a quiet pane, exactly the wrong signal to drop. Transcript WORKING → WORKING for a **remote adapter** (its backend is authoritative; the local pane runs a bare shell), else WORKING when the workspace is ACTIVE **or the transcript itself is fresh** (`last_event_at` within the sidecar window — a thinking/long-tool agent emits no pane output, and demoting on a quiet pane alone was the WORKING→IDLE flapping), else IDLE; no transcript yet → STARTING. "Has a transcript" generalizes for remote adapters: materialized = files exist **or** the parsed state is non-UNKNOWN.
- **Native interactive-session status refines the existing tmux dimension after `_blend`, never replaces it.** Claude's pid-keyed registry is valid only while its `/proc` process and (when present) start tick agree; native `busy`/`idle` may switch only a blended WORKING/IDLE result. It has no BLOCKED state, so the hook-sidecar override remains later in the ordering and always wins; no live native record leaves the existing blend byte-for-byte intact. The registry is a stat-signature memoized poll reader, so its per-tick cost is bounded by one small-file stat plus liveness check rather than another transcript parse.
- **A minted id that is a dead pointer yields the primary slot to discovery — but only to a discovery `WorkspaceState.adopts_session` accepts.** Three rules in `_minted_unmaterialized`: **(1) a session with a transcript on disk is NEVER a dead pointer**, so a just-remapped/resumed session whose last hook event is `SessionEnd` stays primary showing its honest idle state. A dead pointer is a *transcript-less* minted id whose blend is STARTING/UNKNOWN **or whose sidecar's latest event is `SessionEnd`** — the latter settles the blend to IDLE, so without the explicit check the dead pointer reads as live and recovery never runs. **(2) Concurrent-session extras are pre-filtered before any full parse:** candidates union `adapter.discover_births` across `state.scan_cwds`, the adoption gate applies to that cheap metadata, and only then is a full parse paid, so per-tick cost is **O(new sessions)** and a transcript predating the workspace is excluded entirely. **(3)** With the minted id dead the newest adopted extra is promoted, and the minted entry reclaims primary the moment it materializes. Discovery is **always ungated by hooks** (a read-only glob); each session's sidecar is read once per tick and threaded to the blend, the dead-pointer test and the adoption evidence.
- **Adoption weighs two evidence axes, and the live-here axis is PANE-verified.** `adopts_session(born_at, *, live_here_at=None)` adopts on transcript BIRTH ≥ `created_at` **or** a hook sidecar ts ≥ `created_at`; birth alone silently dropped every session the user **resumed** in the pane. The predicate stays **pure** — the boundary distills the evidence. **The live-here match is by tmux PANE, not cwd:** cwd alone is a cross-tenant hole, since a fresh ROOT workspace at the shared repo root would adopt a *different* live workspace's session off a matching ts and cwd. The candidate sidecar's `tmux_pane` must equal the pane recorded on the MINTED session's sidecar; no minted-sidecar pane → birth-only, with remap as the escape hatch. `ClaudeHook.adopts(...)` is the ONE composition seam both adoption sites call, taking **pre-read** sidecar records so the two cannot drift.
- **Discovery scans the UNION `state.scan_cwds` = {`agent_cwd`, worktree root}; each parse keys by the cwd it was DISCOVERED under.** Both filesystem adapters *exact-string-match* the recorded cwd, so keying to `agent_cwd` alone dropped a session hand-started at the worktree ROOT of a nested project. The union is deduped (`agent_cwd` first) and candidates merge newest-first by mtime.
- **The blend has memory: `_settle` keeps the last definitive state through a degraded read** — a JSONL tail mid-write, a remote timeout, a glob racing a rotation, all of which flashed the card back to STARTING/UNKNOWN on a memoryless blend. Per-session `(state, settled_at)`, consulted after blend + sidecar. **A settled WORKING expires on the sidecar window**, because `SessionStart` settles WORKING before any transcript exists and an agent dead at boot would otherwise answer degraded reads with WORKING forever; settled WAITING/BLOCKED/ERROR/IDLE stay age-less. Companion: `tmux.activity_threshold_seconds` defaults to 30, not 5 — at 5 s every thinking agent reads IDLE and the blend demotes WORKING on that signal.
- **`AgentActivityState` is a separate axis from `WorkspaceStatus` — never overload the workspace enum.** A dead agent surfaces as `AgentActivityState.ERROR` (already in `ATTENTION_STATES`) with the reason on `current_task`, **never `WorkspaceStatus.ERROR`**: `respawn` reconciles before `ensure_can_respawn`, which demands OFFLINE, and attach/steer require LIVE statuses, so promoting the workspace axis leaves the user unable to respawn, attach to or steer the workspace just reported broken. It is also false in substance — the session is up, the worktree intact, the pane attachable. **A "make it visible" change that leaves the user unable to act is worse than the invisibility.**
- **A dead agent is otherwise invisible, because the agent process is not the pane:** a launch failure leaves a live fallback shell and no transcript, which blends to IDLE, so `grove ls` reports it as idle and `create` already returned 0. **A launch failure is a lifecycle fact recorded at launch time, not an activity fact inferred later in `_blend`.** Nothing is observable at launch (`launch()` returns once the command has been typed; the agent dies after), so launch *installs a recorder* and the read side reports a recorded fact. **No-flapping is a property of the shape, not a threshold** — absence means "has not exited", so a slow start takes the failure branch never, not rarely. Placed after the sidecar override so an agent that pushed `SessionStart` then died cannot read as working.
- **A recorder that cannot write is WORSE than no recorder, when absence is the value meaning "still running".** The host exit recorder is a shell redirect under a directory nothing created, so the pane answered `no such file or directory` while Grove saw a perfectly composed suffix and no record — **the unit tests exercised the format, not the path**, and stayed green. `AgentExit.prepare()` creates the directory *and* drops any stale record in one call, because both are "the recorder can actually record" and splitting them is how one gets forgotten. A command that terminates the recording shell leaves no record — safe, but it is why the producer's tests run a real shell rather than asserting on composed text. Under a container the in-pane `remain-on-exit` status is the equivalent signal; `WorkspaceManager.agent_exit(state)` is the one seam the blend reads.
- **The timer lives at the edge.** The daemon lifespan / TUI ticker call `poll_once()`; the service keeps time-of-day out of its core and emits `DashboardDelta` on its bus.
- **A maintained tmux stream carries two facts with different fan-out costs: liveness edges and activity pulses.** `%output` advances the authoritative `host_tmux_activity` timestamp on every frame, but only liveness changes invalidate the full projection; the daemon source treats its first output after idle as the one IDLE→ACTIVE edge and re-arms one expiry from the LAST output. A pulse while already active only moves that expiry. Expiry is measured from the observed timestamp, not the scheduler's clock: stale bootstrap evidence is already IDLE, and an old lifecycle edge must not grant it another full threshold. The expiry carries that instant as a generation token, so a superseded callback cannot either publish IDLE or discard the newer deadline. Death/unknown clear the timestamp and cancel its deadline; recovery and bootstrap seed it from the witness. A periodic refresh would hide the N-byte amplification rather than preserve the edge contract.
- **Telemetry — collection, export and ingestion — lives in [telemetry](telemetry/CLAUDE.md).** The trace vocabulary, the three tiers, what makes a trace navigable, and the replay's completeness rules are all there. The LAUNCH-boundary half stays above (`_launch_spec`, the reserved OTel environment), because that is a manager decision about a process, not a decision about a span.
- **"Latest activity" on a card is the last commit, not the transcript** — commits are the precise record of what was done and when, where task text goes stale, so the transcript `current_task` is kept only as the live ongoing-action line. Three fingerprint invariants: **`observed_at` is excluded** (it changes every tick → every card re-emits every poll); the **last commit sha IS included** so an amend on the tip streams even though ahead/behind does not move; and the fingerprint covers **every session, not just the primary** (a hand-started secondary must stream, and a sessions set going empty is itself a change). `dirty_files` is in it too — the agent-is-editing signal must stream before anything is committed. The per-row git reads are best-effort and never break the snapshot.
- **`WorkspaceState.agent_kind` is persisted at create so the dashboard resolves an adapter without re-reading config.** `sessions_for` **prefers `state.agent_kind`** (config lookup only when absent), so the agent axis survives the agent being removed from config and skips a config read on the hot poll path. `AgentKind` lives in `config.py` and is imported by `workspace.py` (no cycle).
- **A workspace with no minted `agent_session_id` still surfaces its live session via discovery — `sessions_for` must NOT early-return on a falsy id.** Three shapes have no id: a non-`claude_code` agent at create, a pre-minting record, a hand-started `claude`; an `if not id: return []` blanked the whole agent axis with a transcript on disk. Walk the cheap `discover_births` union over `scan_cwds` (newest-first) and adopt the most recent transcript the gate accepts as the `fs_discovered` primary. **With no minted id there is no reference pane, so adoption here is BIRTH-ONLY.** With nothing adopted the workspace is honestly sessionless. Adapter-gated (generic/shell → no-op), **not** gated by `cfg.hooks.enabled` (that gates only sidecar install).
- **The task-phase axis (`phase.py`) reads off the WORKTREE ROOT, and the reason is the git exclude.** `info/exclude` **anchors** any pattern containing a slash to the working-tree root, so `.grove/phase/` excludes the worktree-root directory and NOT `<worktree>/sub/.grove/phase/`; and `git worktree remove` refuses on untracked files. Reading from `agent_cwd` would leave every nested-project workspace's phase file unexcluded and break `pause`/`kill` for exactly the workspaces the subpath feature exists to serve. `ensure_excluded` therefore lives in `create()` unconditionally, root placement included (a root workspace writes into the user's live checkout). **Before choosing where a generated artifact lives, check whether the mechanism that neutralizes it is path-anchored; an exclusion that silently does not match is indistinguishable from no exclusion until teardown.**
- **A CWD IS NOT AN IDENTITY.** The phase file's proudest property — the agent writes where it already is and never needs to know its own identity — rests on cwd uniquely identifying a workspace, which two SHIPPED configurations disprove without erroring: **ROOT placement** puts every root workspace on a repo on one file, clobbering silently and permanently, and **several agents in one container** run over one worktree under one record. **Grove composes the path and PUBLISHES it (`PhaseFile.PATH_ENV` = `GROVE_PHASE_FILE`) rather than the agent deriving one**, which also retires the nested-subpath hazard, since "write `.grove/phase.json` in your worktree" is exactly the sentence an agent standing in a subdir resolves against its cwd. `_launch_env` was already the whole seam, so the wire, the store and every client are untouched. **The key is workspace id + slot and the primary is the BARE id** rather than the primary slot name, because that name is operator-settable and keying on it would orphan a workspace's phase file the day somebody renamed it (`.` separates them safely because slot validation already refuses `.`). **A container gets the CONTAINER path or no variable at all**, re-rooted off the record's own container path — the read side here is the agent and it acts on whatever it is handed. The older single file is read but never written.
- **The first-turn brief has TWO delivery channels and one predicate choosing between them (`_briefed_by_hook`), because the injection channel's absence is invisible from the option.** The hook road needs all three of claude_code, `hooks.enabled` and a NON-container runtime (a container has no `grove-agent-hook` on PATH, so the rendered command spools and prints nothing — a real delivery failure that reads as a working feature); everything else is briefed by prepending the text to the create-time `initial_prompt`, which costs no extra turn and still lands on turn one. **`_brief_env` renders the file and returns the variable in ONE call** for the `AgentSharePlan` reason: a variable naming a file that was never written is a hook reading nothing on every prompt forever. Mechanism in [agents](agents/CLAUDE.md).
- **The prompt fallback inherits `_compose_launch`'s narrower kind policy, and that leaves codex genuinely unbriefed today** — the initial prompt rides the launch as a trailing positional for claude_code ONLY and reaches mewbo through the remote dispatch, so a codex workspace's prompt is dropped before it can carry anything. A pre-existing gap in prompt delivery, not in the brief. **A create with no prompt is also honestly unbriefed:** handing an agent the brief AS its task starts a turn about nothing.
- **`brief` is resolved from the cascade at create and PERSISTED, the `runtime` shape rather than the `skip_init` one** — it is re-applied at every launch, so flipping `brief.enabled` later must not re-decide for a workspace that already exists. Defaults to `False` on the record so pre-brief records load as what they are, with no migration.
- **The brief's TEXT is now per workspace, and `_brief_text` is the one composer BECAUSE a workspace only ever takes one of the two delivery roads.** Two things made the content vary where it used to be one host-global string: `brief.instructions` resolves through the config cascade, which is per repository, and the self-naming nudge is added only for a workspace with an empty description. The env var can say yes or no; it cannot say *which text*, so `paths.agent_brief_path` gained the workspace id. Composing it in two places would be the drift nobody catches — the hook road and the prompt road are mutually exclusive per workspace, so a divergence is invisible from any single workspace and shows up only as "the containerized ones are told something different".
- **The self-naming nudge keys on an EMPTY DESCRIPTION, not on "was the title generated", and that substitution is the whole trick.** Title generation happens in the CLIENT — `grove create` and the web composer each mint their own — so the engine cannot learn it without a new request field threaded through every caller, which is exactly the ballooning the smallest-seam rule warns about. The description is the same signal one layer down, already persisted, already read, and it asks the better question anyway: a workspace a person described needs no nudge whatever its title looks like.
- **`tls.ca_path` ADDS a deployment's own root; it never replaces the default set, and that distinction is the only thing worth testing.** A repository can be hosted on a private forge and mirrored to a public one, so the enterprise CA and the public roots must both verify from one process — a bundle that replaces the defaults passes every test that only checks the private host and fails the first time anybody hits the public one. The proof is therefore a single later-created client verifying **both** chains, not an assertion that the loading code ran. A named path that is missing or unreadable **fails loudly at startup**: a silent fallback to the default trust set is indistinguishable from the feature working, which is this area's recurring failure. There is deliberately no disable-verification knob. It carries an `x-env-var` declaration so `grove.mcp` — which may not import `grove.core.config` — reaches the same setting.
- **Python 3.13 enforces RFC 5280 conformance where 3.12 did not, and the symptom looks like a Grove bug.** `ssl.create_default_context()` gained `VERIFY_X509_STRICT` in 3.13, so a CA certificate whose `basicConstraints` extension is not marked critical is rejected — measured on the reference host as the *same* request succeeding from a 3.12 venv and failing from a 3.13 one, with the trust store correctly found in both. **Do not relax the flag to make such a certificate work**: re-issuing it is the fix, and it repairs every other 3.13 client on that network too. Check the interpreter version before concluding anything about a trust failure that appeared without a code change.
- **TLS verification is a PROCESS-level decision made at the entry point (`grove._truststore`), never a `verify=` threaded through each client.** Grove builds `httpx` clients at a dozen sites and will build more, so a per-client argument is a rule the thirteenth site silently does not have. The failure it fixes is diagnostically nasty in the way this tree keeps re-learning: a venv's `certifi` carries the public roots only, so on a host with a private CA `urllib` succeeds and `httpx` fails *against the same URL in the same shell* — and it never presents as TLS, because a bare `TicketRef` is the legitimate persisted shape, so the symptom is **"these tickets have no titles"**. Measured on the reference host: 120 roots in the venv against 123 in the system store. The module sits at the package ROOT for `_mcp_sdk`'s reason (`grove.mcp` may not import `grove.core`, and both entry points need it), imports `truststore` defensively so an older install still starts, and is switched by env rather than config because `grove.mcp` may read no config at all and the decision precedes any repo's cascade.
- **The phase axis is PER TICKET, and the whole feature is one optional field on a file Grove already reads once per workspace per tick — zero new I/O, no new store, no new endpoint.** One shared phase could not say that the issue is delivering while the PR closing it is blocked, and a workspace routinely holds both. The key is `f"{provider}:{id}"` — the identity `ticket_refs` already dedupes on and the string `attach_ticket` already emits — composed in exactly one place as `TicketRef.key`. **The join is STORE-AUTHORITATIVE: the ticket LIST comes from the store and only the phase per ticket comes from the file**, so a missing entry reads as *not reported* and a stale entry for a detached ticket is inert rather than wrong. That is also why `PhaseFile.seed` is additive and never prunes — a write racing the agent costs a real claim, a leftover entry costs nothing.
- **The DOCUMENT holds a map and the REPORT a sorted tuple, and that asymmetry is forced rather than stylistic.** A map is what a language model writes correctly by hand, and the document has exactly one author. But `WorkspaceActivity` puts the whole `PhaseReport` inside its change fingerprint, and **a Pydantic model carrying a `dict` field is unhashable** — the tick would have raised on the first workspace that ever reported a ticket. Sorting makes it deterministic too, so an unchanged file cannot re-emit a delta because a mapping iterated differently. **Any future field on a report that rides the fingerprint has the same constraint: hashable, and ordered if it is a collection.**
- **Tolerance has to be applied at the granularity the failure occurs at.** `tickets` validated as a plain nested field made ONE unknown phase fail the whole document — so a single typo in one ticket's entry cost the workspace phase *and* every other ticket's, a total blackout from a local error. The validator filters per entry instead, which is what the module's own "tolerant inward" promise already meant at document level. **General form: when a tolerant reader gains a nested collection, ask whether one bad element can still take the whole read down.**
- **`blocked` is a FLAG beside the phase, never a seventh member of `PHASE_ORDER`.** "Stuck" and "how far it got" are two facts an orchestrator triages differently — `scoping + blocked` is a ticket nobody can start, `verifying + blocked` is work wanting one decision — and a blocked task has no position on a linear ramp, so spending a member would force `index` to start lying or become nullable at every call site. It does NOT duplicate `AgentActivityState.BLOCKED`: that means *waiting on a human right now* and clears when they answer, while this is a claim about the WORK that survives the agent going idle, dying or being respawned. **The discriminator for any future "should this be a state or a flag" question: does it compose with the existing states, or replace them?**
- **Two phase questions decided by NOT building anything, both cheap to re-propose.** *Staleness:* Grove renders no stale verdict — a phase is a claim about the TASK (three hours in `implementing` is a long task, not a stale report) while "is the agent alive" is what the other two axes answer; `PhaseReport.updated_at` is on the wire, so an orchestrator wanting an age has one. *Survival past `pause`:* the phase dies with the worktree, because living inside the worktree is what gives it the bind-mount property; keeping it would mean a second persisted store holding a claim by nobody, and the durable record is already the commit log.
- **The activity tick carries two "how is the TASK going" fields, both DERIVED per tick and never persisted (`WorkspaceActivity.phase` / `.todo`).** They join the fingerprint whole rather than field-by-field — both are frozen value objects, so including the object cannot forget a field a later change adds (unlike the per-session tuple, which churns every tick and must stay explicit). `PhaseReport.updated_at` is in the key on purpose: unlike `observed_at` it moves only when the agent rewrites the file, so a re-report of the same phase is a real "still here" signal. **The todo one is COUNTS ONLY (`TodoProgress`), a wire rule rather than a preference** — the full list stays behind `GET /workspaces/{id}/todo` because this rides the ~1 Hz delta for every workspace on the host, and a truncated list would read as *the* list where "4 of 10" cannot be misread.
- **`latest_todo_for(state)` is the `pane_target_for` split applied again, and the poll path must use it** — the id-taking `latest_todo` re-fetches from the store and re-reconciles a state the tick already reconciled. They differ in contract by design: the id seam raises `AgentSessionNotFound` (a caller that named a workspace is owed the 404-vs-`None` difference), the state seam degrades to `None`. Measured against the largest real transcript (33 MB / 9458 messages): **warm `latest_todo` 14.5 ms** against the **76 ms `parse_activity`** the same tick already pays per session (cold, for scale: 1.3 s), O(appended bytes) intact because the memo keys on the backing files' stat signature. **When adding a read to this loop, measure it against `parse_activity` rather than against zero.**
- **The todo axis stays PULL-ONLY: no orchestrator may push a list in.** The discriminator against every other (settable) axis is **whether a derived value already exists for a push to arbitrate against**: nothing in a transcript reveals a task phase, so `phase.py` is a push axis with no second writer, while the agent already writes its todo where Grove can see it. Precedence has no good answer either way — a pushed list that wins is clobbered by the agent's next whole-list rewrite, one that loses is inert — and a push duplicates mechanism an orchestrator already has (`initial_prompt` at create, send-message after). **Re-open only against a caller that has demonstrably tried the prompt path and failed.**
- **What "leave it derived" OWES is that the derived read actually resolves, and it did not for a whole provider.** `latest_todo` keyed on `agent_session_id`, which is right only for a claude_code workspace whose mint is alive: **codex mints nothing**, so every codex workspace answered 404 with blank counts everywhere while its adapter parsed the plan perfectly, and a rotated/ended claude id fails the same way. `_todo_session_id` resolves it on the per-request seam (materialization via `locate_transcripts`, else a bounded adoption-gated `for_workspace`), while the ~1 Hz seam does **no discovery at all** and is HANDED the primary `sessions_for` resolved on the same tick. **Two resolutions, same outcome, opposite cost profiles: collapsing them looks like a DRY win and puts a directory scan on the poll path.** Generalizable: **a projection keyed on a *minted* id silently excludes every provider that cannot be launched with one, and the adapter tests keep passing because the adapter was never the broken part.**
- **The task-text axis makes the same tick-vs-request split, and the two reads must select the SAME text.** `AgentActivity.current_task` is capped at 500 chars by every adapter because it rides the ~1 Hz delta for every workspace on the host plus every TUI row and webapp card — right there, and a pure loss for a consumer that renders the text once behind a fold. So **the wire field stays capped and a per-request seam answers whole**: `latest_task` / `latest_task_for` over an `AgentAdapter.latest_task`, exactly the split the todo axis already makes (counts on the tick, the full list behind a per-request read). It inherits that axis's whole shape — `_todo_session_id` resolution so a codex workspace is not excluded here either, `AgentSessionNotFound` on the id seam against `None` on the state seam, scoped and memoized transcript reads, no discovery on the poll path. **Each adapter derives both answers from ONE selection helper, differing only in the cap:** two independent selections would let the issueops sticky comment and the dashboard disagree about what the agent is doing while each looked correct on its own.
## Session exploration (`sessions.py` + `grove sessions`)
- **`SessionExplorer` answers "which agent sessions belong to this project, and how do I read one?" and is read-only by construction** — it composes the adapters' scans and never launches, mutates or deletes. A new agent tool needs zero changes here; `all_adapters()` picks it up.
- **`from_cwd` binds to the MAIN worktree root (`GitRepo.worktree_paths()[0]`), never `detect_root(cwd)`** — the store is keyed by the main root, so binding to a linked worktree's own root finds zero workspaces. `worktree_paths()` works from inside any worktree and returns main-first.
- **The scan set is the union of live `git worktree list` AND every workspace's persisted `worktree_path`** — the first covers hand-made worktrees Grove never managed, the second covers paused workspaces whose directory is gone.
- **Provenance is minted-id equality, workspace association is cwd equality — in that order.** An id matching a workspace's `agent_session_id` is `grove_launched` and binds there even if found elsewhere; otherwise the workspace owning the scanned cwd annotates it (`fs_discovered`).
- **`recollect_for` recovers every direct user query in a session, and it gets its OWN route rather than riding the turns view.** `SessionTurnView` caps `user_text` at 4 KB, which is right for a view whose size scales with the transcript — and wrong here, because the whole point is fidelity after a compaction has taken the earlier half of an agent's context. The bounding argument differs: a transcript is unbounded in the length of a session, while the queries in one are bounded by how many times a human typed something. Measured on this repo's own 26-query session: 22.6 KB total, longest single query 3810 characters — under the cap, but close enough that a pasted spec would have crossed it silently. **Reuse the adapter's existing real-turn filter; never write a second one** — a `type:"user"` record is usually not a human turn (4683 user lines against 80 real turns in one measured session), and the exclusions (tool-result carriers, `isMeta`, command wrappers, compaction summaries, task/teammate envelopes, Codex's injected preamble) are already paid for once.
- **`for_workspace(id)` / `turns_for(listing)` are the bounded per-request variants the daemon serves;** `list()` full-parses every transcript across every worktree — fine per CLI invocation, too heavy per HTTP request. Inside this class body a `list[...]` return annotation resolves to the `list` *method*, not the builtin — return tuples (same shadowing trap as `WorkspaceManager`).
- **`for_workspace` gates a discovered listing on `ClaudeHook.adopts`; `list()` and the project-scoped listing stay ungated.** A workspace's cwd can already hold transcripts older than it (especially ROOT placement, whose cwd is the shared repo root), so without the gate `for_workspace` presents the newest as this workspace's own. It mirrors `sessions_for` — same `adopts` seam, same `scan_cwds` union — so the two cannot drift. A `grove_launched` listing is never gated; the browse-everything views are ungated on purpose.
- **`[0]` on a cwd-scoped listing means "most recently written", never "this workspace's own" — a caller wanting the latter SELECTS by identity, never indexes.** Under ROOT placement every workspace in a repo scans the same cwd, holding every other workspace's transcript plus hand-started ones. Sorting the workspace's own session first was tried and reverted: it makes one identity-seeking caller right by making the browse listing and remap picker, which legitimately want recency, wrong. **The order was never the defect; reading an ordered list as an identity lookup was.**
- **`candidates_for(id)` is the ungated cwd-scoped sibling of `for_workspace` — the remap-picker seam;** both delegate to `_scan_workspace(state, *, adopt_gate)`. **`_scan_workspace` is ALSO kind-scoped — only the adapter for `mgr.effective_kind(state)`, never `all_adapters()`** — because under ROOT placement (a shared repo root where the human runs other tools) scanning every adapter let the ungated picker offer foreign-kind sessions that `remap_session` then rejects on kind equality, breaking "the picker never offers a session the pin would reject". `list()` stays `all_adapters()` but **its cwd-based workspace annotation is itself kind-gated**, so a foreign-kind session sharing a ROOT workspace's cwd renders UNMAPPED instead of borrowing that identity; minted-id equality stays authoritative and ungated. **Why a distinct method, not a flag:** the gate is attribution correctness, and the remap picker is the one consumer where the *human* supplies the attribution the gate withholds, so it must show exactly the sessions the gate drops.
- **`_scan_workspace` resolves the workspace's OWN session by IDENTITY first (`_minted_listing`), then scans cwds for everything else — because a cwd scan cannot find a transcript the provider moved.** Claude Code re-homes a transcript under a native `.claude/worktrees/` checkout the session enters, so the encoded folder names a directory that is neither the session's first cwd nor necessarily its last (the measurements and the general rule are in [agents](agents/CLAUDE.md)). The symptom was the whole listing going EMPTY while `ActivityService.sessions_for` kept advertising the minted id off the store record — a dashboard naming a session whose `/turns` route answered 404, with `/sessions` returning `[]` on a live workspace. **The fix costs nothing where nothing moved:** a minted id is `grove_launched` and therefore never adoption-gated, so resolving it by id weakens no attribution rule, and claiming its `seen` key keeps the ordinary scan from listing it twice. **Do not "simplify" this by widening the scan** — `discover_paths` is one directory listing on the ~1 Hz poll path, and that is the standing constraint. A workspace whose transcript has NOT materialized yet still lists nothing, which is honest.
- **`subagent_turns(workspace_id, thread_id)` is the fleet drill-in read — a read-path FALLBACK, never a session.** A fleet row's `session_id` is the sub-agent's `thread_id`, which `discover_paths` deliberately never lists. The explorer loops the workspace's listings, asks the claude adapter per `(cwd, top_id)` pair, and synthesizes a listing so the daemon reuses `SessionDetailView` verbatim. The `/turns` route tries the listing match first, this fallback second, then a typed 404.
- The `grove sessions` CLI (`tui/cli_sessions.py`) is a thin renderer — filters and unique-prefix resolution live engine-side so the daemon and webapp reuse them.
## SessionCatalog — SessionExplorer's host-wide sibling
**Same module, same question shape, wider scope.** `SessionExplorer` answers "which sessions belong to THIS project"; `SessionCatalog` (`core/sessions.py`) answers "which sessions exist on this HOST, and where did each come from". It enumerates via `discover_all()` across every adapter and reuses `SessionExplorer.list`'s minted-id-first / cwd-second / kind-gated provenance rule unioned over every `known_roots()` repo, rather than re-deriving it.
- **Repos are discovered FROM sessions' recorded cwds, never a host-wide filesystem crawl** — each *distinct* cwd resolves upward, memoized once per scan (373 real transcripts sit behind 92 distinct cwds, so cost tracks the cwd count).
- **Resolve cwd → repo with a walk-up `.git` stat, never `git rev-parse` — 12× cheaper, measured** (0.036 s vs 0.429 s over 92 cwds).
- **A linked worktree's `.git` is itself a pointer FILE, so the walk-up alone stops one level short of the canonical root.** Resolving that one-line pointer (still no subprocess) recovers the true main root; without it every worktree of one repo groups as its OWN project and `is_grove_managed` reads `False` for every worktree-placed session. The file-vs-directory distinction is also what `is_worktree` reads.
- **Branch comes from the SESSION (`SessionRef.git_branch`), never re-derived from git** — it is the branch the session ran on, not whatever the worktree is checked out to now.
- **Honest degradation, never a dropped row:** a session with no recovered cwd, or a cwd under no repo, still emits a row with `project=None` rendered as the bare directory.
### Live-runtime detection
**The only supportable pid↔session correlation is `pid → /proc/<pid>/cwd → sessions recorded at that cwd`, never a 1:1 binding — two verified negatives rule out anything finer.** A running `claude` holds **zero** open fds on its own transcript (it appends and closes), and its argv carries 700–1300 tokens and **no session id**. `core/process.py::list_agent_runtimes()` reads only `/proc/<pid>/comm` and `/proc/<pid>/cwd` for the calling user, once per catalog request — never per row, never on the poll.
- **`fold_liveness` is a PURE function; the join and the I/O are separate concerns.** A row is `live` only when a runtime of the SAME adapter kind exists at its cwd AND its transcript is fresh (reusing `_blend`'s staleness window, not a new threshold). Sessions sharing a cwd all fold to the same honest cwd-level `True`; **the row type has no `pid` field, so a fabricated 1:1 binding is structurally inexpressible.**
### The two guarantees
- **Read-only by construction** — the whole surface is `scan()` plus pure `fold_liveness()`. That is what lets the scan run ungated over every directory on the host, most of which Grove has no lifecycle claim on, with no permission model.
- **Metadata-only by cost, and it is the fragile one.** A row is one bounded head read, **never** a full parse. The trap: `list_sessions()` looks like the obvious way to fill a row and would parse whole transcripts — at host scale (3.4 GB, ~540 sessions) that reproduces the daemon-CPU blowup across every repo. **`activity` is nullable at this scope** (see [contracts](contracts/CLAUDE.md)) *because* the nullability is the guarantee: if a future field cannot be answered from a head read, widen it to null rather than reach for a parse.
- **`size_bytes` looks like it obeys that rule and doesn't — it is free, not cheap.** Every filesystem adapter's `discover_all` already calls `stat()` on each transcript to get `mtime`; `st_size` sits on that same `stat_result`. `SessionRef.size_bytes` carries it through at zero extra I/O, so the catalog fills the column without touching the metadata-only guarantee at all. Stays nullable for a remote-backed session (no local file) or a stat that failed — never a fabricated zero. **Before adding a parse to fill a "null at this scope" field, check whether the value is already sitting unread on a call the scan makes anyway.**
- **`turn_count` is the field that genuinely can't be free, and escapes the rule by moving the parse OUT of the request rather than making it cheap.** `TurnCountCache` (`core/turn_count.py`) is a durable `(mtime, size)`-keyed file the scan LOOKS UP (one file read, 1.6 ms of `stat` for 518 rows) and a background pass FILLS. The read path is still metadata-only; the count is a full `parse_activity` paid once per transcript version, on `SessionCatalog.count_turns`, which the daemon schedules from a scan and never from a timer. **Anything else wanting a parse-derived column copies that shape — a cached answer plus an off-request filler — never a parse inside `scan()`.**
- **The property that makes that cache cheap is the property that makes a widened entry PERMANENTLY wrong, and the entry-level guard cannot see it.** A finished session's transcript never changes again, so its `(mtime, size)` matches forever. `_decode` refuses an entry missing the `duration` KEY — but `DurationView.model_validate` happily accepts a dict missing newly-added nullable FIELDS and fills them with `None`, which the wire contracts as *not measured*. So when `generation_ms`/`tool_ms` landed, every pre-existing row would have reported the split as unmeasurable for the life of the file, indistinguishable from a session that genuinely ran no tools. **The guard held for widening `SessionFacts` and failed for widening a model NESTED inside it** — and `_decode`'s own docstring asserted the invariant it was breaking. Closed by a `shape` key on the file envelope, DERIVED from the two field sets (`_entry_shape`) rather than hand-numbered, because a constant somebody must remember to bump is the same trap one level up. **Generalizable: a fingerprint over the INPUT answers "has the source changed"; a cache also needs one over the OUTPUT SHAPE, answering "has the question changed" — and nullable fields are exactly what makes the second one invisible.**
### DiagramGallery — the catalog's sibling for `.drawio` files (`gallery.py`)
Same shape as the catalog (read-only by construction, request-scoped behind a memo, attribution from paths Grove already knows), joined ON the catalog's rows rather than re-scanning sessions. Four decisions were each paid for once on the reference host.
- **The census is `git ls-files --cached --others --exclude-standard -- '*.drawio'` per worktree PLUS a hand walk of `.grove/attachments/`, and both halves are load-bearing.** Git's view is what keeps `node_modules` and build output unvisited; the attachments tree is git-EXCLUDED (that is where the mockup skill draws) so git cannot see it. Nothing outside a known repo's worktrees is ever visited — a `.drawio` in a stranger's directory is not on this host as far as the gallery is concerned.
- **`git worktree list` reports worktrees a CONTAINER registered under its own namespace (`/workspaces/…`) that do not exist on this host, and `subprocess.run(cwd=…)` raises `FileNotFoundError` before git can say so.** Measured: the first real scan 500'd on one. Skip a worktree that `is_dir()` refuses — the same shape as the catalog's "cwd resolves to nothing" honesty.
- **A tracked diagram is checked out into EVERY worktree of its repo, so a naive census lists it once per workspace under a different attribution each time** (measured: one file, eight rows). One row per `(repo, relative path, content digest)`, the main worktree first in git's order; a worktree that actually changed the file carries a different digest and keeps its row.
- **The session is chosen by TIME SPAN, never by recency, and it picks the workspace — not the other way round.** A ROOT-placement workspace's cwd is the repo root, shared by every session anyone ever ran there, so "the newest session at this cwd" credits every old diagram to whoever opened the directory last (measured: five files from five sessions all attributed to one). `_session_for` prefers the latest-STARTED session whose birth→last-write span covers the file's mtime. Then, because several root workspaces share one worktree, the row names the workspace the CATALOG already attributed that session to — naming a different record for the same file would put two answers to one question on one card.
- **Previews are keyed by CONTENT under the state dir, unlike `DiagramPreviewFiles`, which fences one PNG per workspace to one revision.** A gallery reaches one file through several workspaces and needs a picture for files with none; SHA-256 of the bytes is the key that survives both, and a render outlives the worktree that produced it. The browser is still the only renderer — the daemon owns no draw.io.
### What the catalog deliberately is NOT
Each is cheap to re-propose; re-open one with a measurement, not an intuition.
- **No writes to an unmanaged session.** Most rows are sessions Grove never launched, some belonging to other tools entirely.
- **No persistent index of what the scan can already read.** The scan is ~540 bounded head reads (~166 ms measured) and the daemon's TTL memo collapses repeats to single-digit ms; an index over *that* adds a write path, an invalidation problem and a staleness mode for nothing. Cold cost is dominated by the workspace-map build, so if cold latency ever matters the fix is a cheaper unreconciled workspace-listing seam, not an index. **The exception proves the rule and is worth stating, because it is the only thing durable state can buy here: cache the answer the scan CANNOT get at any price.** A turn count is a 40 s full-store parse or nothing, so `turn_count` is cached and everything else on the row is not — and the invalidation objection evaporates precisely because transcripts are append-only, which makes `(mtime, size)` a complete change detector rather than a heuristic. **Ask of any proposed cache: is the underlying read cheap? Then you are buying a staleness bug for nothing.**
- **No cross-host scanning.** Liveness reads local `/proc` and the transcripts are local files; "sessions that live somewhere else" already has a seam — the remote adapter.
- **No search, tagging or analytics.** Rows come back newest-first and bounded; every surface filters client-side. Search implies the index ruled out above; tagging implies persistent per-session state for an artifact Grove does not own.
- **No "adopt this session into a workspace" verb.** `CreateWorkspaceRequest.resume_session_id` would accept a catalog row's id today — **if it is ever built it belongs on that existing create parameter**, so the catalog stays a reader.
## CLI
- **`grove.cli` adopts the TUI's Typer app (it does not `add_typer`-mount it)** — mounting under a `tui` namespace would bury `grove ls` / `grove version`. The daemon subcommand is `add_typer`ed under a guarded import, so a bare install without the daemon extra still has a working CLI.
- **The workspace verbs are in-process, not daemon round-trips:** they resolve a `WorkspaceManager` and call the method directly, so `grove create` honors project-scoped agents and init scripts for free. Shared seams in `tui/cli_workspace.py` keep every verb a thin shell — `resolve_workspace` / `resolve_or_infer_workspace` (both returning `(manager, state)`) and `clean_exit()` (every `GroveError` → one red line + exit 1). The only real logic is `BranchFlags.to_plan()`, kept off the Typer body so it is unit-testable without `CliRunner`. `attach` resolves then `os.execvp`s whatever `AttachInstruction.terminal_argv()` names, so the host/container fork lives on the variant. **The engine owns every precondition** and raises the typed error the shell renders.
- **An id-addressed verb resolves HOST-wide and takes its repo from the RECORD, never from the cwd — `build()` is only for the verbs that genuinely ask where you are standing.** A workspace id is unique across the host and `WorkspaceState.repo_root` names its repo, so `manager = build(); state = resolve(manager, ref)` coupled two independent things and made every verb inherit a precondition it never had: `grove attach <full id>` refused outside a git repo, which is exactly the situation a person reaches for it in. `RepoRegistry.resolve_workspace(ref)` is the seam — one store read, then only the matching repo's Manager through the ordinary cache, the same shape and the same argument as `resolve_share`. **`build()` survives only where the cwd IS the question**: `create` (which repo are we creating in), the cwd-inference branch of `resolve_or_infer_workspace`, and the ticket verbs (which repo's provider config). Two consequences worth stating because neither is a bug: ambiguity is now judged host-wide, so a prefix unique inside one repo can collide with another's — honest for a ref that no longer carries a repo to disambiguate it; and per-repo config still applies, because the registry is constructed with `config_loader=load_config`.
- **The daemon and MCP had this right all along, which is what identifies it as a CLI defect rather than a design question.** Both serve repos they never stand in, so they were forced to resolve by id through the registry from day one; the CLI happened to have a cwd and used it, and the coupling rode along unexamined for as long as every test invoked it from inside a repo. **When one client of a shared engine needs a precondition its siblings do not, the precondition is usually the bug.**
- **The prefix rule itself lives on `WorkspaceRef` (`workspace.py`), pure over a caller-supplied sequence, because two scopes read it.** The CLI resolves against one repo's reconciled `manager.list()`, the registry against the host-wide store; a second copy would drift on the axis nothing tests — the message a user reads when the ref misses.
- **A cwd answers WHICH WORKSPACE IS HERE; it can never answer WHICH WORKSPACE IS ASKING — so `GROVE_PHASE_FILE` outranks cwd inference, and an unresolvable own-id REFUSES rather than falling back.** This is `core/phase.py`'s founding "a directory does not identify an agent" reaching the CLI, and the gap was that the file channel learned it while the CLI did not: `resolve_or_infer_workspace` asked the directory, so in a shared worktree (every ROOT workspace on a repo, every co-tenant agent in one container) the first-party CLI, used exactly as documented, wrote to a stranger. Measured 2026-09-17 — a session whose own id was `1d58b613…` ran `grove phase scoping` and published onto `845d3e8f…`, a different task whose issue #785 had already merged, walking that ticket from `done` back to `scoping` and reporting success. `own_workspace_ref()` parses the id out of the variable's BASENAME via `PhaseFile.workspace_id_in`, never by locating the file: the path is in the agent's OWN namespace, so for a container every leading component is meaningless on this host while the basename is the part Grove composed — an `exists()` check would silently disable the whole fix for every containerized workspace. **The refusal on a missing own-id is the load-bearing half and the one a mutation caught**: falling back to cwd there re-creates the original incident exactly, and every other test stayed green while it did.
- **The ambiguity refusal was ALREADY THERE, which is why the incident's own bug report misdiagnosed it — a wrong STORE presents identically to an ambiguous cwd.** The 2026-09-17 ticket proposed adding a tie refusal that had shipped on 2026-07-31; the real cause was a leaked `XDG_STATE_HOME` pointing the CLI at a pytest fixture's store, where the caller's own workspace was absent and exactly ONE stranger matched the cwd — so inference was unambiguous and wrong, and no tie-breaking rule could have fired. **Two failures that share a symptom will be reported as the one the reader already knows about**; check whether the proposed fix is present before building it.
- **`resolve_or_infer_workspace` must refuse ties, and ROOT placement is the case that makes ties ordinary** — several ROOT workspaces share the repo root as their worktree, so a strictly-greater `depth` tie-break resolved to whichever `manager.list()` yielded first, silently, in every cwd-inferring verb. **When a docstring states a refusal, check that the refusal is reachable.**
- **Never pass `Path.cwd()` as `repo_root`; call `build(None)` and let it detect and bind to the main root.** From inside a *linked* worktree `build(Path.cwd())` binds to that worktree's own root and `manager.list()` returns **zero** workspaces; outside any repo it treats the cwd as the repo root and shows an empty list instead of the "must be run inside a git repository" error.
- **Loguru is configured once in the `@app.callback(invoke_without_command=True)`.** Per-subcommand configuration leaks the default DEBUG handler.
- **Never type launch commands into a fresh tmux prompt.** A slow interactive shell can echo text while swallowing every early Enter, leaving both the container-shell bridge and agent visibly unsubmitted. A non-empty window-zero bridge replaces that pane via `respawn-pane`; the agent is the new window's atomic `window_shell`, wrapped in the user's login-interactive shell with hermetic unset/export statements and a final interactive shell after exit. Interactive steering is a different seam and keeps its text/Enter settle. Use current libtmux APIs (`Window.select()`; `select_window()` was removed in 0.55).
- **`grove config add-project [PATH]` is a read-modify-write on the RAW user-config JSON layer**, never a round-trip through merged `GroveConfig` — a full merge would bake every cascade default into the user file. Idempotent by resolved-path membership, validated with `model_validate` before the atomic replace, and always the user config (`projects` is a user-level concern).
- **`grove skills install` / `grove mcp install` are a thin CLI shell over the side effects in [agents/onboarding.py](agents/CLAUDE.md).** `--target user|project|all` and `--agent claude|codex|all`; the questionnaire fires only for a missing `--target`, never for `--agent` (detection handles per-tool skip). `grove config init --with-onboarding` runs the identical funnel non-interactively so the three entry points cannot drift.
- **`grove show [<ref>]` is a read-only shell over existing best-effort seams** (`manager.peek`, the explorer's transcript tail) composed in `WorkspaceInspection` (`gather` → `emit`). `<ref>` is inferred from cwd as the *longest* worktree path that is an ancestor-or-equal of the resolved cwd — compare resolved `Path`s, never strings, and a ROOT workspace's worktree is the repo root, so longest wins.
## Release-skew check (`release.py`)
- **One engine-owned, best-effort, cached newer-release check — never a per-client GitHub poll.** `ReleaseChecker` compares the latest release tag on the GitHub mirror against the installed `grove.__version__`. The HTTPS call lives at the edge (`fetch_latest_release_tag`); the checker **never raises** into its render-path callers — a failure logs and the last-good result (or "unknown") stands. TTL-cached 6h: GitHub's unauthenticated budget is 60/hr and a slow GitHub must never stall a render. `fetcher`/`clock` are injectable and a global autouse conftest fixture patches `fetch_latest_release_tag`, so no test hits the network.
- **Semver compare is a pure unit (`_Version`, zero-padded, pre-release suffix dropped) plus `update_available()`** — no `packaging` dependency. Equal/older or unparseable → no nudge.
- **The daemon mirrors `ReleaseStatus` onto `WhoamiView` and the browser reads it from there, never polling GitHub itself**; the route resolves it in the executor so it never blocks. The TUI is a separate process with no daemon, so it holds its **own** `ReleaseChecker` — same engine code, not a second polling implementation. `latest_version` is the bare version, normalized at the single store site.
## Cross-platform
- **Pin `mypy<2.0` until 2.0 ships Linux x86_64 wheels.** Use `pathlib.Path` everywhere, `os.replace` for atomic writes, `shell=False` with list args for every subprocess. tmux on Windows requires WSL2 — surface a clear `TmuxError`, never half-support Windows-native. Degrade unsupported platforms to a typed error, never an import failure (same for POSIX-only stdlib modules — guard them behind `if sys.platform != "win32":`).