docs: split CLAUDE.md into an always-loaded core plus dev-docs/
Nightly Build / build (push) Successful in 10s

CLAUDE.md had grown to 152 KB (~21k words, ~40k tokens) and is loaded into
every coding-agent session. The cost is not the cache read, it is attention:
the rules that are genuinely invariant were drowning in the mechanics of
subsystems that most tasks never touch.

The split criterion is blast radius, not importance. A rule a change anywhere
could violate stays in CLAUDE.md — the commit rule, the production/schema
constraint, domain neutrality, the event-bus rule, the crate boundaries, and
the module map. The mechanism of one subsystem moves to dev-docs/, opened on
entry to that subsystem via a routing table at the top of CLAUDE.md.

Nothing was rewritten: every section was moved verbatim by line range and
verified line-by-line against the original. The only edits are cross-reference
repairs ("see the DB section" -> a link), the promotion of headings in the
extracted files, and a condensed "Current state" whose full text now lives in
dev-docs/users-auth-and-boot.md.

CLAUDE.md: 152 KB -> 31 KB. Twelve subsystem files plus an index under
dev-docs/, which now carries the same standing rule as docs/ and CHANGELOG.md:
a change to a subsystem updates its dev-doc in the same change.

No CHANGELOG entry: this is documentation for coding agents with no observable
effect on the application.
This commit is contained in:
Daniele
2026-08-24 18:04:43 +01:00
parent 52a63286ce
commit 902f47ecd8
14 changed files with 569 additions and 398 deletions
+62
View File
@@ -0,0 +1,62 @@
*Skald dev-docs — architectural reference for coding agents. Index: [README.md](README.md) · Entry point: [../CLAUDE.md](../CLAUDE.md)*
**Read this when:** you touch `crates/agent-loop/`, `loop_adapters/`, `session/handler/`, sub-agents, cancellation, restart recovery or the approval gate.
---
# The agent loop
## The LLM loop (`agent-loop`)
The loop is a **standalone crate** (`crates/agent-loop/`) that knows nothing about Skald: it owns control flow (rounds, model fallback, tool fan-out, recording), the projection of history into wire messages, sub-agent delegation, restart recovery and compaction. Skald supplies content through the traits in `crates/skald-core/src/loop_adapters/`. Nothing in `session/handler/` shapes a `Value` anymore — there is exactly **one** projection in the workspace.
**One `LoopManager` per user** (`UserLoopRuntime`, `loop_adapters/runtime.rs`, blueprint D12), built by `ChatSessionManager`: it owns the event bus, the live-loop registry (which conversations are running, `/stop`, recovery, shutdown), the store, the approval gate, the hooks, the agent catalog and the delegate tool. A turn contributes only what is its own — the agent's prompt, its tool set, its model pin — via `turn_params`.
**Per-turn state rides the `Extensions` type-map** (`loop_adapters/scope.rs::TurnScope`): the gate and the catalog live as long as the user, so they cannot capture a session id or a permission group — they read the turn's scope from the call's extensions. **A call with no scope is denied**, never run with permissive defaults.
Three entry points, all in `session/handler/kernel_turn.rs`:
| entry | when | what it does |
| ---- | ---- | ---- |
| `run_kernel_turn` | a user message | repairs a dangling call from a crashed turn, then `manager.start_turn` |
| `recover_turn` | WS connect, async result delivery, background wake-up | `Recovery::run` — no new message, continue what was interrupted |
| `resolve_pending_call` | an approval answered after a restart | run the call with the gate skipped, then continue |
The event **translator** (`loop_adapters/translate.rs`) is the ONE bus subscriber turning `LoopEvent`s into the session's `ServerEvent`s; byte-parity with the pre-kernel event sequence is its contract.
### Sub-agents
- A sub-agent is a **tool**, not an interception: `DelegateTool` (registered under the legacy names `execute_task` / `execute_subtask`, D11, each keeping its exact legacy schema) opens a child frame and runs a normal loop in it. The parent simply awaits a slow tool call. Max depth `MAX_AGENT_DEPTH = 5`.
- **Parallel batches are the kernel's generic fan-out**: a round whose calls are all `concurrency_safe` (a sync delegate is) runs concurrently, bounded by `max_parallel_calls`. The ordering invariant is unchanged — ids allocated in call order (phase 1) → concurrent execution (phase 2) → recording in call order (phase 3) — so the model reconstructs results by id. Any mixed batch stays sequential. Siblings share the session scratchpad; concurrent writes to the same key are last-writer-wins by design.
- `mode: "async"` submits a durable `scheduled_jobs` row through `loop_adapters/async_task.rs::CronExecutor` and returns a receipt immediately; when the job finishes, `DurableSink` writes the result into the parent conversation (synthetic assistant + a completed `task_completed` call) and resumes it. `mode: "cron"` is scheduling, not delegation, and stays on the cron interface tool.
- **An async task ends in the conversation that started it, whatever happened to it** — and `cron::run_job` is shaped so it cannot do otherwise: one `JobOutcome` classification, then *one* `match job.kind` delivery site for every ending. It used to branch on `Ok`/`Err` first and route by kind only inside `Ok`, so a failure or a kill went out as a "Cron job … failed" notification to the **home** source (`/sethome`) while the parent sat waiting for a `task_completed` that never came — the wrong chat *and* a wedged conversation. The sink has a single channel by design: to the model, "it broke" is a result like any other and must not be overlookable, so the failure is delivered as prose (with whatever partial output the run produced). A cron job has no parent conversation and keeps the home notification — the future plan is to let its creator name a destination. Cancellation is a third outcome, not a flavour of failure: `job_runs.status` always had `'cancelled'` in its CHECK and nothing wrote it, and the classifier keys on the **typed** `session::handler::TurnCancelled` error, never on the message text.
- **The chat shows what it started.** `ServerEvent::TaskUpdate` announces an async task's state to the source of its parent conversation only (a cron job belongs to nobody's chat), and `GET /api/{source}/tasks` (`db::scheduled_jobs::list_for_parent_session`) answers the same question at load time — running tasks plus failures from the last 30 minutes, because the event is a broadcast with no replay and a browser reload would otherwise empty a chat that still has work under it. Successes are absent from that query on purpose: a finished task's result is already a message in the conversation. The strip itself is `web/components/shared/agent-tasks.js` (`renderTaskStrip`), rendered above the composer on desktop and mobile from state owned by `ChatSession`; the drill-in is `#session/{id}`, gated on `_canOpenTaskSession` because the mobile shell routes a fixed set of sections and would silently swallow that hash.
- A child's model is **never inherited** from the parent: passing a concrete name would bypass AUTO selection, so sub-agents auto-select unless explicitly overridden (`args.client``meta.json client` → AUTO by strength).
- `list_agents` returns **task** agents only (never `chat`/`system` ones like the entry agent).
### Restart recovery (`agent_loop::recovery`)
A crash loses RAM (the approval oneshot, the cancellation token), never truth: every state transition is a store write. So recovery does not have a mode of its own — it makes the history well-formed and then runs a **normal loop** on it:
1. **Reap** an interrupted parallel batch (≥2 active frames at one depth is impossible for a linear stack): fail their spawning calls, close the frames. Deliberately lossy.
2. **Resolve** the deepest frame's non-terminal calls. A `Running` one is re-gated and re-executed **unless the tool says otherwise**`execute_cmd` declares `RestartHint::MarkInterrupted` (D7), because a command may already have had its effect. An `AwaitingHuman` one is re-asked (the card reappears).
3. **Un-wedge**: a child that finished but whose result never reached its parent propagates without calling the model again.
4. **Cascade** to the root, resolving each parent call with its child's result — every frame running as **its own** agent, from the catalog, never the root's (B3).
`Cancelled` and `Rejected` are terminal and are never re-executed. Anti-double-driving goes through the manager's registry (a recovery claims the conversation like a live turn), not a host-side flag.
## Cancellation (stop)
- The turn's `CancellationToken` is minted by `LoopManager::start_turn` and **cloned by value** down the whole call tree; a delegate passes `ctx.cancel.child_token()`. It is never re-read from a field mid-turn, which is what makes `/stop` **sticky** across sub-agent recursion.
- `ChatSessionHandler::cancel()``manager.cancel(&conversation)`. The token is checked at each round boundary and before each tool call, wrapped around the in-flight LLM call (`tokio::select!`, aborting the request), and around `execute_cmd` (dropping the future → `kill_on_drop`). Parent and child share the tree, so a cancelled child stops the parent by construction.
## Approval gate
The rule engine `ApprovalManager::check` returns `Allow`/`Deny`/`Require` per tool call (default rules seeded on first boot; the catch-all `* require @999999` gates anything not explicitly allowed — e.g. `execute_cmd`, `execute_task`, writes outside whitelisted paths). It is wired to the loop as `loop_adapters/gate.rs::ApprovalGate` (`agent_loop::gate::Gate`). A `Require` registers a `oneshot` in the in-memory `pending` map keyed by `request_id` and emits an approval event over WS.
Resolution is **source-agnostic**: the WS + Inbox paths resolve by `request_id`; the inline chat card resolves by the durable `tool_call_id` via `POST /api/tools/:tool_call_id/resolve` (`resolve_tool` in `src/frontend/api/sessions.rs`), which derives the owning session from the tool call's own stack row — never a hardcoded source. Live pending cards fire the `oneshot`. Post-restart there is **one** path for every tool, `LoopManager::resolve_pending`: the call runs with the gate skipped (the human just decided) but with the session's real `ToolContext` — owner pool, per-user container — so a resolved `write_file`/`execute_cmd` acts on the user's workspace, never the server cwd/host (this was a §6 escape); then the conversation continues, including a sub-agent dispatch, which simply opens its child frame like any other call. The endpoint returns as soon as the work is scheduled and the result streams over the bus.
The **diff preview** in a `PendingWrite` event (`loop_adapters/preview.rs::read_current_content`, driven by the `SkaldWritePreviewHook`) routes exactly like the fs-tools: `user-memory/`/`shared-memory/``memory_docs` on the right pool, every other agent path → the caller's host workspace via `resolve_host_path(&self.fs, …)`. It must never use the cwd-relative `fs::resolve` — that showed a bogus "new file" on overwrites (or the diff of a same-named cwd file), so the user would approve the wrong diff.
**Tool visibility in the Security-groups UI** (`GET /api/approval/tools`): tools injected outside the `ToolRegistry` (interface/plugin/provider tools) would otherwise be un-configurable. `ToolCatalog::list_all()` covers registry tools + a static `synthetic_tools()` list of core interface tools; everything else is captured by `crates/skald-core/src/tool_discovery.rs` (`ToolDiscovery`), which taps the tool set the loop offers each round (`SkaldToolSet::defs`) and upserts every offered tool into the `known_tools` table (in-memory seen-set guard → background DB write). `list_tools` merges `known_tools` (deduped, `category: "dynamic"`) so any tool offered at least once becomes gate-able. Drift-proof by construction; core never hardcodes plugin tool names.