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
5 changes: 5 additions & 0 deletions bundle/.githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -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
52 changes: 52 additions & 0 deletions bundle/.gnkit/lib/stabilize-agent-docs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env node
/**
* Keep committed agent docs stable across machines.
*
* The GitNexus `analyze` tool injects a `<!-- gitnexus:start -->…<!-- gitnexus:end -->`
* 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 = "<!-- gitnexus:start -->";
const END = "<!-- gitnexus:end -->";
const BLOCK_RE = /\n*<!-- gitnexus:start -->[\s\S]*?<!-- gitnexus:end -->\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(", ")}`,
);
}
}
1 change: 1 addition & 0 deletions bundle/scripts/pack-gitnexus-teaching.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions bundle/scripts/sync-cursor-gitnexus-teaching.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 \
Expand Down
3 changes: 3 additions & 0 deletions lib/kit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
61 changes: 61 additions & 0 deletions lib/kit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
"",
"<!-- gitnexus:start -->",
"This project is indexed by GitNexus (49749 symbols, 117601 relationships).",
"| Components (1103 symbols) | ... |",
"<!-- gitnexus:end -->",
"",
"<!-- gitnexus-agent-kit:BEGIN -->",
"Stable enforcement contract.",
"<!-- gitnexus-agent-kit:END -->",
"",
].join("\n"),
);
// AGENTS.md with the block at the very top.
fs.writeFileSync(
path.join(tmp, "AGENTS.md"),
"<!-- gitnexus:start -->\n(50592 symbols)\n<!-- gitnexus:end -->\n\n<!-- gitnexus-agent-kit:BEGIN -->\nkit\n<!-- gitnexus-agent-kit:END -->\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("<!-- gitnexus:start -->"), "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("<!-- gitnexus:start -->"));
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
Expand Down
Loading