# AGENTS.md
<!-- Generated by APM CLI from distributed .apm/ primitives -->
<!-- Build ID: 968fbad82a7e -->
<!-- APM Version: 0.28.0 -->

## Files matching `**`

## Communication

- **Answer in plain words.** When the user asks you to explain, summarize, or reason about something (not write code), reach for everyday language first and keep it short. Introduce a piece of jargon, a symbol-heavy phrasing, or an internal codename only when it carries weight the plain word can't — then gloss it once. This is the default for *every* turn, not a per-request favor: the user should never have to ask twice.

## Workflow

- Use `/be` to take a task end-to-end. It owns its own phase list and review gauntlet — don't restate them here.
- Run `just fmt` (formatting) before declaring done.
- **Prefer external libraries over hand-rolled code**: Use well-maintained SolidJS-native libraries (Corvu, solid-sonner, @solid-primitives, etc.) to reduce custom code surface area. Less code to maintain = fewer bugs.

## Design philosophy

Three principles the codebase is built on. Honor them **by default** when writing code — a violation is a defect to fix now, not a style preference or a follow-up.

- **Fail fast — no fallbacks, no backward-compat, no override knobs.** An override / knob / graceful-degradation path is a defect, not a feature ("being able to *override* is never a feature"). Bake required values in (e.g. via Nix) and **crash loudly** if one is absent rather than silently degrading. A caught error must surface, never collapse to an empty or default state — see `.agency/code-police.md` → `caught-error-must-not-collapse-to-empty`.
- **Volatility boundaries ("electricity").** A domain-agnostic capability that hides a *hard* volatility (transport, reconnect, persistence, GPU-context loss) is its own `@kolu/*` workspace package, with the dependency arrow pointing **out** — never folded into an app / browse / terminal module, however generic the code reads (location *is* structure). Apply the three tests in `docs/atlas/src/content/atlas/electricity.mdx` before extracting; a tidy generic helper that hides only a bounded algorithm is a *leaf*, not a receptacle.
- **Reuse the existing source of truth.** Prefer the repo's canonical mechanism or an existing code path over a parallel hand-rolled one — `.gitignore` / `@parcel/watcher` ignore globs over a hardcoded ignore list, an existing extension/MIME table over a duplicated constant, a maintained library (the rule above) over custom code. When unsure, grep for the existing path before writing a new one.

## Feature Discoverability (Tips)

When adding a new user-facing feature or shortcut, consider adding a tip so users discover it. See `settings/tips.ts` and `settings/useTips.ts` for the registry and API.

## Reserved Keybindings

