From f5cabe428c67b0540808c464a93b9ea2d772d554 Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Wed, 8 Jul 2026 22:00:51 +0300 Subject: [PATCH] fix: stop committing volatile graph stats (AGENTS/CLAUDE churn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Committed agent docs churned on every teammate's machine because they embed live graph stats that regenerate per re-index: - docs/ARCHITECTURE.gitnexus.md — kit-generated arch doc (generate-arch-doc) - the .. block the GitNexus `analyze` tool injects into AGENTS.md/CLAUDE.md (symbol/relationship/per-area counts) Each teammate's graph differs slightly, so these showed as perpetually "modified" after any refresh — noise for everyone who pulls. Fix: - gitignore docs/ARCHITECTURE.gitnexus.md (kept local, still regenerated) - new .gnkit/lib/stabilize-agent-docs.mjs strips the volatile gitnexus:start block from AGENTS.md/CLAUDE.md, preserving the user's content and the kit's stable gitnexus-agent-kit contract block - run it in sync-teaching (install + autonomous agent-refresh) and in the pre-commit hook (after full-pdg re-injects the block) so commits stay clean Verified on a real 626-line CLAUDE.md: block + stats removed, user content and kit block intact. 56/56 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- bundle/.githooks/pre-commit | 5 ++ bundle/.gnkit/lib/stabilize-agent-docs.mjs | 52 ++++++++++++++++ bundle/scripts/pack-gitnexus-teaching.sh | 1 + .../scripts/sync-cursor-gitnexus-teaching.sh | 7 +++ lib/kit.mjs | 3 + lib/kit.test.mjs | 61 +++++++++++++++++++ 6 files changed, 129 insertions(+) create mode 100644 bundle/.gnkit/lib/stabilize-agent-docs.mjs diff --git a/bundle/.githooks/pre-commit b/bundle/.githooks/pre-commit index 7306f80..75fa1a8 100755 --- a/bundle/.githooks/pre-commit +++ b/bundle/.githooks/pre-commit @@ -8,4 +8,9 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" npm run gitnexus:full-pdg +# Keep AGENTS.md/CLAUDE.md free of the volatile GitNexus stats block that +# full-pdg's analyze re-injects, so commits don't carry machine-specific churn. +if [[ -f ".gnkit/lib/stabilize-agent-docs.mjs" ]]; then + node .gnkit/lib/stabilize-agent-docs.mjs . || true +fi npm run gitnexus:graph-smoke diff --git a/bundle/.gnkit/lib/stabilize-agent-docs.mjs b/bundle/.gnkit/lib/stabilize-agent-docs.mjs new file mode 100644 index 0000000..202fe3a --- /dev/null +++ b/bundle/.gnkit/lib/stabilize-agent-docs.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Keep committed agent docs stable across machines. + * + * The GitNexus `analyze` tool injects a `` + * block into AGENTS.md / CLAUDE.md containing LIVE graph stats (symbol/relationship + * counts, per-area skill counts). Those numbers differ per checkout + re-index, so the + * files churn on every refresh — perpetual "modified" noise for every teammate who pulls. + * + * This strips that volatile block wherever it lands, preserving the user's own content + * and the kit's stable `gitnexus-agent-kit` contract block. Run after every analyze + * (sync-teaching + pre-commit) so the block never persists in a committed file. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const START = ""; +const END = ""; +const BLOCK_RE = /\n*[\s\S]*?\n?/g; + +/** @param {string} root @returns {string[]} files changed */ +export function stabilizeAgentDocs(root) { + const changed = []; + for (const rel of ["AGENTS.md", "CLAUDE.md"]) { + const p = path.join(root, rel); + if (!fs.existsSync(p)) continue; + const orig = fs.readFileSync(p, "utf8"); + if (!orig.includes(START) || !orig.includes(END)) continue; + const next = orig + .replace(BLOCK_RE, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/^\n+/, ""); + if (next !== orig) { + fs.writeFileSync(p, next); + changed.push(rel); + } + } + return changed; +} + +const isMain = + process.argv[1] && + fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); +if (isMain) { + const changed = stabilizeAgentDocs(process.argv[2] || process.cwd()); + if (changed.length) { + console.log( + ` ✓ stabilized agent docs (dropped volatile GitNexus stats block): ${changed.join(", ")}`, + ); + } +} diff --git a/bundle/scripts/pack-gitnexus-teaching.sh b/bundle/scripts/pack-gitnexus-teaching.sh index 2dd6858..9be535d 100755 --- a/bundle/scripts/pack-gitnexus-teaching.sh +++ b/bundle/scripts/pack-gitnexus-teaching.sh @@ -72,6 +72,7 @@ BUNDLE_PATHS=( .gnkit/lib/stale-policy.mjs .gnkit/lib/cypher-cli.mjs .gnkit/lib/generate-arch-doc.mjs + .gnkit/lib/stabilize-agent-docs.mjs .gnkit/lib/commit-message.mjs .gnkit/lib/detect-api-router.mjs .gnkit/lib/graph-smoke.mjs diff --git a/bundle/scripts/sync-cursor-gitnexus-teaching.sh b/bundle/scripts/sync-cursor-gitnexus-teaching.sh index a1342c6..abf4e88 100755 --- a/bundle/scripts/sync-cursor-gitnexus-teaching.sh +++ b/bundle/scripts/sync-cursor-gitnexus-teaching.sh @@ -43,6 +43,7 @@ HOOK_LIBS=( ".gnkit/lib/stale-policy.mjs" ".gnkit/lib/cypher-cli.mjs" ".gnkit/lib/generate-arch-doc.mjs" + ".gnkit/lib/stabilize-agent-docs.mjs" ".gnkit/lib/commit-message.mjs" ".gnkit/lib/detect-api-router.mjs" ".gnkit/lib/graph-smoke.mjs" @@ -244,6 +245,12 @@ case "$RUNTIME" in *claude*|*all*) link_skills ".claude/skills" "Claude s info " [4/5] Teaching bundle manifest" write_manifest +# Drop the volatile GitNexus stats block from AGENTS.md/CLAUDE.md so committed +# agent docs stay stable across machines (the `analyze` tool re-adds it each refresh). +if [[ -f ".gnkit/lib/stabilize-agent-docs.mjs" ]]; then + node .gnkit/lib/stabilize-agent-docs.mjs . || true +fi + info " [5/5] Quick hook smoke test" if printf '%s' '{"tool_name":"SemanticSearch","tool_input":{"query":"test"}}' \ | bash .cursor/hooks/gitnexus-grep-guard.sh 2>/dev/null \ diff --git a/lib/kit.mjs b/lib/kit.mjs index b85ad5e..6fa7ec1 100644 --- a/lib/kit.mjs +++ b/lib/kit.mjs @@ -71,6 +71,9 @@ const GITIGNORE_BASE = [ // API profile are ignored; the IDE skill symlink dirs are ignored + regenerated. ".gnkit/.gitnexus-*", ".gnkit/gitnexus-api-profile.json", + // Derived architecture doc — regenerated from live graph stats on every refresh + // (machine-specific), so it churns git for every teammate. Kept local. + "docs/ARCHITECTURE.gitnexus.md", ]; /** @param {import('./constants.mjs').Runtime} runtime */ diff --git a/lib/kit.test.mjs b/lib/kit.test.mjs index ab1ae1c..e615f84 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -1436,6 +1436,67 @@ describe("gitnexus-agent-kit", () => { fs.rmSync(tmp, { recursive: true, force: true }); }); + it("stabilize-agent-docs strips the volatile stats block, keeps user + kit content", async () => { + const { stabilizeAgentDocs } = await import( + new URL( + "../bundle/.gnkit/lib/stabilize-agent-docs.mjs", + import.meta.url, + ).href + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-stabilize-")); + // CLAUDE.md: user content, then the gitnexus tool block (volatile), then the kit block. + fs.writeFileSync( + path.join(tmp, "CLAUDE.md"), + [ + "# My Project Rules", + "Always check docs first.", + "", + "", + "This project is indexed by GitNexus (49749 symbols, 117601 relationships).", + "| Components (1103 symbols) | ... |", + "", + "", + "", + "Stable enforcement contract.", + "", + "", + ].join("\n"), + ); + // AGENTS.md with the block at the very top. + fs.writeFileSync( + path.join(tmp, "AGENTS.md"), + "\n(50592 symbols)\n\n\n\nkit\n\n", + ); + + const changed = stabilizeAgentDocs(tmp); + assert.deepEqual(changed.sort(), ["AGENTS.md", "CLAUDE.md"]); + + const claude = fs.readFileSync(path.join(tmp, "CLAUDE.md"), "utf8"); + assert.ok(!claude.includes(""), "volatile block removed"); + assert.ok(!claude.includes("49749 symbols"), "live stats removed"); + assert.ok(claude.includes("# My Project Rules"), "user content kept"); + assert.ok(claude.includes("Always check docs first."), "user content kept"); + assert.ok(claude.includes("gitnexus-agent-kit:BEGIN"), "stable kit block kept"); + + const agents = fs.readFileSync(path.join(tmp, "AGENTS.md"), "utf8"); + assert.ok(!agents.includes("")); + assert.ok(agents.includes("gitnexus-agent-kit:BEGIN")); + assert.ok(!agents.startsWith("\n"), "no leading blank after top-of-file strip"); + + // Idempotent: nothing left to strip. + assert.deepEqual(stabilizeAgentDocs(tmp), []); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("gitignore snippet ignores the derived architecture doc", async () => { + const { appendGitignore } = await import("./kit.mjs"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-archignore-")); + appendGitignore(tmp, "all"); + const gi = fs.readFileSync(path.join(tmp, ".gitignore"), "utf8"); + assert.ok(gi.includes("docs/ARCHITECTURE.gitnexus.md")); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("eval harness loads and validates task specs", async () => { const { loadTasks, validateTask } = await import( new URL("../eval/run-eval.mjs", import.meta.url).href