Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"source": "git-subdir",
"url": "devmaxxx/zerograph",
"path": "agent/claude-code",
"ref": "v0.7.1"
"ref": "v0.7.2"
}
}
]
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion agent/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
53 changes: 43 additions & 10 deletions agent/claude-code/scripts/hook.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -200,16 +200,16 @@ 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.
*/
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. */
Expand Down Expand Up @@ -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]; };
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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));
Expand All @@ -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;
Expand Down
81 changes: 78 additions & 3 deletions agent/claude-code/scripts/hook.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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}'` }] }] } }));
Expand Down
64 changes: 61 additions & 3 deletions agent/claude-code/scripts/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
// cannot branch on it, so the choice between the option and PATH is made here.
//
// Usage: node mcp.mjs '<binary option>'
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';

Expand All @@ -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;
Expand All @@ -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));
}

Expand Down
Loading
Loading