diff --git a/bundle/.claude/hooks/gitnexus-mcp-guard.mjs b/bundle/.claude/hooks/gitnexus-mcp-guard.mjs index 38eb52e..816f5d6 100644 --- a/bundle/.claude/hooks/gitnexus-mcp-guard.mjs +++ b/bundle/.claude/hooks/gitnexus-mcp-guard.mjs @@ -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 ?? ""; @@ -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"); + } } diff --git a/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh b/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh index 558337d..cc21394 100755 --- a/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh +++ b/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh @@ -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 = @@ -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'); diff --git a/bundle/.cursor/rules/00-gitnexus-enforcement.mdc b/bundle/.cursor/rules/00-gitnexus-enforcement.mdc index 4cf470d..f68d4db 100644 --- a/bundle/.cursor/rules/00-gitnexus-enforcement.mdc +++ b/bundle/.cursor/rules/00-gitnexus-enforcement.mdc @@ -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) ``` diff --git a/bundle/.gnkit/lib/check-staleness.mjs b/bundle/.gnkit/lib/check-staleness.mjs index 696e213..fe6a3c6 100755 --- a/bundle/.gnkit/lib/check-staleness.mjs +++ b/bundle/.gnkit/lib/check-staleness.mjs @@ -6,6 +6,7 @@ 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(); @@ -13,6 +14,47 @@ 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 = @@ -28,6 +70,7 @@ const out = { nodeCount: 0, embeddingCount: 0, embeddingsReady: false, + driftingFiles: 0, }; const metaPath = path.join(root, '.gitnexus/meta.json'); @@ -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'; diff --git a/bundle/.gnkit/lib/classify.mjs b/bundle/.gnkit/lib/classify.mjs index ec566e5..0f07d6f 100644 --- a/bundle/.gnkit/lib/classify.mjs +++ b/bundle/.gnkit/lib/classify.mjs @@ -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", + }; +} diff --git a/bundle/.gnkit/lib/hook-helpers.mjs b/bundle/.gnkit/lib/hook-helpers.mjs index ff2573e..84223a4 100644 --- a/bundle/.gnkit/lib/hook-helpers.mjs +++ b/bundle/.gnkit/lib/hook-helpers.mjs @@ -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); @@ -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)); } @@ -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} [vars] */ export function userMessage(key, vars = {}) { @@ -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] ?? diff --git a/bundle/scripts/gitnexus-agent.mjs b/bundle/scripts/gitnexus-agent.mjs index 0379886..eed312b 100644 --- a/bundle/scripts/gitnexus-agent.mjs +++ b/bundle/scripts/gitnexus-agent.mjs @@ -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); } @@ -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( diff --git a/bundle/scripts/gitnexus-teaching/script-gates.mjs b/bundle/scripts/gitnexus-teaching/script-gates.mjs index 797f6c7..c37abf0 100644 --- a/bundle/scripts/gitnexus-teaching/script-gates.mjs +++ b/bundle/scripts/gitnexus-teaching/script-gates.mjs @@ -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", diff --git a/bundle/templates/AGENTS.gitnexus.md b/bundle/templates/AGENTS.gitnexus.md index 7b0b1b4..10fa90b 100644 --- a/bundle/templates/AGENTS.gitnexus.md +++ b/bundle/templates/AGENTS.gitnexus.md @@ -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) ``` diff --git a/bundle/templates/CLAUDE.gitnexus.md b/bundle/templates/CLAUDE.gitnexus.md index 03ecd56..b3a0127 100644 --- a/bundle/templates/CLAUDE.gitnexus.md +++ b/bundle/templates/CLAUDE.gitnexus.md @@ -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) ``` diff --git a/lib/kit.test.mjs b/lib/kit.test.mjs index e615f84..ed5911e 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -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( diff --git a/scripts/contract/enforcement-contract.md b/scripts/contract/enforcement-contract.md index 96a79d7..93cf26e 100644 --- a/scripts/contract/enforcement-contract.md +++ b/scripts/contract/enforcement-contract.md @@ -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) ```