diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f194bf3..d49458b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "source": "git-subdir", "url": "devmaxxx/zerograph", "path": "agent/claude-code", - "ref": "v0.7.1" + "ref": "v0.7.2" } } ] diff --git a/Cargo.lock b/Cargo.lock index 4a88047..8d05cd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3450,7 +3450,7 @@ dependencies = [ [[package]] name = "zerograph" -version = "0.7.1" +version = "0.7.2" dependencies = [ "aho-corasick", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 62b1b4e..83afa92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zerograph" -version = "0.7.1" +version = "0.7.2" edition = "2021" rust-version = "1.98" description = "A project knowledge graph built and queried at zero API tokens" diff --git a/agent/claude-code/.claude-plugin/plugin.json b/agent/claude-code/.claude-plugin/plugin.json index d8257f0..1fd87c3 100644 --- a/agent/claude-code/.claude-plugin/plugin.json +++ b/agent/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "zerograph", - "version": "0.7.1", + "version": "0.7.2", "description": "zerograph in Claude Code: its MCP server, slash commands that call it (ask, impact, trace, changes, explain, status, refresh), a skill that reaches for the graph before grep, and the index's state at the end of the prompt's hint line", "author": { "name": "devmaxxx" diff --git a/agent/claude-code/scripts/hook.mjs b/agent/claude-code/scripts/hook.mjs index 0fc5e10..1856ade 100644 --- a/agent/claude-code/scripts/hook.mjs +++ b/agent/claude-code/scripts/hook.mjs @@ -200,8 +200,8 @@ function checkoutRoot(start) { } /** - * Whether a checkout asked for an index: it carries a `zerograph.toml`, its main worktree has a - * built index, or either holds the hook an older `install-agent` wrote. The plugin runs this hook + * Whether a checkout asked for an index: it carries a `zerograph.toml`, it or its main worktree has + * a built index, or either holds the hook an older `install-agent` wrote. The plugin runs this hook * in every repository a session opens, and a build started in each of them would be the cost a git * hook used to pay on every worktree add. */ @@ -209,7 +209,7 @@ function optedIn(checkout) { const asked = (dir) => ['zerograph.toml', '.claude/hooks/zerograph-hook.mjs', '.codex/hooks/zerograph-hook.mjs'] .some((p) => existsSync(join(dir, ...p.split('/')))); const main = mainCheckout(checkout); - return asked(checkout) || (main !== null && (asked(main) || built(main))); + return asked(checkout) || built(checkout) || (main !== null && (asked(main) || built(main))); } /** The main worktree of a linked one, read off its `.git` file and the `commondir` beside it, or null. */ @@ -479,14 +479,35 @@ function updateNotice(root) { const entry = isExact(current) && Object.hasOwn(versions, current) ? versions[current] : null; const latest = entry?.checked ? entry.latest : null; if (!isExact(latest) || !newer(latest, current)) return null; - const npm = `npm i -g zerograph@${latest}`; - const pnpm = `pnpm add -g zerograph@${latest}`; + const [npm, pnpm] = installCommands(`zerograph@${latest}`); return { brief: `zerograph: ${latest} is out and this is ${current}; \`${npm}\` or \`${pnpm}\`.`, message: `▲ zerograph ${latest} available (you have ${current})\n ${npm} · ${pnpm}`, }; } +function installCommands(spec) { + return [`npm i -g ${spec}`, `pnpm add -g ${spec}`]; +} + +/** + * For a checkout that asked for an index and has no binary to run, built or not: a teammate who got + * the plugin from the repository's settings would otherwise get a silent hook and never learn why. + */ +function missingNotice(cwd) { + const checkout = cwd && checkoutRoot(cwd); + if (!checkout || !optedIn(checkout) || binaryFound(checkout)) return null; + const [npm, pnpm] = installCommands('zerograph'); + return { + brief: `zerograph: this repository uses zerograph and it is not installed here, so \`${COMMAND}\` and the graph tools are unavailable until \`${npm}\` or \`${pnpm}\` installs it.`, + message: `▲ zerograph is not installed — this repository uses it\n ${npm} · ${pnpm}`, + }; +} + +function sessionNotice(p, root) { + return missingNotice(p.cwd || root) ?? updateNotice(root); +} + /** Whether `a` is a later release than `b`: by the three numbers, then a release over its pre-release. */ function newer(a, b) { const split = (v) => { const [core, pre] = v.split('+')[0].split(/-(.*)/s); return [core.split('.').map(Number), pre]; }; @@ -563,9 +584,9 @@ const handlers = { if (!autostartServe(root)) startUpdate(root); text = brief(root); } - const update = updateNotice(root); - if (!update) return text; - return { additionalContext: [text, update.brief].filter(Boolean).join('\n'), systemMessage: update.message }; + const notice = sessionNotice(p, root); + if (!notice) return text; + return { additionalContext: [text, notice.brief].filter(Boolean).join('\n'), systemMessage: notice.message }; }, SubagentStart: (p, root) => (built(root) ? rule() : null), PreToolUse: (p, root, key) => { @@ -641,6 +662,9 @@ function legacyAnswers(event, payload, root, harness) { return Boolean(path) && isAbsolute(path) && existsSync(path); }); } + // Only the project Claude Code read its settings from: the old entries run the script under + // `$CLAUDE_PROJECT_DIR`, so a main worktree's copy, or a nested clone's the session cd'd into, + // answers nothing here. const project = process.env.CLAUDE_PROJECT_DIR || root; return existsSync(join(project, '.claude', 'hooks', LEGACY_HOOK)) && hookCommands(join(project, '.claude', 'settings.json'), event, payload).some((cmd) => cmd.includes(LEGACY_HOOK)); @@ -660,13 +684,22 @@ async function main({ harness = process.argv.includes('--codex') ? 'codex' : 'cl if (!handler) return; // The harness's cwd follows a `cd` into a subdirectory, which has no `.zerograph/` of its own. const root = (payload.cwd && indexRoot(payload.cwd)) || payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); - if (legacyAnswers(event, payload, root, harness)) { debug('legacy hook answers', event); return; } + let answer = handler; + if (legacyAnswers(event, payload, root, harness)) { + debug('legacy hook answers', event); + if (event !== 'SessionStart') return; + // The old hook has no notice of either kind, so standing down keeps those and drops the rest. + answer = (p, r) => { + const notice = sessionNotice(p, r); + return notice && { additionalContext: notice.brief, systemMessage: notice.message }; + }; + } // The subagent's own id is in the key: a subagent shares its parent's session_id, so a counter // keyed by session alone finds the count already past zero on a subagent's first call and says // nothing — which is a second reason 140 unprimed subagents went on grepping. const sessionKey = `${payload.session_id || 'nosession'}-${payload.agent_id || 'main'}`; let out; - try { out = handler(payload, root, sessionKey); } catch (e) { debug('handler', e); return; } + try { out = answer(payload, root, sessionKey); } catch (e) { debug('handler', e); return; } if (!out) return; // `systemMessage` is a top-level field Claude Code shows the person; Codex is given only context. const { systemMessage, ...rest } = typeof out === 'string' ? { additionalContext: out } : out; diff --git a/agent/claude-code/scripts/hook.test.mjs b/agent/claude-code/scripts/hook.test.mjs index 7620a28..6762df7 100644 --- a/agent/claude-code/scripts/hook.test.mjs +++ b/agent/claude-code/scripts/hook.test.mjs @@ -286,7 +286,7 @@ test('a cloned repository that ships an executable node_modules/.bin/zerograph n copyFileSync(w.launcher, join(shim, 'zerograph')); chmodSync(join(shim, 'zerograph'), 0o755); const env = { PATH: join(w.root, 'nothing-here') }; - assert.equal(fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, env), null, 'no binary of its own: no build claimed'); + assert.equal(fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, env).systemMessage, MISSING_MESSAGE, 'no binary of its own: no build claimed'); fire(w, { hook_event_name: 'PreToolUse', tool_name: 'Grep', tool_input: { pattern: 'cancellation window' } }, env); await new Promise((r) => setTimeout(r, 300)); assert.deepEqual(argv(w), [], 'the shipped shim ran'); @@ -506,11 +506,42 @@ test('the plugin\'s binary option names the binary when ZEROGRAPH_BIN does not', assert.equal(c, 'zerograph: 3 doc nodes'); }); -test('an opted-in checkout with no binary to start claims no build', { skip: WIN }, async () => { +const MISSING_MESSAGE = '▲ zerograph is not installed — this repository uses it\n npm i -g zerograph · pnpm add -g zerograph'; + +test('an opted-in checkout with no binary says so to the person, claims no build, and tells the model the tools are out', () => { + for (const store of [false, true]) { + const w = world({ store }); + mkdirSync(join(w.root, '.git')); + writeFileSync(join(w.root, 'zerograph.toml'), ''); + const out = fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, { PATH: join(w.root, 'nothing-here'), ZEROGRAPH_BIN: '' }); + assert.equal(out.systemMessage, MISSING_MESSAGE, `store ${store}`); + const c = context(out); + assert.equal(c.split('\n').length, 1, c); + assert.match(c, /not installed/); + assert.doesNotMatch(c, /being built/, 'nothing was started'); + } +}); + +test('a checkout opted in by nothing but its own index hears that the binary is missing', () => { + const w = world(); + mkdirSync(join(w.root, '.git')); + const out = fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, { PATH: join(w.root, 'nothing-here'), ZEROGRAPH_BIN: '' }); + assert.equal(out?.systemMessage, MISSING_MESSAGE); +}); + +test('the missing binary is only the context line under Codex', () => { const w = world({ store: false }); mkdirSync(join(w.root, '.git')); writeFileSync(join(w.root, 'zerograph.toml'), ''); - assert.equal(fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, { PATH: join(w.root, 'nothing-here') }), null); + const out = fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, { PATH: join(w.root, 'nothing-here'), ZEROGRAPH_BIN: '' }, HOOK, ['--codex']); + assert.equal(out.systemMessage, undefined); + assert.match(context(out), /not installed/); +}); + +test('a repository that never asked for zerograph hears nothing about a missing binary', () => { + const w = world({ store: false }); + mkdirSync(join(w.root, '.git')); + assert.equal(fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, { PATH: join(w.root, 'nothing-here'), ZEROGRAPH_BIN: '' }), null); }); test('while the first session builds, a search is told the build is under way, not to start one', () => { @@ -592,6 +623,50 @@ test('a legacy entry silences the plugin only where its matcher matches', () => assert.ok(argv(w2).some((a) => a.includes('ask')), 'Bash is not in Grep|Glob|Agent, so the plugin asks'); }); +test('only the project Claude Code runs can stand the plugin down: not a main worktree, not a clone the session cd\'d into', () => { + const grep = { hook_event_name: 'PreToolUse', tool_name: 'Grep', tool_input: { pattern: 'cancellation window' } }; + // The old entries run `$CLAUDE_PROJECT_DIR/.claude/hooks/zerograph-hook.mjs`, so a worktree whose + // branch retired the script runs nothing of its main's. + const w = world({ answer: 'FR-PAY-22 docs/a.md:1 cancellation' }); + const main = mkdtempSync(join(tmpdir(), 'zerograph-main-')); + const linked = join(main, '.git', 'worktrees', 'wt'); + mkdirSync(linked, { recursive: true }); + writeFileSync(join(linked, 'commondir'), '../..\n'); + writeFileSync(join(w.root, '.git'), `gitdir: ${linked}\n`); + legacyClaude({ root: main }, ['PreToolUse']); + fire(w, grep, { CLAUDE_PROJECT_DIR: w.root }); + assert.ok(argv(w).some((a) => a.includes('ask')), 'the plugin answers the worktree'); + + const a = world({ store: false }); + mkdirSync(join(a.root, '.git')); + const b = join(a.root, 'vendor', 'b'); + mkdirSync(join(b, '.git'), { recursive: true }); + legacyClaude({ root: b }, ['PreToolUse']); + assert.notEqual(fire(a, { ...grep, cwd: b }, { CLAUDE_PROJECT_DIR: a.root }), null, 'B\'s settings are not the session\'s'); + + const own = world({ answer: 'FR-PAY-22 docs/a.md:1 cancellation' }); + mkdirSync(join(own.root, '.git')); + mkdirSync(join(own.root, 'apps', 'web'), { recursive: true }); + legacyClaude(own, ['PreToolUse']); + assert.equal(fire(own, { ...grep, cwd: join(own.root, 'apps', 'web') }, { CLAUDE_PROJECT_DIR: own.root }), null, 'a cd below the project changes nothing'); +}); + +test('standing down at SessionStart keeps the notices the 0.6.x hook never shows, and nothing else', () => { + const w = world({ answer: 'zerograph: 3076 doc nodes, 5240 code nodes' }); + legacyClaude(w, ['SessionStart']); + const out = fire(w, { hook_event_name: 'SessionStart', source: 'startup' }, { ...updateHome(w, CACHED), CLAUDE_PROJECT_DIR: w.root }); + assert.match(out.systemMessage, /^▲ zerograph 0\.7\.1 available/); + assert.match(context(out), /^zerograph: 0\.7\.1 is out/, 'no brief: the old hook gave it'); + assert.deepEqual(argv(w).map((l) => l.split(' ').pop()), ['--version'], 'no serve, no update, no prime'); + + const bare = world({ store: false }); + mkdirSync(join(bare.root, '.git')); + legacyClaude(bare, ['SessionStart']); + const missing = fire(bare, { hook_event_name: 'SessionStart', source: 'startup' }, + { CLAUDE_PROJECT_DIR: bare.root, PATH: join(bare.root, 'nothing-here'), ZEROGRAPH_BIN: '' }); + assert.equal(missing.systemMessage, MISSING_MESSAGE); +}); + /** A machine `hooks.json` as 0.6.x printed it for Codex, running `script` as spelled. */ function legacyCodex(home, spelled, matcher = 'startup|resume|clear|compact') { writeFileSync(join(home, 'hooks.json'), JSON.stringify({ hooks: { SessionStart: [{ matcher, hooks: [{ type: 'command', command: `node '${spelled}'` }] }] } })); diff --git a/agent/claude-code/scripts/mcp.mjs b/agent/claude-code/scripts/mcp.mjs index 398b69d..b82162f 100644 --- a/agent/claude-code/scripts/mcp.mjs +++ b/agent/claude-code/scripts/mcp.mjs @@ -4,7 +4,8 @@ // cannot branch on it, so the choice between the option and PATH is made here. // // Usage: node mcp.mjs '' -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { statSync } from 'node:fs'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -17,6 +18,50 @@ export function argvOf(bin, checkout, comspec) { return commandOf(bin, ['--repo', checkout, 'mcp'], comspec); } +/** The release `bin` reports, or null when it cannot say. */ +function versionOf(bin, comspec) { + const c = commandOf(bin, ['--version'], comspec); + if (!c.argv) return null; + const r = spawnSync(c.argv[0], c.argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', timeout: 5000, windowsHide: true }); + return r.stdout?.trim().match(/^zerograph (\S+)$/)?.[1] ?? null; +} + +/** + * A line naming the release now installed at `bin` when it is not the one this server started, or + * null. A session keeps its server for its whole life and a plugin cannot restart it, so after an + * upgrade the old binary goes on writing a store the new one owns; all that can be done is to say + * so. The binary is asked again only when the file it resolves to has moved, which an install over + * it always does, so a tool call pays one stat. + */ +function driftCheck(bin, comspec) { + const where = () => { try { const r = real(bin); const st = statSync(r); return `${r}:${st.ino}:${st.mtimeMs}:${st.size}`; } catch { return null; } }; + const serving = versionOf(bin, comspec); + let seen = where(); + let line = null; + return () => { + const now = where(); + if (serving && now !== seen) { + seen = now; + const installed = versionOf(bin, comspec); + line = installed && installed !== serving + ? `zerograph was updated to ${installed} (this session runs ${serving}) — restart the session or run /reload-plugins` + : null; + } + return line; + }; +} + +/** One protocol line from the server, with `line` put first in a tool result's content. */ +function withLine(raw, line) { + if (!line || !raw.includes('"content"')) return raw; + try { + const msg = JSON.parse(raw); + if (!Array.isArray(msg?.result?.content)) return raw; + msg.result.content.unshift({ type: 'text', text: line }); + return JSON.stringify(msg); + } catch { return raw; } +} + export function main() { const win = process.platform === 'win32'; const env = process.env; @@ -33,13 +78,26 @@ export function main() { process.stderr.write(`zerograph: ${launch.error}\n`); process.exit(1); } + const drift = driftCheck(found.bin, env.ComSpec); const [cmd, ...args] = launch.argv; // Never from the checkout: npm's `.cmd` shim starts a bare `node`, and `cmd` looks for that in // the working directory before PATH. The cut PATH keeps a checkout's `node_modules/.bin/node` // from being the one the shim, or anything the server starts, finds. - const child = spawn(cmd, args, { stdio: 'inherit', cwd: dirname(found.bin), windowsHide: true, env: childEnv(env, checkout, win) }); + // stdout is read line by line, the stdio transport's framing, so a tool result can carry the line. + const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'], cwd: dirname(found.bin), windowsHide: true, env: childEnv(env, checkout, win) }); + let pending = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + pending += chunk; + const lines = pending.split('\n'); + pending = lines.pop(); + const line = lines.length ? drift() : null; + if (lines.length) process.stdout.write(lines.map((l) => withLine(l, line)).join('\n') + '\n'); + }); + child.stdout.on('end', () => { if (pending) process.stdout.write(pending); }); child.on('error', err => { process.stderr.write(`zerograph: ${found.bin}: ${err.message}\n`); process.exit(1); }); - child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0))); + // `close`, not `exit`, and no process.exit: the last result may still be on its way to stdout. + child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); }); for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => child.kill(sig)); } diff --git a/agent/claude-code/scripts/mcp.test.mjs b/agent/claude-code/scripts/mcp.test.mjs index 0b0db21..11c9979 100644 --- a/agent/claude-code/scripts/mcp.test.mjs +++ b/agent/claude-code/scripts/mcp.test.mjs @@ -197,3 +197,62 @@ test('a session opened through a link finds the checkout by its real path, as th symlinkSync(join(w.checkout, 'packages', 'app'), link); assert.strictEqual(checkoutOf(link), w.checkout); }); + +/** + * A fake that reports the version in `version.txt` beside it and answers every tool call; before + * answering the request named `upgradeAt` it writes `next` there and moves its own mtime, as an + * install over it would. + */ +function versioned(w, { first = '0.7.1', next = '0.8.0', upgradeAt = 0 } = {}) { + const fake = join(w.machine, 'zerograph'); + const versionFile = join(w.machine, 'version.txt'); + writeFileSync(versionFile, first); + writeFileSync(fake, `#!/usr/bin/env node +const { readFileSync, writeFileSync, utimesSync } = require('node:fs'); +if (process.argv.includes('--version')) { process.stdout.write('zerograph ' + readFileSync(${JSON.stringify(versionFile)}, 'utf8') + '\\n'); process.exit(0); } +let buf = ''; +process.stdin.on('data', (d) => { buf += d; }); +// One answer per 150 ms, so the launcher has read each before the next request can upgrade the file. +process.stdin.on('end', async () => { + for (const raw of buf.trim().split('\\n')) { + const req = JSON.parse(raw); + if (req.id === ${upgradeAt}) { writeFileSync(${JSON.stringify(versionFile)}, ${JSON.stringify(next)}); utimesSync(__filename, new Date(), new Date(Date.now() + 60000)); } + const result = req.method === 'tools/call' ? { content: [{ type: 'text', text: 'answer ' + req.id }] } : {}; + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); + await new Promise((r) => setTimeout(r, 150)); + } +}); +`); + chmodSync(fake, 0o755); + return (requests) => { + const r = spawnSync(process.execPath, [LAUNCHER, ''], { + cwd: w.base, + input: requests.map((q) => JSON.stringify({ jsonrpc: '2.0', ...q }) + '\n').join(''), + env: { PATH: [dirname(process.execPath), w.machine].join(sep), CLAUDE_PROJECT_DIR: w.checkout }, + encoding: 'utf8', + }); + assert.strictEqual(r.status, 0, r.stderr); + return r.stdout.trim().split('\n').map((l) => JSON.parse(l)); + }; +} + +const call = (id) => ({ id, method: 'tools/call', params: { name: 'status', arguments: {} } }); +const STALE = 'zerograph was updated to 0.8.0 (this session runs 0.7.1) — restart the session or run /reload-plugins'; + +test('a binary updated under a running server is named on every tool result after it, as one line', { skip: WIN && 'a POSIX launcher for the fake' }, () => { + const w = world(); + const out = versioned(w, { upgradeAt: 2 })([call(1), { id: 9, method: 'ping' }, call(2), call(3)]); + const texts = (id) => out.find((m) => m.id === id).result.content?.map((c) => c.text); + assert.deepStrictEqual(texts(1), ['answer 1']); + assert.deepStrictEqual(out.find((m) => m.id === 9).result, {}, 'a result with no content is not a tool result'); + assert.deepStrictEqual(texts(2), [STALE, 'answer 2']); + assert.deepStrictEqual(texts(3), [STALE, 'answer 3']); +}); + +test('a binary that did not move leaves every result as the server wrote it', { skip: WIN && 'a POSIX launcher for the fake' }, () => { + const w = world(); + const out = versioned(w)([call(1), call(2)]); + assert.deepStrictEqual(out.map((m) => m.result.content.map((c) => c.text)), [['answer 1'], ['answer 2']]); + const same = versioned(world(), { next: '0.7.1', upgradeAt: 1 })([call(1), call(2)]); + assert.deepStrictEqual(same.map((m) => m.result.content.length), [1, 1], 'touched but the same release'); +}); diff --git a/agent/codex/.codex-plugin/plugin.json b/agent/codex/.codex-plugin/plugin.json index efd62c2..781e9df 100644 --- a/agent/codex/.codex-plugin/plugin.json +++ b/agent/codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "zerograph", - "version": "0.7.1", + "version": "0.7.2", "description": "zerograph in Codex: its MCP server, skills that call it (ask, impact, trace, changes, explain, status, refresh), a skill that reaches for the graph before grep, and a session hook that keeps the index fresh and hands every subagent the rule", "author": { "name": "devmaxxx" }, "homepage": "https://github.com/devmaxxx/zerograph", diff --git a/agent/codex/scripts/hook.mjs b/agent/codex/scripts/hook.mjs index 0fc5e10..1856ade 100644 --- a/agent/codex/scripts/hook.mjs +++ b/agent/codex/scripts/hook.mjs @@ -200,8 +200,8 @@ function checkoutRoot(start) { } /** - * Whether a checkout asked for an index: it carries a `zerograph.toml`, its main worktree has a - * built index, or either holds the hook an older `install-agent` wrote. The plugin runs this hook + * Whether a checkout asked for an index: it carries a `zerograph.toml`, it or its main worktree has + * a built index, or either holds the hook an older `install-agent` wrote. The plugin runs this hook * in every repository a session opens, and a build started in each of them would be the cost a git * hook used to pay on every worktree add. */ @@ -209,7 +209,7 @@ function optedIn(checkout) { const asked = (dir) => ['zerograph.toml', '.claude/hooks/zerograph-hook.mjs', '.codex/hooks/zerograph-hook.mjs'] .some((p) => existsSync(join(dir, ...p.split('/')))); const main = mainCheckout(checkout); - return asked(checkout) || (main !== null && (asked(main) || built(main))); + return asked(checkout) || built(checkout) || (main !== null && (asked(main) || built(main))); } /** The main worktree of a linked one, read off its `.git` file and the `commondir` beside it, or null. */ @@ -479,14 +479,35 @@ function updateNotice(root) { const entry = isExact(current) && Object.hasOwn(versions, current) ? versions[current] : null; const latest = entry?.checked ? entry.latest : null; if (!isExact(latest) || !newer(latest, current)) return null; - const npm = `npm i -g zerograph@${latest}`; - const pnpm = `pnpm add -g zerograph@${latest}`; + const [npm, pnpm] = installCommands(`zerograph@${latest}`); return { brief: `zerograph: ${latest} is out and this is ${current}; \`${npm}\` or \`${pnpm}\`.`, message: `▲ zerograph ${latest} available (you have ${current})\n ${npm} · ${pnpm}`, }; } +function installCommands(spec) { + return [`npm i -g ${spec}`, `pnpm add -g ${spec}`]; +} + +/** + * For a checkout that asked for an index and has no binary to run, built or not: a teammate who got + * the plugin from the repository's settings would otherwise get a silent hook and never learn why. + */ +function missingNotice(cwd) { + const checkout = cwd && checkoutRoot(cwd); + if (!checkout || !optedIn(checkout) || binaryFound(checkout)) return null; + const [npm, pnpm] = installCommands('zerograph'); + return { + brief: `zerograph: this repository uses zerograph and it is not installed here, so \`${COMMAND}\` and the graph tools are unavailable until \`${npm}\` or \`${pnpm}\` installs it.`, + message: `▲ zerograph is not installed — this repository uses it\n ${npm} · ${pnpm}`, + }; +} + +function sessionNotice(p, root) { + return missingNotice(p.cwd || root) ?? updateNotice(root); +} + /** Whether `a` is a later release than `b`: by the three numbers, then a release over its pre-release. */ function newer(a, b) { const split = (v) => { const [core, pre] = v.split('+')[0].split(/-(.*)/s); return [core.split('.').map(Number), pre]; }; @@ -563,9 +584,9 @@ const handlers = { if (!autostartServe(root)) startUpdate(root); text = brief(root); } - const update = updateNotice(root); - if (!update) return text; - return { additionalContext: [text, update.brief].filter(Boolean).join('\n'), systemMessage: update.message }; + const notice = sessionNotice(p, root); + if (!notice) return text; + return { additionalContext: [text, notice.brief].filter(Boolean).join('\n'), systemMessage: notice.message }; }, SubagentStart: (p, root) => (built(root) ? rule() : null), PreToolUse: (p, root, key) => { @@ -641,6 +662,9 @@ function legacyAnswers(event, payload, root, harness) { return Boolean(path) && isAbsolute(path) && existsSync(path); }); } + // Only the project Claude Code read its settings from: the old entries run the script under + // `$CLAUDE_PROJECT_DIR`, so a main worktree's copy, or a nested clone's the session cd'd into, + // answers nothing here. const project = process.env.CLAUDE_PROJECT_DIR || root; return existsSync(join(project, '.claude', 'hooks', LEGACY_HOOK)) && hookCommands(join(project, '.claude', 'settings.json'), event, payload).some((cmd) => cmd.includes(LEGACY_HOOK)); @@ -660,13 +684,22 @@ async function main({ harness = process.argv.includes('--codex') ? 'codex' : 'cl if (!handler) return; // The harness's cwd follows a `cd` into a subdirectory, which has no `.zerograph/` of its own. const root = (payload.cwd && indexRoot(payload.cwd)) || payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(); - if (legacyAnswers(event, payload, root, harness)) { debug('legacy hook answers', event); return; } + let answer = handler; + if (legacyAnswers(event, payload, root, harness)) { + debug('legacy hook answers', event); + if (event !== 'SessionStart') return; + // The old hook has no notice of either kind, so standing down keeps those and drops the rest. + answer = (p, r) => { + const notice = sessionNotice(p, r); + return notice && { additionalContext: notice.brief, systemMessage: notice.message }; + }; + } // The subagent's own id is in the key: a subagent shares its parent's session_id, so a counter // keyed by session alone finds the count already past zero on a subagent's first call and says // nothing — which is a second reason 140 unprimed subagents went on grepping. const sessionKey = `${payload.session_id || 'nosession'}-${payload.agent_id || 'main'}`; let out; - try { out = handler(payload, root, sessionKey); } catch (e) { debug('handler', e); return; } + try { out = answer(payload, root, sessionKey); } catch (e) { debug('handler', e); return; } if (!out) return; // `systemMessage` is a top-level field Claude Code shows the person; Codex is given only context. const { systemMessage, ...rest } = typeof out === 'string' ? { additionalContext: out } : out; diff --git a/agent/codex/scripts/mcp.mjs b/agent/codex/scripts/mcp.mjs index 398b69d..b82162f 100644 --- a/agent/codex/scripts/mcp.mjs +++ b/agent/codex/scripts/mcp.mjs @@ -4,7 +4,8 @@ // cannot branch on it, so the choice between the option and PATH is made here. // // Usage: node mcp.mjs '' -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; +import { statSync } from 'node:fs'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -17,6 +18,50 @@ export function argvOf(bin, checkout, comspec) { return commandOf(bin, ['--repo', checkout, 'mcp'], comspec); } +/** The release `bin` reports, or null when it cannot say. */ +function versionOf(bin, comspec) { + const c = commandOf(bin, ['--version'], comspec); + if (!c.argv) return null; + const r = spawnSync(c.argv[0], c.argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', timeout: 5000, windowsHide: true }); + return r.stdout?.trim().match(/^zerograph (\S+)$/)?.[1] ?? null; +} + +/** + * A line naming the release now installed at `bin` when it is not the one this server started, or + * null. A session keeps its server for its whole life and a plugin cannot restart it, so after an + * upgrade the old binary goes on writing a store the new one owns; all that can be done is to say + * so. The binary is asked again only when the file it resolves to has moved, which an install over + * it always does, so a tool call pays one stat. + */ +function driftCheck(bin, comspec) { + const where = () => { try { const r = real(bin); const st = statSync(r); return `${r}:${st.ino}:${st.mtimeMs}:${st.size}`; } catch { return null; } }; + const serving = versionOf(bin, comspec); + let seen = where(); + let line = null; + return () => { + const now = where(); + if (serving && now !== seen) { + seen = now; + const installed = versionOf(bin, comspec); + line = installed && installed !== serving + ? `zerograph was updated to ${installed} (this session runs ${serving}) — restart the session or run /reload-plugins` + : null; + } + return line; + }; +} + +/** One protocol line from the server, with `line` put first in a tool result's content. */ +function withLine(raw, line) { + if (!line || !raw.includes('"content"')) return raw; + try { + const msg = JSON.parse(raw); + if (!Array.isArray(msg?.result?.content)) return raw; + msg.result.content.unshift({ type: 'text', text: line }); + return JSON.stringify(msg); + } catch { return raw; } +} + export function main() { const win = process.platform === 'win32'; const env = process.env; @@ -33,13 +78,26 @@ export function main() { process.stderr.write(`zerograph: ${launch.error}\n`); process.exit(1); } + const drift = driftCheck(found.bin, env.ComSpec); const [cmd, ...args] = launch.argv; // Never from the checkout: npm's `.cmd` shim starts a bare `node`, and `cmd` looks for that in // the working directory before PATH. The cut PATH keeps a checkout's `node_modules/.bin/node` // from being the one the shim, or anything the server starts, finds. - const child = spawn(cmd, args, { stdio: 'inherit', cwd: dirname(found.bin), windowsHide: true, env: childEnv(env, checkout, win) }); + // stdout is read line by line, the stdio transport's framing, so a tool result can carry the line. + const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'], cwd: dirname(found.bin), windowsHide: true, env: childEnv(env, checkout, win) }); + let pending = ''; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + pending += chunk; + const lines = pending.split('\n'); + pending = lines.pop(); + const line = lines.length ? drift() : null; + if (lines.length) process.stdout.write(lines.map((l) => withLine(l, line)).join('\n') + '\n'); + }); + child.stdout.on('end', () => { if (pending) process.stdout.write(pending); }); child.on('error', err => { process.stderr.write(`zerograph: ${found.bin}: ${err.message}\n`); process.exit(1); }); - child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0))); + // `close`, not `exit`, and no process.exit: the last result may still be on its way to stdout. + child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); }); for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => child.kill(sig)); } diff --git a/docs/commands.md b/docs/commands.md index 45ea1d0..b3355d4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -2,7 +2,7 @@ ## Status -0.7.1 is the version `main` carries. Its default globs read TypeScript and JavaScript, Kotlin, Java, +0.7.2 is the version `main` carries. Its default globs read TypeScript and JavaScript, Kotlin, Java, C#, Rust, Python, Dart, Swift, GraphQL, SQL, Bicep, HCL, Shell, Vue, Lua, Go, C, C++, PHP, Ruby, Scala, Elixir and OCaml; every command below is implemented rather than planned: diff --git a/docs/install.md b/docs/install.md index 4b2da87..fbc2d05 100644 --- a/docs/install.md +++ b/docs/install.md @@ -68,8 +68,11 @@ somebody edited, a link, or a directory with files of its own is left and named. is left too, and listed in one line: it is the team's, and serves every teammate who has no plugin yet. Once all of them have it, `zerograph install-agent --claude --retire-repo` (or `--codex --retire-repo`) takes those out as well, for a commit of their own. Until then the plugin's hook -stays silent on every event the committed entries in `.claude/settings.json` still answer — in -Codex, the entries left in `$CODEX_HOME/hooks.json` — so a session hears one brief, not two. Both take the `post-merge`/`post-checkout` block an earlier release wrote out of the git +stays silent on every event the committed entries in the session project's +`.claude/settings.json` still answer, with the script beside them; in Codex, the entries left in +`$CODEX_HOME/hooks.json` — so a session hears one brief, not two. At session start it still shows +the two notices the old hook has no word for: that zerograph is not installed, and that a newer +release is out. Both take the `post-merge`/`post-checkout` block an earlier release wrote out of the git hooks, deleting a hook that held nothing else. The install runs only once the plugin installed: when the `claude` or `codex` CLI is missing or a step fails, it prints the commands to run by hand and leaves the older install in place. @@ -198,7 +201,7 @@ to `agent/claude-code/scripts/` reaches the Codex plugin's copies through `agent/codex/` is the same integration as a Codex plugin, listed by `.agents/plugins/marketplace.json`: ```bash -codex plugin marketplace add devmaxxx/zerograph --ref v0.7.1 +codex plugin marketplace add devmaxxx/zerograph --ref v0.7.2 codex plugin add zerograph@zerograph ``` diff --git a/docs/keeping-fresh.md b/docs/keeping-fresh.md index 188d82c..a9b53d1 100644 --- a/docs/keeping-fresh.md +++ b/docs/keeping-fresh.md @@ -147,7 +147,9 @@ inside the checkout: a cloned repository that commits a `zerograph.toml` and an `node_modules/.bin/zerograph` would otherwise have that file started as soon as a session opened in it. A project-local install (`pnpm add -D zerograph`) puts no `zerograph` on `PATH`, so set `ZEROGRAPH_BIN` to its absolute path, written out, in the environment the agent starts from, or -install globally; without either the hook stays silent. A relative `ZEROGRAPH_BIN`, and a `PATH` +install globally. Without either, a checkout that asked for an index hears at session start that +zerograph is not installed, with the two install commands, and nothing more; any other stays +silent. A relative `ZEROGRAPH_BIN`, and a `PATH` entry that is relative or lies anywhere inside the checkout, are skipped, since each names the checkout's own file. On Windows a `zerograph.exe` anywhere on `PATH` is taken before npm's `zerograph.cmd` shim, which only `cmd` can start; when the shim is all there is, a call that runs diff --git a/docs/mcp.md b/docs/mcp.md index 9f01759..8595dc3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -15,6 +15,14 @@ project directory (`CLAUDE_PROJECT_DIR` under Claude Code, the working directory in) and runs `zerograph --repo mcp`, so a session opened in a package of a monorepo serves the whole repository. +A session keeps the server it started with, and a plugin cannot restart it, so after `npm i -g +zerograph` or `pnpm add -g zerograph` the old binary goes on serving, and writing a store the new +one owns. `mcp.mjs` asks the binary its version at start and, on each tool result, looks at the +file it resolved to; when that file has moved and now reports another release, every tool result +from then on begins with one line, `zerograph was updated to X (this session runs Y) — restart the +session or run /reload-plugins`. Nothing is stopped or restarted. The hook cannot see which release +a session's server runs, so the line comes only from the server. + Releases before 0.7.0 registered the server through `claude mcp add` at local scope, once per worktree. A re-run of `install-agent --claude` removes those registrations from `~/.claude.json` (or `.claude.json` in `CLAUDE_CONFIG_DIR`) for the checkout and its worktrees, and for a checkout diff --git a/docs/releases/0.7.2.md b/docs/releases/0.7.2.md new file mode 100644 index 0000000..03a60af --- /dev/null +++ b/docs/releases/0.7.2.md @@ -0,0 +1,40 @@ +# zerograph 0.7.2 + +The release notes for the GitHub release page. The why behind each change is in the docs each line +links to. + +## New + +- **Claude Code says when a repository wants zerograph and it is not installed.** A teammate who + gets the plugin from a repository's project settings but has no zerograph binary used to get a + silent session start. In a checkout that asked for an index, built or not, with no binary to run, + the hook now shows the person a message in the update notice's style naming the npm and pnpm + install commands, and tells the agent in one context line that the graph tools are unavailable + until it is installed. A repository that never asked stays silent; Codex gets the context line + only ([keeping fresh](../keeping-fresh.md)). +- **A session that runs an older zerograph than the machine has says so.** A session keeps the MCP + server it started with, so after `npm i -g zerograph` or `pnpm add -g zerograph` the old binary + goes on serving. `mcp.mjs` asks the binary its version at start and, when the file it resolved + to has moved and reports another release, begins every tool result from then on with + + ``` + zerograph was updated to 0.7.3 (this session runs 0.7.2) — restart the session or run /reload-plugins + ``` + + Nothing is stopped or restarted, and every other line passes through unchanged + ([MCP](../mcp.md)). + +## Changed + +- **The plugin stands down only for the project Claude Code runs.** While a repository still + commits a 0.6.x zerograph hook, the plugin's hook stays silent on the events it answers only when + the session's project directory (`CLAUDE_PROJECT_DIR`) holds both the script and the + `.claude/settings.json` entries that run it. A main worktree's copy, or a nested clone's, no + longer silences a linked worktree whose branch retired the hook. At session start a stand-down + still shows the not-installed and newer-release notices, which the old hook has no word for + ([install](../install.md)). +- **A checkout's own index opts it in.** A main checkout that asked for nothing but has a built + index of its own now counts as wanting zerograph, so it hears the not-installed notice too; + before, only a linked worktree's main index counted. +- **The MCP launcher exits once the server's stdout has closed**, so the last tool result is never + cut. diff --git a/npm/zerograph/package.json b/npm/zerograph/package.json index c17784d..15a2639 100644 --- a/npm/zerograph/package.json +++ b/npm/zerograph/package.json @@ -1,6 +1,6 @@ { "name": "zerograph", - "version": "0.7.1", + "version": "0.7.2", "description": "Knowledge graph of a repository's requirement docs and code in 24 languages, built and refreshed at zero model tokens, asked by id, keyword or paraphrase", "license": "MIT", "repository": {