diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d6b98..59f1ec4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 own cost, the model's real window and, for a long-lived token login, the plan gauge without spending a request to measure it. Needs a lich that serves the mod-usage contract; an older one answers 404 and the mod stops reporting. +- **A Claude Code session is told when another lich session just edited the + same file.** A subagent that shares the asking session's checkout can edit + the files it edits. After an edit, a write or a notebook edit to a file + another lich session edited in the last ten minutes, the model reads which + session it was, when, and that `send_to_session` or `lich send` reaches it. + Nothing is blocked: Claude Code already refuses an edit that would overwrite + a change it has not read. Works inside lich, in a git repository, with + Claude Code mods on. ## [0.14.0] - 2026-10-03 diff --git a/CLAUDE.md b/CLAUDE.md index f9d9509..39e3dee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,10 +17,11 @@ hooks/crush-hooks.json # hook registration, Crush (hand-merged into c hooks/ # hook scripts (all four run them; only how they are addressed differs) hooks/detail.jq # what a tool call acts on, shared by two of those scripts hooks/win-run.cmd # runs one of those scripts on Windows (Codex) -hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the three mods below +hooks/lich.js # the one module hooks/hooks.json names under `modules`: registers the four mods below hooks/mod-control.js # Claude Code mod: lich drives the session hooks/agent-cards.js # Claude Code mod: a general-purpose subagent runs as a lich session hooks/mod-usage.js # Claude Code mod: the session reports its own context, rate limits and cost +hooks/edit-guard.js # Claude Code mod: an edit says which other lich session edited the file opencode/lich.js # opencode client: a module, not a command omp/lich.js # omp client: a module, not a command docs/ # lich ⇄ plugin communication contracts @@ -59,6 +60,7 @@ Contracts are **canonical in the lich repository** (`docs/hooks/` there); this p - [docs/mod-control.md](docs/mod-control.md): lich drives a Claude Code session (prompt, abort, model, effort, command) and asks it side questions (ask) through a mod that long-polls for commands and acks each one; Claude Code only, the other harnesses have no mod system - [docs/agent-cards.md](docs/agent-cards.md): a general-purpose subagent Claude Code starts runs as a lich session, through the `lich open` CLI rather than an HTTP contract; Claude Code only - [docs/mod-usage.md](docs/mod-usage.md): a Claude Code session reports the context window, rate limits and cost it measured itself, through a mod hooking `session.measure`; Claude Code only +- [docs/edit-guard.md](docs/edit-guard.md): an edit to a file another lich session edited in the last minutes carries a note naming it, through markers in the checkout's git dir rather than an HTTP contract; Claude Code only - [docs/providers.md](docs/providers.md) — the per-harness map, including what installing on omp takes and what it cannot report Each doc carries the event mapping for **every** provider, one column each — a contract is implemented once and registered per harness. [docs/providers.md](docs/providers.md) is the map: which file each harness reads, where the event vocabularies differ, and what adding another provider takes. A harness that cannot close a state does not get that report registered: an unendable `busy` is worse on a card than a card with no indicator. @@ -93,6 +95,9 @@ Claude Code mod, handing it a fake `$` and asserting its acks against the mod-control fixtures and its polls against the commands lich sends; [tests/mod-usage.test.mjs](tests/mod-usage.test.mjs) does the same for its usage reports against the mod-usage fixtures. +[tests/edit-guard.test.mjs](tests/edit-guard.test.mjs) hands the edit guard a +fake `$` with an in-memory git dir and asserts the markers it leaves and the +note another session's marker adds. [tests/agent-cards.test.mjs](tests/agent-cards.test.mjs) hands the agent-cards mod the same kind of fake `$` and asserts the `lich open` argv it runs, the Agent result it answers each outcome with, and that a call it does @@ -102,7 +107,8 @@ stub, shared so every client answers to the same lines. Node only, no dependencies. [tests/mod-control.engine.test.ts](tests/mod-control.engine.test.ts) and [tests/agent-cards.engine.test.ts](tests/agent-cards.engine.test.ts) and -[tests/mod-usage.engine.test.ts](tests/mod-usage.engine.test.ts) run the +[tests/mod-usage.engine.test.ts](tests/mod-usage.engine.test.ts) and +[tests/edit-guard.engine.test.ts](tests/edit-guard.engine.test.ts) run the mods under Claude Code's own engine (`claude plugin test .`); they need a `claude` binary, so CI does not run them. The kit does not check a `tool.call` result against the tool's output schema (measured on 2.1.289), so whether diff --git a/README.md b/README.md index a683068..e46aee0 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,10 @@ checkout as a native subagent does, or in a worktree of its own when the subagent asks for isolation. The Agent call returns at once as a background subagent, and the session's full report arrives at the asking session's prompt on its own as a `[lich]` note; Explore, Plan and other agent types stay inside Claude Code. [docs/agent-cards.md](docs/agent-cards.md) has the -rules and what happens when lich cannot be reached. +rules and what happens when lich cannot be reached. Since that subagent shares +the checkout, an edit to a file another lich session edited in the last ten +minutes tells the model which session it was and how to reach it +([docs/edit-guard.md](docs/edit-guard.md)). It also ships skills for the parts of lich you configure from inside a session: @@ -58,10 +61,11 @@ hooks/report-tool.sh # session-state hook: the tool a turn is runni hooks/report-session-start.sh # session-start hook hooks/report-title.sh # session-title hook hooks/report-touched.sh # session-touched hook -hooks/lich.js # the Claude Code mods' entry, which registers the three below +hooks/lich.js # the Claude Code mods' entry, which registers the four below hooks/mod-control.js # mod-control client, a Claude Code mod hooks/agent-cards.js # a general-purpose subagent as a lich session, a Claude Code mod hooks/mod-usage.js # mod-usage client, a Claude Code mod +hooks/edit-guard.js # names the other lich session that edited a file, a Claude Code mod opencode/lich.js # opencode client: all four reports plus the seven tools, one module omp/lich.js # omp client: the reports, one module docs/ # client-side docs, one per contract diff --git a/docs/edit-guard.md b/docs/edit-guard.md new file mode 100644 index 0000000..f91193c --- /dev/null +++ b/docs/edit-guard.md @@ -0,0 +1,77 @@ +# Mod: edit guard + +Tells a Claude Code session inside lich that the file it just edited was also +edited, minutes earlier, by another lich session working in the same checkout. +There is no lich contract behind it: the sessions meet in the checkout's git +dir, and nothing is sent to lich. + +| client | Claude Code | Codex | Antigravity | opencode | omp | Crush | +|-----------------------|---------------------------------------------------------|---------------------|---------------------|---------------------|---------------------|---------------------| +| `hooks/edit-guard.js` | mod, registered by `hooks/lich.js`, the `modules` entry | none: no mod system | none: no mod system | none: no mod system | none: no mod system | none: no mod system | + +## Why + +A general-purpose subagent lich opens ([agent-cards.md](agent-cards.md)) works +in the asking session's checkout by default, so two Claude Code sessions can +edit the same file at once. Each one only sees the other's change when it reads +the file again, and neither knows who made it or that the other may still be at +work on it. + +## What Claude Code already does + +Measured on Claude Code 2.1.289 (`--model haiku`, a scratch git repository): a +session read four files, another process then changed each of them, and the +session edited them without reading again. + +| tool | result | +|-----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Edit`, `old_string` still in the file | applied, the other change kept: `The file … has been updated successfully. (note: the file had been modified on disk since you last read it — the edit applied cleanly, but the file contains other changes not in your context. Read it before edits that depend on surrounding content.)` | +| `Edit`, `old_string` changed by the other side | refused: `File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.` | +| `Write` | refused, same message | +| `NotebookEdit` | refused, same message | + +`MultiEdit` is not a tool in that release. So Claude Code never overwrites a +change it has not read: the guard denies nothing, and only adds who made the +change. + +## How it behaves + +It hooks `tool.call` on `Edit`, `Write` and `NotebookEdit`, and does nothing +unless `LICH_SESSION_ID` is set. + +1. Before the call it asks git for the git dir of the folder holding the file + (`git -C rev-parse --absolute-git-dir`, the worktree's own for a + linked worktree). Outside a repository it stops here. +2. It reads `/lich-edits/`, the marker the last + session to edit that file left: `{"path", "session", "at"}`, `at` in epoch + milliseconds. +3. It runs the call. A `deny` from a hook beneath is passed on as it came. +4. When the call succeeded, it writes its own marker over that one. +5. When the marker named a different lich session and is under 10 minutes old, + the result goes back with one more `context` line, which the model reads + after the tool's result: the path, the session id, the time and how long + ago, and that `send_to_session` or `lich send` reaches that session. A + refused call carries the line too, since it explains the refusal. + +Ten minutes spans the turn in which the other session made its edit; past that +the work is likely finished and the file as read is the news. + +A failure of the guard itself (git, a marker it cannot read or write) is logged +with `$.ui.log`, a dim line in the transcript, and the edit goes on as if the +guard were not there. + +## Limits + +- It speaks only after the edit. The `context` of a `tool.call` result is read + with the tool's result; there is no slot to warn before the call runs. The + refusals above are what keep the edit safe. +- The marker names the lich session by id. lich gives a session no label in its + environment, and the guard sends nothing to lich to ask for one. +- One marker per file, the last editor's: two other sessions editing the same + file leave only the newer one's name. +- A file in a folder that does not exist yet is outside any repository to git, + so a `Write` creating a new folder is not recorded. +- Edits made by `Bash` (`sed -i`, a formatter) are not seen, nor are edits by + harnesses other than Claude Code, which have no mod system. +- Markers are never cleaned up; each is a few hundred bytes, one per file a + lich session edited, under the git dir where git ignores it. diff --git a/docs/providers.md b/docs/providers.md index 2f4ff0f..2dcba91 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -24,10 +24,11 @@ This repository is one plugin, packaged for each harness it supports: | `hooks/crush-hooks.json` | Crush | hook registration, merged by hand | | `hooks/*.sh` | the four | the reports themselves | | `hooks/win-run.cmd` | Codex | runs a script on Windows | -| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the three below | +| `hooks/lich.js` | Claude Code | the one module `hooks/hooks.json` names under `modules`; registers the four below | | `hooks/mod-control.js` | Claude Code | the mod-control client, a mod | | `hooks/agent-cards.js` | Claude Code | runs a general-purpose subagent as a lich session, a mod | | `hooks/mod-usage.js` | Claude Code | the mod-usage client, a mod | +| `hooks/edit-guard.js` | Claude Code | names the other lich session that edited a file, a mod | | `opencode/lich.js` | opencode | the whole client, as a module | | `omp/lich.js` | omp | the whole client, as a module | | `skills/` | all | skills, same layout | diff --git a/hooks/edit-guard.js b/hooks/edit-guard.js new file mode 100644 index 0000000..aa380d8 --- /dev/null +++ b/hooks/edit-guard.js @@ -0,0 +1,131 @@ +// Tells a Claude Code session inside lich when another lich session edited the +// file it is editing. Docs: ../docs/edit-guard.md. No lich contract behind it: +// the sessions meet in the checkout's git dir, not over HTTP. +// +// A subagent lich opens shares its caller's checkout, so two sessions can edit +// one file. Claude Code already refuses an edit that would overwrite a change +// it has not read (measured on 2.1.289, in the doc), so this is coordination, +// not protection: every edit leaves a marker naming the session, and an edit +// to a file another session marked recently carries a note naming it. +// +// Every function that takes `$` is declared here at the top level: the loader +// refuses a module that hands `$` to a nested function. + +// Long enough to span the other session's turn that made the edit, short +// enough that a marker from work long finished stops raising notes. +const RECENT_MS = 10 * 60_000 +const MARKER_DIR = "lich-edits" +const GIT_TIMEOUT_MS = 5_000 + +/** + * @typedef {import('claude-code').EngineInterface} Engine + * @typedef {{ path: string, session: string, at: number }} Marker + */ + +/** FNV-1a, 32 bits: a file name for a path; the marker keeps the path itself. */ +function hashOf(text) { + let hash = 0x811c9dc5 + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(16).padStart(8, "0") +} + +/** @param {string} path */ +function folderOf(path) { + const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + return cut <= 0 ? path.slice(0, cut + 1) || "." : path.slice(0, cut) +} + +/** + * Where the marker for `path` lives, or undefined outside a git repository. + * + * @param {Engine} $ + * @param {string} path + */ +async function markerPathOf($, path) { + const git = await $.process.run(["git", "-C", folderOf(path), "rev-parse", "--absolute-git-dir"], { + timeoutMs: GIT_TIMEOUT_MS, + }) + if (git.exitCode !== 0) return undefined + return `${git.stdout.trim()}/${MARKER_DIR}/${hashOf(path)}` +} + +/** + * The marker another session left on `path`, if any. + * + * @param {Engine} $ + * @param {string} markerPath + * @param {string} path + * @returns {Promise} + */ +async function readMarker($, markerPath, path) { + if (!(await $.fs.exists(markerPath))) return undefined + /** @type {Marker} */ + const marker = JSON.parse(await $.fs.read(markerPath)) + return marker.path === path ? marker : undefined +} + +/** + * @param {Marker | undefined} marker + * @param {string} session + * @param {number} now + */ +function noteFor(marker, session, now) { + if (!marker || marker.session === session || now - marker.at > RECENT_MS) return undefined + const minutes = Math.round((now - marker.at) / 60_000) + const ago = minutes === 0 ? "under a minute ago" : `${minutes} min ago` + return ( + `${marker.path} was also edited by the lich session ${marker.session} at ` + + `${new Date(marker.at).toISOString()} (${ago}), which shares this checkout and may still be ` + + `working on it. Re-read the file before building on it, and coordinate with that session through ` + + `send_to_session or lich send rather than undoing its change.` + ) +} + +/** @param {Engine} $ @param {unknown} error */ +function logFailure($, error) { + $.ui.log(`lich edit guard: ${error instanceof Error ? error.message : String(error)}`) +} + +/** + * @param {Engine} $ + * @param {{ file_path?: string, notebook_path?: string }} e + * @param {(e: any) => Promise} next + */ +async function guard($, e, next) { + const session = await $.env.get("LICH_SESSION_ID") + const path = e.file_path ?? e.notebook_path + if (!session || !path) return next(e) + + let markerPath + let previous + try { + markerPath = await markerPathOf($, path) + previous = markerPath && (await readMarker($, markerPath, path)) + } catch (error) { + logFailure($, error) + return next(e) + } + + const result = await next(e) + if (!markerPath || result.deny !== undefined) return result + + let note + try { + const now = await $.clock.now() + note = noteFor(previous, session, now) + if (!result.isError) await $.fs.write(markerPath, JSON.stringify({ path, session, at: now })) + } catch (error) { + logFailure($, error) + } + return note ? { ...result, context: [...(result.context ?? []), note] } : result +} + +/** @param {import('claude-code').On} on */ +export function register(on) { + on("tool.call", { tool: "Edit" }, guard) + on("tool.call", { tool: "Write" }, guard) + on("tool.call", { tool: "NotebookEdit" }, guard) +} diff --git a/hooks/lich.js b/hooks/lich.js index 24fa8bf..ce79d8b 100644 --- a/hooks/lich.js +++ b/hooks/lich.js @@ -3,6 +3,7 @@ // from here. import { register as registerAgentCards } from "./agent-cards.js" +import { register as registerEditGuard } from "./edit-guard.js" import { register as registerModControl } from "./mod-control.js" import { register as registerModUsage } from "./mod-usage.js" @@ -11,4 +12,5 @@ export function register(on) { registerModControl(on) registerAgentCards(on) registerModUsage(on) + registerEditGuard(on) } diff --git a/tests/edit-guard.engine.test.ts b/tests/edit-guard.engine.test.ts new file mode 100644 index 0000000..12d639d --- /dev/null +++ b/tests/edit-guard.engine.test.ts @@ -0,0 +1,64 @@ +// The edit guard under Claude Code's own engine: `claude plugin test .` loads +// hooks/lich.js from hooks/hooks.json, which registers hooks/edit-guard.js. +// The test's hooks stand for git (`process.run`), the checkout's git dir +// (`fs.*`) and the Edit tool itself (the `tool.call` beneath the mod). +// +// tests/edit-guard.test.mjs is the suite CI runs; this one proves the module +// loads beside the other mods and its note reaches the caller as the engine +// relays a tool result. + +import { expect, mock, test } from 'claude-code/testing' +import type { On } from 'claude-code' + +const GIT_DIR = '/w/.git' +const FILE = '/w/src/app.js' +const T0 = Date.parse('2026-10-05T12:00:00.000Z') +const EDIT = { tool: 'Edit', tool_use_id: 'toolu_1', file_path: FILE, old_string: 'a', new_string: 'b' } as const + +/** A checkout another lich session (`lich-a`) edited one minute before T0. */ +function world(on: On, session: string | undefined) { + const disk = new Map() + const edited: string[] = [] + mock.env(on, session ? { LICH_SESSION_ID: session } : {}) + mock.clock(on, { now: T0 }) + on('process.run', async () => ({ + value: { exitCode: 0, stdout: `${GIT_DIR}\n`, stderr: '', isStdoutTruncated: false, isStderrTruncated: false }, + })) + on('fs.exists', async (_$, e) => ({ value: disk.has(e.path) })) + on('fs.read', async (_$, e) => ({ value: disk.get(e.path) ?? '' })) + on('fs.write', async (_$, e) => { + disk.set(e.path, e.text) + return { value: undefined } + }) + on('tool.call', { tool: 'Edit' }, async (_$, e) => { + edited.push(e.file_path) + return { result: { filePath: e.file_path } } + }) + return { disk, edited } +} + +const markerOf = (disk: Map) => [...disk.entries()].find(([k]) => k.startsWith(`${GIT_DIR}/lich-edits/`)) + +test('an edit to a file another lich session just edited goes ahead with a note', async ($, on) => { + const w = world(on, 'lich-a') + await $.tool.call(EDIT) + const [path] = markerOf(w.disk) ?? [] + expect(path).toBeDefined() + w.disk.set(path!, JSON.stringify({ path: FILE, session: 'lich-b', at: T0 - 60_000 })) + + const ran = await $.tool.call(EDIT) + + expect(w.edited).toHaveLength(2) + expect(ran.deny).toBe(undefined) + expect(ran.context?.[0]).toContain('lich session lich-b') + expect(ran.context?.[0]).toContain('send_to_session') + expect(JSON.parse(markerOf(w.disk)![1]).session).toBe('lich-a') +}) + +test('outside lich the edit runs untouched and nothing is recorded', async ($, on) => { + const w = world(on, undefined) + const ran = await $.tool.call(EDIT) + expect(w.edited).toHaveLength(1) + expect(ran.context).toBe(undefined) + expect(w.disk.size).toBe(0) +}) diff --git a/tests/edit-guard.test.mjs b/tests/edit-guard.test.mjs new file mode 100644 index 0000000..1cfc612 --- /dev/null +++ b/tests/edit-guard.test.mjs @@ -0,0 +1,230 @@ +// Pins the Claude Code edit guard (hooks/edit-guard.js): what it records after +// an edit, and when it tells the model another lich session edited the file. +// +// Like the other mod suites it imports the module and hands it a fake engine: +// `$.fs` is an in-memory map, `$.process.run` answers `git rev-parse` for one +// repository, `$.clock.now` is a number the test moves. +// +// Run: node --test tests/ + +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { register } from '../hooks/edit-guard.js' +import { register as registerEntry } from '../hooks/lich.js' + +const REPO = '/work/repo' +const GIT_DIR = `${REPO}/.git` +const FILE = `${REPO}/src/app.js` +const MINUTE = 60_000 +const T0 = Date.parse('2026-10-05T12:00:00.000Z') + +const EDITED = { ref: 1, result: { filePath: FILE }, text: 'The file has been updated successfully.' } +const STALE = { + ref: 2, + isError: true, + result: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', + text: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', +} + +/** + * One Claude Code session with the guard loaded. Sessions built over the same + * `disk` share a checkout. `beneath` answers the call as core would. + */ +function session({ id, disk = new Map(), clock = { now: T0 }, beneath = async () => EDITED, failWrites = false }) { + const hooks = new Map() + register((event, matcher, hook) => { + assert.equal(event, 'tool.call') + hooks.set(matcher.tool, hook) + }) + const logs = [] + const env = id === undefined ? {} : { LICH_SESSION_ID: id } + const $ = { + env: { get: async (name) => env[name] }, + clock: { now: async () => clock.now }, + ui: { log: (text) => logs.push(text) }, + process: { + run: async (argv) => { + assert.deepEqual(argv.slice(0, 1), ['git']) + const dir = argv[2] + const inside = dir === REPO || dir.startsWith(`${REPO}/`) + return inside + ? { exitCode: 0, stdout: `${GIT_DIR}\n`, stderr: '' } + : { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository' } + }, + }, + fs: { + exists: async (path) => disk.has(path), + read: async (path) => { + if (!disk.has(path)) throw new Error(`ENOENT: ${path}`) + return disk.get(path) + }, + write: async (path, text) => { + if (failWrites) throw new Error('EACCES: permission denied') + disk.set(path, text) + }, + }, + } + const call = (tool, input) => { + const hook = hooks.get(tool) + const e = { tool, tool_use_id: 'toolu_1', ...input } + return hook ? hook($, e, beneath) : beneath(e) + } + return { + disk, + clock, + logs, + hooks, + edit: (path = FILE) => call('Edit', { file_path: path, old_string: 'a', new_string: 'b' }), + write: (path = FILE) => call('Write', { file_path: path, content: 'x' }), + notebook: (path) => call('NotebookEdit', { notebook_path: path, new_source: 'x = 1' }), + } +} + +const markers = (disk) => [...disk.keys()].filter((k) => k.startsWith(`${GIT_DIR}/lich-edits/`)) + +test('an edit is recorded under the checkout git dir', async () => { + const a = session({ id: 'lich-a' }) + + const result = await a.edit() + + assert.equal(result, EDITED, 'the result is passed on untouched') + const [marker] = markers(a.disk) + assert.ok(marker, 'no marker written') + assert.deepEqual(JSON.parse(a.disk.get(marker)), { path: FILE, session: 'lich-a', at: T0 }) +}) + +test('another session editing the file within the window is told who and when', async () => { + const disk = new Map() + const clock = { now: T0 } + const a = session({ id: 'lich-a', disk, clock }) + const b = session({ id: 'lich-b', disk, clock }) + + await a.edit() + clock.now = T0 + 3 * MINUTE + const result = await b.edit() + + assert.equal(result.ref, EDITED.ref, 'the edit went ahead') + assert.equal(result.context.length, 1) + assert.match(result.context[0], /lich session lich-a/) + assert.match(result.context[0], /2026-10-05T12:00:00\.000Z/) + assert.match(result.context[0], /3 min ago/) + assert.match(result.context[0], /send_to_session/) + assert.match(result.context[0], /lich send/) + assert.equal(JSON.parse(disk.get(markers(disk)[0])).session, 'lich-b', 'the marker now names the last editor') +}) + +test('the note rides on a refused edit too, where it explains the refusal', async () => { + const disk = new Map() + const a = session({ id: 'lich-a', disk }) + const b = session({ id: 'lich-b', disk, beneath: async () => STALE }) + + await a.edit() + const result = await b.write() + + assert.equal(result.isError, true) + assert.equal(result.text, STALE.text) + assert.match(result.context[0], /lich session lich-a/) + assert.match(result.context[0], /under a minute ago/) + assert.equal(JSON.parse(disk.get(markers(disk)[0])).session, 'lich-a', 'a refused edit is not recorded') +}) + +test('a session re-editing its own file gets no note', async () => { + const a = session({ id: 'lich-a' }) + + await a.edit() + const result = await a.edit() + + assert.equal(result, EDITED) +}) + +test('an edit older than the window gets no note', async () => { + const disk = new Map() + const clock = { now: T0 } + const a = session({ id: 'lich-a', disk, clock }) + const b = session({ id: 'lich-b', disk, clock }) + + await a.edit() + clock.now = T0 + 11 * MINUTE + const result = await b.edit() + + assert.equal(result, EDITED) +}) + +test('another file edited by another session gets no note', async () => { + const disk = new Map() + const a = session({ id: 'lich-a', disk }) + const b = session({ id: 'lich-b', disk }) + + await a.edit(`${REPO}/other.js`) + const result = await b.edit() + + assert.equal(result, EDITED) + assert.equal(markers(disk).length, 2) +}) + +test('Write and NotebookEdit are guarded like Edit', async () => { + const disk = new Map() + const nb = `${REPO}/n.ipynb` + const a = session({ id: 'lich-a', disk }) + const b = session({ id: 'lich-b', disk }) + + await a.write() + await a.notebook(nb) + + assert.match((await b.edit()).context[0], /lich-a/) + assert.match((await b.notebook(nb)).context[0], /lich-a/) +}) + +test('a hook deny beneath is passed on and nothing is recorded', async () => { + const deny = { deny: 'blocked by policy' } + const a = session({ id: 'lich-a', beneath: async () => deny }) + + assert.equal(await a.edit(), deny) + assert.equal(markers(a.disk).length, 0) +}) + +test('outside lich nothing is read or written', async () => { + const a = session({ id: undefined }) + + assert.equal(await a.edit(), EDITED) + assert.equal(a.disk.size, 0) +}) + +test('outside a git repository nothing is written', async () => { + const a = session({ id: 'lich-a' }) + + assert.equal(await a.edit('/elsewhere/notes.txt'), EDITED) + assert.equal(a.disk.size, 0) + assert.deepEqual(a.logs, []) +}) + +test('a file system failure is logged and the edit result still returned', async () => { + const a = session({ id: 'lich-a', failWrites: true }) + + assert.equal(await a.edit(), EDITED) + assert.equal(a.logs.length, 1) + assert.match(a.logs[0], /EACCES/) +}) + +test('an unreadable marker is logged and the edit result still returned', async () => { + const disk = new Map() + const a = session({ id: 'lich-a', disk }) + await a.edit() + disk.set(markers(disk)[0], '{not json') + const b = session({ id: 'lich-b', disk }) + + const result = await b.edit() + + assert.equal(result.ref, EDITED.ref) + assert.equal(result.context, undefined) + assert.equal(b.logs.length, 1) +}) + +test('the plugin entry module registers the guard on every file-editing tool', () => { + const tools = [] + registerEntry((event, matcher) => { + if (event === 'tool.call') tools.push(matcher.tool) + }) + for (const tool of ['Edit', 'Write', 'NotebookEdit']) assert.ok(tools.includes(tool), `${tool} is not guarded`) +})