diff --git a/CHANGELOG.md b/CHANGELOG.md index 6706971..cc9721b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **The edit guard names the other session by its card's label.** A note that a + file was also edited by another lich session read that session's id, which + meant nothing to the model or to you; it now reads the label on its card, the + name `lich send` reaches it by, and falls back to the id only when lich cannot + say which session it was. Needs lich 0.60.0, whose `lich sessions --json` lists + session ids. + ## [0.16.0] - 2026-10-05 ### Changed diff --git a/docs/edit-guard.md b/docs/edit-guard.md index f91193c..3a9b186 100644 --- a/docs/edit-guard.md +++ b/docs/edit-guard.md @@ -49,10 +49,19 @@ unless `LICH_SESSION_ID` is set. 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 + after the tool's result: the path, the session, 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. +The line names the session by the label on its card, the name the user and +`lich send` know it by. lich puts no label in a session's environment and no +command tells a session its own, so the marker keeps the id and the label is +looked up when a line is about to be added, never on an edit that gets none: +`$LICH_BIN sessions --json`, two seconds at most, matched on its `id` field +(lich 0.60.0 and later). When the CLI is missing or fails, the session is +gone, or the lich is older and lists no ids, the line names the id +instead. + 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. @@ -65,8 +74,8 @@ guard were not there. - 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. +- A lich older than 0.60.0 lists no session ids in `lich sessions --json`, so + there the line names the session by id. - 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, diff --git a/hooks/edit-guard.js b/hooks/edit-guard.js index aa380d8..809294d 100644 --- a/hooks/edit-guard.js +++ b/hooks/edit-guard.js @@ -16,10 +16,13 @@ const RECENT_MS = 10 * 60_000 const MARKER_DIR = "lich-edits" const GIT_TIMEOUT_MS = 5_000 +// The edit's result waits on it, so it is cut well short of the git timeout. +const LICH_TIMEOUT_MS = 2_000 /** * @typedef {import('claude-code').EngineInterface} Engine * @typedef {{ path: string, session: string, at: number }} Marker + * @typedef {{ label: string, id?: string }} Peer */ /** FNV-1a, 32 bits: a file name for a path; the marker keeps the path itself. */ @@ -71,19 +74,64 @@ async function readMarker($, markerPath, path) { * @param {Marker | undefined} marker * @param {string} session * @param {number} now + * @returns {marker is Marker} */ -function noteFor(marker, session, now) { - if (!marker || marker.session === session || now - marker.at > RECENT_MS) return undefined +function isNews(marker, session, now) { + return !!marker && marker.session !== session && now - marker.at <= RECENT_MS +} + +/** + * The label on the card of the session `id`, or undefined when lich cannot say. + * + * A lich older than the `id` field in `lich sessions --json` lists none, and + * the session goes unnamed there. + * + * @param {Engine} $ + * @param {string} id + */ +async function labelOf($, id) { + const lich = await $.env.get("LICH_BIN") + if (!lich) return undefined + const { exitCode, stdout } = await $.process.run([lich, "sessions", "--json"], { timeoutMs: LICH_TIMEOUT_MS }) + if (exitCode !== 0) return undefined + /** @type {Peer[]} */ + const peers = JSON.parse(stdout) + return peers.find((peer) => peer.id === id)?.label +} + +/** + * @param {Marker} marker + * @param {string} who + * @param {number} now + */ +function noteFor(marker, who, now) { 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 ` + + `${marker.path} was also edited by the lich session ${who} 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.` ) } +/** + * The note for `marker`, naming its session by label when lich can say which. + * + * @param {Engine} $ + * @param {Marker} marker + * @param {number} now + */ +async function noteNaming($, marker, now) { + let label + try { + label = await labelOf($, marker.session) + } catch (error) { + logFailure($, error) + } + return noteFor(marker, label ? `"${label}"` : marker.session, now) +} + /** @param {Engine} $ @param {unknown} error */ function logFailure($, error) { $.ui.log(`lich edit guard: ${error instanceof Error ? error.message : String(error)}`) @@ -115,7 +163,7 @@ async function guard($, e, next) { let note try { const now = await $.clock.now() - note = noteFor(previous, session, now) + if (isNews(previous, session, now)) note = await noteNaming($, previous, now) if (!result.isError) await $.fs.write(markerPath, JSON.stringify({ path, session, at: now })) } catch (error) { logFailure($, error) diff --git a/tests/edit-guard.engine.test.ts b/tests/edit-guard.engine.test.ts index 12d639d..2a78252 100644 --- a/tests/edit-guard.engine.test.ts +++ b/tests/edit-guard.engine.test.ts @@ -1,6 +1,6 @@ // 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 +// The test's hooks stand for git and the lich CLI (`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 @@ -13,17 +13,20 @@ 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 LICH = '/usr/bin/lich' +const B_ID = '7c0e93aa-0b1d-4f52-a8e1-2d9f6c4b3e10' 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) { +function world(on: On, session: string | undefined, roster?: object[]) { const disk = new Map() const edited: string[] = [] - mock.env(on, session ? { LICH_SESSION_ID: session } : {}) + mock.env(on, session ? { LICH_SESSION_ID: session, ...(roster ? { LICH_BIN: LICH } : {}) } : {}) mock.clock(on, { now: T0 }) - on('process.run', async () => ({ - value: { exitCode: 0, stdout: `${GIT_DIR}\n`, stderr: '', isStdoutTruncated: false, isStderrTruncated: false }, - })) + on('process.run', async (_$, e) => { + const stdout = e.argv[0] === LICH ? `${JSON.stringify(roster)}\n` : `${GIT_DIR}\n` + return { value: { exitCode: 0, stdout, 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) => { @@ -55,6 +58,17 @@ test('an edit to a file another lich session just edited goes ahead with a note' expect(JSON.parse(markerOf(w.disk)![1]).session).toBe('lich-a') }) +test('the note names the other session by the label on its card', async ($, on) => { + const w = world(on, 'lich-a', [{ label: 'quiet-comet', name: 'repo-7c0e', project: 'repo', kind: 'claude', state: 'busy', id: '7c0e93aa-0b1d-4f52-a8e1-2d9f6c4b3e10' }]) + await $.tool.call(EDIT) + const [path] = markerOf(w.disk) ?? [] + w.disk.set(path!, JSON.stringify({ path: FILE, session: B_ID, at: T0 - 60_000 })) + + const ran = await $.tool.call(EDIT) + + expect(ran.context?.[0]).toContain('lich session "quiet-comet" at') +}) + test('outside lich the edit runs untouched and nothing is recorded', async ($, on) => { const w = world(on, undefined) const ran = await $.tool.call(EDIT) diff --git a/tests/edit-guard.test.mjs b/tests/edit-guard.test.mjs index 1cfc612..6fe331d 100644 --- a/tests/edit-guard.test.mjs +++ b/tests/edit-guard.test.mjs @@ -3,7 +3,8 @@ // // 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. +// repository and `lich sessions --json` with the roster a test hands it, +// `$.clock.now` is a number the test moves. // // Run: node --test tests/ @@ -20,6 +21,12 @@ 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 LICH = '/usr/bin/lich' +const A_ID = '1fd224be-45cd-4322-9cb9-157b15cdd3cc' +const B_ID = '7c0e93aa-0b1d-4f52-a8e1-2d9f6c4b3e10' +const A_PEER = { label: 'quiet-comet', name: 'repo-1fd2', project: 'repo', kind: 'claude', state: 'busy', id: A_ID } +const OTHER_PEER = { label: 'docs', name: 'repo-9f8e', project: 'repo', kind: 'codex', state: 'done', id: B_ID } + const STALE = { ref: 2, isError: true, @@ -31,20 +38,35 @@ const STALE = { * 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 }) { +function session({ + id, + disk = new Map(), + clock = { now: T0 }, + beneath = async () => EDITED, + failWrites = false, + lich, + roster = async () => ({ exitCode: 0, stdout: '[]\n', stderr: '' }), +}) { 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 = id === undefined ? {} : { LICH_SESSION_ID: id, LICH_BIN: lich } + const lichRuns = [] const $ = { env: { get: async (name) => env[name] }, clock: { now: async () => clock.now }, ui: { log: (text) => logs.push(text) }, process: { - run: async (argv) => { + run: async (argv, options) => { + if (argv[0] === LICH) { + assert.deepEqual(argv, [LICH, 'sessions', '--json']) + assert.ok(options?.timeoutMs > 0, 'the lich CLI runs without a timeout') + lichRuns.push(argv) + return roster() + } assert.deepEqual(argv.slice(0, 1), ['git']) const dir = argv[2] const inside = dir === REPO || dir.startsWith(`${REPO}/`) @@ -75,6 +97,7 @@ function session({ id, disk = new Map(), clock = { now: T0 }, beneath = async () clock, logs, hooks, + lichRuns, 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' }), @@ -221,6 +244,65 @@ test('an unreadable marker is logged and the edit result still returned', async assert.equal(b.logs.length, 1) }) +/** Session A edits, then session B edits the same file a minute later. */ +async function editedByAThenB(options) { + const disk = new Map() + const clock = { now: T0 } + await session({ id: A_ID, disk, clock }).edit() + clock.now = T0 + MINUTE + const b = session({ id: B_ID, disk, clock, lich: LICH, ...options }) + return { b, result: await b.edit() } +} + +test('the note names the other session by the label on its card', async () => { + const { b, result } = await editedByAThenB({ + roster: async () => ({ exitCode: 0, stdout: `${JSON.stringify([OTHER_PEER, A_PEER])}\n`, stderr: '' }), + }) + + assert.match(result.context[0], /lich session "quiet-comet" at/) + assert.doesNotMatch(result.context[0], new RegExp(A_ID)) + assert.equal(b.lichRuns.length, 1) +}) + +test('the note falls back to the session id when the label cannot be learned', async (t) => { + const answers = { + 'the session is gone': async () => ({ exitCode: 0, stdout: `${JSON.stringify([OTHER_PEER])}\n`, stderr: '' }), + 'the CLI fails': async () => ({ exitCode: 1, stdout: '', stderr: 'lich: no running lich' }), + 'the CLI prints no JSON': async () => ({ exitCode: 0, stdout: 'No other live sessions.\n', stderr: '' }), + 'the CLI cannot run': async () => { + throw new Error('timed out') + }, + 'an older lich lists no ids': async () => { + const { id, ...withoutId } = A_PEER + return { exitCode: 0, stdout: `${JSON.stringify([withoutId])}\n`, stderr: '' } + }, + } + for (const [name, roster] of Object.entries(answers)) { + await t.test(name, async () => { + const { result } = await editedByAThenB({ roster }) + + assert.equal(result.ref, EDITED.ref, 'the edit went ahead') + assert.match(result.context[0], new RegExp(`lich session ${A_ID} at`)) + }) + } +}) + +test('without LICH_BIN the note names the session id', async () => { + const { b, result } = await editedByAThenB({ lich: undefined }) + + assert.match(result.context[0], new RegExp(`lich session ${A_ID} at`)) + assert.equal(b.lichRuns.length, 0) +}) + +test('the lich CLI runs only when a note is added', async () => { + const a = session({ id: A_ID, lich: LICH }) + + await a.edit() + await a.edit() + + assert.equal(a.lichRuns.length, 0) +}) + test('the plugin entry module registers the guard on every file-editing tool', () => { const tools = [] registerEntry((event, matcher) => {