From 3fdde93ce7fb718ea2e3ffe2ccb2ada1ac2034a1 Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Sun, 12 Jul 2026 03:07:02 +0300 Subject: [PATCH 1/3] Lift the embeddings cap on refresh: --embeddings 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--embeddings` with no arg keeps GitNexus's default embedding limit; `0` removes it so the full symbol set gets vectors. Applied to all four analyze scripts (refresh / full / pdg / full-pdg) — the ones agent-refresh and the pre-commit hook run. Co-Authored-By: Claude Opus 4.8 (1M context) --- bundle/scripts/gitnexus-teaching/script-gates.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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", From 6ac05d0e1f4ce000059d09fc99ef9211ad358c9b Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Sun, 12 Jul 2026 03:07:20 +0300 Subject: [PATCH 2/3] Mid-session drift gate: refresh when the agent's own edits outrun the index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staleness was purely commit-based (indexedCommit === HEAD → "fresh"), so the uncommitted edits an agent makes mid-session never marked the index stale. Agents only refreshed at trigger events (session start / commit), never during routine work — so graph queries silently ran against a graph that ignored the edits. - check-staleness now reports `driftingFiles` = git-dirty SOURCE files modified since the index build (mtime > indexedAt). Cheap (stats only the dirty files) and RESETS on refresh because indexedAt advances — a plain `git status` count would nag forever. - new shared `classifyMcpDrift`: graph QUERY tools (query/context/cypher/impact/pdg_query/ trace/explain/api_impact/route_map/shape_check) hard-block past a drift threshold with a nudge to the FAST incremental `gitnexus:refresh`. Non-query tools (detect_changes, rename, check, tool_map…) pass; edits/reads/greps are untouched, so editing flow isn't interrupted. - wired into the Claude + Cursor MCP guards, AFTER the must_refresh check (so it only applies when the index is otherwise commit-fresh). - `driftRefreshThreshold` config (default 3; 0 disables). Surfaced in `gitnexus:status` + a `driftRefreshBlocks` scorecard counter. Contract now teaches proactive incremental refresh. Tests: 58/58 — classifyMcpDrift logic + real check-staleness drift/reset in a temp git repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- bundle/.claude/hooks/gitnexus-mcp-guard.mjs | 14 +++- .../.cursor/hooks/gitnexus-mcp-allowlist.sh | 15 ++++ .../.cursor/rules/00-gitnexus-enforcement.mdc | 2 + bundle/.gnkit/lib/check-staleness.mjs | 45 ++++++++++++ bundle/.gnkit/lib/classify.mjs | 50 ++++++++++++++ bundle/.gnkit/lib/hook-helpers.mjs | 7 ++ bundle/scripts/gitnexus-agent.mjs | 7 ++ bundle/templates/AGENTS.gitnexus.md | 2 + bundle/templates/CLAUDE.gitnexus.md | 2 + lib/kit.test.mjs | 68 +++++++++++++++++++ scripts/contract/enforcement-contract.md | 2 + 11 files changed, 211 insertions(+), 3 deletions(-) diff --git a/bundle/.claude/hooks/gitnexus-mcp-guard.mjs b/bundle/.claude/hooks/gitnexus-mcp-guard.mjs index 38eb52e..cee39bf 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,14 @@ 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"); + // Commit-fresh but working tree drifted? 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); + 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..13b9418 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,18 @@ if (policy.phase === 'must_refresh') { process.exit(0); } +// Commit-fresh but working tree drifted? A graph QUERY tool would ignore the agent's +// uncommitted edits → require a fast incremental refresh (must_refresh handled above). +const drift = classifyMcpDrift(tool, stale, config); +if (drift.decision === 'deny') { + 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..4f6455b 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`** (fast **incremental** — reindexes only changed files) 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..f4c08c3 100755 --- a/bundle/.gnkit/lib/check-staleness.mjs +++ b/bundle/.gnkit/lib/check-staleness.mjs @@ -13,6 +13,47 @@ function git(cmd) { return execSync(cmd, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } +const SOURCE_RE = + /\.(m?[jt]sx?|cjs|py|go|rs|java|rb|php|c|h|cc|cpp|hpp|cs|kt|kts|swift|scala|dart|vue|svelte)$/i; + +/** + * 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) + */ +function countDrift(at) { + const atMs = at ? Date.parse(at) : NaN; + if (!Number.isFinite(atMs)) return 0; + let porcelain = ''; + try { + // NOTE: no .trim() — porcelain status is 2 chars + a space, so a leading-space + // status (" M path") must keep its column alignment for slice(3) to be correct. + porcelain = execSync('git 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).trim(); + if (f.startsWith('"') && f.endsWith('"')) f = f.slice(1, -1); + if (f.includes(' -> ')) f = f.split(' -> ').pop().trim(); // rename → new path + if (!SOURCE_RE.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 +69,7 @@ const out = { nodeCount: 0, embeddingCount: 0, embeddingsReady: false, + driftingFiles: 0, }; const metaPath = path.join(root, '.gitnexus/meta.json'); @@ -73,6 +115,9 @@ try { process.exit(0); } +// Working-tree drift (uncommitted edits since the index) — orthogonal to commit-staleness. +out.driftingFiles = countDrift(out.indexedAt); + if (out.indexedCommit === out.headCommit) { if (out.nodeCount > 0 && !out.embeddingsReady) { out.fresh = false; diff --git a/bundle/.gnkit/lib/classify.mjs b/bundle/.gnkit/lib/classify.mjs index ec566e5..6ab500b 100644 --- a/bundle/.gnkit/lib/classify.mjs +++ b/bundle/.gnkit/lib/classify.mjs @@ -700,3 +700,53 @@ 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). Called from the MCP guards AFTER the must_refresh check, so it + * only applies when the index is otherwise commit-fresh. + * @param {string} toolName + * @param {{ driftingFiles?: number }} stale + * @param {{ driftRefreshThreshold?: number }} config + * @returns {Verdict} + */ +export function classifyMcpDrift(toolName, stale, config) { + 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` " + + "(fast — incremental, reindexes only changed files), 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..0df67d7 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)); } @@ -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/templates/AGENTS.gitnexus.md b/bundle/templates/AGENTS.gitnexus.md index 7b0b1b4..11c00ce 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`** (fast **incremental** — reindexes only changed files) 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..4716bc0 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`** (fast **incremental** — reindexes only changed files) 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..32634b3 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -1732,6 +1732,74 @@ 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 FAST 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"); + }); + + 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..565994d 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`** (fast **incremental** — reindexes only changed files) 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) ``` From 52bc075b9893e1de4e62f359a0c1f9914400625e Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Sun, 12 Jul 2026 03:31:27 +0300 Subject: [PATCH 3/3] Microscope pass #1 fixes: phase-gate drift, reconcile source-ext, trim cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review (microscope waves, 5 independent lenses) of the drift gate — fixes: - HIGH: the drift gate fired in classical_fallback too (the else-of-must_refresh covers it), overriding the classical-fallback escape hatch and telling the agent to run the refresh that just FAILED. classifyMcpDrift now enforces phase === "fresh" itself; both guards pass ctx.phase. - MEDIUM: countDrift used a 3rd hand-rolled source-ext regex diverging from the kit's DEFAULT_SOURCE_EXT_RE (silently no-drift for .cts/.ex/.clj/.lua/…, and ignored the sourceExts config override). Now uses loadHookConfig(root).sourceExtRe. - MEDIUM: countDrift's `git status` ran on EVERY tool call (grep/read/edit/shell/stale) though driftingFiles is consumed only by the MCP guards on the fresh path. Moved it into the commit-fresh branch + skip it entirely when the gate is disabled (threshold <= 0). - LOW: non-ASCII paths undercounted (git octal-escaping) -> `-c core.quotePath=false`. - LOW: Cursor never bumped the driftRefreshBlocks scorecard counter -> added. - LOW: tempered the "fast" overclaim (uncapped embeddings make large batches / cold model slower). Verified-correct by the review (no change): --embeddings 0 = "disable the cap" (traced in the gitnexus CLI source; also a latent-bug fix vs bare --embeddings capping at 50k nodes), and the core premise holds (incremental analyze hashes on-disk content -> reindexes uncommitted edits + advances indexedAt). 58/58 (+ phase-gate regression test). Co-Authored-By: Claude Opus 4.8 (1M context) --- bundle/.claude/hooks/gitnexus-mcp-guard.mjs | 7 +++-- .../.cursor/hooks/gitnexus-mcp-allowlist.sh | 8 +++-- .../.cursor/rules/00-gitnexus-enforcement.mdc | 2 +- bundle/.gnkit/lib/check-staleness.mjs | 31 +++++++++++-------- bundle/.gnkit/lib/classify.mjs | 12 ++++--- bundle/.gnkit/lib/hook-helpers.mjs | 2 +- bundle/templates/AGENTS.gitnexus.md | 2 +- bundle/templates/CLAUDE.gitnexus.md | 2 +- lib/kit.test.mjs | 12 ++++++- scripts/contract/enforcement-contract.md | 2 +- 10 files changed, 51 insertions(+), 29 deletions(-) diff --git a/bundle/.claude/hooks/gitnexus-mcp-guard.mjs b/bundle/.claude/hooks/gitnexus-mcp-guard.mjs index cee39bf..816f5d6 100644 --- a/bundle/.claude/hooks/gitnexus-mcp-guard.mjs +++ b/bundle/.claude/hooks/gitnexus-mcp-guard.mjs @@ -32,9 +32,10 @@ if (ctx.phase === "must_refresh") { { root, mode: ctx.config.mode }, ); } else { - // Commit-fresh but working tree drifted? 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); + // 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 { diff --git a/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh b/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh index 13b9418..cc21394 100755 --- a/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh +++ b/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh @@ -57,10 +57,12 @@ if (policy.phase === 'must_refresh') { process.exit(0); } -// Commit-fresh but working tree drifted? A graph QUERY tool would ignore the agent's -// uncommitted edits → require a fast incremental refresh (must_refresh handled above). -const drift = classifyMcpDrift(tool, stale, config); +// 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, diff --git a/bundle/.cursor/rules/00-gitnexus-enforcement.mdc b/bundle/.cursor/rules/00-gitnexus-enforcement.mdc index 4f6455b..f68d4db 100644 --- a/bundle/.cursor/rules/00-gitnexus-enforcement.mdc +++ b/bundle/.cursor/rules/00-gitnexus-enforcement.mdc @@ -125,7 +125,7 @@ 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`** (fast **incremental** — reindexes only changed files) so graph answers reflect your changes. Graph query tools hard-block past a small drift threshold until you do. +**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 f4c08c3..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,24 +14,23 @@ function git(cmd) { return execSync(cmd, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } -const SOURCE_RE = - /\.(m?[jt]sx?|cjs|py|go|rs|java|rb|php|c|h|cc|cpp|hpp|cs|kt|kts|swift|scala|dart|vue|svelte)$/i; - /** * 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) { +function countDrift(at, sourceExtRe) { const atMs = at ? Date.parse(at) : NaN; if (!Number.isFinite(atMs)) return 0; let porcelain = ''; try { - // NOTE: no .trim() — porcelain status is 2 chars + a space, so a leading-space - // status (" M path") must keep its column alignment for slice(3) to be correct. - porcelain = execSync('git status --porcelain', { + // -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'], @@ -41,10 +41,11 @@ function countDrift(at) { 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).trim(); + 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 (f.includes(' -> ')) f = f.split(' -> ').pop().trim(); // rename → new path - if (!SOURCE_RE.test(f)) continue; + if (!sourceExtRe.test(f)) continue; try { if (fs.statSync(path.join(root, f)).mtimeMs > atMs) n++; } catch { @@ -115,10 +116,14 @@ try { process.exit(0); } -// Working-tree drift (uncommitted edits since the index) — orthogonal to commit-staleness. -out.driftingFiles = countDrift(out.indexedAt); - 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 6ab500b..0f07d6f 100644 --- a/bundle/.gnkit/lib/classify.mjs +++ b/bundle/.gnkit/lib/classify.mjs @@ -726,14 +726,18 @@ export function mcpToolSuffix(name) { * 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). Called from the MCP guards AFTER the must_refresh check, so it - * only applies when the index is otherwise commit-fresh. + * 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) { +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; @@ -745,7 +749,7 @@ export function classifyMcpDrift(toolName, stale, config) { 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` " + - "(fast — incremental, reindexes only changed files), then retry.", + "(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 0df67d7..84223a4 100644 --- a/bundle/.gnkit/lib/hook-helpers.mjs +++ b/bundle/.gnkit/lib/hook-helpers.mjs @@ -336,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 = {}) { diff --git a/bundle/templates/AGENTS.gitnexus.md b/bundle/templates/AGENTS.gitnexus.md index 11c00ce..10fa90b 100644 --- a/bundle/templates/AGENTS.gitnexus.md +++ b/bundle/templates/AGENTS.gitnexus.md @@ -120,7 +120,7 @@ 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`** (fast **incremental** — reindexes only changed files) so graph answers reflect your changes. Graph query tools hard-block past a small drift threshold until you do. +**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 4716bc0..b3a0127 100644 --- a/bundle/templates/CLAUDE.gitnexus.md +++ b/bundle/templates/CLAUDE.gitnexus.md @@ -120,7 +120,7 @@ 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`** (fast **incremental** — reindexes only changed files) so graph answers reflect your changes. Graph query tools hard-block past a small drift threshold until you do. +**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 32634b3..ed5911e 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -1755,11 +1755,21 @@ describe("gitnexus-agent-kit", () => { 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 FAST incremental refresh + is scored + // 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 () => { diff --git a/scripts/contract/enforcement-contract.md b/scripts/contract/enforcement-contract.md index 565994d..93cf26e 100644 --- a/scripts/contract/enforcement-contract.md +++ b/scripts/contract/enforcement-contract.md @@ -116,7 +116,7 @@ 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`** (fast **incremental** — reindexes only changed files) so graph answers reflect your changes. Graph query tools hard-block past a small drift threshold until you do. +**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)