From f4be532b2c169756d62eb646d5ab3ab9229b82d6 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 23:08:47 +0200 Subject: [PATCH 1/5] feat(plugin): say 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 got a silent SessionStart and never learned why. In a checkout that asked for an index, built or not, with no binary to run, the hook now shows the person the update notice's style of message, 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. --- agent/claude-code/scripts/hook.mjs | 27 +++++++++++++++++----- agent/claude-code/scripts/hook.test.mjs | 30 ++++++++++++++++++++++--- agent/codex/scripts/hook.mjs | 27 +++++++++++++++++----- docs/keeping-fresh.md | 4 +++- 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/agent/claude-code/scripts/hook.mjs b/agent/claude-code/scripts/hook.mjs index 0fc5e10..8145aaa 100644 --- a/agent/claude-code/scripts/hook.mjs +++ b/agent/claude-code/scripts/hook.mjs @@ -479,14 +479,31 @@ 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}`, + }; +} + /** 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 +580,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 = missingNotice(p.cwd || root) ?? updateNotice(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) => { diff --git a/agent/claude-code/scripts/hook.test.mjs b/agent/claude-code/scripts/hook.test.mjs index 7620a28..4dbf278 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,35 @@ 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('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', () => { diff --git a/agent/codex/scripts/hook.mjs b/agent/codex/scripts/hook.mjs index 0fc5e10..8145aaa 100644 --- a/agent/codex/scripts/hook.mjs +++ b/agent/codex/scripts/hook.mjs @@ -479,14 +479,31 @@ 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}`, + }; +} + /** 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 +580,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 = missingNotice(p.cwd || root) ?? updateNotice(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) => { 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 From 5cf4eb99860dce7fc07b21deccc3dcaace92ec53 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 23:12:05 +0200 Subject: [PATCH 2/5] fix(plugin): stand down where a repository still runs its own zerograph hook The plugin's hook looked for a 0.6.x repository hook only under CLAUDE_PROJECT_DIR, or the cwd without it, so a linked worktree whose session runs the main worktree's settings answered every event a second time. It now looks in the project directory, the checkout and the main worktree, and counts one only where the script and the settings entries that run it sit together. Standing down is still per event and matcher. At SessionStart a stand-down keeps the not-installed and newer-release notices, which the old hook never shows, and drops the brief, serve and update. --- agent/claude-code/scripts/hook.mjs | 28 +++++++++++++++----- agent/claude-code/scripts/hook.test.mjs | 35 +++++++++++++++++++++++++ agent/codex/scripts/hook.mjs | 28 +++++++++++++++----- docs/install.md | 5 +++- 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/agent/claude-code/scripts/hook.mjs b/agent/claude-code/scripts/hook.mjs index 8145aaa..a641266 100644 --- a/agent/claude-code/scripts/hook.mjs +++ b/agent/claude-code/scripts/hook.mjs @@ -504,6 +504,10 @@ function missingNotice(cwd) { }; } +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]; }; @@ -580,7 +584,7 @@ const handlers = { if (!autostartServe(root)) startUpdate(root); text = brief(root); } - const notice = missingNotice(p.cwd || root) ?? updateNotice(root); + const notice = sessionNotice(p, root); if (!notice) return text; return { additionalContext: [text, notice.brief].filter(Boolean).join('\n'), systemMessage: notice.message }; }, @@ -658,9 +662,12 @@ function legacyAnswers(event, payload, root, harness) { return Boolean(path) && isAbsolute(path) && existsSync(path); }); } - 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)); + // A linked worktree may run the main worktree's settings, and the hook may be started without + // CLAUDE_PROJECT_DIR; the script and the entries that run it count only when they sit together. + const checkout = checkoutRoot(root); + const dirs = new Set([process.env.CLAUDE_PROJECT_DIR || root, checkout, checkout && mainCheckout(checkout)].filter(Boolean)); + return [...dirs].some((dir) => existsSync(join(dir, '.claude', 'hooks', LEGACY_HOOK)) + && hookCommands(join(dir, '.claude', 'settings.json'), event, payload).some((cmd) => cmd.includes(LEGACY_HOOK))); } async function readStdin() { @@ -677,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 4dbf278..9dda483 100644 --- a/agent/claude-code/scripts/hook.test.mjs +++ b/agent/claude-code/scripts/hook.test.mjs @@ -616,6 +616,41 @@ 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('a linked worktree stands down on its main worktree\'s 0.6.x hook, and on its own from a subdirectory', () => { + const w = world({ store: false, 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']); + const grep = { hook_event_name: 'PreToolUse', tool_name: 'Grep', tool_input: { pattern: 'cancellation window' } }; + assert.equal(fire(w, grep), null, 'without CLAUDE_PROJECT_DIR the main worktree\'s entries still answer'); + assert.deepEqual(argv(w), []); + + 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') }), null, 'a cd below the checkout still finds its settings'); +}); + +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/codex/scripts/hook.mjs b/agent/codex/scripts/hook.mjs index 8145aaa..a641266 100644 --- a/agent/codex/scripts/hook.mjs +++ b/agent/codex/scripts/hook.mjs @@ -504,6 +504,10 @@ function missingNotice(cwd) { }; } +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]; }; @@ -580,7 +584,7 @@ const handlers = { if (!autostartServe(root)) startUpdate(root); text = brief(root); } - const notice = missingNotice(p.cwd || root) ?? updateNotice(root); + const notice = sessionNotice(p, root); if (!notice) return text; return { additionalContext: [text, notice.brief].filter(Boolean).join('\n'), systemMessage: notice.message }; }, @@ -658,9 +662,12 @@ function legacyAnswers(event, payload, root, harness) { return Boolean(path) && isAbsolute(path) && existsSync(path); }); } - 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)); + // A linked worktree may run the main worktree's settings, and the hook may be started without + // CLAUDE_PROJECT_DIR; the script and the entries that run it count only when they sit together. + const checkout = checkoutRoot(root); + const dirs = new Set([process.env.CLAUDE_PROJECT_DIR || root, checkout, checkout && mainCheckout(checkout)].filter(Boolean)); + return [...dirs].some((dir) => existsSync(join(dir, '.claude', 'hooks', LEGACY_HOOK)) + && hookCommands(join(dir, '.claude', 'settings.json'), event, payload).some((cmd) => cmd.includes(LEGACY_HOOK))); } async function readStdin() { @@ -677,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/docs/install.md b/docs/install.md index 4b2da87..87b4aaf 100644 --- a/docs/install.md +++ b/docs/install.md @@ -69,7 +69,10 @@ is left too, and listed in one line: it is the team's, and serves every teammate 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 +the checkout or, from a linked worktree, in its main worktree; 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. From a3d6d3a9a7253877886feb78d1ffd6764bf9a649 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 23:15:12 +0200 Subject: [PATCH 3/5] feat(plugin): say when this session runs an older zerograph than the machine has A session keeps the MCP server it started with, so after a global upgrade the old binary goes on serving and writing a store the new one owns, and a plugin cannot restart it. mcp.mjs now asks the binary its version at start and reads the server's stdout line by line; on each tool result it stats the file the binary resolves to, asks the version again only when that file moved, and while the installed release differs from the serving one puts one line first in every tool result: zerograph was updated to X (this session runs Y), restart the session or run /reload-plugins. Nothing is stopped or restarted, and every other line passes through unchanged. The launcher now exits once the server's stdout has closed, so the last result is never cut. The hook cannot see the serving release, and the plugin version in the marketplace cache is no reliable marker of what a session loaded, so neither is compared. --- agent/claude-code/scripts/mcp.mjs | 64 ++++++++++++++++++++++++-- agent/claude-code/scripts/mcp.test.mjs | 59 ++++++++++++++++++++++++ agent/codex/scripts/mcp.mjs | 64 ++++++++++++++++++++++++-- docs/mcp.md | 8 ++++ 4 files changed, 189 insertions(+), 6 deletions(-) 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/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/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 From a921d4951375e19d0bdfa9080d8aceebdb7f59d1 Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 23:23:01 +0200 Subject: [PATCH 4/5] fix(plugin): stand down only for the project Claude Code runs, and count a checkout's own index The 0.6.x 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 in this session. Standing down on either left a worktree whose branch retired the hook with no zerograph at all. The plugin now stands down only when the session's project holds both the script and the entry, as before this branch. A main checkout opted in by nothing but its own built index heard nothing about a missing binary, since optedIn counted an index only in a linked worktree's main. It counts the checkout's own now; its other callers run only where the checkout has no index, so they are unchanged. --- agent/claude-code/scripts/hook.mjs | 18 ++++++++-------- agent/claude-code/scripts/hook.test.mjs | 28 +++++++++++++++++++------ agent/codex/scripts/hook.mjs | 18 ++++++++-------- docs/install.md | 4 ++-- 4 files changed, 42 insertions(+), 26 deletions(-) diff --git a/agent/claude-code/scripts/hook.mjs b/agent/claude-code/scripts/hook.mjs index a641266..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. */ @@ -662,12 +662,12 @@ function legacyAnswers(event, payload, root, harness) { return Boolean(path) && isAbsolute(path) && existsSync(path); }); } - // A linked worktree may run the main worktree's settings, and the hook may be started without - // CLAUDE_PROJECT_DIR; the script and the entries that run it count only when they sit together. - const checkout = checkoutRoot(root); - const dirs = new Set([process.env.CLAUDE_PROJECT_DIR || root, checkout, checkout && mainCheckout(checkout)].filter(Boolean)); - return [...dirs].some((dir) => existsSync(join(dir, '.claude', 'hooks', LEGACY_HOOK)) - && hookCommands(join(dir, '.claude', 'settings.json'), event, payload).some((cmd) => cmd.includes(LEGACY_HOOK))); + // 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)); } async function readStdin() { diff --git a/agent/claude-code/scripts/hook.test.mjs b/agent/claude-code/scripts/hook.test.mjs index 9dda483..6762df7 100644 --- a/agent/claude-code/scripts/hook.test.mjs +++ b/agent/claude-code/scripts/hook.test.mjs @@ -522,6 +522,13 @@ test('an opted-in checkout with no binary says so to the person, claims no build } }); +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')); @@ -616,23 +623,32 @@ 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('a linked worktree stands down on its main worktree\'s 0.6.x hook, and on its own from a subdirectory', () => { - const w = world({ store: false, answer: 'FR-PAY-22 docs/a.md:1 cancellation' }); +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']); - const grep = { hook_event_name: 'PreToolUse', tool_name: 'Grep', tool_input: { pattern: 'cancellation window' } }; - assert.equal(fire(w, grep), null, 'without CLAUDE_PROJECT_DIR the main worktree\'s entries still answer'); - assert.deepEqual(argv(w), []); + 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') }), null, 'a cd below the checkout still finds its settings'); + 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', () => { diff --git a/agent/codex/scripts/hook.mjs b/agent/codex/scripts/hook.mjs index a641266..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. */ @@ -662,12 +662,12 @@ function legacyAnswers(event, payload, root, harness) { return Boolean(path) && isAbsolute(path) && existsSync(path); }); } - // A linked worktree may run the main worktree's settings, and the hook may be started without - // CLAUDE_PROJECT_DIR; the script and the entries that run it count only when they sit together. - const checkout = checkoutRoot(root); - const dirs = new Set([process.env.CLAUDE_PROJECT_DIR || root, checkout, checkout && mainCheckout(checkout)].filter(Boolean)); - return [...dirs].some((dir) => existsSync(join(dir, '.claude', 'hooks', LEGACY_HOOK)) - && hookCommands(join(dir, '.claude', 'settings.json'), event, payload).some((cmd) => cmd.includes(LEGACY_HOOK))); + // 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)); } async function readStdin() { diff --git a/docs/install.md b/docs/install.md index 87b4aaf..45b7932 100644 --- a/docs/install.md +++ b/docs/install.md @@ -68,8 +68,8 @@ 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 -the checkout or, from a linked worktree, in its main worktree; in Codex, the entries left in +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 From 47b07a37e77f01b4128a53deca663eeb66a0349f Mon Sep 17 00:00:00 2001 From: devmaxxx Date: Mon, 19 Oct 2026 23:51:10 +0200 Subject: [PATCH 5/5] chore(release): 0.7.2 Cargo.toml, Cargo.lock, both plugin manifests, the marketplace ref, the npm package, the status line in docs/commands.md and the Codex install command move to 0.7.2. docs/releases/0.7.2.md names the not-installed notice, the stale-session line in MCP results, the stand-down that now counts only the session project's own 0.6.x hook, and the checkout's own index opting it in. --- .claude-plugin/marketplace.json | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- agent/claude-code/.claude-plugin/plugin.json | 2 +- agent/codex/.codex-plugin/plugin.json | 2 +- docs/commands.md | 2 +- docs/install.md | 2 +- docs/releases/0.7.2.md | 40 ++++++++++++++++++++ npm/zerograph/package.json | 2 +- 9 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 docs/releases/0.7.2.md 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/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/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 45b7932..fbc2d05 100644 --- a/docs/install.md +++ b/docs/install.md @@ -201,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/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": {