# Project Notes — design **Date:** 2026-09-01 · **Baseline:** v0.4 · Companion to [ROADMAP.md](../../../ROADMAP.md) and [DESIGN-REVIEW.md](../../../DESIGN-REVIEW.md). A per-project notes surface, with a per-note **Send to agent** action that puts the note into a running Claude session's prompt. --- ## Why this earns a slot DESIGN-REVIEW's coherence test says every screen answers exactly one question. Notes answers *"what do I want to hand this agent, and what did I keep learning here?"* — and it answers it **while the container is stopped**, which is the gap the stop/start container model creates and the same reasoning that made Sessions/Resume the flagship. Two things already do part of this job, and the design is shaped to avoid both: - `Project.claude_instructions` (`models/project.rs:405`, editor at `components/projects/ClaudeInstructionsEditor.tsx`) is per-project free text merged into the container's `CLAUDE.md` on every start. It is **ambient** — always in context, never addressed. Notes are **discrete and fired on demand**. If Notes drifts into a second instructions box, it is redundant with a feature that already ships. - A `NOTES.md` in the workspace is readable by the agent already, but unreadable by the user when the container is stopped, and invisible to the fleet view. What Notes uniquely adds is *addressable items with a fire-at-the-session action*. ## Decisions taken | Decision | Choice | Rationale | |---|---|---| | Audience | Human scratchpad **and** agent prompts, one surface | See "no note types" below | | Storage | Own file per project, host-side | Keeps prose out of `projects.json`; works with the container stopped | | Send target | Project's own sessions; picker when >1 | Never guesses; mirrors STT's target-pinning guard | | Surface | Side dock that takes space **inward**; never resizes the OS window | Phase 0 spike: growing corrupts under native Wayland, §6.1 | | Formatting | Plain text, no markdown | It is a scratchpad; see §3 | | Tab position | Last, after Browser | A companion to the work, not a step in it | **No note *types*.** A note is a title plus a body. What makes one "for the agent" is that you pressed the button, not a mode set at creation. The moment there is a "prompt note" vs "scratch note" toggle, the pane is two features wearing one coat, and every note costs a classification decision at the moment of writing — which is the moment the user is least willing to make one. --- ## 1. Storage New `app/src-tauri/src/storage/notes_store.rs`, modeled on `migration_store.rs` rather than on `projects_store.rs`: ``` /triple-c/notes/{project_id}.json ``` - **`sanitize()` on the project id**, copied from `migration_store.rs:41-46`. The id arrives over IPC; it must not be able to steer the write. - **Atomic *and durable* write** — `.tmp`, `sync_all()`, `rename()`, then fsync the directory, per `migration_store.rs:203-261` rather than `projects_store.rs:167-179`. That file's comment is explicit that write-temp-then-rename alone is only half of it: `fs::write` returns once the bytes are in the page cache, so losing power in the window leaves the rename applied and the data not written — a truncated file produced by the very code meant to prevent one. Notes are user prose; that is the data least worth losing to a half-write. - **Corrupt file is copied aside and left in place**, per `migration_store.rs:49-125` — timestamped, capped, and never overwriting an earlier copy, because the first copy is the one taken before anything rewrote the file. - **Path resolution is split for testability.** `dirs::data_dir()` is resolved in thin public wrappers; the real work takes an explicit `&Path`. `ProjectsStore::new()` hardcodes `dirs::data_dir()` and is therefore not constructible against a temp dir, which is why its own tests only exercise free functions. The notes store should not inherit that limit. ```rust struct Note { id: String, // uuid v4 title: String, body: String, pinned: bool, created_at: String, // RFC 3339 updated_at: String, } struct ProjectNotes { version: u32, notes: Vec } ``` Order is pinned-first then `updated_at` descending. Manual reordering is deliberately out. ### Why not a field on `Project` `projects.json` is written on **every blur** by the debounced `useProjectSave` path (`hooks/useSaveState.ts`, threaded through `ProjectHome.tsx:79-81` into Overview and Config). Long user prose on that record means (a) the whole project list is rewritten every time a note changes, and (b) a note edit and a Config edit can race, with the loser's write clobbering the winner's. `migration_store.rs:1-12` already documents this exact reasoning for why *it* is not in `projects.json`. Notes inherit it. A per-project file also means a corrupt notes file loses notes for one project, not the project list. ### Lifecycle `remove_project` deletes the project's notes file. A failure there is logged, never fatal — an orphaned notes file is harmless, a project that cannot be removed is not. ## 2. Commands and frontend state Registered in `lib.rs` via `generate_handler!`. Per CLAUDE.md, application commands need **no** entry in `capabilities/default.json`. - `list_notes(projectId) -> Vec` - `save_note(projectId, note) -> Note` — upsert; stamps `updated_at` backend-side - `delete_note(projectId, noteId)` There is deliberately **no whole-list setter**. Bulk writes are the clobbering mechanism the storage choice above exists to avoid. `notes_store` is a **free-function module** keyed by project id, exactly like `migration_store` — no struct, nothing held in `AppState`, no in-memory copy of the notes. `ProjectsStore`'s `Mutex` exists because it caches the project list in memory; a notes store that reads and writes the file per call has nothing to cache and nothing to guard. What it does need is that each upsert's read-modify-write is not interleaved with another's, so the module holds one process-wide write lock (`OnceLock>`, the idiom already in `browser_view/popout.rs`) taken for the read-modify-write, not for the read path. Frontend: wrappers in `lib/tauri-commands.ts`, a `hooks/useNotes.ts`, and notes cached in zustand keyed by project id. Rust is the source of truth; the cache is a cache. The dock and the tab live in one webview, so zustand alone suffices. The store boundary is drawn so that a future detached window (§8) only swaps the transport: Rust emits a `notes-changed` event, both windows listen. ## 3. Editor — plain text, deliberately A note is a title and a `