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
15 changes: 12 additions & 3 deletions bundle/.claude/hooks/gitnexus-mcp-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const lib = (rel) =>

const { gnContext, emitVerdict } = await lib("claude-emit.mjs");
const { setMcpToolUsed, bumpScore } = await lib("session-primer.mjs");
const { classifyMcpDrift } = await lib("classify.mjs");

const ctx = gnContext(root);
const tool = input.tool_name ?? "";
Expand All @@ -31,7 +32,15 @@ if (ctx.phase === "must_refresh") {
{ root, mode: ctx.config.mode },
);
} else {
// Record the graph call so the impact/detect gates clear; then allow silently.
setMcpToolUsed(root, tool);
bumpScore(root, "graphCalls");
// Drift gate (classifyMcpDrift enforces phase === "fresh" itself): on a commit-fresh index a
// graph QUERY tool would return stale results that ignore the agent's uncommitted edits →
// require a fast incremental refresh.
const drift = classifyMcpDrift(tool, ctx.stale, ctx.config, ctx.phase);
if (drift.decision === "deny") {
emitVerdict(drift, { root, mode: ctx.config.mode });
} else {
// Record the graph call so the impact/detect gates clear; then allow silently.
setMcpToolUsed(root, tool);
bumpScore(root, "graphCalls");
}
}
17 changes: 17 additions & 0 deletions bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const { evaluateStalePolicy, staleRefreshAgentMessage } = await import(
const { setMcpToolUsed, bumpScore } = await import(
pathToFileURL(path.join(root, '.gnkit/lib/session-primer.mjs')).href
);
const { classifyMcpDrift } = await import(
pathToFileURL(path.join(root, '.gnkit/lib/classify.mjs')).href
);

const config = helpers.loadHookConfig(root);
const isGitnexus =
Expand Down Expand Up @@ -54,6 +57,20 @@ if (policy.phase === 'must_refresh') {
process.exit(0);
}

// Drift gate (classifyMcpDrift enforces phase === 'fresh' itself, so never fires during
// classical_fallback). A graph QUERY tool on a drifted-but-fresh index would ignore the
// agent's uncommitted edits → require a fast incremental refresh.
const drift = classifyMcpDrift(tool, stale, config, policy.phase);
if (drift.decision === 'deny') {
bumpScore(root, 'driftRefreshBlocks');
out({
permission: 'deny',
agent_message: drift.agentMessage,
user_message: helpers.userMessage('drift.refresh'),
});
process.exit(0);
}

setMcpToolUsed(root, `${tool} ${url} ${cmd}`);
bumpScore(root, 'graphCalls');

Expand Down
2 changes: 2 additions & 0 deletions bundle/.cursor/rules/00-gitnexus-enforcement.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ stale → agent-refresh (Shell, pre-approved)

Session start runs auto-refresh when stale. Do **not** grep/read “while refreshing” — refresh is the next tool, not a background hint.

**Mid-session drift (your own edits):** commit-equality can't see uncommitted edits, so after you change a few source files the graph silently falls behind your working tree. Don't wait for the block — once you've edited code and are about to `query`/`context`/`impact`/`cypher`/`pdg_query` again, run **`npm run gitnexus:refresh`** (**incremental** — reindexes only your changed files; quick for a few edits, longer on large batches / first run) so graph answers reflect your changes. Graph query tools hard-block past a small drift threshold until you do.

## Gates (do not skip — every task)

```
Expand Down
50 changes: 50 additions & 0 deletions bundle/.gnkit/lib/check-staleness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,55 @@
import fs from 'node:fs';
import { execSync } from 'node:child_process';
import path from 'node:path';
import { loadHookConfig } from './hook-helpers.mjs';

const root = process.argv[2] ?? process.cwd();

function git(cmd) {
return execSync(cmd, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
}

/**
* Count git-dirty SOURCE files modified since the index was built (mtime > indexedAt).
* Commit-equality can't see UNCOMMITTED edits (HEAD unchanged → "fresh" forever), so this
* is the working-tree drift that lets guards require a fast incremental resync. Only stats
* the handful of dirty files (fast), and RESETS on refresh because indexedAt advances.
* @param {string|null} at meta.indexedAt (ISO)
* @param {RegExp} sourceExtRe the kit's canonical source-file matcher (loadHookConfig)
*/
function countDrift(at, sourceExtRe) {
const atMs = at ? Date.parse(at) : NaN;
if (!Number.isFinite(atMs)) return 0;
let porcelain = '';
try {
// -c core.quotePath=false → real UTF-8 paths (no octal escaping) so non-ASCII source
// names still stat. No .trim() on the output — the leading-space status column (" M path")
// must keep its alignment for slice(3).
porcelain = execSync('git -c core.quotePath=false status --porcelain', {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
} catch {
return 0;
}
let n = 0;
for (const line of porcelain.split('\n')) {
if (line.length < 4) continue; // "XY path" is ≥4 chars
let f = line.slice(3);
if (f.includes(' -> ')) f = f.split(' -> ').pop(); // rename → new path (before unquote)
f = f.trim();
if (f.startsWith('"') && f.endsWith('"')) f = f.slice(1, -1);
if (!sourceExtRe.test(f)) continue;
try {
if (fs.statSync(path.join(root, f)).mtimeMs > atMs) n++;
} catch {
/* deleted/renamed source — skip */
}
}
return n;
}

const staleHookNote =
'Hooks block Grep/Read/MCP/shell until refresh succeeds or fails.';
const agentFix =
Expand All @@ -28,6 +70,7 @@ const out = {
nodeCount: 0,
embeddingCount: 0,
embeddingsReady: false,
driftingFiles: 0,
};

const metaPath = path.join(root, '.gitnexus/meta.json');
Expand Down Expand Up @@ -74,6 +117,13 @@ try {
}

if (out.indexedCommit === out.headCommit) {
// Working-tree drift matters ONLY when commit-fresh (mid-session edits; HEAD unchanged).
// When behind/diverged a full refresh is needed regardless, so don't pay the git-status
// cost there — and skip it entirely when the drift gate is disabled (threshold ≤ 0).
const config = loadHookConfig(root);
if (config.driftRefreshThreshold > 0) {
out.driftingFiles = countDrift(out.indexedAt, config.sourceExtRe);
}
if (out.nodeCount > 0 && !out.embeddingsReady) {
out.fresh = false;
out.reason = 'missing_embeddings';
Expand Down
54 changes: 54 additions & 0 deletions bundle/.gnkit/lib/classify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -700,3 +700,57 @@ export function classifyShell(req, ctx) {
userKey: "block.shell.stale",
};
}

// ── Graph query tools gated by working-tree DRIFT ────────────────────────────
// The grep/shell gates keep the agent ON the graph, but the graph goes stale vs the
// agent's UNCOMMITTED edits — commit-based staleness can't see them (HEAD unchanged →
// "fresh" forever). These tools READ graph structure, so after N source edits they
// return answers that ignore the edits → require a FAST incremental refresh first.
// Non-query tools (detect_changes, rename, check, tool_map…) always pass.
const DRIFT_GATED_TOOLS = new Set([
"query", "context", "cypher", "impact", "pdg_query",
"trace", "explain", "api_impact", "route_map", "shape_check",
]);

/** Normalize a GitNexus MCP tool name to its bare suffix (query/context/pdg_query/…). */
export function mcpToolSuffix(name) {
return String(name || "")
.toLowerCase()
.replace(/^mcp__gitnexus__/, "")
.replace(/^mcp_gitnexus_/, "")
.replace(/^gitnexus[_.]/, "")
.trim();
}

/**
* Drift gate for graph QUERY tools. When ≥threshold source files changed since the index
* (stale.driftingFiles), those tools return results that ignore the edits → deny with a
* nudge to a FAST incremental refresh. Allow for non-query tools, under threshold, or when
* disabled (threshold ≤ 0), or when the phase isn't `fresh`.
* @param {string} toolName
* @param {{ driftingFiles?: number }} stale
* @param {{ driftRefreshThreshold?: number }} config
* @param {string} [phase] staleness phase — drift only applies on `fresh`
* @returns {Verdict}
*/
export function classifyMcpDrift(toolName, stale, config, phase) {
// Drift applies ONLY on a commit-FRESH index. Never in classical_fallback (a failed refresh
// OR a user-granted fallback) — forcing a refresh there would loop or override the escape
// hatch — nor must_refresh (already handled). Undefined phase = caller pre-checked (allow through).
if (phase != null && phase !== "fresh") return { decision: "allow" };
const threshold = Number(config?.driftRefreshThreshold);
if (!Number.isFinite(threshold) || threshold <= 0) return { decision: "allow" };
const count = Number(stale?.driftingFiles) || 0;
if (count < threshold) return { decision: "allow" };
const suffix = mcpToolSuffix(toolName);
if (!DRIFT_GATED_TOOLS.has(suffix)) return { decision: "allow" };
return {
decision: "deny",
agentMessage:
`Graph is ${count} uncommitted edit(s) behind your working tree — gitnexus_${suffix} would ` +
"return STALE results that ignore your changes. Resync first: `npm run gitnexus:refresh` " +
"(incremental — reindexes only your changed files; usually quick), then retry.",
userKey: "drift.refresh",
scoreEvent: "driftRefreshBlocks",
};
}
9 changes: 8 additions & 1 deletion bundle/.gnkit/lib/hook-helpers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export function loadHookConfig(root) {
broadGlobRes: DEFAULT_BROAD_GLOB_RES,
sourceExtRe: DEFAULT_SOURCE_EXT_RE,
stalenessCacheTtlMs: 2500,
// Working-tree drift: after this many uncommitted source edits since the index,
// graph query tools require a fast incremental refresh. 0 disables the drift gate.
driftRefreshThreshold: 3,
};

const cfgPath = path.join(root, CONFIG_FILE);
Expand All @@ -101,6 +104,8 @@ export function loadHookConfig(root) {
cfg.readLineThreshold = file.readLineThreshold;
if (typeof file.stalenessCacheTtlMs === "number")
cfg.stalenessCacheTtlMs = file.stalenessCacheTtlMs;
if (typeof file.driftRefreshThreshold === "number")
cfg.driftRefreshThreshold = file.driftRefreshThreshold;
if (Array.isArray(file.sourceGlobs) && file.sourceGlobs.length) {
cfg.sourcePathRes = file.sourceGlobs.map((g) => globToRegExp(g));
}
Expand Down Expand Up @@ -331,7 +336,7 @@ export function midSessionGraphNudge(graphUsedThisSession, root = "") {

/**
* Human-facing hook messages — enforcement stays on; voice explains the benefit.
* @param {'block.glob'|'block.semantic'|'block.grep.noGraph'|'block.grep.symbol'|'block.grep.likely'|'block.grep.field'|'block.read.full'|'block.edit.stale'|'block.shell.stale'|'stale.must_refresh'|'stale.classical'} key
* @param {'block.glob'|'block.semantic'|'block.grep.noGraph'|'block.grep.symbol'|'block.grep.likely'|'block.grep.field'|'block.read.full'|'block.edit.stale'|'block.shell.stale'|'stale.must_refresh'|'stale.classical'|'drift.refresh'} key
* @param {Record<string, string | number>} [vars]
*/
export function userMessage(key, vars = {}) {
Expand Down Expand Up @@ -366,6 +371,8 @@ export function userMessage(key, vars = {}) {
"GitNexus index is behind — the agent must refresh the graph first (not grep/read). Hooks enforce refresh-then-graph, not skip-to-classical.",
"stale.classical":
"GitNexus refresh failed — the agent may use classic search now and must say why the graph could not be updated.",
"drift.refresh":
"The agent edited code since the last index, so graph queries would be stale — it will run a fast incremental refresh before continuing.",
};
return (
templates[key] ??
Expand Down
7 changes: 7 additions & 0 deletions bundle/scripts/gitnexus-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ if (cmd === "status") {
if ((stale.embeddingCount ?? 0) > 0) {
console.log(` embeddings: ${stale.embeddingCount} vectors`);
}
if ((stale.driftingFiles ?? 0) > 0) {
console.log(
` ⚠ working tree: ${stale.driftingFiles} source file(s) edited since index — graph queries may be stale.`,
);
console.log(" Resync: npm run gitnexus:refresh (fast, incremental)");
}
console.log(systemTmp);
process.exit(0);
}
Expand Down Expand Up @@ -520,6 +526,7 @@ if (cmd === "scorecard") {
editStaleBlocks: "Stale-edit blocks",
compactions: "Context compactions",
classicalFallbackGranted: "Classical-fallback grants (GN distrusted)",
driftRefreshBlocks: "Graph-drift refresh blocks (edited since index)",
};
console.log("GitNexus enforcement scorecard (this session)");
console.log(
Expand Down
8 changes: 4 additions & 4 deletions bundle/scripts/gitnexus-teaching/script-gates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ export const GITNEXUS_SCRIPT_GATES = [
description:
"Humans/CI refresh the graph. Agents run gitnexus:agent-refresh autonomously when stale (hook pre-approved).",
scripts: {
"gitnexus:refresh": `${WRAP} npx gitnexus@latest analyze --embeddings --skills`,
"gitnexus:full": `${WRAP} npx gitnexus@latest analyze --force --embeddings --skills`,
"gitnexus:pdg": `${WRAP} npx gitnexus@latest analyze --embeddings --skills --pdg`,
"gitnexus:full-pdg": `${WRAP} npx gitnexus@latest analyze --force --embeddings --skills --pdg`,
"gitnexus:refresh": `${WRAP} npx gitnexus@latest analyze --embeddings 0 --skills`,
"gitnexus:full": `${WRAP} npx gitnexus@latest analyze --force --embeddings 0 --skills`,
"gitnexus:pdg": `${WRAP} npx gitnexus@latest analyze --embeddings 0 --skills --pdg`,
"gitnexus:full-pdg": `${WRAP} npx gitnexus@latest analyze --force --embeddings 0 --skills --pdg`,
"gitnexus:status": `${WRAP} npx gitnexus@latest status`,
"gitnexus:agent-refresh": "node scripts/gitnexus-agent.mjs refresh",
"gitnexus:agent-review": "node scripts/gitnexus-agent.mjs review",
Expand Down
2 changes: 2 additions & 0 deletions bundle/templates/AGENTS.gitnexus.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ stale → agent-refresh (Shell, pre-approved)

Session start runs auto-refresh when stale. Do **not** grep/read “while refreshing” — refresh is the next tool, not a background hint.

**Mid-session drift (your own edits):** commit-equality can't see uncommitted edits, so after you change a few source files the graph silently falls behind your working tree. Don't wait for the block — once you've edited code and are about to `query`/`context`/`impact`/`cypher`/`pdg_query` again, run **`npm run gitnexus:refresh`** (**incremental** — reindexes only your changed files; quick for a few edits, longer on large batches / first run) so graph answers reflect your changes. Graph query tools hard-block past a small drift threshold until you do.

## Gates (do not skip — every task)

```
Expand Down
2 changes: 2 additions & 0 deletions bundle/templates/CLAUDE.gitnexus.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ stale → agent-refresh (Shell, pre-approved)

Session start runs auto-refresh when stale. Do **not** grep/read “while refreshing” — refresh is the next tool, not a background hint.

**Mid-session drift (your own edits):** commit-equality can't see uncommitted edits, so after you change a few source files the graph silently falls behind your working tree. Don't wait for the block — once you've edited code and are about to `query`/`context`/`impact`/`cypher`/`pdg_query` again, run **`npm run gitnexus:refresh`** (**incremental** — reindexes only your changed files; quick for a few edits, longer on large batches / first run) so graph answers reflect your changes. Graph query tools hard-block past a small drift threshold until you do.

## Gates (do not skip — every task)

```
Expand Down
78 changes: 78 additions & 0 deletions lib/kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1732,6 +1732,84 @@ describe("gitnexus-agent-kit", () => {
fs.rmSync(tmp, { recursive: true, force: true });
});

it("classifyMcpDrift gates graph query tools on working-tree drift", async () => {
const { classifyMcpDrift, mcpToolSuffix } = await import(
new URL("../bundle/.gnkit/lib/classify.mjs", import.meta.url).href
);
const cfg = { driftRefreshThreshold: 3 };
const drift = (tool, n, config = cfg) =>
classifyMcpDrift(tool, { driftingFiles: n }, config).decision;

// suffix normalization across name formats
assert.equal(mcpToolSuffix("mcp__gitnexus__query"), "query");
assert.equal(mcpToolSuffix("mcp__gitnexus__pdg_query"), "pdg_query");
assert.equal(mcpToolSuffix("gitnexus_context"), "context");

// query tools deny at/over threshold, allow under
assert.equal(drift("mcp__gitnexus__query", 3), "deny");
assert.equal(drift("mcp__gitnexus__query", 2), "allow");
assert.equal(drift("gitnexus_context", 5), "deny");
assert.equal(drift("mcp__gitnexus__pdg_query", 4), "deny");
// non-query tools always pass (detect_changes helps SEE drift; rename is an action)
assert.equal(drift("mcp__gitnexus__detect_changes", 9), "allow");
assert.equal(drift("mcp__gitnexus__rename", 9), "allow");
// threshold 0 disables the gate
assert.equal(drift("mcp__gitnexus__query", 9, { driftRefreshThreshold: 0 }), "allow");
// deny message steers to the incremental refresh + is scored
const v = classifyMcpDrift("mcp__gitnexus__impact", { driftingFiles: 4 }, cfg);
assert.match(v.agentMessage, /gitnexus:refresh/);
assert.match(v.agentMessage, /incremental/i);
assert.equal(v.scoreEvent, "driftRefreshBlocks");

// PHASE gate (regression): drift applies ONLY on a commit-fresh index. It must NOT fire in
// classical_fallback (a failed refresh OR a user-granted fallback) — that would override the
// escape hatch / tell the agent to run the refresh that just failed.
const ph = (tool, n, phase) =>
classifyMcpDrift(tool, { driftingFiles: n }, cfg, phase).decision;
assert.equal(ph("mcp__gitnexus__query", 5, "fresh"), "deny");
assert.equal(ph("mcp__gitnexus__query", 5, "classical_fallback"), "allow");
assert.equal(ph("mcp__gitnexus__query", 5, "must_refresh"), "allow");
assert.equal(ph("mcp__gitnexus__query", 5, undefined), "deny"); // undefined = caller pre-checked
});

it("check-staleness detects working-tree drift + resets on refresh", async () => {
const { execFileSync } = await import("node:child_process");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-drift-"));
execFileSync(
"bash",
["-c", "git init -q && git config user.email x@x && git config user.name x"],
{ cwd: tmp },
);
fs.writeFileSync(path.join(tmp, "src.js"), "export function a(){}\n");
execFileSync("bash", ["-c", "git add -A && git commit -qm init"], { cwd: tmp });
const head = execFileSync("git", ["-C", tmp, "rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
fs.mkdirSync(path.join(tmp, ".gitnexus"), { recursive: true });
const setIndexedAt = (at) =>
fs.writeFileSync(
path.join(tmp, ".gitnexus/meta.json"),
JSON.stringify({ lastCommit: head, indexedAt: at, stats: { nodes: 10, embeddings: 5 } }),
);
const CS = new URL("../bundle/.gnkit/lib/check-staleness.mjs", import.meta.url).pathname;
const stat = () => JSON.parse(execFileSync(process.execPath, [CS, tmp], { encoding: "utf8" }));

setIndexedAt(new Date(Date.now() - 3600e3).toISOString());
let s = stat();
assert.equal(s.fresh, true, "commit-fresh");
assert.equal(s.driftingFiles, 0, "clean tree → no drift");

fs.writeFileSync(path.join(tmp, "src.js"), "export function a(){return 1}\n"); // edit
assert.equal(stat().driftingFiles, 1, "one edited source → drift 1");
fs.writeFileSync(path.join(tmp, "note.md"), "doc\n"); // non-source
assert.equal(stat().driftingFiles, 1, "non-source edit does not count");

// refresh: indexedAt advances → drift resets even though files stay uncommitted
setIndexedAt(new Date(Date.now() + 2000).toISOString());
assert.equal(stat().driftingFiles, 0, "refresh resets drift");
fs.rmSync(tmp, { recursive: true, force: true });
});

it("session-health-audit builds agent context and user message", async () => {
const auditMod = await import(
new URL(
Expand Down
2 changes: 2 additions & 0 deletions scripts/contract/enforcement-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ stale → agent-refresh (Shell, pre-approved)

Session start runs auto-refresh when stale. Do **not** grep/read “while refreshing” — refresh is the next tool, not a background hint.

**Mid-session drift (your own edits):** commit-equality can't see uncommitted edits, so after you change a few source files the graph silently falls behind your working tree. Don't wait for the block — once you've edited code and are about to `query`/`context`/`impact`/`cypher`/`pdg_query` again, run **`npm run gitnexus:refresh`** (**incremental** — reindexes only your changed files; quick for a few edits, longer on large batches / first run) so graph answers reflect your changes. Graph query tools hard-block past a small drift threshold until you do.

## Gates (do not skip — every task)

```
Expand Down
Loading