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
+32
View File
@@ -0,0 +1,32 @@
*Skald dev-docs — architectural reference for coding agents. Index: [README.md](README.md) · Entry point: [../CLAUDE.md](../CLAUDE.md)*
**Read this when:** you touch projects, shared folders, the file explorer or the `#files` page.
---
# Projects, shared folders & the file explorer
## Projects
A **project** is a shareable, self-service workspace: a folder at `{WD}/projects/{owner_userid}/{slug}` plus membership in the registry. `projects` (accessor `db/projects.rs` — slug is immutable, `UNIQUE(owner_user_id, slug)`) + `project_members` (junction with `can_write`; the owner is always a write-member, so a private project = one member). Sharing is **not** admin-gated: the owner and any write-member can add/remove/re-grant members and edit metadata; only the owner can delete. Each membership mutation emits `SystemEvent::UserMountsChanged` for the affected user; the lifecycle reconciler remounts their container in place (`Skald::refresh_user_mounts`), so the folder is browsable at once (the explorer reads host-side) and reachable from `execute_cmd` a moment later. The mount appears in the agent namespace as `projects/{owner_username}/{slug}` (host keys on the stable userid, agent path on the username) — read-only members get a read-only bind mount in the container.
**API** (`src/frontend/api/projects.rs`): `GET/POST /api/projects`, `GET/PUT/DELETE /api/projects/{id}`, `POST /api/projects/{id}/members`, `DELETE .../members/{user_id}`, `POST /api/projects/{id}/session`. `ProjectDetail` carries `root_path` — the agent path of the folder, computed server-side (owner username ≠ `owner_name`, which may be a display name) — the explorer's root. A `project-{id}` chat source provisions the `project-coordinator` agent with a project `RunContext` (`provisioning_for_source``skald_core::projects::build_project_run_context`: `project_root` + a system block with name/description/folder/members); every member keeps their **own private** `project-{id}` session — only the folder is shared.
**UI** (`web/components/projects/`): `index.js` (`<projects-page>` host — hash-routed: `#projects`, `#projects/{id}`, `#projects/{id}/sharing`, back/forward-aware), `project-list.js` (card grid + create/edit/delete modal), `project-board.js` (`<project-board-section>` — the detail page: header with **Open chat**, then a **Files / Sharing** tab bar using the `.project-tab-bar` styles in `css/projects/board.css`, the Files tab being the shared `<file-explorer>` pointed at the project folder). The mobile app has its own read-only `shared/projects-page.js` (list → open project chat).
**The explorer** (`web/components/shared/file-explorer.js`, `<file-explorer>`): **not a project component** — it browses one subtree of the caller's namespace, given a `root` agent path (a project folder, a shared folder, the home, a memory store) and a `rootLabel` for the first crumb; projects are one caller of it. One directory at a time via `GET /api/files/dir?path=…` (`src/frontend/api/files.rs`: `{ path, can_write, entries }`, each entry `name/path/is_dir/size/created_at/modified_at`, dirs-first; same `resolve_view_path` scoping as `/api/file`, except a memory path, classified **before** it and listed from `memory_docs` — see the memory-namespace note in [database.md](database.md)). **`can_write` is read from that listing, never passed in**: it changes per branch (a shared folder without the flag, `skills/`, `docs/`, a memory store) and comes from the same `UserFs::can_write_to` the server rejects writes with, so the buttons offered and the writes accepted cannot disagree — a caller that thought it knew better would be the one place they could. Breadcrumb rooted at `root`; file click → `window.openFile` (existing viewer); folder click → navigate. **Live**: it subscribes the open directory on the existing `/api/file/watch` socket (`web/lib/file-watcher.js` singleton — `notify` NonRecursive on a dir reports its direct children) and reloads debounced 300 ms, so files created by other members or by the agent in-container appear without a refresh. Write actions (new folder, upload incl. drag&drop, rename, delete) are shown only to `can_write` members and ride the existing `/api/file` endpoints — `POST` gained `dir:true` (mkdir), `DELETE` handles directories (`remove_dir_all`), and binary upload is the new `POST /api/file/upload?path=…` (raw body, 256 MiB `DefaultBodyLimit`). **Server-side write gate**: all `/api/file` write handlers now call `UserFs::can_write_to(agent_path)` (core-api) — home → true, `shared/`/`projects/` → the membership's `can_write`, `docs/` → false — closing the host-side bypass of the read-only bind mount (the container mount only gates in-container writes).
## Files (`#files`)
The general file section: one page over **everything the caller can reach**, and the second consumer of `<file-explorer>` (see Projects above for the component itself).
**The root is virtual, and that is the whole design.** Anchoring at `~` was the obvious move and is wrong: the explorer reads **host-side**, where the home is `{WD}/homes/{userid}` while `shared/{X}`, `projects/{O}/{S}`, `skills/` and `docs/` are bind mounts *inside the container* — so a page rooted at the home would show less than the user has, with no way to reach the rest, and on native Linux would show the mountpoint stubs Docker creates in the bind source: `shared/`, `docs/`, `skills/` present and **empty**. That is the memory-signpost failure exactly — a door that appears to work and leads nowhere. So level 0 is a synthetic list from `GET /api/files/roots`, serialized from the caller's `UserFs` (plus the two memory roots, which are virtual and so are not in it): `FsRoot { kind, path, name, owner, can_write }`, with **no `label`** — the server sends the discriminant, the frontend maps `kind` → label + icon, because labels are copy and get translated. Seven kinds, not six: `user-memory` and `shared-memory` are separate rather than one `memory` with a scope, since they are two stores with two names and a `scope` field would be a discriminant inside a discriminant. `skills`/`docs` appear only if the `UserFs` has them.
**The URL carries the agent path of the open folder** — one `path` parameter (`#files?path=shared/casa/foto`), the same vocabulary the assistant uses, so a link is shareable *and* pasteable into a conversation. Which root it belongs to is **derived** (`FilesPage._resolve`, longest-prefix over the roots list), never stored beside it: two values that can disagree are two chances to be wrong. A path under no root — a hand-edited URL, or a container-only `/tmp/…`, which this page does not serve — falls back to the root list with an error, rather than to an explorer that cannot explain itself.
**Deep-linking needed the explorer to be steerable without a two-way binding**, hence `rel` in + `explorer-navigate` out. The loop those two would form is cut by *what the event means*: it fires only for a click (`_navigate`), never for a `rel` the host set (`_open`), so echoing the event back as a property is a no-op — and a host that ignores the event entirely (the project board) still gets a working explorer.
**Memory is read-only here**, and it is scope rather than a property (blueprint `dir-explorer.md` task 5): every writer in `files.rs` routes through `resolve_view_path`, which refuses memory paths, and `shared-memory/*` is `@fs_write require` for the agent — giving a user a button that walks past that rule is a decision of its own. The listing side *is* wired: `list_dir` classifies memory **before** `resolve_view_path` and derives one level from the flat key space via `memory_docs::immediate_children`.
**Naming trap in the sidebar**: the `workspace` group already holds "Shared folders", which is the admin's CRUD *over one kind of root* — not this. The two entries must stay obviously different in copy.