When adding or rebinding a global shortcut in `input/actions.ts`, check `input/prohibitedKeybinds.ts` — those chords are claimed by tools that run inside kolu PTYs (Claude Code's Ctrl+B / Ctrl+J today) and must reach the terminal. The collision is unit-tested in `keyboard.test.ts`; add an entry there when a new tool reserves a chord.

## Git

- Use [conventional commits](https://www.conventionalcommits.org/) (e.g. `feat:`, `fix:`, `docs:`, `chore:`, `refactor:`, `test:`).

## Files matching `**/*.nix`

## Nix

- **DO NOT add flake inputs** to `flake.nix`. Each input adds ~1.5s to `nix develop` cold start. The flake intentionally has zero inputs — nixpkgs and other sources are managed by [npins](https://github.com/andir/npins) and imported via `fetchTarball`. Use `npins add`/`npins update` for new or updated sources.
- **Shared env vars** live in `koluEnv` (defined in `default.nix`). Both the build and the devShell consume it — don't duplicate env var definitions.
- **Measure `nix develop` time** after Nix changes: `time nix develop -c echo test`. Current target: ~2.6s cold, ~0.3s warm.

## Files matching `{.agents/**,.claude/**,.codex/**,.opencode/**,AGENTS.md,opencode.json}`

## Generated Files — Do Not Edit Directly

Everything under `.claude/`, `.codex/`, `.agents/`, and `.opencode/` is generated from top-level `.apm/` sources by APM. Direct edits will be overwritten on the next regeneration.

To modify agent configuration, edit the source files under `.apm/`, then run `just ai::apm` to regenerate the runtime directories and `AGENTS.md`.

## Files matching `{.apm/**,.agents/**,.claude/**,.codex/**,.opencode/**,AGENTS.md,agents/**,apm.yml,apm.lock.yaml,opencode.json}`

## APM Workflow

APM is not a global CLI — it runs via `uvx` through justfile recipes in `agents/ai.just`. Never try to run `apm` directly; always use the just recipes:

- **Install/regenerate** agent runtime directories from sources: `just ai::apm`
- **Update a dependency** to its latest ref: `just ai::apm-update <package>` (e.g. `just ai::apm-update srid/agency`)

### Skill/instruction sources live in TWO trees

`.claude/skills/<name>/` and `.claude/rules/<name>.md` are generated from one of two source trees — grep **both** before assuming something isn't apm-managed:

- **Root `.apm/`** — this repo's own package: skills like `atlas`, `test`, `dev-server`, `evidence`, `release`, `self-improve`; instructions like this file, under `.apm/instructions/`.
- **`agents/.apm/`** — the reusable `agents/` package (a local `path:` dependency in the root `apm.yml`): the skills `be`, `be-review`, `kolu`, `lens-debate`, `agent-debate`, `perfection-review`, `surface`, `architecture-first-principles`.

So editing e.g. the **kolu** skill means editing `agents/.apm/skills/kolu/SKILL.md`, **not** root `.apm/skills/`.

### An `agents/` source edit must be committed before it propagates

`just ai::apm` vendors the `agents/` path-dependency from a **git checkout** (pinned by the `agents/` package version in `apm.lock.yaml`), *not* your working tree. So an **uncommitted** edit to `agents/.apm/skills/**` regenerates the OLD cached content and silently reverts your change. Commit the source edit first, then `just ai::apm`, then **confirm the change actually landed** in the generated `.claude/skills/<name>/` — a stale vendor snapshot can ship the old text with no error.

## Files matching `{README.md,website/src/content/docs/**}`

## kolu.dev docs are the canonical product docs

The user-facing description of kolu lives in the **kolu.dev docs** at `website/src/content/docs/` (rendered through `DocsLayout`, grouped by the `section` frontmatter). This is the canonical place a user reads about what kolu does and how to use it — **not** the root `README.md`, which is a slim map that points into these pages.

- When you add or materially change a **user-facing feature**, update the matching docs page in the same PR — a new agent's detection → `agent-detection.mdx`; a theming option → `theming.mdx`; a canvas behaviour → `canvas.mdx`; a terminal-tile behaviour → `tiles.mdx`; a dock behaviour → `dock.mdx`; a session/sleep-wake feature → `sessions.mdx`. (`concepts.mdx` is vocabulary-only — behaviour never lands there; if no page owns the surface, that's the signal to create one, not to widen Core Concepts.) Add a **new page** (frontmatter: `title` · `description` · `order` · `section`) when the feature doesn't fit an existing one; the sidebar orders by `order` and groups by `section`.
- How to write `<Change>` entries lives in its own rule, `changelog.instructions.md` (scoped to `website/src/content/changelog/**`) — including the discipline that a changelog entry is the trigger to fix the docs page it just made stale, in the same PR.
- Keep the README a pointer. Don't re-grow a feature catalog there — add or adjust a bullet under "What it does" that links to the doc page instead.
- Diagrams are hand-authored **inline SVG** reusing the `ax-d-*` / `doc-figure` classes (the site has no mermaid). Style both light and dark via the `--color-*` CSS tokens — never raw hex.
- This is the product-docs mirror of the `surface-reference` rule (which governs the `@kolu/surface` Reference pages). Same discipline: the record must not lie.

## Files matching `{README.md,website/src/content/docs/architecture.mdx}`

## Architecture (docs)

- The canonical architecture write-up is the **kolu.dev docs** page at `website/src/content/docs/architecture.mdx` (served at <https://kolu.dev/architecture>): the daemon stack (PWA client · kolu-server · padi · kaval), how the layers talk over the wire (the single websocket + Effect RPC over ndjson + the same-origin gate), the two data-flow loops, and the package map — with hand-authored SVG diagrams (the site has no mermaid). **Read it before declaring done** on any structural change, and update every part that no longer matches (the stack table + SVG, the communication section, the data-flow loops + SVG, the package map).
- The root `README.md` Architecture section is now only a **pointer** to that page — do not re-grow the package table, the communication tables, or the data-flow diagram there. The README is a map; the kolu.dev docs are canonical.

## Files matching `{packages/client/favicon.svg,packages/client/public/icon-192.png,packages/client/public/icon-512.png,packages/client/public/icon-512-maskable.png,packages/client/index.html,packages/client/src/AboutDialog.tsx,packages/client/src/WelcomeDialog.tsx,packages/client/src/ChromeBar.tsx,packages/client/src/MobileChromeSheet.tsx,packages/client/src/terminal/useActivityAlerts.ts,packages/client/src/screenshotTerminal.ts,packages/client/src/kaval/KavalInfoDialog.tsx,packages/server/src/index.ts,packages/server/src/pwaIdentity.ts,packages/server/src/pwaIdentity.test.ts,packages/padi/logo.svg,packages/kaval/logo.svg,packages/kaval/README.md,website/default.nix,website/public/favicon.svg,website/public/padi-logo.svg,website/public/kaval-logo.svg,website/src/**,README.md}`

## Kolu Logo

- Treat `packages/client/favicon.svg` as the source vector. Browser/chrome uses it transparent.
- Regenerate `packages/client/public/icon-192.png`, `icon-512.png`, and `icon-512-maskable.png` after changing it. These are the PWA/Dock assets and need a rounded-square backing with safe-zone-friendly artwork.
- Keep the mark readable at 16px: no text, no thin strokes, no crowded prompt glyph.
- Verify both surfaces: browser tab favicon and macOS Dock/PWA-sized icon.
- Keep app identity as `Kolu [host]`; do not reintroduce `kolu@host` or `short_name: "kolu"`.

## Website Palette

- Keep the website palette in sync with the Kolu logo. When `packages/client/favicon.svg` changes colour, update the website tokens in `website/src/styles/global.css` and any non-CSS consumers such as the OG RGB tuple in `website/src/site.ts`.
- Treat the logo as three colour roles on the website: the middle step is the primary action/link/hover colour; the top step is the formatting accent for code, prompts, markers, and warnings; the bottom step is the body/detail accent for rules, tips, quotes, diagram streams, and live/success accents.
- Use the website's palette variables (`--color-kolu-logo-*`, `--color-kolu-primary*`, `--color-kolu-top*`, `--color-kolu-bottom*`) instead of duplicating raw hex values or reintroducing old amber/purple/violet site tokens.
- If a website diagram or page represents Kolu, Padi, or Kaval, use the relevant program logo asset from `website/public/` rather than drawing an unrelated placeholder mark.

## Padi Logo

- Treat `packages/padi/logo.svg` as the source vector for Padi. The website copies it to `website/public/padi-logo.svg`.
- Keep the Padi SVG background transparent. Let the consuming surface provide any tile or panel behind it.

## Kaval Logo

- Treat `packages/kaval/logo.svg` as the source vector for Kaval. The website copies it to `website/public/kaval-logo.svg`, and the app imports it in `KavalInfoDialog`.
- Keep the Kaval SVG background transparent. Let the consuming surface provide any tile or panel behind it.
- Keep it icon-like, not wordmark-like: no long text, no tiny labels, no dense terminal clusters. It must read at 24px in the Kaval dialog and at favicon-adjacent sizes.
- Preserve the meaning: Kaval is the PTY daemon that guards/watches terminals. A simple prompt, shield/watch shape, and a small session cue are enough.

## Files matching `{packages/client/src,packages/server/src,packages/common/src}/**`

## Streaming members

Three invariants an agent editing any single file would otherwise miss. They are independent rules (different layers, different enforcement mechanisms) that share a trigger: **touching any streaming member**.

### 1. Apply the retry fence — via `client.rawStream` or `unenrolledStreamCall`, never a bare `client.*` call

A streaming member hands back a **lazy Effect `Stream`**. Every one the client consumes must be wrapped in the framework's **per-subscription retry fence** (`fenceStream` + `STREAM_RETRY`, `@kolu/surface/src/client.ts`) so a dropped socket transparently re-subscribes. The fence is per SUBSCRIPTION and not per link, deliberately: Effect RPC's own `retryTransientErrors` reconnects the socket but **re-issues nothing** — an established-socket close fails every in-flight call and no layer below re-opens them. Two paths bake the fence in:

- **`client.rawStream(name, proc, input, { onItem, onRetry, isExpectedStop })`** — a SURFACE-scoped raw stream that ENROLS into `client.health()` (throws outside a reactive owner, so a raw stream can't silently escape the health fact). This is the default for a surface stream member.
- **`unenrolledStreamCall(proc, input, { onRetry })`** (from `@kolu/surface/client`) — the bare, un-enrolled call: the fence applied, but DELIBERATELY carved out of any surface `health()`. Reach for it only when the stream's health is a per-consumer concern that must not flicker the global gate.

`onRetry` fires once per RETRYABLE failure, INSIDE the fence and before the delay, so "fired ⇒ a re-subscribe follows" holds and a consumer that clears its buffer is never left with a cleared view and no new stream. The terminal **attach** stream (`padiSurface.streams.terminalAttach`, in `terminal/Terminal.tsx`) uses `unenrolledStreamCall` for exactly this reason (Leak-A carve-out): a single terminal's re-attach — overflow re-attach #1591, PTY exit — must never light padi's connection-health indicator. Its `onRetry` resets xterm + the scroll lock and re-arms the snapshot boundary, because imperative consumers must clear their buffer before the retried stream delivers its fresh snapshot — otherwise scrollback double-paints.

What the fence retries is a **positive** test (`shouldRetryStreamError`): an Effect RPC `RpcClientError` transport failure, plus the one named retryable relay end (`SurfaceRelayTransportLost`). Everything else fails the stream on its first occurrence — a **declared** (PLAN D4) tagged procedure error is an application-level answer, and the two permanently-dead transport tags (`SurfaceTransportRetired`, `SurfaceStdioTransportClosed`) are corpses a retry loop would storm against.

**When adding a new streaming member**, pick `client.rawStream` (enrolled) unless the stream is a deliberate health carve-out (`unenrolledStreamCall`). Consuming a member's `Stream` directly — without either wrapper — silently loses reconnect handling.

**There is no `signal`.** Cancellation is fiber interruption (PLAN D10/#18): interrupting the subscription's scoped fiber runs the stream's own finalizers, which IS the unsubscribe. A one-shot read of the opening frame is `@kolu/surface/first-frame`'s `firstFrameOrThrow` / `firstFrameOrUndefined`, which take the `Stream` itself.

### 2. Server handlers yield snapshot-then-deltas

Every server-side streaming handler (`packages/server/src/router.ts`, and every `implementSurface` `streams.<key>` slot) MUST yield a full state snapshot as its first item, then stream deltas. This is the invariant that makes the fence's transparent re-subscribe work: `Stream.retry` re-runs the WHOLE stream, so the next frame the consumer sees is a fresh snapshot that replaces stale client state.

Two acceptable shapes:

- **Implicit**: each yield is already a full replacement (e.g. `onMetadataChange` yields a current `TerminalMetadata`; `preferences.get` yields a current `Preferences`; `activity.get` yields a current `ActivityFeed`; `session.get` yields the current `SavedSession | null`; `terminal.list` yields a current `TerminalInfo[]`). Client reducers can just use the latest value.
- **Explicit discriminated union**: when clients accumulate deltas into a derived structure, yield `{ kind: "snapshot", ... } | { kind: "delta", ... }`. Client reducers replace on snapshot, append on delta. Without the discriminator, reconnect replays the history into an already-populated accumulator and duplicates state.

If a new handler yields deltas only (no initial snapshot), reconnects will silently lose state with no error.

### 3. A member's input is CAPTURED — never a live fact

The fence re-subscribes by REPLAYING the input it read once, at the first call. So a stream whose input carries a LIVE fact (a size, a cursor, a viewport) re-sends a STALE one after any transport drop — and if the server acts on that input (kolu's terminal attach resizes the PTY to it), the replay actively moves shared state backwards.

Either keep the input a **stable key**, or have the consumer REFUSE an answer computed for the stale fact and reopen the stream. The terminal attach is the worked example: it remembers the grid it asked at, drops a snapshot answering any other grid, and re-reads the live grid on every open (`client/src/terminal/Terminal.tsx`). Painting the stale answer and correcting afterwards does NOT work — the damage (scrollback wrapped at the wrong width) is already done.

## Files matching `{packages/server/src/state.ts,packages/server/src/preferences.ts,packages/server/src/activity.ts,packages/server/src/session.ts,packages/server/src/hostPersistence.ts,packages/common/src/surface.ts,packages/common/src/hostKey.ts}`

## Persistent State (conf)

State is stored via `conf` in `packages/server/src/state.ts`, which owns the on-disk shape (`PersistedStateSchema`, internal to that file) and the migration ladder. Domain modules (`preferences.ts`, `hostPersistence.ts`) read and write their own keys against the shared `store`.

When changing what is persisted (any of the per-domain schemas — `PreferencesSchema` in `packages/common/src/surface.ts`, `PersistedHostsSchema` in `packages/common/src/hostKey.ts`), **you must add a migration** in `state.ts`'s `migrations` object and bump `SCHEMA_VERSION`. Without a migration, existing users' state files silently lose or misinterpret data on schema changes.

The disk shape is one schema, one migration ladder, one source of truth. `PersistedStateSchema` is intentionally not exported; consumers go through the domain accessors (`getPreferences()`, `getPersistedHosts()`).

**Host-map membership rides this store too — it is not a special case.** The fleet you add via the selector strip persists as the `hosts` field of `PersistedStateSchema`, owned by `hostPersistence.ts` (its `getPersistedHosts()` reader + `savePoolMembership()` persist-hook shaper) and carried by the SAME schema + migration ladder as every other domain. It IS real user data (unlike `preferences`, a silent reset would eat the fleet), so it must never be silently emptied — and `conf` already guarantees that: a corrupt/unparseable store **THROWS on read** (`clearInvalidConfig` is false, conf 15's default), it does NOT reset to defaults. That fail-fast is exactly why `hosts` belongs in this store rather than a hand-rolled file. An invalid-but-parseable value (a hand-edited `local`, a duplicate) additionally fails loud where it's read — `getPersistedHosts` re-validates through `PersistedHostsSchema` (in `kolu-common/hostKey`) and throws, never silently normalizing. Changing the `hosts` shape takes a migration + `SCHEMA_VERSION` bump like any other domain.

## Files matching `{packages/terminal-vocab/src/agentProjection.ts,packages/client/src/canvas/**}`

## The Dock's agent-state folds

kolu's on-canvas **Dock** (`packages/client/src/canvas/dock`) renders agent state through fenced folds that live **once** in **`@kolu/terminal-vocab/agentProjection`**.

- **One source of truth.** The agent-state vocabulary — how `AgentInfo['state']` folds to a coarse class — lives **once** in `agentProjection`, as `state satisfies never`-fenced folds: `agentUrgency` (needs-you ranking), `agentPaintClass` (pip/glyph colour), `attentionClass` + `attentionActive` (which attention list a terminal is in, and whether something is happening in it), `alertClass` (notify membership). **Never re-derive any of these** with a hand-rolled switch over the state literals — import the fold. A new state literal must force one decision, in the fenced fold, not silently route through a copy.

- **The order≠colour split is load-bearing — don't collapse it.** A just-finished `waiting` agent **RANKS** idle (`agentUrgency`) but **PAINTS** `linger` (`agentPaintClass`) — the dimmed "it just finished" cue, distinct from the full-strength `awaiting` a genuinely blocked agent wears. Every surface keeps that decoupling: **sort** by urgency, **colour** the pip/glyph by paint. `awaiting_user` (genuinely blocked) is what floats to the top; `waiting` (the post-turn lull) does not.

- **"Is something happening" is ONE question with ONE answer.** `attentionActive(attentionClass(agent, finished), live)` decides a mark's MOTION and every count that summarises a scope — a host tab, a repo section header. Never fold your own notion of busy for a count: four such notions once coexisted, and every attention bug in PR #2019 was two of them disagreeing (a tab reading 2 beside three moving marks). It takes padi's EF2 finish verdict and raw byte motion as well as the agent state, so it lives beside — not inside — the state-only folds above.

- **The folds' home is settled — don't repatriate them.** They live in the shared `@kolu/terminal-vocab` package even though the Dock is today their only paint consumer. That is history, not misplacement: the browser fleet mirror (`pulam-web`) they once also served **died with the pulam world** (the W2.3 burial deleted every `pulam*` package), and `padi-tui` is deliberately *not* a paint mirror (a thin uncoloured `status`/`watch`/`wait`/`create` CLI — no needs-you sort, no paint classes, no alert). Judging a fold's home by *today's* consumer count and "tidying" it into a kolu-local module is churn, not structure — and if a fleet mirror ever returns, it consumes these same folds, and the old two-surface sync contract (git history of this file) revives with it.

## Files matching `{website/src/content/docs/**,packages/client/src/ui/DocLink.tsx}`

## Doc links — one registry, every surface

In-app links to kolu.dev product docs are owned by **one leaf module**:
`packages/client/src/ui/DocLink.tsx`.

- **Touch a doc page's existence or name** (add / rename / delete under
  `website/src/content/docs/*.mdx`) → update `DOC_SLUGS` in `DocLink.tsx` in the
  **same change**. The set-equality test in `DocLink.test.ts` fails both ways
  when they drift.
- **Link to docs from the UI only through `DocLink` / `docUrl`.** Never hand-roll
  a `https://kolu.dev/...` href (or any other `kolu.dev` literal) under
  `packages/client/src`. The grep law in `DocLink.test.ts` bans it outside
  `DocLink.tsx` itself. Button-chrome sites take `docUrl(slug)` for the href and
  keep their own styling; inline "learn more" text uses `<DocLink slug={…}>`.
- Tips that point at a docs page set optional `doc?: DocSlug` on the tip entry
  (`settings/tips.ts`); `TipBanner` renders the link when present.

---
*This file was generated by APM CLI. Do not edit manually.*
*To regenerate: `apm compile`*
