diff --git a/.changeset/claude-edit-read-guard.md b/.changeset/claude-edit-read-guard.md new file mode 100644 index 0000000..d10d4ab --- /dev/null +++ b/.changeset/claude-edit-read-guard.md @@ -0,0 +1,5 @@ +--- +"@colony/hooks": patch +--- + +Remind Claude Code to read existing files before edit tools so Update/Edit/MultiEdit calls do not stop with "File must be read first". diff --git a/CLAUDE.md b/CLAUDE.md index 89f7b0c..e1ebfa0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,8 @@ The signature property of the project is that **memory is stored compressed**. E 8. **Local by default.** Default embedding provider is local (Transformers.js). Remote providers are opt-in via settings. Do not add default network calls. 9. **No silent failures.** Hook and worker errors are logged as structured JSON; user-visible commands surface failures with a non-zero exit code and a short message. 10. **No daemon on the write path.** Hooks write observations synchronously through `MemoryStore.addObservation` — never across a network or HTTP boundary. Hooks may *detach-spawn* the worker to kick off background embedding, but they must never wait on it. If the worker is down, writes still succeed; only the semantic-search side is degraded (BM25 keeps working). -11. **Never edit on the local base branch.** Treat the local `main` checkout as read-only. Every task — even a typo or one-line fix — runs on a dedicated `agent/*` branch inside a worktree. Do not run `git checkout main` / `git switch main` to start work, do not `git commit` on the primary working tree, and do not push to `main` directly. This matches what codex does via Guardex and keeps parallel lanes safe. +11. **Read before edit tools.** Claude Code rejects `Edit` / `Update` / `MultiEdit` on an existing file unless that exact file path was read first in the current session. Before any edit tool call, run `Read` on the target file with the same relative path you will edit. +12. **Never edit on the local base branch.** Treat the local `main` checkout as read-only. Every task — even a typo or one-line fix — runs on a dedicated `agent/*` branch inside a worktree. Do not run `git checkout main` / `git switch main` to start work, do not `git commit` on the primary working tree, and do not push to `main` directly. This matches what codex does via Guardex and keeps parallel lanes safe. ## Worktree discipline diff --git a/packages/hooks/src/handlers/user-prompt-submit.ts b/packages/hooks/src/handlers/user-prompt-submit.ts index ef1d8f1..720980a 100644 --- a/packages/hooks/src/handlers/user-prompt-submit.ts +++ b/packages/hooks/src/handlers/user-prompt-submit.ts @@ -7,6 +7,8 @@ import type { HookInput } from '../types.js'; * so one chatty agent can't drown out the signal in another agent's context. */ const MAX_INJECTED_MESSAGES = 6; +const CLAUDE_EDIT_READ_GUARD = + 'Claude Code edit safety: Read each existing target file before Edit/Update/MultiEdit, or Claude Code rejects with "File must be read first".'; export async function userPromptSubmit(store: MemoryStore, input: HookInput): Promise { // Compute the "since" cursor *before* recording this turn's prompt so the @@ -24,7 +26,8 @@ export async function userPromptSubmit(store: MemoryStore, input: HookInput): Pr const activity = buildTaskUpdatesPreface(store, input.session_id, sinceTs); const conflicts = buildConflictPreface(store, input.session_id); const pheromones = buildPheromoneConflictPreface(store, input.session_id); - return [activity, conflicts, pheromones].filter(Boolean).join('\n\n'); + const editGuard = input.ide === 'claude-code' ? CLAUDE_EDIT_READ_GUARD : ''; + return [editGuard, activity, conflicts, pheromones].filter(Boolean).join('\n\n'); } /** diff --git a/packages/hooks/test/runner.test.ts b/packages/hooks/test/runner.test.ts index 1d04596..bc9471f 100644 --- a/packages/hooks/test/runner.test.ts +++ b/packages/hooks/test/runner.test.ts @@ -52,6 +52,33 @@ describe('runHook', () => { expect(tl[0]?.content).not.toMatch(/basically/i); }); + it('user-prompt-submit reminds Claude Code to read files before edit tools', async () => { + const claude = await runHook( + 'user-prompt-submit', + { + session_id: 'sess-claude-edit', + ide: 'claude-code', + prompt: 'Update packages/hooks/src/handlers/user-prompt-submit.ts', + }, + { store }, + ); + expect(claude.ok).toBe(true); + expect(claude.context).toContain('Read each existing target file before Edit/Update/MultiEdit'); + expect(claude.context).toContain('File must be read first'); + + const codex = await runHook( + 'user-prompt-submit', + { + session_id: 'sess-codex-edit', + ide: 'codex', + prompt: 'Update packages/hooks/src/handlers/user-prompt-submit.ts', + }, + { store }, + ); + expect(codex.ok).toBe(true); + expect(codex.context ?? '').not.toContain('Edit/Update/MultiEdit'); + }); + it('post-tool-use records a tool_use observation with metadata', async () => { await runHook('session-start', { session_id: 'sess-c', ide: 'claude-code' }, { store }); const r = await runHook(