From e2e9dae5e745ed4b1c36ee3de1d296b9cdbf53e0 Mon Sep 17 00:00:00 2001 From: prode Date: Sun, 2 Aug 2026 00:50:33 -0300 Subject: [PATCH 1/3] =?UTF-8?q?docs(spec):=20embedded=20agent=20=E2=80=94?= =?UTF-8?q?=20a=20chat=20pane=20backed=20by=20a=20DeepAgents=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds specs/embedded-agent/{requirements,design,tasks}.md for a chat pane in the desktop application, backed by a DeepAgents agent that reads the open project the way a harness does and writes the wiki only through the validated store. Enabled by adr:0019. v1, decided at kickoff: read + write (consultation + maintenance), Groq only (the existing credential's second purpose), execute and task excluded (the former can't be scope-guarded; the latter is dangerous by adr:0019), and every wiki write paused for human approve/reject/edit before it lands. The convention (harness entry file + scaffolded skills) is carried in unchanged. scc validate: no findings. Co-Authored-By: Claude --- specs/embedded-agent/design.md | 137 +++++++++++++++++++++++++++ specs/embedded-agent/requirements.md | 111 ++++++++++++++++++++++ specs/embedded-agent/tasks.md | 53 +++++++++++ 3 files changed, 301 insertions(+) create mode 100644 specs/embedded-agent/design.md create mode 100644 specs/embedded-agent/requirements.md create mode 100644 specs/embedded-agent/tasks.md diff --git a/specs/embedded-agent/design.md b/specs/embedded-agent/design.md new file mode 100644 index 0000000..5336d96 --- /dev/null +++ b/specs/embedded-agent/design.md @@ -0,0 +1,137 @@ +# Embedded agent — design + +## What changes + +A chat pane is added to the desktop shell, and an embedded agent runs behind it in the +Electron main process. The agent is a DeepAgents `createDeepAgent` graph, scoped to the open +project, that reads the project with the harness set and writes `wiki/` only through the +validated store. `adr:0019` decided that this may exist; this design is how. + +Three new things, and each is load-bearing: + +1. **A `BackendProtocol` implementation that is the guardrail by scope** — + `apps/desktop/src/main/agent/wiki-gate-backend.ts`. It implements the same interface + `StateBackend` and `FilesystemBackend` implement (`ls`, `read`, `write`, `edit`, `glob`, + `grep`). Reads (`ls`/`read`/`glob`/`grep`) operate on the real project directory, every + path confined with `assertWithin(projectRoot)` from `@open-wiki/access`. Writes + (`write`/`edit`) accept only paths that resolve inside `/wiki/` and route + them through the store: `gateWrite` to validate, then `writePage(projectRoot, pagePath, + content, { kind: "agent" })` to write atomically, log the operation with origin `agent`, + and leave it undoable. A write to any other path returns an error and writes nothing. + `execute` is not implemented, so the DeepAgents middleware hides the `execute` tool. + + The built-in `write_file`/`edit_file` tools are **kept and re-pointed** at this backend, + not removed. ADR 0019 excludes "an agent toolkit's filesystem surface — `write_file`, + `edit_file`, `execute`" as "a second writer with direct disk access." The exclusion is + the *direct disk access*, not the names: backed by the gate, `write_file` and `edit_file` + are the same door the editor uses (`writePage` with an origin), and `edit_file`'s + exact-string replace is the optimized edit the product wants — it sends `old_string` and + `new_string`, never the whole page. `execute` and `task` stay excluded (R4.4): `execute` + because shell escapes any path rule (the DeepAgents middleware itself refuses to combine + `permissions` with an execution-capable backend), `task` because `adr:0019` names + subagents dangerous and v1 is one agent. + +2. **The agent construction in `apps/desktop/src/main/agent/agent.ts`** — + `createDeepAgent` with: `model = new ChatGroq({ model: , apiKey })` read + from `readSecrets` (the same Groq key the recorder uses); `backend = wikiGateBackend`; + `systemPrompt` = the project's harness entry file, resolved at runtime and carried in + unchanged (today `CLAUDE.md`; `plans/harness-portability.md` will write an entry file per + harness — Codex, opencode, Claude — and a project may carry several, as renderings of one + convention, so the resolver reads one and does not duplicate); `skills = [".claude/skills/"]` + (the shared skills location `adr:0015` chose, loaded by the middleware's `read_file` from + the same backend); `checkpointer = new MemorySaver()` keyed by a `thread_id` per + conversation; `interruptOn` set for `write_file`, `edit_file`, `rename_page`, + `delete_page`. No `subagents`. The convention is carried in, never re-authored — + `generateClaudeMd` and `scaffoldSkills` already write the on-disk files the agent reads, + and the harness-portability plan writes the same convention at each harness's paths. + +3. **The IPC surface and the chat pane** — new channels in + `apps/desktop/src/main/channels.ts`: `chat:send`, `chat:resume`, `chat:cancel`, and a + push channel `chat:event`. `agent.ts` runs `agent.streamEvents(input, { version: "v3" })` + and forwards token, tool-call, and interrupt events to the renderer over `chat:event`. + The renderer's `Chat.tsx` (a new pane component, sibling of `Sources.tsx`) registers in + `navigation.ts` (widen `Pane`), `Rail.tsx` (`PANES`), and `App.tsx` (the pane switch), + following the `reloadKey` + `bridge()` + live-guard pattern the other panes use. The + interrupt event renders the proposed write and the approve/reject/edit controls; the + user's decision comes back over `chat:resume` as a DeepAgents `Command({ resume: { + decisions } })`. + +The credential is reused, not duplicated: `readSecrets` already returns the Groq key; +`settings.ts` already validates it against `/models`. The settings screen gains the +two-purpose notice and the curated model list. `stack.md` gains `deepagents`, +`@langchain/groq`, `@langchain/langgraph`, `langchain`, and `@langchain/core`. + +Serves R1.1, R1.2, R1.3, R1.4, R1.5, R2.1, R2.2, R2.3, R2.4, R2.5, R3.1, R3.2, R4.1, R4.2, +R4.3, R4.4, R4.5, R5.1, R5.2, R5.3, R5.4, R6.1, R7.1. + +## Boundaries and contracts + +- **Process boundary.** The agent runs in main; the renderer only sends messages and + renders events. Keys never cross to the renderer (the existing rule in `settings.ts`). + The renderer has no `fetch` and the CSP is `default-src 'none'`; the Groq call happens in + main, so no CSP change is needed. +- **The write boundary is the store, not a second writer.** `write_file`/`edit_file`/ + `rename_page`/`delete_page` call `gateWrite` + `writePage`/`supersedePage` + `appendOperation` + from `@open-wiki/access` — the same path the editor and the hooks use. The agent never + calls `atomicWrite` or `node:fs` directly. The `Origin` on every write is `{ kind: "agent" + }`. +- **The read boundary is `assertWithin`.** Every read path is resolved and checked against + the project root with the same `assertWithin` `packages/mcp` uses; a path that escapes + throws `OutsideProjectError`, which the backend returns as a tool error. +- **IPC contract.** `chat:send({ text })`, `chat:resume({ decisions })`, + `chat:cancel()`, and push `chat:event({ kind, ... })` where `kind` is `token` | `tool` | + `interrupt` | `done` | `error`. Typed in `bridge.ts` `OwBridge`; the preload parity check + (`preload.ts:104`) and `dispatch`'s unknown-channel throw (`ipc.ts`) enforce completeness. + +## Data + +- **`Origin`** — extended (or a new variant) to `{ kind: "agent" }`, alongside the + existing origins, so the operation log distinguishes an agent write. The log and undo + machinery (`appendOperation`, `undo`) are unchanged. +- **Conversation state** — a `MemorySaver` holding the LangGraph thread per `thread_id`. + In memory only for v1; not on disk; not in the project directory. +- **Interrupt payload** — the proposed write: `{ tool, file_path, old_string?, new_string?, + content? }`, rendered as a diff in the pane. Resume carries `decisions: [{ type: + "approve" | "reject" | "edit", ... }]`. + +## Alternatives considered + +- **Own `create_page`/`edit_page` tools instead of re-pointing `write_file`/`edit_file`.** + Rejected for v1: the product wants the optimized exact-string edit DeepAgents already + ships, and re-pointing the built-in tools at a gate-backed backend reuses the middleware + (line numbering, large-result eviction, permission filtering) instead of rebuilding it. + The ADR's exclusion is honored by what the backend does (route through the store), not by + the tool names. +- **`FilesystemBackend` with `permissions` for read-only project + a separate gate tool.** + Rejected: `FilesystemBackend` writes to real disk, so `write_file`/`edit_file` would be a + second writer unless separately disabled, and `permissions` is permissive when no rule + matches (the failure mode `adr:0019` names). A custom backend makes confinement + structural — there is no permissive default to misconfigure. +- **A separate `packages/agent` workspace package.** Deferred: the runtime is desktop-only + for v1 (it lives in main and reads the desktop-held credential). It stays in + `apps/desktop/src/main/agent/` alongside `recorder.ts` and `transcribe-run.ts`; it can be + extracted when a second consumer appears. +- **Including `execute` and `task`.** Rejected: `execute` cannot be scope-guarded (shell + escapes path rules), `task`/subagents are dangerous by `adr:0019`. Decided with the user + at spec time. + +The hard-to-reverse choice — adopting DeepAgents as the harness — is already recorded in +`adr:0019`, which names `deepagents@1.12.1` and its allowlist. No new ADR is needed; the +gate-backed backend is reversible (it is our code, not a framework commitment). + +## Risks + +- **A well-formed and wrong page passes the gate.** This is `adr:0019`'s stated cost; the + mitigation is the human-in-the-loop approval (R5), not the gate. The interrupt shows the + proposed change so a human can reject a plausible-but-wrong page. +- **The DeepAgents `BackendProtocol` is an internal interface.** It is exported but not + versioned as a stable public API; a `deepagents` upgrade could change it. Pinned in + `package.json`; the proof tests (R6.1) would fail on a behaviour change, which is the + signal to update. +- **Skills/`CLAUDE.md` shapes diverge.** `adr:0019` names this as luck, not foresight; + `SKILLS_VERSION` is the only staleness signal. Carrying in unchanged means a divergence + is silent; the agent would still run, reading the older shape. +- **Two writers of the wiki.** The external harness and the embedded agent share one + convention (the generated files) but nothing enforces they agree at runtime. The + operation log's `origin` field is the audit trail; concurrent writes are the user's to + sequence. \ No newline at end of file diff --git a/specs/embedded-agent/requirements.md b/specs/embedded-agent/requirements.md new file mode 100644 index 0000000..5e6f794 --- /dev/null +++ b/specs/embedded-agent/requirements.md @@ -0,0 +1,111 @@ +--- +autonomy: auto +ci: wait +--- + +# Embedded agent — requirements + +Enabled by `adr:0019-an-embedded-agent-that-reads-freely-and-writes-through-the-gate`, +which narrowed `adr:0013` to allow the application to run an embedded agent. This spec +is the first thing that record's "Consequences" call for. + +## Purpose + +A chat pane in the desktop application, backed by a DeepAgents agent that reads the open +project the way a harness does and writes the wiki only through the validated store. It is +the lesser door, for the user who downloaded the installer and has no harness — not an +equivalent to one. The agent consults (list, glob, grep, read) and maintains the wiki +(create, edit, rename, delete), with every wiki write paused for human approval before it +lands. + +## R1 · The chat pane + +- **R1.1** The desktop application shall present a chat pane in the shell rail, alongside + wiki, sources, and checks. +- **R1.2** While a Groq credential is configured, the chat pane shall let the user send a + message and shall stream the embedded agent's response into the pane. +- **R1.3** While no Groq credential is configured, the chat pane shall show an empty state + that names the Groq key as the requirement and links to settings, and shall not accept a + message. +- **R1.4** If an agent run errors, then the chat pane shall surface the error in place and + preserve the conversation that produced it. +- **R1.5** The chat pane shall replace the "there is no model behind this window" copy + shipped today with the empty state in R1.3, in any release that carries this feature. + +## R2 · The embedded agent runtime + +- **R2.1** The embedded agent shall run in the Electron main process, scoped to the open + project directory, and the renderer shall reach it only over IPC. +- **R2.2** The embedded agent shall use Groq as its model, via `ChatGroq`, with the same + Groq credential the recorder uses for transcription; no second credential shall be + introduced. +- **R2.3** The embedded agent's instructions shall be the project's harness entry file — + the file the project was scaffolded for its harness to read — and the scaffolded skills, + carried in unchanged; no hand-written system prompt shall be added beside them. A project + scaffolded for more than one harness carries more than one entry file, and they are + renderings of one convention, so the embedded agent shall read one and shall not + duplicate the convention in its prompt. +- **R2.4** While the project has no Groq credential, the desktop application shall disable + the embedded agent and state in the settings screen that a Groq key is required for the + agent and serves transcription and the agent both. +- **R2.5** The settings screen shall offer a curated model selection for the agent, not the + raw provider model list, with one default chosen for tool-calling reliability. + +## R3 · Reading — unrestricted within the project + +- **R3.1** The embedded agent's read tools (`ls`, `read_file`, `glob`, `grep`) shall read + the project directory, and every path they touch shall be confined with `assertWithin` to + the project root. +- **R3.2** If a read path resolves outside the project, then the embedded agent shall return + an error and deny the request. + +## R4 · Writing — only through the gate + +- **R4.1** The embedded agent's `write_file` and `edit_file` tools shall write into `wiki/` + only, through the validated store — `gateWrite` then `writePage` with origin `agent` — + which validates frontmatter, resolves wikilinks and citations, writes atomically, logs the + operation, and leaves it undoable. +- **R4.2** The embedded agent shall have `rename_page` and `delete_page` tools that act on + wiki pages through the same store, and that supersede a page rather than editing one when + the operation is a supersession. +- **R4.3** If a write path resolves outside wiki, then the embedded agent shall return an + error and write nothing. +- **R4.4** The embedded agent shall not expose the `execute` (shell) or `task` (subagent) + tools; no shell command shall run and no subagent shall spawn from the embedded agent. +- **R4.5** The embedded agent shall carry the origin `agent` on every wiki write it makes, + recorded in the operation log, so a bad run is one undo rather than an archaeology. + +## R5 · Human-in-the-loop approval + +- **R5.1** When the embedded agent calls a write tool, the embedded agent shall pause the + run before the write executes and wait for a human decision. +- **R5.2** While a write is paused, the chat pane shall show the proposed change — the page + slug, and the old and new content (or the rename/delete target) — and shall offer approve, + reject, and edit. +- **R5.3** When the user rejects a paused write, the tool shall not execute and the agent + shall be told the write was rejected and not to retry it unless asked. +- **R5.4** When the user edits the arguments of a paused write, the tool shall execute with + the edited arguments, and the gate shall validate them as if the agent had proposed them. + +## R6 · The line is proved, not configured + +- **R6.1** The embedded agent shall be unable to write to disk except through the validated + store, so that no permissive configuration can let a write escape the gate; the `execute` + and `task` tools shall not be exposed. + +## R7 · Conversation state + +- **R7.1** The embedded agent shall keep conversation state in memory, keyed by a thread id, + one per conversation, and shall resume a paused run from the same thread. + +## Out of scope + +- Durable conversation persistence across application restarts (in-memory for v1; a + checkpointer on disk is a later spec). +- Subagents (`task`) and shell execution (`execute`) — excluded by R4.4 and by `adr:0019`. +- A second LLM provider (OpenAI, Anthropic). Groq only for v1, per `adr:0019`'s "second + credential purpose." +- MCP-over-HTTP (`adr:0018`, unbuilt). The embedded agent reads the project directly; it does + not go through the MCP server. +- Re-authoring the convention. The system prompt stays the generated `CLAUDE.md` plus the + scaffolded skills. \ No newline at end of file diff --git a/specs/embedded-agent/tasks.md b/specs/embedded-agent/tasks.md new file mode 100644 index 0000000..37a19ff --- /dev/null +++ b/specs/embedded-agent/tasks.md @@ -0,0 +1,53 @@ +# Embedded agent — tasks + +## 1 · The wiki-gate backend + +- [ ] 1.1 (Unit) Implement `WikiGateBackend.ls` over the project directory, confining every path with `assertWithin(projectRoot)` from `@open-wiki/access` — R3.1, R3.2 +- [ ] 1.2 (Unit) Implement `WikiGateBackend.read` (offset/limit, line-numbered) over the project directory, confined with `assertWithin` — R3.1, R3.2 +- [ ] 1.3 (Unit) Implement `WikiGateBackend.glob` and `grep` (literal, passage-level) over the project directory, confined with `assertWithin` — R3.1, R3.2 +- [ ] 1.4 (TDD) Implement `WikiGateBackend.write` to accept only paths inside `wiki/` and route them through `gateWrite` + `writePage(projectRoot, pagePath, content, { kind: "agent" })` + `appendOperation`; watch red on a non-wiki path, then green when it returns an error and writes nothing — R4.1, R4.3, R4.5, R6.1 +- [ ] 1.5 (TDD) Implement `WikiGateBackend.edit` as exact `old_string`→`new_string` replace (`replace_all` supported) over a wiki page, routing through the store; assert it never rewrites the whole page and that a non-wiki path fails — R4.1, R4.3, R6.1 +- [ ] 1.6 (Unit) Omit `execute` from the backend so the DeepAgents middleware hides the `execute` tool — R4.4 + +## 2 · The agent runtime + +- [ ] 2.1 (Unit) Build the agent in `apps/desktop/src/main/agent/agent.ts` with `createDeepAgent`: `ChatGroq` from the Groq credential, the curated default model, `backend = WikiGateBackend`, `MemorySaver` keyed by `thread_id`, and `tools: ["read_file","ls","glob","grep","write_file","edit_file"]` — R2.1, R2.2, R2.5, R4.4, R7.1 +- [ ] 2.2 (Unit) Resolve the project's harness entry file (the file the project was scaffolded for its harness to read; today `CLAUDE.md`, plural once `plans/harness-portability.md` lands) as `systemPrompt`, and the scaffolded skills as `skills`, both read from disk in main and carried in unchanged; assert no hand-written prompt is appended beside them — R2.3 +- [ ] 2.3 (Unit) Implement `rename_page` and `delete_page` tools backed by `supersedePage` and the store, with origin `agent` — R4.2, R4.5 +- [ ] 2.4 (Unit) Set `interruptOn` for `write_file`, `edit_file`, `rename_page`, `delete_page`, and emit a `chat:event` interrupt carrying the proposed change — R5.1 +- [ ] 2.5 (Unit) Stream `agent.streamEvents({ version: "v3" })` and forward token and tool-call events to the renderer; resume a paused run with `Command({ resume: { decisions } })` on the same `thread_id` — R1.2, R5.3, R5.4, R7.1 +- [ ] 2.6 (Unit) Run all agent invocations in the desktop main process; assert the renderer cannot reach the model or the credential directly — R2.1 + +## 3 · IPC channels + +- [ ] 3.1 (Unit) Add `chat:send`, `chat:resume`, `chat:cancel` request channels and the `chat:event` push channel in `channels.ts`; expose in `preload.ts`, type in `bridge.ts` `OwBridge`, and route in `dispatch` — R1.2, R5.2, R5.3, R5.4 +- [ ] 3.2 (Unit) Bind the push channel through the buffered `send()` pattern so events before `did-finish-load` queue rather than drop — R1.2 + +## 4 · The chat pane + +- [ ] 4.1 (Unit) Widen `Pane` in `navigation.ts`, add the chat entry to `PANES` in `Rail.tsx`, and render `` in the `App.tsx` pane switch — R1.1 +- [ ] 4.2 (Unit) Build `Chat.tsx` with `bridge()` + `useEffect` + live-guard, sending over `chat:send` and rendering streamed tokens and tool calls from `chat:event` — R1.2, R1.4 +- [ ] 4.3 (Unit) Render the interrupt payload (slug, old/new content, or rename/delete target) with approve, reject, and edit controls, dispatching `chat:resume` — R5.1, R5.2, R5.3, R5.4 +- [ ] 4.4 (Unit) Show the empty state naming the Groq key requirement and linking to settings while no credential is configured, and disable the composer; this replaces the "there is no model behind this window" copy — R1.3, R1.5 +- [ ] 4.5 (Unit) Surface a run error in place and preserve the conversation that produced it — R1.4 + +## 5 · Credential, model, settings + +- [ ] 5.1 (Unit) Read the Groq key from `readSecrets` for the agent; assert no second credential store is added — R2.2 +- [ ] 5.2 (Unit) Add the curated model selection to the settings screen, with one tool-calling-reliable default — R2.5 +- [ ] 5.3 (Unit) Add the two-purpose notice (transcription + agent) and the whisper.cpp no-agent notice to the settings screen — R2.4 + +## 6 · The line is proved + +- [ ] 6.1 (TDD) Test that `write_file` to a path outside `wiki/` fails and writes nothing — R4.3, R6.1 +- [ ] 6.2 (TDD) Test that `edit_file` to a path outside `wiki/` fails and writes nothing — R4.3, R6.1 +- [ ] 6.3 (TDD) Test that `execute` is not in the agent's tool set and a call is rejected — R4.4, R6.1 +- [ ] 6.4 (TDD) Test that `task`/subagents are not registered — R4.4, R6.1 +- [ ] 6.5 (TDD) Test that a `write_file` whose content the gate rejects (bad frontmatter, dangling wikilink) is denied and writes nothing — R4.1, R6.1 +- [ ] 6.6 (TDD) Test that a read outside the project root returns an error and reads nothing — R3.2, R6.1 +- [ ] 6.7 (TDD) Test that an approved write lands through the store (validated, logged with origin `agent`, undoable) and a rejected write does not — R4.1, R4.5, R5.3 + +## 7 · Docs + +- [ ] 7.1 (Unit) Add `deepagents`, `@langchain/groq`, `@langchain/langgraph`, `langchain`, and `@langchain/core` to `docs/stack.md` with one line each on why — R2.2 +- [ ] 7.2 (Unit) Add canonical terms (embedded agent, chat pane, wiki-gate backend) to `docs/glossary.md` with the synonyms to avoid — R1.1, R2.1 \ No newline at end of file From bc2c8d6c0d2df0820c9ab5a1914f4916665f22dd Mon Sep 17 00:00:00 2001 From: prode Date: Sun, 2 Aug 2026 01:07:57 -0300 Subject: [PATCH 2/3] =?UTF-8?q?docs(spec):=20embedded=20agent=20=E2=80=94?= =?UTF-8?q?=20apply=20review=20fixes=20(code-review=20+=20security-review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address findings from the code-review and security-review subagents on the embedded-agent spec (the first commit). Verified against the deepagents@1.12.1 source and the @open-wiki/access API. code-review (compile-blocking + factual): - Origin is a string union, not { kind: "agent" }; extend it with "agent" and write origin: "agent" at every call site (task 1.7, 1.4, R4.5). - createDeepAgent's `tools` takes tool objects, not a string allowlist; drop tools: ["read_file",...] and pass rename_page/delete_page as tool objects. The fs tools auto-attach from the harness profile (task 2.1). - "No subagents" does not disable task; createDeepAgent auto-adds a general-purpose subagent. Register a Groq harness profile with generalPurposeSubagent.enabled=false so task is never built; execute is hidden by the non-sandbox backend (task 2.4, R4.4). - No gated deletePage exists in @open-wiki/access (the desktop's calls node:fs with a hardcoded origin); spec a new gated delete primitive, and make rename_page refuse clobber + NON_ENTITY_PAGES (R4.2, R4.6, tasks 1.8, 2.3). - BackendProtocol is the deprecated v1 alias; implement BackendProtocolV2. security-review (design gaps, now stated + proved): - replace_all interrupt shows every match site / full resulting page (R5.2, 6.9). - The middleware's large-result eviction calls the SAME WikiGateBackend.write to /large_tool_results/ and /conversation_history/, both outside wiki/, so the gate rejects it — fail-closed, no disk write (6.11). - rename/delete refuse clobber + the wiki index, changelog, and log (R4.6, 6.10). - Risk: the system prompt itself is project-controlled for an untrusted project. - Risk: NON_ENTITY_PAGES are gated only by human approval. - Tracing exfiltration: disable tracing before construction (set LANGCHAIN_TRACING_V2=false), read no LANGCHAIN_*/LANGSMITH_* env var, so library-level LangSmith auto-init does not fire (R2.6, 2.8, 6.12). - Prove interrupt fires before any store write (6.8); prove a junction/symlink read escape is denied (6.6). scc validate: no findings. Both re-reviews returned clean. Co-Authored-By: Claude --- specs/embedded-agent/design.md | 146 +++++++++++++++++++-------- specs/embedded-agent/requirements.md | 20 +++- specs/embedded-agent/tasks.md | 29 ++++-- 3 files changed, 139 insertions(+), 56 deletions(-) diff --git a/specs/embedded-agent/design.md b/specs/embedded-agent/design.md index 5336d96..32604a3 100644 --- a/specs/embedded-agent/design.md +++ b/specs/embedded-agent/design.md @@ -9,16 +9,18 @@ validated store. `adr:0019` decided that this may exist; this design is how. Three new things, and each is load-bearing: -1. **A `BackendProtocol` implementation that is the guardrail by scope** — - `apps/desktop/src/main/agent/wiki-gate-backend.ts`. It implements the same interface - `StateBackend` and `FilesystemBackend` implement (`ls`, `read`, `write`, `edit`, `glob`, - `grep`). Reads (`ls`/`read`/`glob`/`grep`) operate on the real project directory, every - path confined with `assertWithin(projectRoot)` from `@open-wiki/access`. Writes - (`write`/`edit`) accept only paths that resolve inside `/wiki/` and route - them through the store: `gateWrite` to validate, then `writePage(projectRoot, pagePath, - content, { kind: "agent" })` to write atomically, log the operation with origin `agent`, - and leave it undoable. A write to any other path returns an error and writes nothing. - `execute` is not implemented, so the DeepAgents middleware hides the `execute` tool. +1. **A `BackendProtocolV2` implementation that is the guardrail by scope** — + `apps/desktop/src/main/agent/wiki-gate-backend.ts`. It implements `BackendProtocolV2` — + the current interface `StateBackend` and `FilesystemBackend` implement (`ls`, `read`, + `write`, `edit`, `glob`, `grep`). (The bare `BackendProtocol` export is the deprecated v1 + alias; `WikiGateBackend` implements v2.) Reads (`ls`/`read`/`glob`/`grep`) operate on the + real project directory, every path confined with `assertWithin(projectRoot)` from + `@open-wiki/access`. Writes (`write`/`edit`) accept only paths that resolve inside + `/wiki/` and route them through the store: `gateWrite` to validate, then + `writePage(projectRoot, pagePath, content, "agent")` to write atomically, log the operation + with origin `agent`, and leave it undoable. A write to any other path returns an error and + writes nothing. `execute` is not implemented, so `WikiGateBackend` is not a sandbox backend + (`isSandboxBackend` is false) and the DeepAgents middleware filters the `execute` tool. The built-in `write_file`/`edit_file` tools are **kept and re-pointed** at this backend, not removed. ADR 0019 excludes "an agent toolkit's filesystem surface — `write_file`, @@ -27,9 +29,20 @@ Three new things, and each is load-bearing: are the same door the editor uses (`writePage` with an origin), and `edit_file`'s exact-string replace is the optimized edit the product wants — it sends `old_string` and `new_string`, never the whole page. `execute` and `task` stay excluded (R4.4): `execute` - because shell escapes any path rule (the DeepAgents middleware itself refuses to combine - `permissions` with an execution-capable backend), `task` because `adr:0019` names - subagents dangerous and v1 is one agent. + because the backend does not implement it (the middleware then hides it; shell escapes any + path rule anyway — the middleware itself refuses to combine `permissions` with an + execution-capable backend), `task` because `adr:0019` names subagents dangerous and v1 is + one agent. + + The middleware's own large-result eviction is also confined by this. When a tool result + exceeds the token threshold, the filesystem middleware evicts it by calling + `backend.write("/large_tool_results/.txt", …)` (and human messages to + `/conversation_history/`) — the *same* `WikiGateBackend.write`. Those paths lie outside + `wiki/`, so the gate rejects them: the write returns an error, no file is created, and the + model receives a truncated preview plus "the result could not be saved." The eviction path + therefore fails closed through the gate — it is not a second writer. (If `createDeepAgent` + exposes the eviction threshold, set it to `null` to skip the attempt entirely; either way + the gate is the line.) 2. **The agent construction in `apps/desktop/src/main/agent/agent.ts`** — `createDeepAgent` with: `model = new ChatGroq({ model: , apiKey })` read @@ -40,10 +53,31 @@ Three new things, and each is load-bearing: convention, so the resolver reads one and does not duplicate); `skills = [".claude/skills/"]` (the shared skills location `adr:0015` chose, loaded by the middleware's `read_file` from the same backend); `checkpointer = new MemorySaver()` keyed by a `thread_id` per - conversation; `interruptOn` set for `write_file`, `edit_file`, `rename_page`, - `delete_page`. No `subagents`. The convention is carried in, never re-authored — - `generateClaudeMd` and `scaffoldSkills` already write the on-disk files the agent reads, - and the harness-portability plan writes the same convention at each harness's paths. + conversation; `interruptOn` set for `write_file`, `edit_file`, `rename_page`, `delete_page`; + `tools = [renamePageTool, deletePageTool]` (the two custom tools, as tool objects — `tools` + takes tool objects, not a string allowlist). + + The `task` (subagent) tool is removed at construction, not merely unused: `createDeepAgent` + auto-adds a general-purpose subagent — which provides `task` — unless the harness profile + disables it, and `subagents: []` alone does **not** suffice. So a Groq harness profile is + registered once, at module load, via the exported `registerHarnessProfile(createHarnessProfile({ + generalPurposeSubagent: { enabled: false } }))`, keyed to Groq. With the general-purpose + subagent disabled, the `task` tool is never built. `execute` is never built because + `WikiGateBackend` is not a sandbox backend. The filesystem tools (`ls`, `read_file`, + `write_file`, `edit_file`, `glob`, `grep`) are auto-attached by `createDeepAgent` from the + harness profile; they are re-pointed at `WikiGateBackend` because that is the `backend`, so + `write_file`/`edit_file` route through the gate. The convention is carried in, never + re-authored — `generateClaudeMd` and `scaffoldSkills` already write the on-disk files the + agent reads, and the harness-portability plan writes the same convention at each harness's + paths. + + No tracing or telemetry is enabled for the agent's runs. Before the agent is constructed, + the agent path sets `LANGCHAIN_TRACING_V2=false` and reads no `LANGCHAIN_*` / `LANGSMITH_*` + environment variable; project content the agent reads is sent only to Groq. LangChain/ + LangGraph can auto-initialize LangSmith from those env vars at import time, before the + agent path runs — disabling tracing before construction is what closes that, not merely + refusing to read the vars in our own code. (A developer who sets them globally would + otherwise export the project directory to a third party.) 3. **The IPC surface and the chat pane** — new channels in `apps/desktop/src/main/channels.ts`: `chat:send`, `chat:resume`, `chat:cancel`, and a @@ -61,8 +95,8 @@ The credential is reused, not duplicated: `readSecrets` already returns the Groq two-purpose notice and the curated model list. `stack.md` gains `deepagents`, `@langchain/groq`, `@langchain/langgraph`, `langchain`, and `@langchain/core`. -Serves R1.1, R1.2, R1.3, R1.4, R1.5, R2.1, R2.2, R2.3, R2.4, R2.5, R3.1, R3.2, R4.1, R4.2, -R4.3, R4.4, R4.5, R5.1, R5.2, R5.3, R5.4, R6.1, R7.1. +Serves R1.1, R1.2, R1.3, R1.4, R1.5, R2.1, R2.2, R2.3, R2.4, R2.5, R2.6, R3.1, R3.2, R4.1, +R4.2, R4.3, R4.4, R4.5, R4.6, R5.1, R5.2, R5.3, R5.4, R6.1, R7.1. ## Boundaries and contracts @@ -73,11 +107,12 @@ R4.3, R4.4, R4.5, R5.1, R5.2, R5.3, R5.4, R6.1, R7.1. - **The write boundary is the store, not a second writer.** `write_file`/`edit_file`/ `rename_page`/`delete_page` call `gateWrite` + `writePage`/`supersedePage` + `appendOperation` from `@open-wiki/access` — the same path the editor and the hooks use. The agent never - calls `atomicWrite` or `node:fs` directly. The `Origin` on every write is `{ kind: "agent" - }`. + calls `atomicWrite` or `node:fs` directly. The `Origin` on every write is `"agent"`. - **The read boundary is `assertWithin`.** Every read path is resolved and checked against the project root with the same `assertWithin` `packages/mcp` uses; a path that escapes - throws `OutsideProjectError`, which the backend returns as a tool error. + (including a symlink or junction inside the project that points outside — the realistic + escape on Windows, the only supported platform) throws `OutsideProjectError`, which the + backend returns as a tool error. - **IPC contract.** `chat:send({ text })`, `chat:resume({ decisions })`, `chat:cancel()`, and push `chat:event({ kind, ... })` where `kind` is `token` | `tool` | `interrupt` | `done` | `error`. Typed in `bridge.ts` `OwBridge`; the preload parity check @@ -85,35 +120,50 @@ R4.3, R4.4, R4.5, R5.1, R5.2, R5.3, R5.4, R6.1, R7.1. ## Data -- **`Origin`** — extended (or a new variant) to `{ kind: "agent" }`, alongside the - existing origins, so the operation log distinguishes an agent write. The log and undo - machinery (`appendOperation`, `undo`) are unchanged. +- **`Origin`** — the `Origin` union in `@open-wiki/access` (today `"editor" | "cli" | "hook" + | "observer"`) is extended with `"agent"`, a string variant alongside the existing ones, + so the operation log distinguishes an agent write. The log and undo machinery + (`appendOperation`, `undo`) are unchanged. +- **A gated delete primitive.** `@open-wiki/access` exports no `deletePage`; the desktop's + `deletePage` (`apps/desktop/src/main/edit.ts`) calls `node:fs.rmSync` directly and hardcodes + `origin: "editor"`, bypassing the gate. A new `deletePage(projectRoot, pagePath, origin)` + is added to `@open-wiki/access` that routes through `gateWrite` + `appendOperation` (mark the + page superseded, or remove it, logged with the given origin, undoable). `rename_page` writes + the new page through `gateWrite` + `writePage`, marks the old page superseded via + `supersedePage`, refuses to clobber an existing target, and refuses to operate on the wiki's + index, changelog, and log (`gateWrite` already passes those `NON_ENTITY_PAGES` with no content + validation — R4.6 closes that for the agent). `delete_page` refuses the same set. - **Conversation state** — a `MemorySaver` holding the LangGraph thread per `thread_id`. In memory only for v1; not on disk; not in the project directory. - **Interrupt payload** — the proposed write: `{ tool, file_path, old_string?, new_string?, - content? }`, rendered as a diff in the pane. Resume carries `decisions: [{ type: - "approve" | "reject" | "edit", ... }]`. + content? }`, rendered as a diff in the pane. For `edit_file` with `replace_all`, the payload + carries every match site (or the full resulting page), so the human sees the complete effect + of the tool call, not only the two strings — a short `old_string` that matches in several + places must not be smuggled past review. Resume carries `decisions: [{ type: "approve" | + "reject" | "edit", ... }]`. ## Alternatives considered - **Own `create_page`/`edit_page` tools instead of re-pointing `write_file`/`edit_file`.** - Rejected for v1: the product wants the optimized exact-string edit DeepAgents already - ships, and re-pointing the built-in tools at a gate-backed backend reuses the middleware - (line numbering, large-result eviction, permission filtering) instead of rebuilding it. - The ADR's exclusion is honored by what the backend does (route through the store), not by - the tool names. + Rejected for v1: the product wants the optimized exact-string edit DeepAgents already + ships, and re-pointing the built-in tools at a gate-backed backend reuses the middleware + (line numbering, large-result eviction, permission filtering) instead of rebuilding it. + The ADR's exclusion is honored by what the backend does (route through the store), not by + the tool names. The large-result eviction, re-pointed at the same backend, is itself + gate-confined — it cannot reach disk outside `wiki/`. - **`FilesystemBackend` with `permissions` for read-only project + a separate gate tool.** - Rejected: `FilesystemBackend` writes to real disk, so `write_file`/`edit_file` would be a - second writer unless separately disabled, and `permissions` is permissive when no rule - matches (the failure mode `adr:0019` names). A custom backend makes confinement - structural — there is no permissive default to misconfigure. + Rejected: `FilesystemBackend` writes to real disk, so `write_file`/`edit_file` would be a + second writer unless separately disabled, and `permissions` is permissive when no rule + matches (the failure mode `adr:0019` names). A custom backend makes confinement + structural — there is no permissive default to misconfigure. - **A separate `packages/agent` workspace package.** Deferred: the runtime is desktop-only - for v1 (it lives in main and reads the desktop-held credential). It stays in - `apps/desktop/src/main/agent/` alongside `recorder.ts` and `transcribe-run.ts`; it can be - extracted when a second consumer appears. + for v1 (it lives in main and reads the desktop-held credential). It stays in + `apps/desktop/src/main/agent/` alongside `recorder.ts` and `transcribe-run.ts`; it can be + extracted when a second consumer appears. - **Including `execute` and `task`.** Rejected: `execute` cannot be scope-guarded (shell - escapes path rules), `task`/subagents are dangerous by `adr:0019`. Decided with the user - at spec time. + escapes path rules), `task`/subagents are dangerous by `adr:0019`. Decided with the user + at spec time. `task` is removed by disabling the general-purpose subagent in the Groq + harness profile (the framework's own switch), not by a custom filter. The hard-to-reverse choice — adopting DeepAgents as the harness — is already recorded in `adr:0019`, which names `deepagents@1.12.1` and its allowlist. No new ADR is needed; the @@ -124,6 +174,20 @@ gate-backed backend is reversible (it is our code, not a framework commitment). - **A well-formed and wrong page passes the gate.** This is `adr:0019`'s stated cost; the mitigation is the human-in-the-loop approval (R5), not the gate. The interrupt shows the proposed change so a human can reject a plausible-but-wrong page. +- **The system prompt is project-controlled.** The agent's instructions are the project's + `CLAUDE.md` and skills, read from disk. Opening an untrusted project with the agent + enabled is the trust decision: a malicious `CLAUDE.md` is the agent's rules, not just the + content it reads, and it can instruct well-formed wrong pages that pass the gate. This is + the accepted cost of carrying the convention in unchanged; human-in-the-loop is the only + mitigation. The agent is the lesser door, not a harness. +- **The wiki's index, changelog, and log are `NON_ENTITY_PAGES`.** `gateWrite` passes them + with no content validation — they are "themselves." An agent `write_file` to `wiki/index.md` + is therefore gated only by human approval, not by the gate's form checks. R4.6 keeps the + agent from deleting or renaming them; overwriting them by edit remains a human call. +- **Tracing exfiltration.** LangChain/LangGraph ship LangSmith tracing that activates on + `LANGCHAIN_*` / `LANGSMITH_*` env vars and would send prompts, tool calls, and tool results + (project content) to a third party. The agent path reads none of those env vars and sets no + tracing client (R2.6); a user who sets them globally does not expose the agent's runs. - **The DeepAgents `BackendProtocol` is an internal interface.** It is exported but not versioned as a stable public API; a `deepagents` upgrade could change it. Pinned in `package.json`; the proof tests (R6.1) would fail on a behaviour change, which is the diff --git a/specs/embedded-agent/requirements.md b/specs/embedded-agent/requirements.md index 5e6f794..d529e46 100644 --- a/specs/embedded-agent/requirements.md +++ b/specs/embedded-agent/requirements.md @@ -50,6 +50,10 @@ lands. agent and serves transcription and the agent both. - **R2.5** The settings screen shall offer a curated model selection for the agent, not the raw provider model list, with one default chosen for tool-calling reliability. +- **R2.6** The embedded agent shall send project content only to Groq; it shall not read + tracing or telemetry environment variables, shall set tracing disabled before the agent is + constructed (so library-level auto-initialization does not fire), and shall not transmit + prompts, tool calls, or tool results to any third party. ## R3 · Reading — unrestricted within the project @@ -66,22 +70,28 @@ lands. which validates frontmatter, resolves wikilinks and citations, writes atomically, logs the operation, and leaves it undoable. - **R4.2** The embedded agent shall have `rename_page` and `delete_page` tools that act on - wiki pages through the same store, and that supersede a page rather than editing one when - the operation is a supersession. + wiki pages through the validated store — `gateWrite`, then `writePage` or `supersedePage`, + then `appendOperation`, with origin `agent` — using a gated delete primitive in + `@open-wiki/access`. The desktop's existing `deletePage` writes with `node:fs` and a + hardcoded origin, so it shall not be reused. - **R4.3** If a write path resolves outside wiki, then the embedded agent shall return an error and write nothing. - **R4.4** The embedded agent shall not expose the `execute` (shell) or `task` (subagent) tools; no shell command shall run and no subagent shall spawn from the embedded agent. - **R4.5** The embedded agent shall carry the origin `agent` on every wiki write it makes, recorded in the operation log, so a bad run is one undo rather than an archaeology. +- **R4.6** The `rename_page` tool shall refuse to clobber an existing page, and the + `rename_page` and `delete_page` tools shall refuse to operate on the wiki's index, + changelog, and log pages. ## R5 · Human-in-the-loop approval - **R5.1** When the embedded agent calls a write tool, the embedded agent shall pause the run before the write executes and wait for a human decision. -- **R5.2** While a write is paused, the chat pane shall show the proposed change — the page - slug, and the old and new content (or the rename/delete target) — and shall offer approve, - reject, and edit. +- **R5.2** While a write is paused, the chat pane shall show the complete effect of the + proposed change — the page slug, and the old and new content (or the rename/delete target) + — and for an `edit_file` with `replace_all` shall show every match site (or the full + resulting page), and shall offer approve, reject, and edit. - **R5.3** When the user rejects a paused write, the tool shall not execute and the agent shall be told the write was rejected and not to retry it unless asked. - **R5.4** When the user edits the arguments of a paused write, the tool shall execute with diff --git a/specs/embedded-agent/tasks.md b/specs/embedded-agent/tasks.md index 37a19ff..3597550 100644 --- a/specs/embedded-agent/tasks.md +++ b/specs/embedded-agent/tasks.md @@ -5,18 +5,22 @@ - [ ] 1.1 (Unit) Implement `WikiGateBackend.ls` over the project directory, confining every path with `assertWithin(projectRoot)` from `@open-wiki/access` — R3.1, R3.2 - [ ] 1.2 (Unit) Implement `WikiGateBackend.read` (offset/limit, line-numbered) over the project directory, confined with `assertWithin` — R3.1, R3.2 - [ ] 1.3 (Unit) Implement `WikiGateBackend.glob` and `grep` (literal, passage-level) over the project directory, confined with `assertWithin` — R3.1, R3.2 -- [ ] 1.4 (TDD) Implement `WikiGateBackend.write` to accept only paths inside `wiki/` and route them through `gateWrite` + `writePage(projectRoot, pagePath, content, { kind: "agent" })` + `appendOperation`; watch red on a non-wiki path, then green when it returns an error and writes nothing — R4.1, R4.3, R4.5, R6.1 +- [ ] 1.4 (TDD) Implement `WikiGateBackend.write` to accept only paths inside `wiki/` and route them through `gateWrite` + `writePage(projectRoot, pagePath, content, "agent")` + `appendOperation`; watch red on a non-wiki path, then green when it returns an error and writes nothing — R4.1, R4.3, R4.5, R6.1 - [ ] 1.5 (TDD) Implement `WikiGateBackend.edit` as exact `old_string`→`new_string` replace (`replace_all` supported) over a wiki page, routing through the store; assert it never rewrites the whole page and that a non-wiki path fails — R4.1, R4.3, R6.1 -- [ ] 1.6 (Unit) Omit `execute` from the backend so the DeepAgents middleware hides the `execute` tool — R4.4 +- [ ] 1.6 (Unit) Omit `execute` from `WikiGateBackend` so it is not a sandbox backend and the DeepAgents middleware hides the `execute` tool — R4.4 +- [ ] 1.7 (Unit) Extend the `Origin` union in `@open-wiki/access` with the string variant `"agent"`, alongside `"editor" | "cli" | "hook" | "observer"`, so the operation log distinguishes an agent write — R4.5 +- [ ] 1.8 (Unit) Add a gated `deletePage(projectRoot, pagePath, origin)` primitive to `@open-wiki/access` that routes through `gateWrite` + `appendOperation` and accepts an origin; the desktop's existing `deletePage` calls `node:fs` with a hardcoded origin and shall not be reused — R4.2, R4.5 ## 2 · The agent runtime -- [ ] 2.1 (Unit) Build the agent in `apps/desktop/src/main/agent/agent.ts` with `createDeepAgent`: `ChatGroq` from the Groq credential, the curated default model, `backend = WikiGateBackend`, `MemorySaver` keyed by `thread_id`, and `tools: ["read_file","ls","glob","grep","write_file","edit_file"]` — R2.1, R2.2, R2.5, R4.4, R7.1 +- [ ] 2.1 (Unit) Build the agent in `apps/desktop/src/main/agent/agent.ts` with `createDeepAgent`: `model = new ChatGroq(...)` from the Groq credential and the curated default model, `backend = WikiGateBackend`, `checkpointer = new MemorySaver()` keyed by `thread_id`, `systemPrompt` = the resolved harness entry file, `skills = [".claude/skills/"]`, and `tools = [renamePageTool, deletePageTool]` as tool objects; the filesystem tools auto-attach from the harness profile and re-point at the backend — R2.1, R2.2, R2.3, R2.5, R4.4, R7.1 - [ ] 2.2 (Unit) Resolve the project's harness entry file (the file the project was scaffolded for its harness to read; today `CLAUDE.md`, plural once `plans/harness-portability.md` lands) as `systemPrompt`, and the scaffolded skills as `skills`, both read from disk in main and carried in unchanged; assert no hand-written prompt is appended beside them — R2.3 -- [ ] 2.3 (Unit) Implement `rename_page` and `delete_page` tools backed by `supersedePage` and the store, with origin `agent` — R4.2, R4.5 -- [ ] 2.4 (Unit) Set `interruptOn` for `write_file`, `edit_file`, `rename_page`, `delete_page`, and emit a `chat:event` interrupt carrying the proposed change — R5.1 -- [ ] 2.5 (Unit) Stream `agent.streamEvents({ version: "v3" })` and forward token and tool-call events to the renderer; resume a paused run with `Command({ resume: { decisions } })` on the same `thread_id` — R1.2, R5.3, R5.4, R7.1 -- [ ] 2.6 (Unit) Run all agent invocations in the desktop main process; assert the renderer cannot reach the model or the credential directly — R2.1 +- [ ] 2.3 (Unit) Implement `rename_page` and `delete_page` tools over the gated store with origin `"agent"`: `rename_page` writes the new page through `gateWrite` + `writePage`, marks the old superseded via `supersedePage`, refuses to clobber an existing target, and refuses the wiki index, changelog, and log; `delete_page` routes through the new `deletePage` primitive and refuses the same set — R4.2, R4.5, R4.6 +- [ ] 2.4 (Unit) Register a Groq harness profile via `registerHarnessProfile(createHarnessProfile({ generalPurposeSubagent: { enabled: false } }))` so the general-purpose subagent — and with it the `task` tool — is never constructed; assert `subagents: []` alone does not remove `task` — R4.4 +- [ ] 2.5 (Unit) Set `interruptOn` for `write_file`, `edit_file`, `rename_page`, `delete_page`, and emit a `chat:event` interrupt carrying the proposed change — R5.1 +- [ ] 2.6 (Unit) Stream `agent.streamEvents({ version: "v3" })` and forward token and tool-call events to the renderer; resume a paused run with `Command({ resume: { decisions } })` on the same `thread_id` — R1.2, R5.3, R5.4, R7.1 +- [ ] 2.7 (Unit) Run all agent invocations in the desktop main process; assert the renderer cannot reach the model or the credential directly — R2.1 +- [ ] 2.8 (Unit) Disable tracing/telemetry for the agent's runs — set `LANGCHAIN_TRACING_V2=false` and read no `LANGCHAIN_*` / `LANGSMITH_*` environment variable before the agent is constructed, so library-level auto-initialization does not fire and project content is sent only to Groq — R2.6 ## 3 · IPC channels @@ -27,7 +31,7 @@ - [ ] 4.1 (Unit) Widen `Pane` in `navigation.ts`, add the chat entry to `PANES` in `Rail.tsx`, and render `` in the `App.tsx` pane switch — R1.1 - [ ] 4.2 (Unit) Build `Chat.tsx` with `bridge()` + `useEffect` + live-guard, sending over `chat:send` and rendering streamed tokens and tool calls from `chat:event` — R1.2, R1.4 -- [ ] 4.3 (Unit) Render the interrupt payload (slug, old/new content, or rename/delete target) with approve, reject, and edit controls, dispatching `chat:resume` — R5.1, R5.2, R5.3, R5.4 +- [ ] 4.3 (Unit) Render the interrupt payload (slug, old/new content or the full resulting page for `replace_all`, or the rename/delete target) with approve, reject, and edit controls, dispatching `chat:resume` — R5.1, R5.2, R5.3, R5.4 - [ ] 4.4 (Unit) Show the empty state naming the Groq key requirement and linking to settings while no credential is configured, and disable the composer; this replaces the "there is no model behind this window" copy — R1.3, R1.5 - [ ] 4.5 (Unit) Surface a run error in place and preserve the conversation that produced it — R1.4 @@ -42,10 +46,15 @@ - [ ] 6.1 (TDD) Test that `write_file` to a path outside `wiki/` fails and writes nothing — R4.3, R6.1 - [ ] 6.2 (TDD) Test that `edit_file` to a path outside `wiki/` fails and writes nothing — R4.3, R6.1 - [ ] 6.3 (TDD) Test that `execute` is not in the agent's tool set and a call is rejected — R4.4, R6.1 -- [ ] 6.4 (TDD) Test that `task`/subagents are not registered — R4.4, R6.1 +- [ ] 6.4 (TDD) Test that `task`/subagents are not registered (the general-purpose subagent is disabled for the Groq profile) — R4.4, R6.1 - [ ] 6.5 (TDD) Test that a `write_file` whose content the gate rejects (bad frontmatter, dangling wikilink) is denied and writes nothing — R4.1, R6.1 -- [ ] 6.6 (TDD) Test that a read outside the project root returns an error and reads nothing — R3.2, R6.1 +- [ ] 6.6 (TDD) Test that a read outside the project root — including a symlink or junction inside the project that points outside — returns an error and reads nothing — R3.2, R6.1 - [ ] 6.7 (TDD) Test that an approved write lands through the store (validated, logged with origin `agent`, undoable) and a rejected write does not — R4.1, R4.5, R5.3 +- [ ] 6.8 (TDD) Test that a write tool call always emits an interrupt before any `writePage`/`supersedePage`/`deletePage` call — R5.1, R6.1 +- [ ] 6.9 (TDD) Test that a `replace_all` edit's interrupt shows every match site, and that an approved `replace_all` lands exactly as shown and alters nothing else — R5.2, R6.1 +- [ ] 6.10 (TDD) Test that `rename_page` refuses to clobber an existing target, and that `rename_page` and `delete_page` refuse the wiki index, changelog, and log — R4.6, R6.1 +- [ ] 6.11 (TDD) Test that a tool result large enough to trigger the middleware's eviction creates no file on disk (under `/large_tool_results/` or `/conversation_history/`) — the gate rejects the eviction path — R4.3, R6.1 +- [ ] 6.12 (TDD) Test that constructing the agent with `LANGCHAIN_*` / `LANGSMITH_*` env vars set constructs no tracing client and sends nothing to a third party — R2.6, R6.1 ## 7 · Docs From 0605dbc00f3376f22eafcda623a2cafb45f8aa38 Mon Sep 17 00:00:00 2001 From: prode Date: Sun, 2 Aug 2026 01:47:03 -0300 Subject: [PATCH 3/3] =?UTF-8?q?docs(spec):=20embedded=20agent=20=E2=80=94?= =?UTF-8?q?=20apply=20CodeRabbit=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold in the substantive findings from the CodeRabbit review on PR #33. Verified the factual claims against the deepagents@1.12.1 source before writing them in; skipped the ones already correct. - readRaw is required on BackendProtocolV2 (edit is inherited from v1, delete is optional). Add WikiGateBackend.readRaw, confined with assertWithin (R3.1/R3.2, task 1.9). The backend implements ls/read/readRaw/write/edit/glob/grep; delete is not implemented. - edit_file is a logical replacement, not a partial write: read the page, apply the exact-string replace, write the full resulting page atomically through writePage — the gate validates the whole new page, not the diff. - registerHarnessProfile takes (key, profile); fix the call to registerHarnessProfile("groq", createHarnessProfile({ generalPurposeSubagent: { enabled: false } })) — the first arg is the provider key createDeepAgent resolves for the model (task 2.4). - streamEvents(input, {version:"v3"}) takes input first; restore the input arg dropped in task 2.6 (the design already had it right). - Confine the harness entry file (system prompt) read with assertWithin + real-path check in main; skills load through the backend's read_file. - Tracing disabled before the agent's dependencies are imported (start of main), not just before construction — LangChain auto-inits at import (R2.6, task 2.8). - Atomic rename/delete: new renamePage/deletePage primitives are single operations (snapshot, gate+write, one log entry, rollback on failure); deletion is supersession; no half-applied rename. - TOCTOU on approval: the interrupt stores a content hash; on resume the backend revalidates the page and re-interrupts with a fresh proposal if another writer changed it (R5.5, task 6.14). - Stable conversation identity + discriminated event schemas: IPC carries thread_id/run_id/interrupt_id; a resume references the interrupt_id; one MemorySaver per project window; events discriminated by kind (R7.2, task 3.1). - Deterministic harness-entry selection: resolve from scaffold metadata or the active harness, reject ambiguity before construction (R2.3). - Narrow R4.6's scope explicitly: reserved-page protection covers rename/delete (structural); content edits to index/changelog/log remain a human-approved agent capability — maintaining them is the agent's job. - Minor: fix the ADR reference sentence in requirements.md. scc validate: no findings. CI was green before this push. Co-Authored-By: Claude --- specs/embedded-agent/design.md | 116 +++++++++++++++++---------- specs/embedded-agent/requirements.md | 20 +++-- specs/embedded-agent/tasks.md | 19 +++-- 3 files changed, 97 insertions(+), 58 deletions(-) diff --git a/specs/embedded-agent/design.md b/specs/embedded-agent/design.md index 32604a3..fac986c 100644 --- a/specs/embedded-agent/design.md +++ b/specs/embedded-agent/design.md @@ -10,17 +10,22 @@ validated store. `adr:0019` decided that this may exist; this design is how. Three new things, and each is load-bearing: 1. **A `BackendProtocolV2` implementation that is the guardrail by scope** — - `apps/desktop/src/main/agent/wiki-gate-backend.ts`. It implements `BackendProtocolV2` — - the current interface `StateBackend` and `FilesystemBackend` implement (`ls`, `read`, - `write`, `edit`, `glob`, `grep`). (The bare `BackendProtocol` export is the deprecated v1 - alias; `WikiGateBackend` implements v2.) Reads (`ls`/`read`/`glob`/`grep`) operate on the - real project directory, every path confined with `assertWithin(projectRoot)` from - `@open-wiki/access`. Writes (`write`/`edit`) accept only paths that resolve inside - `/wiki/` and route them through the store: `gateWrite` to validate, then - `writePage(projectRoot, pagePath, content, "agent")` to write atomically, log the operation - with origin `agent`, and leave it undoable. A write to any other path returns an error and - writes nothing. `execute` is not implemented, so `WikiGateBackend` is not a sandbox backend - (`isSandboxBackend` is false) and the DeepAgents middleware filters the `execute` tool. + `apps/desktop/src/main/agent/wiki-gate-backend.ts`. It implements `BackendProtocolV2`: + `ls`, `read`, `readRaw`, `write`, `glob`, and `grep` (required) and `edit` (inherited from + the v1 protocol); `delete` is optional and is not implemented (the custom `delete_page` + tool uses the access primitive below, not `backend.delete`). (The bare `BackendProtocol` + export is the deprecated v1 alias; `WikiGateBackend` implements v2.) Reads + (`ls`/`read`/`readRaw`/`glob`/`grep`) operate on the real project directory, every path + confined with `assertWithin(projectRoot)` from `@open-wiki/access`. Writes (`write`/`edit`) + accept only paths that resolve inside `/wiki/` and route them through the + store: `gateWrite` to validate, then `writePage(projectRoot, pagePath, content, "agent")` to + write atomically, log the operation with origin `agent`, and leave it undoable. `edit` is a + logical replacement, not a partial write: the backend reads the page, applies the + exact-string replacement (`replace_all` supported), and writes the full resulting page + atomically through `writePage` — the gate validates the whole new page, not the diff. A + write to any other path returns an error and writes nothing. `execute` is not implemented, + so `WikiGateBackend` is not a sandbox backend (`isSandboxBackend` is false) and the + DeepAgents middleware filters the `execute` tool. The built-in `write_file`/`edit_file` tools are **kept and re-pointed** at this backend, not removed. ADR 0019 excludes "an agent toolkit's filesystem surface — `write_file`, @@ -50,7 +55,9 @@ Three new things, and each is load-bearing: `systemPrompt` = the project's harness entry file, resolved at runtime and carried in unchanged (today `CLAUDE.md`; `plans/harness-portability.md` will write an entry file per harness — Codex, opencode, Claude — and a project may carry several, as renderings of one - convention, so the resolver reads one and does not duplicate); `skills = [".claude/skills/"]` + convention, so the resolver reads one — chosen deterministically from the scaffold metadata + or the active harness, rejecting ambiguity before the agent is constructed — and does not + duplicate); `skills = [".claude/skills/"]` (the shared skills location `adr:0015` chose, loaded by the middleware's `read_file` from the same backend); `checkpointer = new MemorySaver()` keyed by a `thread_id` per conversation; `interruptOn` set for `write_file`, `edit_file`, `rename_page`, `delete_page`; @@ -60,8 +67,11 @@ Three new things, and each is load-bearing: The `task` (subagent) tool is removed at construction, not merely unused: `createDeepAgent` auto-adds a general-purpose subagent — which provides `task` — unless the harness profile disables it, and `subagents: []` alone does **not** suffice. So a Groq harness profile is - registered once, at module load, via the exported `registerHarnessProfile(createHarnessProfile({ - generalPurposeSubagent: { enabled: false } }))`, keyed to Groq. With the general-purpose + registered once, at module load, via the exported `registerHarnessProfile("groq", + createHarnessProfile({ generalPurposeSubagent: { enabled: false } }))` — the first argument + is the provider key `createDeepAgent` resolves for the model (`getModelProvider(ChatGroq)`, + expected to be `"groq"`; the implementer confirms), so an unknown model that would otherwise + fall back to `EMPTY_HARNESS_PROFILE` instead gets this profile. With the general-purpose subagent disabled, the `task` tool is never built. `execute` is never built because `WikiGateBackend` is not a sandbox backend. The filesystem tools (`ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`) are auto-attached by `createDeepAgent` from the @@ -71,13 +81,14 @@ Three new things, and each is load-bearing: agent reads, and the harness-portability plan writes the same convention at each harness's paths. - No tracing or telemetry is enabled for the agent's runs. Before the agent is constructed, - the agent path sets `LANGCHAIN_TRACING_V2=false` and reads no `LANGCHAIN_*` / `LANGSMITH_*` - environment variable; project content the agent reads is sent only to Groq. LangChain/ - LangGraph can auto-initialize LangSmith from those env vars at import time, before the - agent path runs — disabling tracing before construction is what closes that, not merely - refusing to read the vars in our own code. (A developer who sets them globally would - otherwise export the project directory to a third party.) + No tracing or telemetry is enabled for the agent's runs. The agent path sets + `LANGCHAIN_TRACING_V2=false` (and unsets `LANGSMITH_*`) before the agent's dependencies are + imported — at the start of main, ahead of any `langchain` / `@langchain/*` import — and + reads no `LANGCHAIN_*` / `LANGSMITH_*` environment variable; project content the agent reads + is sent only to Groq. LangChain/LangGraph can auto-initialize LangSmith from those env vars + at import time, before the agent path runs — disabling tracing before the import is what + closes that, not merely refusing to read the vars in our own code. (A developer who sets + them globally would otherwise export the project directory to a third party.) 3. **The IPC surface and the chat pane** — new channels in `apps/desktop/src/main/channels.ts`: `chat:send`, `chat:resume`, `chat:cancel`, and a @@ -96,7 +107,7 @@ two-purpose notice and the curated model list. `stack.md` gains `deepagents`, `@langchain/groq`, `@langchain/langgraph`, `langchain`, and `@langchain/core`. Serves R1.1, R1.2, R1.3, R1.4, R1.5, R2.1, R2.2, R2.3, R2.4, R2.5, R2.6, R3.1, R3.2, R4.1, -R4.2, R4.3, R4.4, R4.5, R4.6, R5.1, R5.2, R5.3, R5.4, R6.1, R7.1. +R4.2, R4.3, R4.4, R4.5, R4.6, R5.1, R5.2, R5.3, R5.4, R5.5, R6.1, R7.1, R7.2. ## Boundaries and contracts @@ -112,11 +123,19 @@ R4.2, R4.3, R4.4, R4.5, R4.6, R5.1, R5.2, R5.3, R5.4, R6.1, R7.1. the project root with the same `assertWithin` `packages/mcp` uses; a path that escapes (including a symlink or junction inside the project that points outside — the realistic escape on Windows, the only supported platform) throws `OutsideProjectError`, which the - backend returns as a tool error. -- **IPC contract.** `chat:send({ text })`, `chat:resume({ decisions })`, - `chat:cancel()`, and push `chat:event({ kind, ... })` where `kind` is `token` | `tool` | - `interrupt` | `done` | `error`. Typed in `bridge.ts` `OwBridge`; the preload parity check - (`preload.ts:104`) and `dispatch`'s unknown-channel throw (`ipc.ts`) enforce completeness. + backend returns as a tool error. The harness entry file (the agent's system prompt) is + resolved and read in main with the same `assertWithin` + real-path check, not a bare + `node:fs` read; the scaffolded skills are loaded by the middleware's `read_file` through the + backend, so they are confined too. +- **IPC contract.** `chat:send({ text, thread_id })`, `chat:resume({ decisions, interrupt_id, + run_id })`, `chat:cancel({ run_id })`, and push `chat:event({ kind, thread_id, run_id, ... })` + where `kind` is `token` | `tool` | `interrupt` | `done` | `error`, each carrying the fields + its kind requires (an `interrupt` carries the tool, file path, old/new content or the full + resulting page for `replace_all`, or the rename/delete target, plus an `interrupt_id` and a + content hash of the page at interrupt time; a `tool` event carries the call and its result). + One `MemorySaver` — and one agent instance — is scoped to the project window, keyed by + `thread_id`. Typed in `bridge.ts` `OwBridge`; the preload parity check (`preload.ts:104`) and + `dispatch`'s unknown-channel throw (`ipc.ts`) enforce completeness. ## Data @@ -124,23 +143,29 @@ R4.2, R4.3, R4.4, R4.5, R4.6, R5.1, R5.2, R5.3, R5.4, R6.1, R7.1. | "observer"`) is extended with `"agent"`, a string variant alongside the existing ones, so the operation log distinguishes an agent write. The log and undo machinery (`appendOperation`, `undo`) are unchanged. -- **A gated delete primitive.** `@open-wiki/access` exports no `deletePage`; the desktop's - `deletePage` (`apps/desktop/src/main/edit.ts`) calls `node:fs.rmSync` directly and hardcodes - `origin: "editor"`, bypassing the gate. A new `deletePage(projectRoot, pagePath, origin)` - is added to `@open-wiki/access` that routes through `gateWrite` + `appendOperation` (mark the - page superseded, or remove it, logged with the given origin, undoable). `rename_page` writes - the new page through `gateWrite` + `writePage`, marks the old page superseded via - `supersedePage`, refuses to clobber an existing target, and refuses to operate on the wiki's - index, changelog, and log (`gateWrite` already passes those `NON_ENTITY_PAGES` with no content - validation — R4.6 closes that for the agent). `delete_page` refuses the same set. +- **Gated, atomic rename and delete primitives.** `@open-wiki/access` exports no + `deletePage` or `renamePage`; the desktop's `deletePage` (`apps/desktop/src/main/edit.ts`) + calls `node:fs.rmSync` directly and hardcodes `origin: "editor"`, bypassing the gate. New + `deletePage(projectRoot, pagePath, origin)` and `renamePage(projectRoot, oldPath, newPath, + origin)` primitives are added, each a single atomic operation: snapshot the affected pages, + gate + write, record one operation with the given origin, and roll back on failure — no + half-applied rename. Deletion is supersession (the page is marked superseded, not unlinked, + so it stays visible in history and is one undo). `renamePage` writes the new page through + `gateWrite` + `writePage` and marks the old superseded in the same operation, refuses to + clobber an existing target, and refuses the wiki's index, changelog, and log (`gateWrite` + passes those `NON_ENTITY_PAGES` with no content validation — R4.6 closes that for the + agent). `deletePage` refuses the same set. - **Conversation state** — a `MemorySaver` holding the LangGraph thread per `thread_id`. In memory only for v1; not on disk; not in the project directory. - **Interrupt payload** — the proposed write: `{ tool, file_path, old_string?, new_string?, - content? }`, rendered as a diff in the pane. For `edit_file` with `replace_all`, the payload - carries every match site (or the full resulting page), so the human sees the complete effect - of the tool call, not only the two strings — a short `old_string` that matches in several - places must not be smuggled past review. Resume carries `decisions: [{ type: "approve" | - "reject" | "edit", ... }]`. + content? }`, rendered as a diff in the pane, plus a content hash of the page at interrupt + time. For `edit_file` with `replace_all`, the payload carries every match site (or the full + resulting page), so the human sees the complete effect of the tool call, not only the two + strings — a short `old_string` that matches in several places must not be smuggled past + review. Resume carries `decisions: [{ type: "approve" | "reject" | "edit", ... }]` and the + `interrupt_id` it answers. On resume the backend revalidates the page against the stored + hash; if another writer changed it in the window between interrupt and resume, the stale + edit is not applied — the run re-interrupts with a fresh proposal (R5.5). ## Alternatives considered @@ -181,9 +206,12 @@ gate-backed backend is reversible (it is our code, not a framework commitment). the accepted cost of carrying the convention in unchanged; human-in-the-loop is the only mitigation. The agent is the lesser door, not a harness. - **The wiki's index, changelog, and log are `NON_ENTITY_PAGES`.** `gateWrite` passes them - with no content validation — they are "themselves." An agent `write_file` to `wiki/index.md` - is therefore gated only by human approval, not by the gate's form checks. R4.6 keeps the - agent from deleting or renaming them; overwriting them by edit remains a human call. + with no content validation — they are "themselves." R4.6 protects them against + `rename_page`/`delete_page` (structural removal or replacement) but not against + `write_file`/`edit_file`: an agent content edit to `wiki/index.md` is gated only by human + approval, not by the gate's form checks. This is intentional — maintaining the index and the + changelog is part of wiki maintenance, so the agent may edit them with approval, but may not + rename or delete them. - **Tracing exfiltration.** LangChain/LangGraph ship LangSmith tracing that activates on `LANGCHAIN_*` / `LANGSMITH_*` env vars and would send prompts, tool calls, and tool results (project content) to a third party. The agent path reads none of those env vars and sets no diff --git a/specs/embedded-agent/requirements.md b/specs/embedded-agent/requirements.md index d529e46..a0e82bb 100644 --- a/specs/embedded-agent/requirements.md +++ b/specs/embedded-agent/requirements.md @@ -7,7 +7,7 @@ ci: wait Enabled by `adr:0019-an-embedded-agent-that-reads-freely-and-writes-through-the-gate`, which narrowed `adr:0013` to allow the application to run an embedded agent. This spec -is the first thing that record's "Consequences" call for. +is the first thing that record's "Consequences" section calls for. ## Purpose @@ -43,17 +43,18 @@ lands. the file the project was scaffolded for its harness to read — and the scaffolded skills, carried in unchanged; no hand-written system prompt shall be added beside them. A project scaffolded for more than one harness carries more than one entry file, and they are - renderings of one convention, so the embedded agent shall read one and shall not - duplicate the convention in its prompt. + renderings of one convention, so the embedded agent shall resolve one deterministically — + from the scaffold metadata or the active harness, rejecting ambiguity before construction — + and shall not duplicate the convention in its prompt. - **R2.4** While the project has no Groq credential, the desktop application shall disable the embedded agent and state in the settings screen that a Groq key is required for the agent and serves transcription and the agent both. - **R2.5** The settings screen shall offer a curated model selection for the agent, not the raw provider model list, with one default chosen for tool-calling reliability. - **R2.6** The embedded agent shall send project content only to Groq; it shall not read - tracing or telemetry environment variables, shall set tracing disabled before the agent is - constructed (so library-level auto-initialization does not fire), and shall not transmit - prompts, tool calls, or tool results to any third party. + tracing or telemetry environment variables, shall set tracing disabled before the agent's + dependencies are imported (so library-level auto-initialization does not fire), and shall + not transmit prompts, tool calls, or tool results to any third party. ## R3 · Reading — unrestricted within the project @@ -96,6 +97,9 @@ lands. shall be told the write was rejected and not to retry it unless asked. - **R5.4** When the user edits the arguments of a paused write, the tool shall execute with the edited arguments, and the gate shall validate them as if the agent had proposed them. +- **R5.5** When the user resumes a paused write, the embedded agent shall revalidate that the + target page is unchanged since the interrupt and, if it has changed, shall return a fresh + proposal rather than apply a stale edit. ## R6 · The line is proved, not configured @@ -107,6 +111,10 @@ lands. - **R7.1** The embedded agent shall keep conversation state in memory, keyed by a thread id, one per conversation, and shall resume a paused run from the same thread. +- **R7.2** The chat channels shall carry a thread id and a run id, one conversation per + project window, and a resume shall reference the interrupt id it answers; the push events + shall be discriminated by kind — token, tool, interrupt, done, or error — each with the + fields its kind requires. ## Out of scope diff --git a/specs/embedded-agent/tasks.md b/specs/embedded-agent/tasks.md index 3597550..73cb57e 100644 --- a/specs/embedded-agent/tasks.md +++ b/specs/embedded-agent/tasks.md @@ -9,29 +9,30 @@ - [ ] 1.5 (TDD) Implement `WikiGateBackend.edit` as exact `old_string`→`new_string` replace (`replace_all` supported) over a wiki page, routing through the store; assert it never rewrites the whole page and that a non-wiki path fails — R4.1, R4.3, R6.1 - [ ] 1.6 (Unit) Omit `execute` from `WikiGateBackend` so it is not a sandbox backend and the DeepAgents middleware hides the `execute` tool — R4.4 - [ ] 1.7 (Unit) Extend the `Origin` union in `@open-wiki/access` with the string variant `"agent"`, alongside `"editor" | "cli" | "hook" | "observer"`, so the operation log distinguishes an agent write — R4.5 -- [ ] 1.8 (Unit) Add a gated `deletePage(projectRoot, pagePath, origin)` primitive to `@open-wiki/access` that routes through `gateWrite` + `appendOperation` and accepts an origin; the desktop's existing `deletePage` calls `node:fs` with a hardcoded origin and shall not be reused — R4.2, R4.5 +- [ ] 1.8 (Unit) Add gated, atomic `deletePage(projectRoot, pagePath, origin)` and `renamePage(projectRoot, oldPath, newPath, origin)` primitives to `@open-wiki/access`, each a single operation (snapshot affected pages, gate + write, one log entry, rollback on failure); deletion is supersession; the desktop's existing `deletePage` calls `node:fs` with a hardcoded origin and shall not be reused — R4.2, R4.5 +- [ ] 1.9 (Unit) Implement `WikiGateBackend.readRaw` (confined with `assertWithin`, returning `FileData` as a `ReadRawResult`) — the required `BackendProtocolV2` read method — R3.1, R3.2 ## 2 · The agent runtime - [ ] 2.1 (Unit) Build the agent in `apps/desktop/src/main/agent/agent.ts` with `createDeepAgent`: `model = new ChatGroq(...)` from the Groq credential and the curated default model, `backend = WikiGateBackend`, `checkpointer = new MemorySaver()` keyed by `thread_id`, `systemPrompt` = the resolved harness entry file, `skills = [".claude/skills/"]`, and `tools = [renamePageTool, deletePageTool]` as tool objects; the filesystem tools auto-attach from the harness profile and re-point at the backend — R2.1, R2.2, R2.3, R2.5, R4.4, R7.1 -- [ ] 2.2 (Unit) Resolve the project's harness entry file (the file the project was scaffolded for its harness to read; today `CLAUDE.md`, plural once `plans/harness-portability.md` lands) as `systemPrompt`, and the scaffolded skills as `skills`, both read from disk in main and carried in unchanged; assert no hand-written prompt is appended beside them — R2.3 -- [ ] 2.3 (Unit) Implement `rename_page` and `delete_page` tools over the gated store with origin `"agent"`: `rename_page` writes the new page through `gateWrite` + `writePage`, marks the old superseded via `supersedePage`, refuses to clobber an existing target, and refuses the wiki index, changelog, and log; `delete_page` routes through the new `deletePage` primitive and refuses the same set — R4.2, R4.5, R4.6 -- [ ] 2.4 (Unit) Register a Groq harness profile via `registerHarnessProfile(createHarnessProfile({ generalPurposeSubagent: { enabled: false } }))` so the general-purpose subagent — and with it the `task` tool — is never constructed; assert `subagents: []` alone does not remove `task` — R4.4 +- [ ] 2.2 (Unit) Resolve the project's harness entry file (today `CLAUDE.md`, plural once `plans/harness-portability.md` lands) deterministically — from scaffold metadata or the active harness, rejecting ambiguity — and read it with `assertWithin` + a real-path check; use it as `systemPrompt` and the scaffolded skills as `skills`, both carried in unchanged; assert no hand-written prompt is appended beside them — R2.3 +- [ ] 2.3 (Unit) Implement `rename_page` and `delete_page` tools over the new atomic `renamePage`/`deletePage` primitives with origin `"agent"`; `rename_page` refuses to clobber an existing target and refuses the wiki index, changelog, and log; `delete_page` refuses the same set — R4.2, R4.5, R4.6 +- [ ] 2.4 (Unit) Register a Groq harness profile via `registerHarnessProfile("groq", createHarnessProfile({ generalPurposeSubagent: { enabled: false } }))` — the first arg is the provider key `createDeepAgent` resolves for the model — so the general-purpose subagent (and the `task` tool) is never constructed; assert `subagents: []` alone does not remove `task` — R4.4 - [ ] 2.5 (Unit) Set `interruptOn` for `write_file`, `edit_file`, `rename_page`, `delete_page`, and emit a `chat:event` interrupt carrying the proposed change — R5.1 -- [ ] 2.6 (Unit) Stream `agent.streamEvents({ version: "v3" })` and forward token and tool-call events to the renderer; resume a paused run with `Command({ resume: { decisions } })` on the same `thread_id` — R1.2, R5.3, R5.4, R7.1 +- [ ] 2.6 (Unit) Stream `agent.streamEvents(input, { version: "v3" })` and forward token and tool-call events to the renderer; resume a paused run with `Command({ resume: { decisions } })` on the same `thread_id`, referencing the `interrupt_id` — R1.2, R5.3, R5.4, R5.5, R7.1, R7.2 - [ ] 2.7 (Unit) Run all agent invocations in the desktop main process; assert the renderer cannot reach the model or the credential directly — R2.1 -- [ ] 2.8 (Unit) Disable tracing/telemetry for the agent's runs — set `LANGCHAIN_TRACING_V2=false` and read no `LANGCHAIN_*` / `LANGSMITH_*` environment variable before the agent is constructed, so library-level auto-initialization does not fire and project content is sent only to Groq — R2.6 +- [ ] 2.8 (Unit) Disable tracing/telemetry for the agent's runs — set `LANGCHAIN_TRACING_V2=false` and unset `LANGSMITH_*` before the agent's dependencies are imported (at the start of main), and read no `LANGCHAIN_*` / `LANGSMITH_*` env var, so library-level auto-initialization does not fire and project content is sent only to Groq — R2.6 ## 3 · IPC channels -- [ ] 3.1 (Unit) Add `chat:send`, `chat:resume`, `chat:cancel` request channels and the `chat:event` push channel in `channels.ts`; expose in `preload.ts`, type in `bridge.ts` `OwBridge`, and route in `dispatch` — R1.2, R5.2, R5.3, R5.4 +- [ ] 3.1 (Unit) Add `chat:send({text, thread_id})`, `chat:resume({decisions, interrupt_id, run_id})`, `chat:cancel({run_id})` and the `chat:event({kind, thread_id, run_id, ...})` push channel (discriminated by `kind`: token/tool/interrupt/done/error) in `channels.ts`; expose in `preload.ts`, type in `bridge.ts` `OwBridge`, and route in `dispatch` — R1.2, R5.2, R5.3, R5.4, R5.5, R7.2 - [ ] 3.2 (Unit) Bind the push channel through the buffered `send()` pattern so events before `did-finish-load` queue rather than drop — R1.2 ## 4 · The chat pane - [ ] 4.1 (Unit) Widen `Pane` in `navigation.ts`, add the chat entry to `PANES` in `Rail.tsx`, and render `` in the `App.tsx` pane switch — R1.1 - [ ] 4.2 (Unit) Build `Chat.tsx` with `bridge()` + `useEffect` + live-guard, sending over `chat:send` and rendering streamed tokens and tool calls from `chat:event` — R1.2, R1.4 -- [ ] 4.3 (Unit) Render the interrupt payload (slug, old/new content or the full resulting page for `replace_all`, or the rename/delete target) with approve, reject, and edit controls, dispatching `chat:resume` — R5.1, R5.2, R5.3, R5.4 +- [ ] 4.3 (Unit) Render the interrupt payload (slug, old/new content or the full resulting page for `replace_all`, or the rename/delete target) with approve, reject, and edit controls, dispatching `chat:resume` with the `interrupt_id`; show a fresh proposal when the backend reports the page changed since the interrupt — R5.1, R5.2, R5.3, R5.4, R5.5 - [ ] 4.4 (Unit) Show the empty state naming the Groq key requirement and linking to settings while no credential is configured, and disable the composer; this replaces the "there is no model behind this window" copy — R1.3, R1.5 - [ ] 4.5 (Unit) Surface a run error in place and preserve the conversation that produced it — R1.4 @@ -55,6 +56,8 @@ - [ ] 6.10 (TDD) Test that `rename_page` refuses to clobber an existing target, and that `rename_page` and `delete_page` refuse the wiki index, changelog, and log — R4.6, R6.1 - [ ] 6.11 (TDD) Test that a tool result large enough to trigger the middleware's eviction creates no file on disk (under `/large_tool_results/` or `/conversation_history/`) — the gate rejects the eviction path — R4.3, R6.1 - [ ] 6.12 (TDD) Test that constructing the agent with `LANGCHAIN_*` / `LANGSMITH_*` env vars set constructs no tracing client and sends nothing to a third party — R2.6, R6.1 +- [ ] 6.13 (TDD) Test that `readRaw` of a path outside the project returns an error and reads nothing — R3.2, R6.1 +- [ ] 6.14 (TDD) Test that resuming a paused write against a page changed since the interrupt does not apply the stale edit and re-interrupts with a fresh proposal — R5.5, R6.1 ## 7 · Docs