diff --git a/.gitignore b/.gitignore index f7482e5..5a0cd6c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,14 @@ node_modules/ *.tar.gz eval/report.md eval/BENCHMARK-realrepo.md +eval/swebench/results/*/trajectories/ +eval/swebench/results/*/config.yaml + +# Python build artifacts +__pycache__/ +*.pyc +.venv/ + +# Local databases / graph indexes +*.db +*.sqlite diff --git a/README.md b/README.md index 6758c2d..6bbb0b1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # gitnexus-agent-kit -**The enforcement layer for GitNexus — Cursor, Zed, and Ollama** +**The enforcement layer for GitNexus — Cursor, Zed, Claude Code, and Ollama** Hooks (Cursor) · Agent profiles (Zed) · MCP · Skills · Cypher · Autonomous refresh — graph-first reasoning on **every task**, not only when code is unfamiliar. @@ -49,7 +49,11 @@ This kit closes that gap with **IDE-specific enforcement** — Cursor hooks, Zed | --- | --- | --- | | **Cursor** (`--runtime cursor`) | Hooks, rules, `.cursor/mcp.json`, skills | Hard — hooks deny grep/read when graph is fresh | | **Zed** (`--runtime zed`) | `.zed/settings.json`, **Zed + GitNexus** agent profile, `.agents/skills/`, `AGENTS.md` | Profile — grep disabled; Zed model + gitnexus MCP | -| **Both** (`--runtime both`, default) | Everything above | Cursor hard gates + Zed profile for the same repo | +| **Claude Code** (`--runtime claude`) | `.mcp.json`, `.claude/settings.json` hooks, `.claude/skills/`, `CLAUDE.md` | Hard — PreToolUse hooks deny symbol grep / large read / blind edits; commit gated on `detect_changes` | +| **Both** (`--runtime both`, default) | Cursor + Zed | Cursor hard gates + Zed profile for the same repo | +| **All** (`--runtime all`) | Cursor + Zed + Claude Code | Every adapter (also any comma list, e.g. `--runtime cursor,claude`) | + +Claude Code shares the **same enforcement core** as Cursor — the `classify.mjs` policy decides allow/deny, and a small `claude-emit` adapter maps the verdict to Claude Code's `PreToolUse` hook protocol. Same gates, same graph-first loop. Skills live once in `.gitnexus/agent-kit/skills/` and are **symlinked** into `.cursor/skills/` and/or `.agents/skills/` — one source of truth, both IDEs stay in sync on update. diff --git a/bundle/.claude/hooks/gitnexus-bash-guard.mjs b/bundle/.claude/hooks/gitnexus-bash-guard.mjs new file mode 100644 index 0000000..7c489ee --- /dev/null +++ b/bundle/.claude/hooks/gitnexus-bash-guard.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Claude Code PreToolUse (Bash) → staleness gate, plus detect_changes-before-commit gate. +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +let raw = ""; +for await (const c of process.stdin) raw += c; +let input = {}; +try { + input = JSON.parse(raw || "{}"); +} catch { + /* empty */ +} +const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +const lib = (rel) => + import(pathToFileURL(path.join(root, ".gnkit/lib", rel)).href); + +const { classifyShell, classifyCommit } = await lib("classify.mjs"); +const { gnContext, emitVerdict } = await lib("claude-emit.mjs"); +const { isDetectUsed } = await lib("session-primer.mjs"); + +const ctx = gnContext(root); +const command = input.tool_input?.command ?? ""; + +let verdict = classifyShell({ command }, ctx); +if (verdict.decision === "allow") { + const commit = classifyCommit( + { command }, + { ...ctx, detectUsed: isDetectUsed(root) }, + ); + if (commit.decision === "deny") verdict = commit; +} +emitVerdict(verdict, { root, mode: ctx.config.mode }); diff --git a/bundle/.claude/hooks/gitnexus-edit-guard.mjs b/bundle/.claude/hooks/gitnexus-edit-guard.mjs new file mode 100644 index 0000000..a305e85 --- /dev/null +++ b/bundle/.claude/hooks/gitnexus-edit-guard.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Claude Code PreToolUse (Edit|Write|MultiEdit) → staleness + impact-before-edit gate. +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +let raw = ""; +for await (const c of process.stdin) raw += c; +let input = {}; +try { + input = JSON.parse(raw || "{}"); +} catch { + /* empty */ +} +const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +const lib = (rel) => + import(pathToFileURL(path.join(root, ".gnkit/lib", rel)).href); + +const { classifyEdit } = await lib("classify.mjs"); +const { gnContext, emitVerdict } = await lib("claude-emit.mjs"); +const { isImpactUsed } = await lib("session-primer.mjs"); + +const ctx = gnContext(root); +const verdict = classifyEdit( + { toolInput: input.tool_input ?? {} }, + { ...ctx, impactUsed: isImpactUsed(root) }, +); +emitVerdict(verdict, { root, mode: ctx.config.mode }); diff --git a/bundle/.claude/hooks/gitnexus-grep-guard.mjs b/bundle/.claude/hooks/gitnexus-grep-guard.mjs new file mode 100644 index 0000000..a5d4564 --- /dev/null +++ b/bundle/.claude/hooks/gitnexus-grep-guard.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Claude Code PreToolUse (Grep|Glob) → route symbol/field/broad searches to GitNexus. +// Thin glue over the shared classify core; Claude protocol mapping in claude-emit.mjs. +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +let raw = ""; +for await (const c of process.stdin) raw += c; +let input = {}; +try { + input = JSON.parse(raw || "{}"); +} catch { + /* empty */ +} +const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +const lib = (rel) => + import(pathToFileURL(path.join(root, ".gnkit/lib", rel)).href); + +const { classifyGrep } = await lib("classify.mjs"); +const { gnContext, emitVerdict } = await lib("claude-emit.mjs"); + +const ctx = gnContext(root); +const verdict = classifyGrep( + { tool: input.tool_name ?? "", toolInput: input.tool_input ?? {} }, + ctx, +); +emitVerdict(verdict, { root, mode: ctx.config.mode }); diff --git a/bundle/.claude/hooks/gitnexus-mcp-guard.mjs b/bundle/.claude/hooks/gitnexus-mcp-guard.mjs new file mode 100644 index 0000000..38eb52e --- /dev/null +++ b/bundle/.claude/hooks/gitnexus-mcp-guard.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Claude Code PreToolUse (mcp__gitnexus__*) → record graph usage; refresh-first when stale. +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +let raw = ""; +for await (const c of process.stdin) raw += c; +let input = {}; +try { + input = JSON.parse(raw || "{}"); +} catch { + /* empty */ +} +const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +const lib = (rel) => + import(pathToFileURL(path.join(root, ".gnkit/lib", rel)).href); + +const { gnContext, emitVerdict } = await lib("claude-emit.mjs"); +const { setMcpToolUsed, bumpScore } = await lib("session-primer.mjs"); + +const ctx = gnContext(root); +const tool = input.tool_name ?? ""; + +if (ctx.phase === "must_refresh") { + emitVerdict( + { + decision: "deny", + agentMessage: ctx.staleMustRefreshMsg, + userKey: "stale.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"); +} diff --git a/bundle/.claude/hooks/gitnexus-precompact.mjs b/bundle/.claude/hooks/gitnexus-precompact.mjs new file mode 100644 index 0000000..5ad2ec6 --- /dev/null +++ b/bundle/.claude/hooks/gitnexus-precompact.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Claude Code PreCompact → SIDE-EFFECT ONLY: checkpoint durable state to memory + log the compaction. +// +// PreCompact CANNOT inject context: Claude Code allows hookSpecificOutput.additionalContext only on +// UserPromptSubmit / PostToolUse / Stop / SubagentStop — NOT PreCompact (emitting it errors the hook). +// There's also no agent turn between this hook and the compaction. So the "preserve everything / +// lose nothing" steering lands elsewhere: the always-on contract keeps the memory current, and the +// SessionStart(source:compact) recovery brief reconciles it afterward. This hook writes no stdout. +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +let raw = ""; +for await (const c of process.stdin) raw += c; +let input = {}; +try { + input = JSON.parse(raw || "{}"); +} catch { + /* empty */ +} +const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +const lib = (rel) => + import(pathToFileURL(path.join(root, ".gnkit/lib", rel)).href); + +const { gnContext } = await lib("claude-emit.mjs"); +const { appendMemoryCheckpoint, isImpactUsed, isDetectUsed, bumpScore } = + await lib("session-primer.mjs"); + +const ctx = gnContext(root); +bumpScore(root, "compactions"); // surfaced in gitnexus:stats +appendMemoryCheckpoint( + root, + `- trigger: ${input.trigger || "auto"} | index: ${ctx.phase} | gates: impact ${isImpactUsed(root) ? "done" : "pending"}, detect_changes ${isDetectUsed(root) ? "done" : "pending"}\n` + + `- (transcript about to be summarized — task/decisions/open-items/file:line above must already be current)`, +); +// No stdout: PreCompact has no valid context-injection channel; steering is handled by the +// contract + SessionStart(compact) recovery. diff --git a/bundle/.claude/hooks/gitnexus-read-guard.mjs b/bundle/.claude/hooks/gitnexus-read-guard.mjs new file mode 100644 index 0000000..4d0cff9 --- /dev/null +++ b/bundle/.claude/hooks/gitnexus-read-guard.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// Claude Code PreToolUse (Read) → block large source reads; route to query/context. +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +let raw = ""; +for await (const c of process.stdin) raw += c; +let input = {}; +try { + input = JSON.parse(raw || "{}"); +} catch { + /* empty */ +} +const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +const lib = (rel) => + import(pathToFileURL(path.join(root, ".gnkit/lib", rel)).href); + +const { classifyRead } = await lib("classify.mjs"); +const { gnContext, emitVerdict } = await lib("claude-emit.mjs"); +const { readPromptHint } = await lib("session-primer.mjs"); + +const ti = input.tool_input ?? {}; +const filePath = ti.file_path ?? ti.path ?? ""; +const ctx = gnContext(root); +const verdict = classifyRead( + { toolInput: ti }, + { + ...ctx, + promptHint: readPromptHint(root), + readLines: () => { + try { + const abs = path.resolve(root, filePath); + return fs.existsSync(abs) + ? fs.readFileSync(abs, "utf8").split("\n").length + : 0; + } catch { + return 0; + } + }, + }, +); +emitVerdict(verdict, { root, mode: ctx.config.mode }); diff --git a/bundle/.claude/hooks/gitnexus-session.mjs b/bundle/.claude/hooks/gitnexus-session.mjs new file mode 100644 index 0000000..8534866 --- /dev/null +++ b/bundle/.claude/hooks/gitnexus-session.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// Claude Code SessionStart → reset per-session gate flags and inject the GitNexus brief. +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +let raw = ""; +for await (const c of process.stdin) raw += c; +let input = {}; +try { + input = JSON.parse(raw || "{}"); +} catch { + /* empty */ +} +const root = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +const lib = (rel) => + import(pathToFileURL(path.join(root, ".gnkit/lib", rel)).href); + +const { existsSync } = await import("node:fs"); +const { gnContext, emitContext } = await lib("claude-emit.mjs"); +const { + clearSessionState, + shouldClearOnSource, + isImpactUsed, + isDetectUsed, + memoryPath, + fallbackGrant, +} = await lib("session-primer.mjs"); + +const source = input.source || "startup"; +// compact | resume = the SAME task continuing → preserve gates + memory; don't re-arm. +const recovering = !shouldClearOnSource(source); +if (!recovering) clearSessionState(root); + +const ctx = gnContext(root); +const mp = memoryPath(root); // Claude Code's native project memory +const grant = fallbackGrant(root); +const staleLine = grant + ? `⚠ CLASSICAL FALLBACK active (${grant.reason || "GitNexus distrusted"}) — classical Grep/Read/shell allowed for ~${Math.max(1, Math.round(grant.remainingMs / 60000))} min. RE-CONFIRM findings with the graph once GitNexus is reliable; end early with \`npm run gitnexus:fallback:off\`.` + : ctx.phase !== "fresh" + ? "Index is STALE — run `npm run gitnexus:agent-refresh` before graph calls (hooks block until refreshed)." + : "Index is fresh — hooks redirect symbol Grep / large Read / blind edits to the graph."; + +let lines; +if (recovering) { + const hasMem = existsSync(memoryPath(root)); + lines = [ + `GitNexus: context was ${source === "compact" ? "COMPACTED" : "resumed"} — the task CONTINUES; enforcement and this session's satisfied gates are PRESERVED.`, + // Graph-first discipline MUST be re-stated here, not only on fresh start: post-compaction is + // exactly where agents drift back to grep/blind-read. "Gates preserved" ≠ "stop using the graph". + "Graph-first STILL applies — do NOT fall back to grep or blind Read: orient with gitnexus_query, drill with gitnexus_context, cypher for structure, impact before edits, detect_changes before commit.", + `Gates already satisfied: impact ${isImpactUsed(root) ? "✓ done" : "pending"}, detect_changes ${isDetectUsed(root) ? "✓ done" : "pending"} — don't redo those for work you ALREADY analyzed, but DO run impact before any NEW edit and detect_changes before every commit.`, + hasMem + ? `RECOVER from your project memory (${mp}): reconcile it with reality NOW and fill gaps — decisions, requirements, open bugs, user intent, key file:line.` + : `Record the task state you still hold in your project memory (${mp}) — decisions, requirements, open items, key file:line — before continuing.`, + "NOTHING important from before the compaction may be lost — if the summary dropped a requirement/decision/finding, reconstruct it from your memory or the code before acting.", + staleLine, + ]; +} else { + lines = [ + "GitNexus enforcement active (Claude Code). Graph-first on EVERY task — see CLAUDE.md.", + "Orient with gitnexus_query; drill with gitnexus_context; cypher for structure; impact before edits; detect_changes before commit.", + `Keep your project memory current as you work (${mp}) — it survives compaction + sessions; the transcript does not.`, + staleLine, + ]; +} +emitContext(lines.join(" "), "SessionStart"); diff --git a/bundle/.claude/skills/gitnexus-enforcement/SKILL.md b/bundle/.claude/skills/gitnexus-enforcement/SKILL.md deleted file mode 100644 index 923ca3c..0000000 --- a/bundle/.claude/skills/gitnexus-enforcement/SKILL.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -name: gitnexus-enforcement -description: >- - North-star tool router when GitNexus hooks block Grep/Read/SemanticSearch. - Graph + embeddings + cypher reasoning, autonomous refresh when stale, classical fallback when GN fails. -disable-model-invocation: false ---- - -# GitNexus Enforcement & Tool Router - -## North star - -> **GitNexus is the default reasoning layer for every task.** Prefer graph + embeddings when fresh. Use `query` to orient. Use `cypher` for precise structural questions (field ACCESSES, N-hop CALLS, overrides). Refresh autonomously when stale or embeddings missing. Classical tools **only after refresh fails** (or MCP down / GN wrong) — say why in one sentence. - -GitNexus tools are for **reasoning throughout the task**, not only the first lookup or unfamiliar code. Local LLM: rebuild context freely; do not skip gates. - -## Graph + embeddings + cypher (layered) - -| Task | Tool | -| --- | --- | -| Fuzzy concept, flow trace, "how does X work?" | `query` (BM25 + embedding vectors) | -| Known symbol, callers, 360° | `context` | -| Known A→B call path | `trace` | -| Control/data flow | `pdg_query` (`flows` / `controls`) when PDG layer exists | -| Field read/write, overrides, process steps | READ schema → `cypher` | -| Security taint/source→sink | `explain` + `pdg_query` + `trace` | -| Pre-edit safety | `impact` (`mode: "pdg"` for high-risk/PDG-backed precision) | -| Pre-commit / done | `detect_changes` | - -`SemanticSearch` is blocked → always `query`. Field/property grep → `cypher` (`ACCESSES`). Missing embeddings = **stale** → `agent-refresh` (includes `--embeddings`). - -## MCP defaults (generous) - -| Tool | Default | -| --- | --- | -| `context` | `include_content: false` | -| `query` | `limit: 5`, `max_symbols: 12` | -| `cypher` | READ `gitnexus://repo/__GITNEXUS_REPO__/schema` first; use `$params` | -| `impact` | `summaryOnly: false`, `limit: 100` | - -Hooks inject calls with these defaults — run verbatim; expand when needed. - -## Decision tree (follow in order) - -``` -START - │ - ├─ New session / new task? - │ └─ npm run gitnexus:agent-brief OR READ context + schema (autonomous) - │ stale or missing embeddings? → npm run gitnexus:agent-refresh (Shell, required_permissions: ["all"]) - │ - ├─ Reasoning about code (any point in task)? - │ └─ query({search_query, task_context, goal, repo}) # graph + embeddings - │ └─ context({name}) or context({uid}) - │ └─ Structural precision needed? - │ ├─ field read/write → cypher ACCESSES - │ ├─ N-hop call chain → cypher CALLS path - │ ├─ overrides / process steps → cypher (see schema) - │ └─ READ process trace if cross-module - │ └─ impact when considering edits - │ └─ Read offset/limit ONLY for exact edit lines - │ - ├─ About to RENAME symbol X → Y (prompt or StrReplace)? - │ └─ impact({target: X, direction: "upstream"}) → rename({symbol_name: X, new_name: Y, dry_run: true}) - │ preview → apply dry_run: false OR manual edits following map - │ - ├─ About to EDIT src/, tests/, apps/, scripts/? - │ └─ impact({target, direction: "upstream"}) FIRST - │ report d=1 + risk → then edit - │ - ├─ About to COMMIT or say "done"? - │ └─ detect_changes({scope: "unstaged"}) - │ - └─ Hook blocked Grep/Read? - ├─ Index stale / embeddings missing / check failed? → run `agent-refresh` FIRST (hooks block classical until refresh succeeds or fails) - ├─ Refresh failed / MCP down? → classical OK; tell user why - ├─ GN suspicious after uid retry + graph used this session? → scoped Grep or Read; tell user why - └─ Otherwise → run the **exact** MCP call from hook agent_message (copy-paste) -``` - -## Hook block → copy-paste replacements - -When blocked, hooks return ready-to-run calls like: - -```javascript -gitnexus_query({ search_query: "auth flow", task_context: "...", goal: "...", repo: "__GITNEXUS_REPO__", limit: 5, max_symbols: 12 }) -gitnexus_context({ name: "", repo: "__GITNEXUS_REPO__" }) -READ gitnexus://repo/__GITNEXUS_REPO__/schema -gitnexus_cypher({ statement: "MATCH (f)-[r:CodeRelation {type: 'ACCESSES'}]->(p:Property {name: $name}) RETURN f.name, f.filePath, r.reason", params: { name: "" }, repo: "__GITNEXUS_REPO__" }) -gitnexus_impact({ target: "", direction: "upstream", repo: "__GITNEXUS_REPO__", summaryOnly: false, limit: 100 }) -``` - -## Classical fallback (when NOT to trust GitNexus) - -| Signal | What to do | -| --- | --- | -| **Stale index** or **missing embeddings** | Hooks block classical — run `agent-refresh` first; edits blocked until fresh | -| **Refresh failed** (ENOSPC, MCP down) | Classical OK; warn user; retry refresh once if feasible | -| **0 upstream** on a known hub | `context({uid})` retry once → scoped Grep in GN-named file (after ≥1 MCP call this session) | -| **impact vs detect_changes** disagree | Trust `detect_changes`; verify with Read/Grep | -| **Wrong/missing file** from graph | Classical Read/Grep; mention GN drift | -| **MCP unreachable** | Warn user; classical OK | - -**Always:** one sentence to the user explaining the bypass. - -## Hook block → replacement (fresh index) - -| Blocked | Replacement | -| --- | --- | -| `Grep("someFunctionName")` | `context({name: "someFunctionName"})` | -| `Grep("address")` (field/property) | READ schema → `cypher` ACCESSES on `$name: "address"` | -| `SemanticSearch("auth flow")` | `query({search_query: "auth flow", task_context, goal})` — uses embeddings | -| `Glob("src/**/*.js")` | `query({search_query: "module area", goal: "entry points"})` | -| `Read(entire large source file)` | `query` → `context` → Read offset/limit | -| Scoped Grep before any GN MCP call | `context` first — scoped Grep only after graph use + suspicion | - -When index is **stale**, hooks **block** classical patterns until refresh succeeds or fails — run `agent-refresh` first. - -## Autonomous agent CLI - -```bash -npm run gitnexus:agent-brief # session orientation + suggested calls -npm run gitnexus:agent-status # exit 1 if stale or embeddings missing -npm run gitnexus:agent-refresh # analyze --embeddings + sync — when stale -``` - -**NEVER** tell the user to run `npx gitnexus analyze` — that is agent work. - -## When hooks can't help (Grep is correct) - -- Config / fixture files (`*.json`, `*.yaml`) — literal values -- Exact string in logs/comments -- Config keys / IDs in data files -- Validating docs paths exist - -## Before saying "done" - -If you edited code: `detect_changes` + summarize affected processes and risk. diff --git a/bundle/.claude/skills/gitnexus-workspace/SKILL.md b/bundle/.claude/skills/gitnexus-workspace/SKILL.md deleted file mode 100644 index 936482f..0000000 --- a/bundle/.claude/skills/gitnexus-workspace/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: gitnexus-workspace -description: >- - Master index for __GITNEXUS_REPO__ GitNexus usage in Cursor. Use at the start - of any code task — exploration, edits, refactors, PR review, or API changes. - Teaches workflow chain, anti-patterns, and which skill to load. ---- - -# GitNexus Workspace (__GITNEXUS_REPO__) - -This repo replaces grep-first navigation with a **knowledge graph + embeddings + Cypher/PDG** for **all code reasoning** (not only the first lookup). **`query`** uses BM25 + semantic vectors for orient/explore. **`trace`** answers known A→B call paths. **`pdg_query`** answers control/data-flow questions when the PDG layer exists. **`cypher`** answers precise graph questions (field ACCESSES, overrides, process steps). **`rename`** coordinates multi-file symbol renames (dry_run first). **Hooks actively block** lazy patterns when the index is fresh; **autonomous refresh** when stale or embeddings missing; **classical fallback** when GN fails — see `00-gitnexus-enforcement` rule. - -## Mandatory workflow chain - -Do not skip steps: - -``` -READ gitnexus://repo/__GITNEXUS_REPO__/context # or npm run gitnexus:agent-brief (autonomous) -READ gitnexus://repo/__GITNEXUS_REPO__/schema # before ad-hoc Cypher -→ query({search_query, task_context, goal, repo, limit: 5, max_symbols: 12}) # graph + embeddings — orient -→ context({name, include_content: false}) or context({uid, include_content: false}) -→ cypher({statement, params}) # structural: field ACCESSES, N-hop CALLS, overrides, process steps -→ impact({target, direction: "upstream", summaryOnly: false, limit: 100}) # BEFORE edit -→ detect_changes({scope}) # BEFORE commit / PR -``` - -**Renames:** `impact` → `rename({symbol_name, new_name, dry_run: true})` — never find-and-replace symbols. - -Stale, missing embeddings, or wrong graph? **`npm run gitnexus:agent-refresh`** autonomously (Shell, `required_permissions: ["all"]`) — includes `--embeddings`; hook pre-approves, do not ask user. - -## HTTP API routing (auto-detected at install) - -After index build, the kit writes `.cursor/gitnexus-api-profile.json`: - -| Profile | Use | -| --- | --- | -| `framework` | `api_impact` / `route_map` / `shape_check` — indexed Route nodes | -| `custom` | **`gitnexus-api-routes`** skill — context on the dispatcher symbol (e.g. `dispatchRequest`) | -| `framework-likely` | Try `api_impact`; if empty, fall back to custom playbook | -| `none` | No HTTP layer detected | - -Run `npm run gitnexus:detect-api` to refresh the profile after major server changes. - -## Pick the right skill - -| Situation | Read | -| --- | --- | -| Unfamiliar code / architecture | `gitnexus-exploring` | -| Pipelines / cross-module flows / "how does X connect" | `gitnexus-imaging` | -| Field read/write / data flow | `cypher` ACCESSES (READ schema) — hooks block field grep | -| Before editing / blast radius | `gitnexus-impact-analysis` | -| Bug / failure / wrong behavior | `gitnexus-debugging` | -| Rename / extract / refactor | `gitnexus-refactoring` + **`rename` MCP** | -| Structured task (pre-commit, PR, cross-module) | `gitnexus-scenarios` | -| PR or branch review | `gitnexus-pr-review` | -| Security / taint / injection review | `gitnexus-security-review` | -| Research HTTP API change | See **HTTP API routing** above | -| Tool reference / Cypher / CLI | `gitnexus-guide` / `gitnexus-cli` | -| Area entry points | `.claude/skills/generated//` | -| Hook blocked Grep/Read | `gitnexus-enforcement` (staleness + suspicion fallback) | -| Full agent contract | `.cursor/rules/gitnexus.mdc` + `00-gitnexus-enforcement.mdc` | - -## Smart query habits - -Always pass context to rank results better: - -```javascript -query({ - search_query: "", - task_context: "what you are doing in this chat", - goal: "what you need to find", - repo: "__GITNEXUS_REPO__", - limit: 5, - max_symbols: 12 -}) -``` - -Pass `task_context` + `goal` — they improve **embedding** ranking, not just keyword match. - -## Anti-patterns (grep is wrong tool) - -- Symbol lookup → `context`, not Grep -- Field/property data flow → `cypher` ACCESSES, not Grep field name -- Understand a module → `query`, not Read whole file (data-flow reads → Cypher first) -- Change scope → `detect_changes`, not git diff \| grep -- Rename symbol → **`rename` dry_run**, not StrReplace across files -- Research API route → profile-driven (`api_impact` vs `gitnexus-api-routes`) - -Grep **is** correct for: preset JSON, log strings, comments, exact config keys in YAML. - -## Persistence in Cursor - -Installed by `npm run gitnexus:setup`: - -- **Enforcement rule** — `.cursor/rules/00-gitnexus-enforcement.mdc` (only `alwaysApply: true` contract) -- **Reference rules** — `.cursor/rules/gitnexus.mdc` + `gitnexus-first.mdc` (load on demand) -- **Hooks** — GN-first when fresh; **refresh-first when stale** (classical only after refresh fails); field grep → Cypher; large data-flow Read → Cypher -- **Skills** — synced to `.cursor/skills/` from this repo's `.claude/skills/` -- **MCP** — `gitnexus` in `.cursor/mcp.json` - -Restart Cursor after setup so MCP + hooks load. - -## Power moves - -| Need | Tool / param | -| --- | --- | -| Ambiguous symbol name | `context({uid: "..."})` from prior output | -| Field read/write trace | `cypher` with `ACCESSES` | -| N-hop call chain | `cypher` CALLS variable-length path | -| Coordinated rename | `rename({symbol_name, new_name, dry_run: true})` | -| Class member blast radius | `impact` + `relationTypes: ["CALLS","IMPORTS","ACCESSES"]` | -| Branch status / PR setup | `npm run gitnexus:branch-status -- ` | -| PR vs main | `detect_changes({scope: "compare", base_ref: "main", branch: ""})` | -| Graph integrity check | `npm run gitnexus:graph-smoke` | -| Architecture doc | MCP prompt `generate_map` | diff --git a/bundle/.claude/skills/gitnexus/gitnexus-api-routes/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-api-routes/SKILL.md deleted file mode 100644 index 6771a7f..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-api-routes/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: gitnexus-api-routes -description: "HTTP API route changes. Use api_impact/route_map/shape_check on framework routers (Express/Fastify/Hono/Next); use graph tools on dispatcher symbols when the project has a custom hand-rolled router (no indexed Route nodes). Examples: add endpoint, change route, trace handler + consumers." ---- - -# API Routes - -The kit auto-detects the router style at install and writes `.cursor/gitnexus-api-profile.json`. -Run `npm run gitnexus:detect-api` to refresh it after major server changes. Route the work by profile. - -| Profile | What it means | Use | -| --- | --- | --- | -| `framework` | Indexed `Route` nodes exist | `api_impact` → `route_map` → `shape_check` | -| `framework-likely` | Framework imports seen, no Route nodes yet | Try `api_impact`; if empty after refresh, treat as custom | -| `custom` | Hand-rolled dispatcher, zero Route nodes | Graph tools on dispatcher symbols (below) | -| `none` | No HTTP layer detected | Skip API tooling | - -## Framework routers (Express / Fastify / Hono / Next route handlers) - -``` -1. gitnexus_api_impact({ route: "/api/", repo: "__GITNEXUS_REPO__" }) # consumers + shape + risk -2. gitnexus_route_map({ route: "/api/" }) # handler + middleware chain -3. gitnexus_shape_check({ route: "/api/" }) # response keys vs consumer access -4. gitnexus_impact upstream on the handler symbol BEFORE editing -5. gitnexus_detect_changes before commit -``` - -`api_impact` is the one-stop pre-change report — prefer it over calling the three separately. - -## Custom hand-rolled router (no indexed Route nodes) - -When `api_impact` / `route_map` return zero routes, the project dispatches HTTP itself. Use the graph on -the dispatcher + handler symbols instead of Route tooling. - -``` -1. gitnexus_query({ - search_query: " request handler", - task_context: "API route change", - goal: "find dispatcher + handler + response envelope" - }) -2. gitnexus_context({ name: "" }) # e.g. the request router / matcher -3. gitnexus_context({ name: "" }) -4. gitnexus_impact upstream on the handler BEFORE editing -5. Update any client/consumer that mirrors the response shape (typed API client, SDK) -6. gitnexus_detect_changes before commit -``` - -Find the dispatcher symbol from the profile's `customSymbols`, or query for "request handler / router / dispatch". - -## Checklist - -``` -- [ ] Profile checked (.cursor/gitnexus-api-profile.json or npm run gitnexus:detect-api) -- [ ] context on dispatcher (custom) OR api_impact (framework) -- [ ] impact upstream on the handler symbol -- [ ] Response envelope / schema preserved (or consumers updated in the same change) -- [ ] Client/SDK types match the new payload shape -- [ ] gitnexus_detect_changes before commit; tests for affected processes green -``` - -## Finding consumers - -``` -gitnexus_shape_check({ route: "/api/" }) # framework: keys returned vs keys accessed -gitnexus_query({ search_query: " fetch client", goal: "find consumer" }) # custom: trace the caller -gitnexus_context({ name: "" }) # incoming edges = who depends on it -``` - -Grep is appropriate for URL string literals in a typed API client when you already know the file. diff --git a/bundle/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-cli/SKILL.md deleted file mode 100644 index 989c082..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: gitnexus-cli -description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" ---- - -# GitNexus CLI Commands - -Commands below use `node .gitnexus/run.cjs ` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required. - -> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939). - -## Commands - -### analyze — Build or refresh the index - -```bash -node .gitnexus/run.cjs analyze -``` - -Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. - -| Flag | Effect | -| -------------- | ---------------------------------------------------------------- | -| `--force` | Force full re-index even if up to date | -| `--embeddings` | Enable embedding generation for semantic search (off by default) | -| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | - -**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. - -### status — Check index freshness - -```bash -node .gitnexus/run.cjs status -``` - -Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. - -### clean — Delete the index - -```bash -node .gitnexus/run.cjs clean -``` - -Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. - -| Flag | Effect | -| --------- | ------------------------------------------------- | -| `--force` | Skip confirmation prompt | -| `--all` | Clean all indexed repos, not just the current one | - -### wiki — Generate documentation from the graph - -```bash -node .gitnexus/run.cjs wiki -``` - -Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). - -| Flag | Effect | -| ------------------- | ----------------------------------------- | -| `--force` | Force full regeneration | -| `--model ` | LLM model (default: minimax/minimax-m2.5) | -| `--base-url ` | LLM API base URL | -| `--api-key ` | LLM API key | -| `--concurrency ` | Parallel LLM calls (default: 3) | -| `--gist` | Publish wiki as a public GitHub Gist | - -### list — Show all indexed repos - -```bash -node .gitnexus/run.cjs list -``` - -Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. - -## After Indexing - -1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded -2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task - -## Troubleshooting - -- **"Not inside a git repository"**: Run from a directory inside a git repo -- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server -- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/bundle/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md deleted file mode 100644 index 30ccf85..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -name: gitnexus-debugging -description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" ---- - -# Debugging with GitNexus - -## When to Use - -- "Why is this function failing?" -- "Trace where this error comes from" -- "Who calls this method?" -- "This endpoint returns 500" -- Investigating bugs, errors, or unexpected behavior - -## Workflow - -``` -1. query({search_query: ""}) → Find related execution flows -2. context({name: ""}) → See callers/callees/processes -3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow -4. trace({from, to}) → Shortest known A→B call path -5. pdg_query({mode: "controls"|"flows"}) → Guards / data flow when PDG exists -``` - -> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. - -## Checklist - -``` -- [ ] Understand the symptom (error message, unexpected behavior) -- [ ] query for error text or related code -- [ ] Identify the suspect function from returned processes -- [ ] context to see callers and callees -- [ ] Trace execution flow via process resource if applicable -- [ ] cypher for custom call chain traces if needed -- [ ] Read source files to confirm root cause -``` - -## Debugging Patterns - -| Symptom | GitNexus Approach | -| -------------------- | ---------------------------------------------------------- | -| Error message | `query` for error text → `context` on throw sites | -| Wrong return value | `context` on the function → trace callees for data flow | -| Intermittent failure | `context` → look for external calls, async deps | -| Performance issue | `context` → find symbols with many callers (hot paths) | -| Recent regression | `detect_changes` to see what your changes affect | - -## Tools - -**query** — find code related to error: - -``` -query({search_query: "payment validation error"}) -→ Processes: CheckoutFlow, ErrorHandling -→ Symbols: validatePayment, handlePaymentError, PaymentException -``` - -**context** — full context for a suspect: - -``` -context({name: "validatePayment"}) -→ Incoming calls: processCheckout, webhookHandler -→ Outgoing calls: verifyCard, fetchRates (external API!) -→ Processes: CheckoutFlow (step 3/7) -``` - -**cypher** — custom call chain traces: - -```cypher -MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) -RETURN [n IN nodes(path) | n.name] AS chain -``` - -## Example: "Payment endpoint returns 500 intermittently" - -``` -1. query({search_query: "payment error handling"}) - → Processes: CheckoutFlow, ErrorHandling - → Symbols: validatePayment, handlePaymentError - -2. context({name: "validatePayment"}) - → Outgoing calls: verifyCard, fetchRates (external API!) - -3. READ gitnexus://repo/my-app/process/CheckoutFlow - → Step 3: validatePayment → calls fetchRates (external) - -4. Root cause: fetchRates calls external API without proper timeout -``` diff --git a/bundle/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md deleted file mode 100644 index 38f828e..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -name: gitnexus-exploring -description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" ---- - -# Exploring Codebases with GitNexus - -## When to Use - -- "How does authentication work?" -- "What's the project structure?" -- "Show me the main components" -- "Where is the database logic?" -- Understanding code you haven't seen before - -## Workflow - -``` -1. READ gitnexus://repo/{name}/context → Codebase overview, check staleness -2. query({search_query: ""}) → Find related execution flows -3. context({name: ""}) → Deep dive on specific symbol -4. cypher({statement, params}) → Field ACCESSES, N-hop chains, overrides (READ schema first) -5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow -``` - -> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. - -## Checklist - -``` -- [ ] READ gitnexus://repo/{name}/context -- [ ] query for the concept you want to understand -- [ ] Review returned processes (execution flows) -- [ ] context on key symbols for callers/callees -- [ ] cypher for field data flow or custom call chains if context is not enough (READ schema first) -- [ ] READ process resource for full execution traces -- [ ] Read source files for implementation details -``` - -## Resources - -| Resource | What you get | -| --------------------------------------- | ------------------------------------------------------- | -| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | -| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | -| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | -| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | - -## Tools - -**query** — find execution flows related to a concept: - -``` -query({search_query: "payment processing"}) -→ Processes: CheckoutFlow, RefundFlow, WebhookHandler -→ Symbols grouped by flow with file locations -``` - -**context** — 360-degree view of a symbol: - -``` -context({name: "validateUser"}) -→ Incoming calls: loginHandler, apiMiddleware -→ Outgoing calls: checkToken, getUserById -→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) -``` - -## Example: "How does payment processing work?" - -``` -1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes -2. query({search_query: "payment processing"}) - → CheckoutFlow: processPayment → validateCard → chargeStripe - → RefundFlow: initiateRefund → calculateRefund → processRefund -3. context({name: "processPayment"}) - → Incoming: checkoutHandler, webhookHandler - → Outgoing: validateCard, chargeStripe, saveTransaction -4. Read src/payments/processor.ts for implementation details -``` diff --git a/bundle/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-guide/SKILL.md deleted file mode 100644 index a543378..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -name: gitnexus-guide -description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" ---- - -# GitNexus Guide - -Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. - -## Always Start Here - -For any task involving code understanding, debugging, impact analysis, or refactoring: - -1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness -2. **Match your task to a skill below** and **read that skill file** -3. **Follow the skill's workflow and checklist** - -> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. - -## Skills - -| Task | Skill to read | -| -------------------------------------------- | ------------------- | -| Understand architecture / "How does X work?" | `gitnexus-exploring` | -| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | -| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | -| Rename / extract / split / refactor | `gitnexus-refactoring` | -| Tools, resources, schema reference | `gitnexus-guide` (this file) | -| Index, status, clean, wiki CLI commands | `gitnexus-cli` | - -## Tools Reference - -| Tool | What it gives you | -| ---------------- | ------------------------------------------------------------------------ | -| `query` | Process-grouped code intelligence — execution flows related to a concept | -| `context` | 360-degree symbol view — categorized refs, processes it participates in | -| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | -| `detect_changes` | Git-diff impact — what do your current changes affect | -| `rename` | Multi-file coordinated rename with confidence-tagged edits | -| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | -| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) | - -### Paginating `list_repos` - -`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns: - -```jsonc -{ - "repositories": [ - { "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } } - ], - "pagination": { - "total": 437, - "limit": 50, - "offset": 0, - "returned": 50, - "hasMore": true, - "nextOffset": 50 - } -} -``` - -To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`: - -```text -list_repos {} → repos 1–50, nextOffset 50, hasMore true -list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true -… -list_repos { offset: 400 } → repos 401–437, hasMore false (done) -``` - -Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged. - -## Resources Reference - -Lightweight reads (~100-500 tokens) for navigation: - -| Resource | Content | -| ---------------------------------------------- | ----------------------------------------- | -| `gitnexus://repo/{name}/context` | Stats, staleness check | -| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | -| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | -| `gitnexus://repo/{name}/processes` | All execution flows | -| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | -| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | - -## Graph Schema - -**Nodes:** File, Function, Class, Interface, Method, Community, Process -**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS - -```cypher -MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) -RETURN caller.name, caller.filePath -``` diff --git a/bundle/.claude/skills/gitnexus/gitnexus-imaging/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-imaging/SKILL.md deleted file mode 100644 index 39d9f70..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-imaging/SKILL.md +++ /dev/null @@ -1,176 +0,0 @@ ---- -name: gitnexus-imaging -description: >- - Graph-first mental models for pipelines, call chains, and cross-module flows. - Use when explaining architecture, tracing business flows, mapping functional areas, or - answering "how does X connect to Y?" — never reconstruct structure from grep. ---- - -# GitNexus Imaging (graph-first thinking) - -Use this skill when the task needs **structure in your head** — not a single symbol lookup. - -**Output contract:** Cite a GitNexus **process** or **cluster** for cross-module flows when the index is fresh. If none found or results look wrong, say so and use classical Read/Grep to verify — tell the user why. - -## When to fall back to classical tools - -| Situation | Action | -| --- | --- | -| Index stale | Hooks allow Grep/Read/Search; prefer refresh before edits | -| 0 callers on known hub | Retry `context({uid})`, then scoped Grep in the file GN named | -| impact vs detect_changes conflict | Trust detect_changes; verify in source | -| Cannot cite any process after query | Say index may be stale or GN incomplete; verify with Read | - -Always tell the user in one sentence when bypassing graph-first imaging. - -## When to use (vs other skills) - -| Question type | Skill | -| --- | --- | -| "How does feature X flow end-to-end?" | **gitnexus-imaging** (this) | -| "What calls ``?" | `gitnexus-exploring` / `context` | -| "Safe to change X?" | `gitnexus-impact-analysis` | -| "Why is this failing?" | `gitnexus-debugging` | -| Pre-commit / PR checklist | `gitnexus-scenarios` | - -## Thinking modes → tools - -| Mental model | Tool | Bad habit | -| --- | --- | --- | -| Business flow ("request → handler → storage") | `query` → **processes** | Read 5 files linearly | -| Call chain ("who calls X?") | `context` (incoming CALLS) | `Grep("X")` | -| Module map ("what lives in area Y?") | `READ clusters` → `cluster/{Area}` | Broad Glob on `src/` | -| Pipeline step trace | `READ process/{name}` | Follow imports manually | -| Blast radius | `impact` + `detect_changes` | Grep callers | -| Field/data flow | `cypher` with `ACCESSES` | Grep field name | - -## Mandatory prep - -``` -READ gitnexus://repo/__GITNEXUS_REPO__/context → staleness first -``` - -If stale → **Cursor agents:** `npm run gitnexus:agent-refresh` autonomously. **Humans/CI:** `npm run gitnexus:refresh`. Hooks block runtime edits until fresh. - ---- - -## Recipe 1 — Explain a pipeline / business flow - -**Trigger:** "How does X work?", "Explain the pipeline", "What happens when I run Y?" - -``` -1. READ context (staleness) -2. query({ - search_query: "", - task_context: "", - goal: "find execution flows and entry symbols", - repo: "__GITNEXUS_REPO__" - }) -3. Pick top 1–3 processes from results -4. READ gitnexus://repo/__GITNEXUS_REPO__/process/{name} for each -5. context({name}) on entry + hub symbols (2–4 symbols max) -6. Read source ONLY at lines cited by context/process — use offset/limit -``` - -**Deliverable format:** - -```markdown -## Flow: {ProcessName} -Entry: `symbol` → … → exit -Steps: (from process trace) -Hub nodes: symbols with most callers -Modules touched: (cluster names from the graph) -``` - ---- - -## Recipe 2 — Map a functional area - -**Trigger:** "What's in area X?", "Map the module" - -``` -1. READ gitnexus://repo/__GITNEXUS_REPO__/clusters -2. READ gitnexus://repo/__GITNEXUS_REPO__/cluster/{AreaName} -3. query({ search_query: "{AreaName} entry points", task_context: "area map", goal: "entry symbols" }) -4. context on 2–3 entry symbols listed in cluster -``` - ---- - -## Recipe 3 — Trace a call chain (depth) - -**Trigger:** "What calls X?", "Trace from CLI to core" - -``` -1. context({ name: "X", repo: "__GITNEXUS_REPO__" }) -2. Walk incoming CALLS (d=1) — do not grep -3. For each caller, note which processes include it -4. Optional: READ process/{name} to see step order -``` - -Stop at process / cluster boundaries, not every leaf. - ---- - -## Recipe 4 — Trace a data field - -**Trigger:** "Who reads/writes ``?", "Where is `` consumed?" - -``` -1. READ gitnexus://repo/__GITNEXUS_REPO__/schema (if unfamiliar with cypher) -2. cypher — ACCESSES edges with reason read/write on field name -3. context on writers first, then readers -4. detect_changes if field changed in WIP -``` - -Widen impact with `relationTypes: ["CALLS","IMPORTS","ACCESSES"]` when editing fields. - ---- - -## Recipe 5 — Cross-module spine - -**Trigger:** A change spanning several modules (e.g. entry → core → storage → client). - -Discover the repo's high-value spines from the graph instead of guessing: - -``` -1. READ gitnexus://repo/__GITNEXUS_REPO__/clusters → top functional areas -2. READ gitnexus://repo/__GITNEXUS_REPO__/processes → longest / most-connected flows -3. query({ search_query: " end to end", task_context: "cross-module change", goal: "spine processes" }) -``` - -After `query`, run `detect_changes` on WIP — it often shows cross-community blast that `impact` on a single symbol misses. - ---- - -## Worked example (generic) - -``` -1. READ context -2. query({ - search_query: " workflow", - task_context: "explain the pipeline", - goal: "processes and hub symbols", - repo: "__GITNEXUS_REPO__" - }) - → expect a process with the entry symbol + a few hubs -3. READ process/{top process name} -4. context({ name: "" }) -5. Summarize with process name + hub symbols — then cite file:line for details -``` - ---- - -## Anti-patterns - -- Describing a 3+ module flow from memory when index is fresh and GN was not tried -- Reading entire adapter files before `query` -- Skipping `process/{name}` when user asked "how does the flow work" -- Trusting `impact` alone on WIP — pair with `detect_changes` for cross-module edits -- Bypassing GN without a stated reason when index is fresh - -## Related - -- Master index: `gitnexus-workspace` -- Structured checklists: `gitnexus-scenarios` -- HTTP routes: `gitnexus-api-routes` (framework `api_impact` or custom dispatcher) diff --git a/bundle/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md deleted file mode 100644 index 89a4927..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -name: gitnexus-impact-analysis -description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" ---- - -# Impact Analysis with GitNexus - -## When to Use - -- "Is it safe to change this function?" -- "What will break if I modify X?" -- "Show me the blast radius" -- "Who uses this code?" -- Before making non-trivial code changes -- Before committing — to understand what your changes affect - -## Workflow - -``` -1. impact({target: "X", direction: "upstream"}) → What depends on this -2. READ gitnexus://repo/{name}/processes → Check affected execution flows -3. detect_changes() → Map current git changes to affected flows -4. Assess risk and report to user -``` - -> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. - -## Checklist - -``` -- [ ] impact({target, direction: "upstream"}) to find dependents -- [ ] For high-risk runtime/security/core edits: impact({target, direction: "upstream", mode: "pdg"}) if PDG layer exists -- [ ] Review d=1 items first (these WILL BREAK) -- [ ] Check high-confidence (>0.8) dependencies -- [ ] READ processes to check affected execution flows -- [ ] detect_changes() for pre-commit check -- [ ] Assess risk level and report to user -``` - -## Understanding Output - -| Depth | Risk Level | Meaning | -| ----- | ---------------- | ------------------------ | -| d=1 | **WILL BREAK** | Direct callers/importers | -| d=2 | LIKELY AFFECTED | Indirect dependencies | -| d=3 | MAY NEED TESTING | Transitive effects | - -## Risk Assessment - -| Affected | Risk | -| ------------------------------ | -------- | -| <5 symbols, few processes | LOW | -| 5-15 symbols, 2-5 processes | MEDIUM | -| >15 symbols or many processes | HIGH | -| Critical path (auth, payments) | CRITICAL | - -## Tools - -**impact** — the primary tool for symbol blast radius: - -``` -impact({ - target: "validateUser", - direction: "upstream", - minConfidence: 0.8, - maxDepth: 3 -}) - -→ d=1 (WILL BREAK): - - loginHandler (src/auth/login.ts:42) [CALLS, 100%] - - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] - -→ d=2 (LIKELY AFFECTED): - - authRouter (src/routes/auth.ts:22) [CALLS, 95%] -``` - -**detect_changes** — git-diff based impact analysis: - -``` -detect_changes({scope: "staged"}) - -→ Changed: 5 symbols in 3 files -→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline -→ Risk: MEDIUM -``` - -## Example: "What breaks if I change validateUser?" - -``` -1. impact({target: "validateUser", direction: "upstream"}) - → d=1: loginHandler, apiMiddleware (WILL BREAK) - → d=2: authRouter, sessionManager (LIKELY AFFECTED) - -2. READ gitnexus://repo/my-app/processes - → LoginFlow and TokenRefresh touch validateUser - -3. Risk: 2 direct callers, 2 processes = MEDIUM -``` diff --git a/bundle/.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md deleted file mode 100644 index 87c5c07..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-pr-review/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: gitnexus-pr-review -description: "Use when reviewing a pull request, understanding what a PR changes, assessing merge risk, or checking test coverage gaps. Examples: \"Review this PR\", \"What does PR #42 change?\", \"Is this PR safe to merge?\"" ---- - -# PR Review with GitNexus - -## When to Use - -- Reviewing a branch before merge -- Assessing risk of a teammate's changes -- Preparing PR description / test plan from actual blast radius - -## Workflow - -``` -1. `npm run gitnexus:branch-status -- ` to confirm current branch/base and suggested MCP calls -2. gitnexus_detect_changes({ scope: "compare", base_ref: "main", repo: "__GITNEXUS_REPO__", branch: "" }) -3. Review summary.risk_level, changed_symbols, affected_processes -4. For HIGH/CRITICAL or unexpected processes → impact on changed entry points with the same `branch` -5. For security/input/file/db/exec changes → `gitnexus-security-review` (`explain`, `pdg_query`, `trace`) -6. Recommend tests per affected process -``` - -## Checklist - -``` -- [ ] detect_changes compare against main (or PR base branch) -- [ ] Risk level acceptable for change intent? -- [ ] affected_processes match PR description? -- [ ] Any surprise cross-community flows (changes spanning unrelated clusters)? -- [ ] Entry-point symbols get individual impact upstream -- [ ] API payload changes paired with their client/consumer (shape_check) -- [ ] Config/fixture-only changes → relevant tests green -- [ ] Index was fresh during review (context resource) -``` - -## Risk interpretation - -| detect_changes risk | Action | -| --- | --- | -| LOW | Spot-check affected processes + related tests | -| MEDIUM | Run all affected process test dirs | -| HIGH | Full integration tests; require explicit reviewer sign-off | -| CRITICAL | Treat as architectural change — verify every affected_process | - -## What GitNexus adds over git diff - -- Maps hunks to **symbols**, not just files -- Traces **execution flows** (processes) impacted -- Surfaces **cross-module** effects grep misses -- Gives **risk level** heuristic for prioritization - -## Example - -``` -detect_changes({scope: "compare", base_ref: "main"}) -→ 12 changed symbols, 8 affected processes -→ -→ Risk: CRITICAL - -Follow-up: -→ impact upstream on each changed entry symbol -→ Recommend: tests covering the affected processes -→ Flag: change crosses multiple unrelated clusters — confirm intentional -``` - -## Related - -- Scenario playbooks: `gitnexus-scenarios/SKILL.md` -- Impact depth: `gitnexus-impact-analysis/SKILL.md` diff --git a/bundle/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md deleted file mode 100644 index 90c8c32..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: gitnexus-refactoring -description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" ---- - -# Refactoring with GitNexus - -## When to Use - -- "Rename this function safely" -- "Extract this into a module" -- "Split this service" -- "Move this to a new file" -- Any task involving renaming, extracting, splitting, or restructuring code - -## Workflow - -``` -1. impact({target: "X", direction: "upstream"}) → Map all dependents -2. query({search_query: "X"}) → Find execution flows involving X -3. context({name: "X"}) → See all incoming/outgoing refs -4. Plan update order: interfaces → implementations → callers → tests -``` - -> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. - -## Checklists - -### Rename Symbol - -``` -- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits -- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) -- [ ] If satisfied: rename({..., dry_run: false}) — apply edits -- [ ] detect_changes() — verify only expected files changed -- [ ] Run tests for affected processes -``` - -### Extract Module - -``` -- [ ] context({name: target}) — see all incoming/outgoing refs -- [ ] impact({target, direction: "upstream"}) — find all external callers -- [ ] Define new module interface -- [ ] Extract code, update imports -- [ ] detect_changes() — verify affected scope -- [ ] Run tests for affected processes -``` - -### Split Function/Service - -``` -- [ ] context({name: target}) — understand all callees -- [ ] Group callees by responsibility -- [ ] impact({target, direction: "upstream"}) — map callers to update -- [ ] Create new functions/services -- [ ] Update callers -- [ ] detect_changes() — verify affected scope -- [ ] Run tests for affected processes -``` - -## Tools - -**rename** — automated multi-file rename: - -``` -rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) -→ 12 edits across 8 files -→ 10 graph edits (high confidence), 2 ast_search edits (review) -→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] -``` - -**impact** — map all dependents first: - -``` -impact({target: "validateUser", direction: "upstream"}) -→ d=1: loginHandler, apiMiddleware, testUtils -→ Affected Processes: LoginFlow, TokenRefresh -``` - -**detect_changes** — verify your changes after refactoring: - -``` -detect_changes({scope: "all"}) -→ Changed: 8 files, 12 symbols -→ Affected processes: LoginFlow, TokenRefresh -→ Risk: MEDIUM -``` - -**cypher** — custom reference queries: - -```cypher -MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) -RETURN caller.name, caller.filePath ORDER BY caller.filePath -``` - -## Risk Rules - -| Risk Factor | Mitigation | -| ------------------- | ----------------------------------------- | -| Many callers (>5) | Use rename for automated updates | -| Cross-area refs | Use detect_changes after to verify scope | -| String/dynamic refs | query to find them | -| External/public API | Version and deprecate properly | - -## Example: Rename `validateUser` to `authenticateUser` - -``` -1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) - → 12 edits: 10 graph (safe), 2 ast_search (review) - → Files: validator.ts, login.ts, middleware.ts, config.json... - -2. Review ast_search edits (config.json: dynamic reference!) - -3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) - → Applied 12 edits across 8 files - -4. detect_changes({scope: "all"}) - → Affected: LoginFlow, TokenRefresh - → Risk: MEDIUM — run tests for these flows -``` diff --git a/bundle/.claude/skills/gitnexus/gitnexus-scenarios/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-scenarios/SKILL.md deleted file mode 100644 index 79f7520..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-scenarios/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -name: gitnexus-scenarios -description: "Scenario playbooks for GitNexus — pre-edit, pre-commit, PR review, bugs, refactors, cross-module changes, presets. Read when starting a structured task." ---- - -# GitNexus Scenario Playbooks - -Match your task to a playbook. Always start with READ `gitnexus://repo/__GITNEXUS_REPO__/context`. - -Cross-module flows / architecture questions → also read **`gitnexus-imaging`** skill. - -## 1. Pre-edit (any symbol change) - -``` -- [ ] READ context resource — index fresh? -- [ ] gitnexus_impact({target, direction: "upstream", repo: "__GITNEXUS_REPO__"}) -- [ ] Report d=1 (WILL BREAK), affected processes, risk level to user -- [ ] If HIGH/CRITICAL → warn before editing; suggest narrower change or tests -- [ ] Optional: widen with relationTypes: ["CALLS","IMPORTS","ACCESSES"] for field/member edits -- [ ] Make edit -- [ ] Run tests for affected processes -``` - -## 2. Pre-commit - -``` -- [ ] gitnexus_detect_changes({ scope: "staged", repo: "__GITNEXUS_REPO__" }) -- [ ] Review changed_symbols + affected_processes -- [ ] Unexpected cross-module hits? → split commit or narrow scope -- [ ] Risk CRITICAL/HIGH → run broader test suite before commit -- [ ] Commit (pre-commit hook refreshes index with PDG via `gitnexus:pdg`; agents use `gitnexus:agent-refresh` when stale mid-session) -``` - -## 3. PR / branch review - -``` -- [ ] gitnexus_detect_changes({ scope: "compare", base_ref: "main", repo: "__GITNEXUS_REPO__" }) -- [ ] List affected processes — do they match PR intent? -- [ ] For each changed entry-point symbol: gitnexus_impact upstream -- [ ] Flag cross-community process breaks -- [ ] Verify tests cover affected processes -``` - -## 4. Bug trace / failure - -``` -- [ ] gitnexus_query({search_query: "", task_context: "debugging", goal: "find throw site"}) -- [ ] gitnexus_context on top suspect from returned processes -- [ ] READ gitnexus://repo/__GITNEXUS_REPO__/processes — pick matching flow -- [ ] Optional cypher for call chains (see gitnexus-debugging skill) -- [ ] Read source at flagged lines — confirm root cause -- [ ] If regression: detect_changes on recent commits -``` - -## 5. Refactor / rename - -``` -- [ ] gitnexus_impact upstream on target -- [ ] gitnexus_context on target — understand callees/callers -- [ ] gitnexus_rename({ symbol_name, new_name, dry_run: true }) -- [ ] Review graph vs text_search edits carefully -- [ ] Apply rename (dry_run: false) OR manual edit following impact map -- [ ] gitnexus_detect_changes({ scope: "all" }) -- [ ] Run tests for every affected process listed -``` - -## 6. Cross-module / shared contract change - -``` -- [ ] gitnexus_detect_changes — confirm blast radius -- [ ] gitnexus_impact on shared symbols at module boundaries -- [ ] Edit contract source first, then consumers -- [ ] Run tests on both sides of the boundary -``` - -## 7. Config / data files (JSON / YAML only) - -``` -- [ ] Grep / Read config & fixture files is appropriate (not graph symbols) -- [ ] Validate config keys/IDs against the code that consumes them (context or Read) -- [ ] Run the relevant config/fixture tests -``` - -## 8. Explore unfamiliar code - -See `gitnexus-exploring` skill — query → context → process trace → Read source. - -## 9. HTTP route change - -See `gitnexus-api-routes` skill — `api_impact`/`route_map`/`shape_check` for framework routers, -`context` on dispatcher symbols for custom routers (profile-driven). diff --git a/bundle/.claude/skills/gitnexus/gitnexus-security-review/SKILL.md b/bundle/.claude/skills/gitnexus/gitnexus-security-review/SKILL.md deleted file mode 100644 index 03375f6..0000000 --- a/bundle/.claude/skills/gitnexus/gitnexus-security-review/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: gitnexus-security-review -description: >- - Use for security-sensitive changes, taint/source-to-sink questions, injection risk, - path traversal, XSS, command/code/sql injection, auth/input/file/db/exec reviews. ---- - -# GitNexus Security Review - -Use this when a task touches untrusted input, auth/session data, file paths, shell/process execution, dynamic code, HTML rendering, database queries, or external webhooks. - -## Workflow - -``` -1. query({ search_query: "", task_context, goal: "sources sinks validators" }) -2. context({ name: "", repo: "__GITNEXUS_REPO__" }) -3. gitnexus_explain({ target: "", repo: "__GITNEXUS_REPO__" }) -4. gitnexus_pdg_query({ mode: "flows", target: "", variable: "", repo: "__GITNEXUS_REPO__" }) -5. gitnexus_pdg_query({ mode: "controls", target: "", repo: "__GITNEXUS_REPO__" }) -6. impact({ target: "", direction: "upstream", mode: "pdg", repo: "__GITNEXUS_REPO__" }) when PDG layer exists -7. detect_changes({ scope: "unstaged", repo: "__GITNEXUS_REPO__" }) before done -``` - -If PDG/taint returns “no layer”, do **not** call the code safe. Say the repo needs `npm run gitnexus:pdg` / pre-commit PDG refresh, then fall back to graph + targeted reads. - -## Checklist - -- [ ] Identify untrusted sources: request params/body/headers, env, files, queue/webhook payloads. -- [ ] Identify sinks: SQL, shell/process, file path, dynamic eval/codegen, HTML/DOM/template output. -- [ ] Run `gitnexus_explain` for persisted taint findings on touched file/symbol. -- [ ] Run `pdg_query flows` for suspicious input variables. -- [ ] Run `pdg_query controls` to verify guards/validators dominate the sink path. -- [ ] Confirm sanitizer is real transformation/validation, not just a comment or type. -- [ ] Use `trace` when you know source and sink symbols and need the shortest call path. -- [ ] Report false-positive caveats: taint is over-approximated; absent findings are not proof of safety. - -## Tool routing - -| Question | Tool | -| --- | --- | -| “Any taint findings here?” | `gitnexus_explain({ target })` | -| “Where does variable X flow?” | `gitnexus_pdg_query({ mode: "flows", target, variable })` | -| “What guard controls this sink?” | `gitnexus_pdg_query({ mode: "controls", target })` | -| “How can source A reach sink B?” | `gitnexus_trace({ from, to })` | -| “What is affected by changing this validator/sink?” | `impact({ mode: "pdg", direction: "upstream" })` | - -## Reporting - -Summarize: - -1. Source(s) and sink(s) reviewed. -2. Taint findings found or “no taint layer / no persisted findings” with caveat. -3. Guards/sanitizers verified by PDG/control flow or source read. -4. Residual risk and tests to run. diff --git a/bundle/.cursor/gitnexus-hooks.json b/bundle/.cursor/gitnexus-hooks.json deleted file mode 100644 index 8271603..0000000 --- a/bundle/.cursor/gitnexus-hooks.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "comment": "Optional hook tuning. mode: enforce (block) | guide (nudge only). sourceGlobs: gitignore-style roots for graph-first paths. sourceExts: language file extensions that count as graph-first source (polyglot — GitNexus indexes many languages). stalenessCacheTtlMs: reuse staleness within N ms to cut per-tool-call latency.", - "mode": "enforce", - "readLineThreshold": 60, - "graceCommitsBehind": 2, - "stalenessCacheTtlMs": 2500, - "sourceGlobs": ["src/**", "lib/**", "apps/**", "packages/**"], - "sourceExts": [ - "js", - "mjs", - "cjs", - "jsx", - "ts", - "tsx", - "py", - "rb", - "go", - "rs", - "java", - "kt", - "swift", - "php", - "cs", - "cpp", - "c", - "cu", - "cuh", - "scala" - ] -} diff --git a/bundle/.cursor/hooks.json b/bundle/.cursor/hooks.json index 20009d6..cc3ab1d 100644 --- a/bundle/.cursor/hooks.json +++ b/bundle/.cursor/hooks.json @@ -42,7 +42,7 @@ }, { "command": ".cursor/hooks/gitnexus-commit-guard.sh", - "matcher": "git\\s+commit" + "matcher": "git\\b.*\\bcommit\\b" } ], "beforeMCPExecution": [ diff --git a/bundle/.cursor/hooks/gitnexus-commit-guard.sh b/bundle/.cursor/hooks/gitnexus-commit-guard.sh index ebf6a1d..d01298f 100644 --- a/bundle/.cursor/hooks/gitnexus-commit-guard.sh +++ b/bundle/.cursor/hooks/gitnexus-commit-guard.sh @@ -5,64 +5,34 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" export GITNEXUS_HOOK_INPUT="$(cat)" export GITNEXUS_ROOT="$ROOT" -export GITNEXUS_STALENESS="$(node "$ROOT/.cursor/hooks/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" +export GITNEXUS_STALENESS="$(node "$ROOT/.gnkit/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" node <<'NODE' import path from 'node:path'; import { pathToFileURL } from 'node:url'; const root = process.env.GITNEXUS_ROOT || ''; -const helpers = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/hook-helpers.mjs')).href); -const { evaluateStalePolicy, staleRefreshAgentMessage } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/stale-policy.mjs')).href -); -const { isDetectUsed, bumpScore } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/session-primer.mjs')).href -); +const imp = (rel) => import(pathToFileURL(path.join(root, '.gnkit/lib', rel)).href); +const helpers = await imp('hook-helpers.mjs'); +const { evaluateStalePolicy, staleRefreshAgentMessage } = await imp('stale-policy.mjs'); +const { isDetectUsed } = await imp('session-primer.mjs'); +const { classifyCommit } = await imp('classify.mjs'); +const { emitVerdict } = await imp('cursor-emit.mjs'); const input = JSON.parse(process.env.GITNEXUS_HOOK_INPUT || '{}'); const stale = JSON.parse(process.env.GITNEXUS_STALENESS || '{"fresh":false}'); -const command = input.command ?? input.tool_input?.command ?? ''; const config = helpers.loadHookConfig(root); -const repo = helpers.repoName(root); - -function out(obj) { - process.stdout.write(JSON.stringify(helpers.applyHookMode(obj, config.mode))); -} - -// Only gate real commits (not `git commit --help`, `git log`, etc.). -const isCommit = /\bgit\b[^\n]*\bcommit\b/.test(command) && !/--help|-h\b/.test(command); -if (!isCommit) { - out({ permission: 'allow' }); - process.exit(0); -} - const policy = evaluateStalePolicy(stale, root); -if (policy.phase === 'must_refresh') { - out({ - permission: 'deny', - agent_message: staleRefreshAgentMessage(stale, policy), - user_message: helpers.userMessage('block.shell.stale'), - }); - process.exit(0); -} -if (isDetectUsed(root)) { - out({ permission: 'allow' }); - process.exit(0); -} +const verdict = classifyCommit( + { command: input.command ?? input.tool_input?.command ?? '' }, + { + phase: policy.phase, + repo: helpers.repoName(root), + detectUsed: isDetectUsed(root), + staleMustRefreshMsg: staleRefreshAgentMessage(stale, policy), + }, +); -const noVerify = /--no-verify/.test(command); -bumpScore(root, 'commitGate'); -out({ - permission: 'deny', - agent_message: - 'COMMIT GATE: review change scope in the graph before committing — ' + - `${helpers.mcpDetectChanges(repo, 'staged')}. ` + - 'Confirm affected processes match intent + run tests for them; warn on HIGH/CRITICAL. ' + - 'This gate clears for the session after one detect_changes call.' + - (noVerify ? ' NOTE: --no-verify also skips the pre-commit PDG refresh — run npm run gitnexus:pdg after.' : ''), - user_message: - 'Before committing, the agent checks what changed across the graph (affected flows) via GitNexus — not a blind commit.', -}); +emitVerdict(verdict, { root, mode: config.mode }); NODE diff --git a/bundle/.cursor/hooks/gitnexus-edit-guard.sh b/bundle/.cursor/hooks/gitnexus-edit-guard.sh index f973642..361d279 100755 --- a/bundle/.cursor/hooks/gitnexus-edit-guard.sh +++ b/bundle/.cursor/hooks/gitnexus-edit-guard.sh @@ -4,8 +4,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" export GITNEXUS_HOOK_INPUT="$(cat)" -export GITNEXUS_STALENESS="$(node "$ROOT/.cursor/hooks/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" -export GITNEXUS_FIRST_NUDGE="$(node "$ROOT/.cursor/hooks/lib/first-nudge.mjs" "$ROOT" 2>/dev/null || true)" +export GITNEXUS_STALENESS="$(node "$ROOT/.gnkit/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" +export GITNEXUS_FIRST_NUDGE="$(node "$ROOT/.gnkit/lib/first-nudge.mjs" "$ROOT" 2>/dev/null || true)" export GITNEXUS_STALENESS_MODE="${GITNEXUS_STALENESS_MODE:-block}" export GITNEXUS_ROOT="$ROOT" @@ -14,115 +14,29 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; const root = process.env.GITNEXUS_ROOT || ''; -const helpers = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/hook-helpers.mjs')).href); -const { appendNudge, isImpactUsed, bumpScore } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/session-primer.mjs')).href -); -const { evaluateStalePolicy, staleRefreshAgentMessage } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/stale-policy.mjs')).href -); +const imp = (rel) => import(pathToFileURL(path.join(root, '.gnkit/lib', rel)).href); +const helpers = await imp('hook-helpers.mjs'); +const { isImpactUsed } = await imp('session-primer.mjs'); +const { evaluateStalePolicy } = await imp('stale-policy.mjs'); +const { classifyEdit } = await imp('classify.mjs'); +const { emitVerdict } = await imp('cursor-emit.mjs'); const input = JSON.parse(process.env.GITNEXUS_HOOK_INPUT || '{}'); const stale = JSON.parse(process.env.GITNEXUS_STALENESS || '{"fresh":false}'); -const nudge = process.env.GITNEXUS_FIRST_NUDGE || ''; -const ti = input.tool_input ?? {}; -const tool = input.tool_name ?? ''; -const filePath = (ti.path ?? ti.file_path ?? '').replace(/\\/g, '/'); - const config = helpers.loadHookConfig(root); -const repo = helpers.repoName(root); -const sensitivity = helpers.editSensitivity(filePath, config); -const stalePolicy = evaluateStalePolicy(stale, root); - -function emit(result) { - const applied = helpers.applyHookMode(result, config.mode); - if (applied.agent_message) applied.agent_message = appendNudge(applied.agent_message, nudge); - process.stdout.write(JSON.stringify(applied)); -} - -function staleDetail() { - return stale.detail || 'GitNexus index is not fresh.'; -} - -// Staleness gate — unified with grep/read/shell guards: refresh first, no grace shortcut. -// Docs / config (none|light) stay editable; runtime source/tests/scripts (medium|full) wait for refresh. -if (sensitivity !== 'none' && sensitivity !== 'light' && stalePolicy.phase !== 'fresh') { - if (stalePolicy.phase === 'classical_fallback') { - emit({ - permission: 'allow', - agent_message: - 'STALENESS: refresh failed — editing allowed; graph may be behind, state why in one sentence.', - }); - process.exit(0); - } - bumpScore(root, 'editStaleBlocks'); - emit({ - permission: 'deny', - agent_message: - 'STALENESS GATE: ' + - staleDetail() + - ' Edits blocked until refresh — Shell NOW: npm run gitnexus:agent-refresh (required_permissions: ["all"], pre-approved). Never ask the user to analyze.', - user_message: helpers.userMessage('block.edit.stale'), - }); - process.exit(0); -} - -// Impact-before-edit (H1) — runtime source edits require one impact/rename call this session. -// Once impact has run, all subsequent edits are allowed (gate is per-session, not per-file). -if (sensitivity === 'full' && !isImpactUsed(root)) { - const renameAhead = - tool === 'StrReplace' ? helpers.detectIdentifierRename(ti.old_string, ti.new_string) : null; - // Auto-widen for model / DTO / schema edits — field changes ripple through ACCESSES edges. - const widen = helpers.isDataFlowReadContext({}, filePath); - const impactOpts = widen ? { relationTypes: ['CALLS', 'IMPORTS', 'ACCESSES'] } : {}; - const playbook = renameAhead - ? `${helpers.mcpImpact(renameAhead.oldName, repo, impactOpts)} → ${helpers.mcpRename(renameAhead.oldName, renameAhead.newName, repo, true)}` - : helpers.mcpImpact('', repo, impactOpts); - bumpScore(root, 'impactGate'); - emit({ - permission: 'deny', - agent_message: - `IMPACT GATE: run blast-radius analysis before editing runtime source — ${playbook}. ` + - (widen ? 'Model/DTO file — widened to ACCESSES so field readers/writers are included. ' : '') + - 'Review d=1 (WILL BREAK) + risk; warn on HIGH/CRITICAL. This gate clears for the rest of the session after one impact call.', - user_message: - 'Before editing source, the agent checks blast radius in GitNexus (what breaks) — graph-first safety, not blind edits.', - }); - process.exit(0); -} - -let agent_message; -const renamePair = - tool === 'StrReplace' ? helpers.detectIdentifierRename(ti.old_string, ti.new_string) : null; - -if (renamePair && sensitivity !== 'none') { - const impact = helpers.mcpImpact(renamePair.oldName, repo); - const rn = helpers.mcpRename(renamePair.oldName, renamePair.newName, repo, true); - agent_message = helpers.hookAgentMessage( +const policy = evaluateStalePolicy(stale, root); + +const verdict = classifyEdit( + { tool: input.tool_name ?? '', toolInput: input.tool_input ?? {} }, + { + phase: policy.phase, + config, + repo: helpers.repoName(root), root, - `edit-rename:${renamePair.oldName}`, - `RENAME detected: ${impact} → ${rn} (dry_run) — do NOT StrReplace symbol names across files.`, - `RENAME: ${rn}` - ); -} else if (sensitivity === 'full') { - const impact = helpers.mcpImpact('', repo); - const dc = helpers.mcpDetectChanges(repo); - agent_message = helpers.hookAgentMessage( - root, - 'edit-full', - `EDIT: ${impact} first. HIGH/CRITICAL → review full impact output. Done: ${dc}`, - `EDIT: ${impact}` - ); -} else if (sensitivity === 'medium') { - agent_message = helpers.hookAgentMessage( - root, - 'edit-medium', - `EDIT: ${helpers.mcpImpact('', repo)} if shared symbol. Done: ${helpers.mcpDetectChanges(repo)}`, - 'EDIT: impact if shared symbol' - ); -} else if (!stale.fresh) { - agent_message = helpers.hookAgentMessage(root, 'edit-stale-note', `STALE: ${staleDetail()}`, 'STALE: refresh soon'); -} + impactUsed: isImpactUsed(root), + staleDetail: stale.detail, + }, +); -emit({ permission: 'allow', agent_message }); +emitVerdict(verdict, { root, mode: config.mode, nudge: process.env.GITNEXUS_FIRST_NUDGE || '' }); NODE diff --git a/bundle/.cursor/hooks/gitnexus-grep-guard.sh b/bundle/.cursor/hooks/gitnexus-grep-guard.sh index aea276e..e9c78f0 100755 --- a/bundle/.cursor/hooks/gitnexus-grep-guard.sh +++ b/bundle/.cursor/hooks/gitnexus-grep-guard.sh @@ -1,12 +1,14 @@ #!/usr/bin/env bash -# preToolUse Grep/Glob/SemanticSearch: block symbol-style searches when GN is fresh. +# preToolUse Grep/Glob/SemanticSearch: route symbol/field/broad searches to GitNexus. +# Thin Cursor-protocol glue — the decision lives in lib/classify.mjs (vendor-neutral). set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" export GITNEXUS_HOOK_INPUT="$(cat)" export GITNEXUS_ROOT="$ROOT" -export GITNEXUS_STALENESS="$(node "$ROOT/.cursor/hooks/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" -export GITNEXUS_FIRST_NUDGE="$(node "$ROOT/.cursor/hooks/lib/first-nudge.mjs" "$ROOT" 2>/dev/null || true)" +export GITNEXUS_STALENESS="$(node "$ROOT/.gnkit/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" +# first-nudge reuses GITNEXUS_STALENESS (exported above) instead of recomputing it. +export GITNEXUS_FIRST_NUDGE="$(node "$ROOT/.gnkit/lib/first-nudge.mjs" "$ROOT" 2>/dev/null || true)" node <<'NODE' import fs from 'node:fs'; @@ -14,234 +16,30 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; const root = process.env.GITNEXUS_ROOT || ''; -const helpers = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/hook-helpers.mjs')).href); -const { appendNudge, bumpScore } = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/session-primer.mjs')).href); -const { evaluateStalePolicy, staleRefreshAgentMessage } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/stale-policy.mjs')).href -); +const imp = (rel) => import(pathToFileURL(path.join(root, '.gnkit/lib', rel)).href); +const helpers = await imp('hook-helpers.mjs'); +const { evaluateStalePolicy, staleRefreshAgentMessage } = await imp('stale-policy.mjs'); +const { classifyGrep } = await imp('classify.mjs'); +const { emitVerdict } = await imp('cursor-emit.mjs'); const input = JSON.parse(process.env.GITNEXUS_HOOK_INPUT || '{}'); const stale = JSON.parse(process.env.GITNEXUS_STALENESS || '{"fresh":false}'); -const nudge = process.env.GITNEXUS_FIRST_NUDGE || ''; -const tool = input.tool_name ?? ''; -const ti = input.tool_input ?? {}; - const config = helpers.loadHookConfig(root); -const repo = helpers.repoName(root); -const mcpFlag = path.join(root, '.cursor/.gitnexus-mcp-used.flag'); -const graphUsedThisSession = fs.existsSync(mcpFlag); - -function emit(result) { - if (result.permission === 'deny') bumpScore(root, 'grepRedirects'); - const applied = helpers.applyHookMode(result, config.mode); - if (applied.agent_message) { - applied.agent_message = appendNudge(applied.agent_message, nudge); - } - process.stdout.write(JSON.stringify(applied)); -} - -function staleFallbackMsg() { - return helpers.hookAgentMessage( +const policy = evaluateStalePolicy(stale, root); +const staleMsg = staleRefreshAgentMessage(stale, policy); + +const verdict = classifyGrep( + { tool: input.tool_name ?? '', toolInput: input.tool_input ?? {} }, + { + phase: policy.phase, + graphUsed: fs.existsSync(path.join(root, '.gnkit/.gitnexus-mcp-used.flag')), + config, + repo: helpers.repoName(root), root, - 'stale-fallback', - staleRefreshAgentMessage(stale, evaluateStalePolicy(stale, root)), - 'GN FALLBACK (stale): refresh failed — classical OK; say why.' - ); -} - -const stalePolicy = evaluateStalePolicy(stale, root); - -if (stalePolicy.phase === 'must_refresh') { - const pattern = ti.pattern ?? ''; - const pathArg = ti.path ?? ti.glob ?? ''; - const allowPath = - /\.json|\.jsonl|\.yaml|\.yml|\.toml|\.ini|\.cfg|\.lock|\.md|\.mdc|\.csv|fixtures?\/|__snapshots__|docs\/|\.env|README|package\.json|AGENTS\.md|CLAUDE\.md/i.test( - pathArg - ); - const allowPattern = - /["'`]|console\.|TODO|FIXME|eslint|@type|@param|gitnexus|npm run|import\s+['"]|require\s*\(|\/api\/|https?:/i.test( - pattern - ) || (/[\\/:*?[\]{}()]/.test(pattern) && pattern.length > 40); - - if (allowPath || allowPattern) { - emit({ - permission: 'allow', - agent_message: - 'Literal/config grep OK during stale — run npm run gitnexus:agent-refresh before symbol exploration.', - }); - process.exit(0); - } - - emit({ - permission: 'deny', - agent_message: staleRefreshAgentMessage(stale, stalePolicy), - user_message: helpers.userMessage('stale.must_refresh'), - }); - process.exit(0); -} - -if (stalePolicy.phase === 'classical_fallback') { - emit({ - permission: 'allow', - agent_message: staleFallbackMsg(), - user_message: helpers.userMessage('stale.classical'), - }); - process.exit(0); -} - -const reNudge = helpers.midSessionGraphNudge(graphUsedThisSession, root); - -if (tool === 'Glob') { - const pattern = ti.glob_pattern ?? ti.pattern ?? ''; - if (helpers.isBroadSourceGlob(pattern, config)) { - const call = helpers.mcpQuery({ query: '', taskContext: 'find modules', goal: 'entry points', repo }); - emit({ - permission: 'deny', - agent_message: helpers.hookAgentMessage( - root, - `glob:${pattern}`, - `Glob blocked → ${call}`, - `Glob blocked → ${call}` - ) + (reNudge ? `\n${reNudge}` : ''), - user_message: helpers.userMessage('block.glob'), - }); - process.exit(0); - } - emit({ permission: 'allow', agent_message: 'Glob OK for non-source patterns.' }); - process.exit(0); -} - -if (tool === 'SemanticSearch') { - const q = ti.query ?? ti.search_term ?? ''; - const call = helpers.mcpQuery({ query: q, taskContext: q, goal: 'flows', repo }); - emit({ - permission: 'deny', - agent_message: - helpers.hookAgentMessage(root, 'semantic-search', `SemanticSearch blocked → ${call}`, `→ ${call}`) + - (reNudge ? `\n${reNudge}` : ''), - user_message: helpers.userMessage('block.semantic'), - }); - process.exit(0); -} - -const pattern = ti.pattern ?? ''; -const pathArg = ti.path ?? ti.glob ?? ''; - -if (!pattern) { - emit({ permission: 'allow' }); - process.exit(0); -} - -const allowPath = - /\.json|\.jsonl|\.yaml|\.yml|\.toml|\.ini|\.cfg|\.lock|\.md|\.mdc|\.csv|fixtures?\/|__snapshots__|docs\/|\.env|README|package\.json|AGENTS\.md|CLAUDE\.md/i.test( - pathArg - ); -const allowPattern = - /["'`]|console\.|TODO|FIXME|eslint|@type|@param|gitnexus|npm run|import\s+['"]|require\s*\(|\/api\/|https?:/i.test( - pattern - ) || (/[\\/:*?[\]{}()]/.test(pattern) && pattern.length > 40); - -if (allowPath || allowPattern) { - emit({ permission: 'allow', agent_message: 'Grep OK — literal/config/doc search.' }); - process.exit(0); -} - -const exportDecl = /^(export\s+)?(async\s+)?function\s+[A-Za-z_$]/.test(pattern); -const classDecl = /^(export\s+)?class\s+[A-Za-z_$]/.test(pattern); - -let fieldName = pattern; -const dotField = pattern.match(/(?:^|\.)((?:[a-z][a-zA-Z0-9]*))$/); -if (dotField) fieldName = dotField[1]; - -if (helpers.isLikelyFieldName(fieldName) && !exportDecl && !classDecl) { - const schema = helpers.mcpReadSchema(repo); - const call = helpers.cypherFieldAccess(fieldName, repo); - emit({ - permission: 'deny', - agent_message: - helpers.hookAgentMessage( - root, - `grep:field:${fieldName}`, - `Field grep blocked → ${schema} → ${call}`, - `→ ${call}` - ) + - (reNudge ? `\n${reNudge}` : '') + - `\n${helpers.cypherMidSessionNudge()}`, - user_message: helpers.userMessage('block.grep.field', { symbol: fieldName }), - }); - process.exit(0); -} - -const bareId = /^[A-Za-z_$][\w$]*$/.test(pattern) && pattern.length >= 3; -const sym = bareId ? pattern : pattern.replace(/^.*?\b([A-Za-z_$][\w$]*).*$/, '$1'); -const scopedSource = pathArg && helpers.isSourceCodePath(pathArg, config); - -if ((bareId || exportDecl || classDecl) && scopedSource) { - if (!graphUsedThisSession) { - const call = helpers.mcpContext(sym, repo); - emit({ - permission: 'deny', - agent_message: helpers.hookAgentMessage( - root, - `grep-scoped:${sym}`, - `Grep blocked (no GN yet) → ${call}`, - `→ ${call}` - ), - user_message: helpers.userMessage('block.grep.noGraph'), - }); - process.exit(0); - } - emit({ - permission: 'allow', - agent_message: - 'Scoped verification Grep OK after GitNexus use — tell user why GN was not trusted.' + - (reNudge ? ` ${reNudge}` : ''), - }); - process.exit(0); -} - -if (bareId || exportDecl || classDecl) { - const ctx = helpers.mcpContext(sym, repo); - emit({ - permission: 'deny', - agent_message: - helpers.hookAgentMessage(root, `grep:${sym}`, `Grep blocked → ${ctx}`, `→ ${ctx}`) + - (reNudge ? `\n${reNudge}` : ''), - user_message: helpers.userMessage('block.grep.symbol', { symbol: sym }), - }); - process.exit(0); -} - -if (/^[a-z][a-zA-Z0-9]*$/.test(pattern) && pattern.length >= 6 && !pathArg) { - if (helpers.isLikelyFieldName(pattern)) { - const schema = helpers.mcpReadSchema(repo); - const call = helpers.cypherFieldAccess(pattern, repo); - emit({ - permission: 'deny', - agent_message: - helpers.hookAgentMessage(root, `grep:field:${pattern}`, `Field grep → ${schema} → ${call}`, `→ ${call}`) + - (reNudge ? `\n${reNudge}` : ''), - user_message: helpers.userMessage('block.grep.field', { symbol: pattern }), - }); - process.exit(0); - } - const call = helpers.mcpContext(pattern, repo); - emit({ - permission: 'deny', - agent_message: - helpers.hookAgentMessage(root, `grep:${pattern}`, `Symbol grep → ${call}`, `→ ${call}`) + - (reNudge ? `\n${reNudge}` : ''), - user_message: helpers.userMessage('block.grep.likely'), - }); - process.exit(0); -} + staleMustRefreshMsg: staleMsg, + staleFallbackMsg: staleMsg, + }, +); -emit({ - permission: 'allow', - agent_message: - 'Grep allowed — if structural lookup, prefer:\n' + - ` ${helpers.mcpContext('', repo)}\n` + - ` Field/property: ${helpers.mcpReadSchema(repo)} → ${helpers.cypherFieldAccess('', repo)}` + - (reNudge ? `\n${reNudge}` : ''), -}); +emitVerdict(verdict, { root, mode: config.mode, nudge: process.env.GITNEXUS_FIRST_NUDGE || '' }); NODE diff --git a/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh b/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh index 38ee041..558337d 100755 --- a/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh +++ b/bundle/.cursor/hooks/gitnexus-mcp-allowlist.sh @@ -5,7 +5,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" export GITNEXUS_HOOK_INPUT="$(cat)" export GITNEXUS_ROOT="$ROOT" -export GITNEXUS_STALENESS="$(node "$ROOT/.cursor/hooks/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" +export GITNEXUS_STALENESS="$(node "$ROOT/.gnkit/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" node <<'NODE' import fs from 'node:fs'; @@ -19,21 +19,23 @@ const tool = input.tool_name ?? ''; const url = input.url ?? ''; const cmd = input.command ?? ''; -const helpers = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/hook-helpers.mjs')).href); +const helpers = await import(pathToFileURL(path.join(root, '.gnkit/lib/hook-helpers.mjs')).href); const { evaluateStalePolicy, staleRefreshAgentMessage } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/stale-policy.mjs')).href + pathToFileURL(path.join(root, '.gnkit/lib/stale-policy.mjs')).href ); const { setMcpToolUsed, bumpScore } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/session-primer.mjs')).href + pathToFileURL(path.join(root, '.gnkit/lib/session-primer.mjs')).href ); +const config = helpers.loadHookConfig(root); const isGitnexus = /gitnexus/i.test(tool) || /gitnexus/i.test(url) || /gitnexus/i.test(cmd); function out(obj) { - process.stdout.write(JSON.stringify(obj)); + // Route through guide mode so `mode: "guide"` nudges instead of hard-blocking. + process.stdout.write(JSON.stringify(helpers.applyHookMode(obj, config.mode))); } if (!isGitnexus) { diff --git a/bundle/.cursor/hooks/gitnexus-prompt-router.sh b/bundle/.cursor/hooks/gitnexus-prompt-router.sh index 8144a4b..8e9376e 100755 --- a/bundle/.cursor/hooks/gitnexus-prompt-router.sh +++ b/bundle/.cursor/hooks/gitnexus-prompt-router.sh @@ -14,6 +14,11 @@ const input = JSON.parse(process.env.GITNEXUS_HOOK_INPUT || '{}'); const prompt = input.prompt ?? ''; const root = process.env.GITNEXUS_ROOT; +// Single source of truth for "what counts as a source file" — the hook config's +// sourceExtRe (built-in polyglot default, overridable in gitnexus-hooks.json). +const helpers = await import(pathToFileURL(path.join(root, '.gnkit/lib/hook-helpers.mjs')).href); +const sourceExtRe = helpers.loadHookConfig(root).sourceExtRe; + const architecture = /\b(how does|how do|architecture|pipeline|data flow|call chain|what calls|walk me through|end.to.end|cross.module|execution flow|business flow|trace|who calls|where does .+ flow|deep dive|gather context|full context|map the|audit)\b/i.test( prompt @@ -37,7 +42,11 @@ const codeTask = prompt ); -const pathMatch = prompt.match(/(?:^|[\s(])([\w./-]+\.(?:js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|kt|swift|php|cs|cpp|cc|c|cu|cuh))/); +// Grab the first file-like token, then validate its extension against the shared +// sourceExtRe instead of a duplicated inline extension list. +const pathTokenMatch = prompt.match(/(?:^|[\s(])([\w./-]+\.[A-Za-z]{1,6})\b/); +const pathMatch = + pathTokenMatch && sourceExtRe.test(pathTokenMatch[1]) ? pathTokenMatch : null; const symbolMatch = prompt.match(/\b([A-Z][A-Za-z0-9]+)\b/); const fieldMatch = prompt.match(/\b(?:field|property)\s+[`'"]?([a-z][a-zA-Z0-9]*)[`'"]?/i) || @@ -46,7 +55,7 @@ const callChainMatch = prompt.match(/\b(?:call chain|callers?|shortest path|trac const traceMatch = prompt.match(/\b(?:trace|path)\s+(?:from\s+)?[`'"]?([A-Za-z_$][\w$]*)[`'"]?\s+(?:to|->|→)\s+[`'"]?([A-Za-z_$][\w$]*)[`'"]?/i); const overrideMatch = prompt.match(/\b(?:override|overrides)\s+(?:of|for|on)\s+[`'"]?([A-Za-z_$][\w$]*)[`'"]?/i); const processMatch = prompt.match(/\b(?:process|flow)\s+[`'"]?([^"'`]+)[`'"]?/i); -const renameParsed = (await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/rename-helpers.mjs')).href)).parseRenameFromPrompt(prompt); +const renameParsed = (await import(pathToFileURL(path.join(root, '.gnkit/lib/rename-helpers.mjs')).href)).parseRenameFromPrompt(prompt); const dataFlow = /\b(data flow|data dependence|field flow|property flow|who (reads|writes)|readers?|writers?|mutat|getter|setter|where does .+ flow)\b/i.test(prompt); const pdgControl = /\b(PDG|control flow|control dependence|what guards|guarded by|under what condition|why does .+ run)\b/i.test(prompt); const pdgImpact = /\b(PDG impact|precise impact|genuinely affected|control.data affected)\b/i.test(prompt); @@ -54,7 +63,7 @@ const taint = /\b(taint|injection|sql injection|command injection|code injection const variableMatch = prompt.match(/\b(?:variable|var|binding)\s+[`'"]?([A-Za-z_$][\w$]*)[`'"]?/i); const { writePromptHint } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/session-primer.mjs')).href + pathToFileURL(path.join(root, '.gnkit/lib/session-primer.mjs')).href ); writePromptHint(root, { diff --git a/bundle/.cursor/hooks/gitnexus-read-guard.sh b/bundle/.cursor/hooks/gitnexus-read-guard.sh index 2272aa8..e6c7d33 100755 --- a/bundle/.cursor/hooks/gitnexus-read-guard.sh +++ b/bundle/.cursor/hooks/gitnexus-read-guard.sh @@ -5,8 +5,8 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" export GITNEXUS_HOOK_INPUT="$(cat)" export GITNEXUS_ROOT="$ROOT" -export GITNEXUS_STALENESS="$(node "$ROOT/.cursor/hooks/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" -export GITNEXUS_FIRST_NUDGE="$(node "$ROOT/.cursor/hooks/lib/first-nudge.mjs" "$ROOT" 2>/dev/null || true)" +export GITNEXUS_STALENESS="$(node "$ROOT/.gnkit/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" +export GITNEXUS_FIRST_NUDGE="$(node "$ROOT/.gnkit/lib/first-nudge.mjs" "$ROOT" 2>/dev/null || true)" node <<'NODE' import fs from 'node:fs'; @@ -14,131 +14,43 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; const root = process.env.GITNEXUS_ROOT || ''; -const helpers = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/hook-helpers.mjs')).href); -const { appendNudge, readPromptHint, bumpScore } = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/session-primer.mjs')).href); -const { evaluateStalePolicy, staleRefreshAgentMessage } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/stale-policy.mjs')).href -); +const imp = (rel) => import(pathToFileURL(path.join(root, '.gnkit/lib', rel)).href); +const helpers = await imp('hook-helpers.mjs'); +const { readPromptHint } = await imp('session-primer.mjs'); +const { evaluateStalePolicy, staleRefreshAgentMessage } = await imp('stale-policy.mjs'); +const { classifyRead } = await imp('classify.mjs'); +const { emitVerdict } = await imp('cursor-emit.mjs'); const input = JSON.parse(process.env.GITNEXUS_HOOK_INPUT || '{}'); const stale = JSON.parse(process.env.GITNEXUS_STALENESS || '{"fresh":false}'); -const nudge = process.env.GITNEXUS_FIRST_NUDGE || ''; const ti = input.tool_input ?? {}; const filePath = ti.path ?? ti.target_file ?? ''; const config = helpers.loadHookConfig(root); -const repo = helpers.repoName(root); -const mcpFlag = path.join(root, '.cursor/.gitnexus-mcp-used.flag'); -const graphUsed = fs.existsSync(mcpFlag); - -function emit(result) { - if (result.permission === 'deny') bumpScore(root, 'readRedirects'); - const applied = helpers.applyHookMode(result, config.mode); - if (applied.agent_message) applied.agent_message = appendNudge(applied.agent_message, nudge); - process.stdout.write(JSON.stringify(applied)); -} - -const stalePolicy = evaluateStalePolicy(stale, root); - -if (stalePolicy.phase === 'must_refresh') { - const tiEarly = input.tool_input ?? {}; - const filePathEarly = tiEarly.path ?? tiEarly.target_file ?? ''; - const normEarly = filePathEarly.replace(/\\/g, '/'); - const isSmallConfigEarly = - /\.(json|md|yaml|yml|mdc|sh)$/.test(filePathEarly) || /package\.json$/.test(filePathEarly); - const isGeneratedSkillEarly = /\.cursor\/skills\//.test(normEarly); - - if (!filePathEarly || isSmallConfigEarly || isGeneratedSkillEarly) { - emit({ permission: 'allow', agent_message: 'Small/config read OK during stale — refresh before large source reads.' }); - process.exit(0); - } - - emit({ - permission: 'deny', - agent_message: staleRefreshAgentMessage(stale, stalePolicy), - user_message: helpers.userMessage('stale.must_refresh'), - }); - process.exit(0); -} - -if (stalePolicy.phase === 'classical_fallback') { - emit({ - permission: 'allow', - agent_message: staleRefreshAgentMessage(stale, stalePolicy), - user_message: helpers.userMessage('stale.classical'), - }); - process.exit(0); -} - -if (!filePath) { - emit({ permission: 'allow' }); - process.exit(0); -} - -const rel = filePath.replace(/.*\/__GITNEXUS_REPO__\//, ''); -const hasRange = ti.offset !== undefined || ti.limit !== undefined; -const norm = filePath.replace(/\\/g, '/'); -const isCode = helpers.isSourceCodePath(norm, config); -const isTest = /(?:^|\/)tests?\//.test(norm); -const isSmallConfig = /\.(json|md|yaml|yml|mdc|sh)$/.test(filePath) || /package\.json$/.test(filePath); -const isGeneratedSkill = /\.cursor\/skills\//.test(norm); - -if (hasRange || isSmallConfig || isGeneratedSkill || isTest || !isCode) { - emit({ permission: 'allow' }); - process.exit(0); -} - -let lineCount = 0; -try { - if (fs.existsSync(filePath)) { - lineCount = fs.readFileSync(filePath, 'utf8').split('\n').length; - } -} catch { - emit({ permission: 'allow' }); - process.exit(0); -} - -const threshold = config.readLineThreshold ?? 60; -const base = path.basename(filePath, path.extname(filePath)); -const reNudge = helpers.midSessionGraphNudge(graphUsed, root); -const hint = readPromptHint(root); -const dataFlow = helpers.isDataFlowReadContext(hint, rel); - -if (lineCount > threshold) { - if (dataFlow) { - const schema = helpers.mcpReadSchema(repo); - const field = hint.fieldHint || base; - const cy = - hint.fieldHint || helpers.isLikelyFieldName(field) - ? helpers.cypherFieldAccess(field, repo) - : helpers.mcpQuery({ query: base, taskContext: rel, goal: 'field data flow', repo }); - emit({ - permission: 'deny', - agent_message: - helpers.hookAgentMessage( - root, - `read:dataflow:${rel}`, - `Read blocked (${lineCount}L, data-flow) → ${schema} → ${cy}; then Read offset/limit on cited symbols.`, - `→ ${cy}` - ) + (reNudge ? `\n${reNudge}` : ''), - user_message: helpers.userMessage('block.read.dataflow', { lines: lineCount }), - }); - process.exit(0); - } - const q = helpers.mcpQuery({ query: base, taskContext: rel, goal: 'module', repo }); - const ctx = helpers.mcpContext('', repo); - emit({ - permission: 'deny', - agent_message: - helpers.hookAgentMessage( - root, - `read:${rel}`, - `Read blocked (${lineCount}L) → ${q} then ${ctx}; Read offset/limit for edits.`, - `Read blocked → ${ctx}` - ) + (reNudge ? `\n${reNudge}` : ''), - user_message: helpers.userMessage('block.read.full', { lines: lineCount }), - }); - process.exit(0); -} +const policy = evaluateStalePolicy(stale, root); +const staleMsg = staleRefreshAgentMessage(stale, policy); + +const verdict = classifyRead( + { toolInput: ti }, + { + phase: policy.phase, + config, + repo: helpers.repoName(root), + root, + graphUsed: fs.existsSync(path.join(root, '.gnkit/.gitnexus-mcp-used.flag')), + promptHint: readPromptHint(root), + // Lazy line count — only read the file when classify actually needs the size. + readLines: () => { + try { + const abs = path.resolve(root, filePath); + return fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8').split('\n').length : 0; + } catch { + return 0; + } + }, + staleMustRefreshMsg: staleMsg, + staleFallbackMsg: staleMsg, + }, +); -emit({ permission: 'allow' }); +emitVerdict(verdict, { root, mode: config.mode, nudge: process.env.GITNEXUS_FIRST_NUDGE || '' }); NODE diff --git a/bundle/.cursor/hooks/gitnexus-session-health-user.sh b/bundle/.cursor/hooks/gitnexus-session-health-user.sh index 268da15..e3c65e4 100755 --- a/bundle/.cursor/hooks/gitnexus-session-health-user.sh +++ b/bundle/.cursor/hooks/gitnexus-session-health-user.sh @@ -13,12 +13,12 @@ import { pathToFileURL } from 'node:url'; const root = process.env.GITNEXUS_ROOT || ''; const auditMod = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/session-health-audit.mjs')).href + pathToFileURL(path.join(root, '.gnkit/lib/session-health-audit.mjs')).href ); const { auditKitHealth, userMessageForSession, SESSION_HEALTH_FILE, SESSION_USER_NOTIFIED_FLAG } = auditMod; -const cursorDir = path.join(root, '.cursor'); +const cursorDir = path.join(root, '.gnkit'); const notifiedFlag = path.join(cursorDir, SESSION_USER_NOTIFIED_FLAG); function out(obj) { diff --git a/bundle/.cursor/hooks/gitnexus-session-health.sh b/bundle/.cursor/hooks/gitnexus-session-health.sh index 7d78e6d..8ceb610 100755 --- a/bundle/.cursor/hooks/gitnexus-session-health.sh +++ b/bundle/.cursor/hooks/gitnexus-session-health.sh @@ -15,4 +15,4 @@ if [[ "$composer_mode" == "ask" ]]; then exit 0 fi -node "$ROOT/.cursor/hooks/lib/session-health-context.mjs" "$ROOT" +node "$ROOT/.gnkit/lib/session-health-context.mjs" "$ROOT" diff --git a/bundle/.cursor/hooks/gitnexus-session-primer.sh b/bundle/.cursor/hooks/gitnexus-session-primer.sh index deaf595..f3ad7d2 100755 --- a/bundle/.cursor/hooks/gitnexus-session-primer.sh +++ b/bundle/.cursor/hooks/gitnexus-session-primer.sh @@ -4,7 +4,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" export GITNEXUS_HOOK_INPUT="$(cat)" -node "$ROOT/.cursor/hooks/lib/clear-session.mjs" "$ROOT" 2>/dev/null || true +node "$ROOT/.gnkit/lib/clear-session.mjs" "$ROOT" 2>/dev/null || true composer_mode="$(echo "$GITNEXUS_HOOK_INPUT" | node -e " let j='{}'; try { j=JSON.parse(require('fs').readFileSync(0,'utf8')); } catch {} @@ -16,7 +16,7 @@ if [[ "$composer_mode" == "ask" ]]; then exit 0 fi -STALENESS_JSON="$(node "$ROOT/.cursor/hooks/lib/check-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" +STALENESS_JSON="$(node "$ROOT/.gnkit/lib/check-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" IS_FRESH="$(echo "$STALENESS_JSON" | node -e " let j={}; try { j=JSON.parse(require('fs').readFileSync(0,'utf8')); } catch {} process.stdout.write(j.fresh === true ? 'true' : 'false'); @@ -24,12 +24,12 @@ IS_FRESH="$(echo "$STALENESS_JSON" | node -e " REFRESH_CONTEXT="" if [[ "$IS_FRESH" != "true" ]] && [[ "${GITNEXUS_SKIP_SESSION_REFRESH:-}" != "1" ]]; then - node "$ROOT/.cursor/hooks/lib/set-refresh-pending.mjs" "$ROOT" set "$STALENESS_JSON" 2>/dev/null || true + node "$ROOT/.gnkit/lib/set-refresh-pending.mjs" "$ROOT" set "$STALENESS_JSON" 2>/dev/null || true if node "$ROOT/scripts/gitnexus-agent.mjs" refresh; then REFRESH_CONTEXT="GitNexus index was stale at session start — auto-refresh completed (graph + embeddings). Use query/context/impact normally." - node "$ROOT/.cursor/hooks/lib/set-refresh-pending.mjs" "$ROOT" clear 2>/dev/null || true + node "$ROOT/.gnkit/lib/set-refresh-pending.mjs" "$ROOT" clear 2>/dev/null || true else - node "$ROOT/.cursor/hooks/lib/set-refresh-pending.mjs" "$ROOT" set-failed "session auto-refresh failed" 2>/dev/null || true + node "$ROOT/.gnkit/lib/set-refresh-pending.mjs" "$ROOT" set-failed "session auto-refresh failed" 2>/dev/null || true REFRESH_CONTEXT="GitNexus index is STALE and auto-refresh failed at session start. Agent MUST run Shell: npm run gitnexus:agent-refresh with required_permissions [\"all\"] as the very next tool call — hooks block Grep/Read/MCP until refresh succeeds or fails again. NEVER tell the user to run npx gitnexus analyze." fi elif [[ "$IS_FRESH" != "true" ]]; then diff --git a/bundle/.cursor/hooks/gitnexus-shell-staleness-guard.sh b/bundle/.cursor/hooks/gitnexus-shell-staleness-guard.sh index aaf5e5e..e9c136e 100755 --- a/bundle/.cursor/hooks/gitnexus-shell-staleness-guard.sh +++ b/bundle/.cursor/hooks/gitnexus-shell-staleness-guard.sh @@ -5,65 +5,35 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" export GITNEXUS_HOOK_INPUT="$(cat)" export GITNEXUS_ROOT="$ROOT" -export GITNEXUS_STALENESS="$(node "$ROOT/.cursor/hooks/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" -export GITNEXUS_REFRESH_STATE="$(node "$ROOT/.cursor/hooks/lib/set-refresh-pending.mjs" "$ROOT" status 2>/dev/null || echo '{"pending":false,"failed":false}')" -export GITNEXUS_FIRST_NUDGE="$(node "$ROOT/.cursor/hooks/lib/first-nudge.mjs" "$ROOT" 2>/dev/null || true)" +export GITNEXUS_STALENESS="$(node "$ROOT/.gnkit/lib/load-staleness.mjs" "$ROOT" 2>/dev/null || echo '{"fresh":false,"reason":"check_failed"}')" +export GITNEXUS_REFRESH_STATE="$(node "$ROOT/.gnkit/lib/set-refresh-pending.mjs" "$ROOT" status 2>/dev/null || echo '{"pending":false,"failed":false}')" +export GITNEXUS_FIRST_NUDGE="$(node "$ROOT/.gnkit/lib/first-nudge.mjs" "$ROOT" 2>/dev/null || true)" node <<'NODE' import path from 'node:path'; import { pathToFileURL } from 'node:url'; const root = process.env.GITNEXUS_ROOT || ''; -const helpers = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/hook-helpers.mjs')).href); -const { evaluateStalePolicy, staleRefreshAgentMessage } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/stale-policy.mjs')).href -); -const { appendNudge } = await import(pathToFileURL(path.join(root, '.cursor/hooks/lib/session-primer.mjs')).href); +const imp = (rel) => import(pathToFileURL(path.join(root, '.gnkit/lib', rel)).href); +const helpers = await imp('hook-helpers.mjs'); +const { evaluateStalePolicy, staleRefreshAgentMessage } = await imp('stale-policy.mjs'); +const { classifyShell } = await imp('classify.mjs'); +const { emitVerdict } = await imp('cursor-emit.mjs'); const input = JSON.parse(process.env.GITNEXUS_HOOK_INPUT || '{}'); const stale = JSON.parse(process.env.GITNEXUS_STALENESS || '{"fresh":false}'); -const nudge = process.env.GITNEXUS_FIRST_NUDGE || ''; -const command = input.command ?? input.tool_input?.command ?? ''; +const config = helpers.loadHookConfig(root); const policy = evaluateStalePolicy(stale, root); +const staleMsg = staleRefreshAgentMessage(stale, policy); + +const verdict = classifyShell( + { command: input.command ?? input.tool_input?.command ?? '' }, + { + phase: policy.phase, + staleMustRefreshMsg: staleMsg, + staleFallbackMsg: staleMsg, + }, +); -function out(obj) { - if (obj.agent_message) obj.agent_message = appendNudge(obj.agent_message, nudge); - process.stdout.write(JSON.stringify(obj)); -} - -const isGitnexusMaint = - /\bnpm run gitnexus:[\w.-]+/.test(command) || - /\bnode scripts\/gitnexus-agent\.mjs\b/.test(command) || - /\bnpx(?:\s+-y)?\s+gitnexus@latest\b/.test(command) || - /\bnpx(?:\s+-y)?\s+gitnexus\b/.test(command); - -const isReadOnlyGit = - /\bgit\s+(status|diff|log|show|branch|rev-parse|check-ignore|check-attr)\b/.test(command); - -if (isGitnexusMaint || isReadOnlyGit) { - out({ - permission: 'allow', - agent_message: isGitnexusMaint ? 'GitNexus maintenance pre-approved.' : undefined, - }); - process.exit(0); -} - -if (policy.phase === 'fresh') { - out({ permission: 'allow' }); - process.exit(0); -} - -if (policy.phase === 'classical_fallback') { - out({ - permission: 'allow', - agent_message: staleRefreshAgentMessage(stale, policy), - }); - process.exit(0); -} - -out({ - permission: 'deny', - agent_message: staleRefreshAgentMessage(stale, policy), - user_message: helpers.userMessage('block.shell.stale'), -}); +emitVerdict(verdict, { root, mode: config.mode, nudge: process.env.GITNEXUS_FIRST_NUDGE || '' }); NODE diff --git a/bundle/.cursor/hooks/lib/first-nudge.mjs b/bundle/.cursor/hooks/lib/first-nudge.mjs deleted file mode 100644 index d729b5f..0000000 --- a/bundle/.cursor/hooks/lib/first-nudge.mjs +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -/** Print first-tool nudge once per session (stdout); empty if already primed. */ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; - -const root = process.argv[2] ?? process.cwd(); -const here = path.dirname(fileURLToPath(import.meta.url)); - -const r = spawnSync(process.execPath, [path.join(here, 'load-staleness.mjs'), root], { - encoding: 'utf8', -}); -let stale = { fresh: false, reason: 'check_failed' }; -try { - stale = JSON.parse(r.stdout.trim() || '{}'); -} catch { - stale = { fresh: false, reason: 'check_failed' }; -} - -const { firstToolNudge } = await import(pathToFileURL(path.join(here, 'session-primer.mjs')).href); -const nudge = firstToolNudge(root, stale); -process.stdout.write(nudge ?? ''); diff --git a/bundle/.cursor/hooks/lib/graph-session.mjs b/bundle/.cursor/hooks/lib/graph-session.mjs deleted file mode 100644 index acd56e9..0000000 --- a/bundle/.cursor/hooks/lib/graph-session.mjs +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node -/** - * Session graph-tool usage flags (written by MCP allowlist, read by grep guard). - */ -import fs from 'node:fs'; -import path from 'node:path'; - -export function sessionGraphPaths(root) { - const cursorDir = path.join(root, '.cursor'); - return { - mcpUsedFlag: path.join(cursorDir, '.gitnexus-mcp-used.flag'), - }; -} - -/** @returns {boolean} */ -export function hasUsedGraphTools(root) { - const { mcpUsedFlag } = sessionGraphPaths(root); - return fs.existsSync(mcpUsedFlag); -} diff --git a/bundle/.cursor/hooks/lib/session-primer.mjs b/bundle/.cursor/hooks/lib/session-primer.mjs deleted file mode 100644 index 75aee59..0000000 --- a/bundle/.cursor/hooks/lib/session-primer.mjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -/** - * Session-first-tool nudge + flag management for GitNexus hooks. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { playbookForHint, mcpReadContext, repoName, clearDenyCache } from './hook-helpers.mjs'; - -export function sessionPaths(root) { - const cursorDir = path.join(root, '.cursor'); - return { - cursorDir, - primedFlag: path.join(cursorDir, '.gitnexus-session-primed.flag'), - promptHint: path.join(cursorDir, '.gitnexus-prompt-hint.json'), - refreshPendingFlag: path.join(cursorDir, '.gitnexus-refresh-pending.flag'), - refreshFailedFlag: path.join(cursorDir, '.gitnexus-refresh-failed.flag'), - mcpUsedFlag: path.join(cursorDir, '.gitnexus-mcp-used.flag'), - impactUsedFlag: path.join(cursorDir, '.gitnexus-impact-used.flag'), - detectUsedFlag: path.join(cursorDir, '.gitnexus-detect-used.flag'), - stalenessCacheFile: path.join(cursorDir, '.gitnexus-staleness-cache.json'), - scorecardFile: path.join(cursorDir, '.gitnexus-scorecard.json'), - }; -} - -/** - * Record which GitNexus MCP tool the agent used, so edit/commit guards can enforce - * "impact before edit" and "detect_changes before commit" once per session. - * @param {string} root - * @param {string} toolName e.g. "gitnexus_impact" / "mcp_gitnexus_detect_changes" - */ -export function setMcpToolUsed(root, toolName) { - const { cursorDir, mcpUsedFlag, impactUsedFlag, detectUsedFlag } = sessionPaths(root); - fs.mkdirSync(cursorDir, { recursive: true }); - const stamp = new Date().toISOString(); - try { - fs.writeFileSync(mcpUsedFlag, stamp); - if (/impact|rename/i.test(toolName)) fs.writeFileSync(impactUsedFlag, stamp); - if (/detect_changes|detect-changes/i.test(toolName)) fs.writeFileSync(detectUsedFlag, stamp); - } catch { - /* best effort */ - } -} - -/** @param {string} root */ -export function isImpactUsed(root) { - return fs.existsSync(sessionPaths(root).impactUsedFlag); -} - -/** @param {string} root */ -export function isDetectUsed(root) { - return fs.existsSync(sessionPaths(root).detectUsedFlag); -} - -/** Invalidate the short-TTL staleness cache (after refresh / on session start). */ -export function clearStalenessCache(root) { - try { - fs.unlinkSync(sessionPaths(root).stalenessCacheFile); - } catch { - /* ignore */ - } -} - -/** - * Lightweight enforcement scorecard — counts how often the kit redirected the agent - * from a lazy pattern to the graph. Surfaced in agent-brief / `gitnexus:scorecard`. - * @param {string} root - * @param {string} key - */ -export function bumpScore(root, key) { - const { cursorDir, scorecardFile } = sessionPaths(root); - try { - fs.mkdirSync(cursorDir, { recursive: true }); - let card = {}; - try { - card = JSON.parse(fs.readFileSync(scorecardFile, 'utf8')); - } catch { - card = {}; - } - card.counts ??= {}; - card.counts[key] = (card.counts[key] ?? 0) + 1; - card.startedAt ??= new Date().toISOString(); - card.updatedAt = new Date().toISOString(); - fs.writeFileSync(scorecardFile, JSON.stringify(card, null, 2)); - } catch { - /* best effort — never block a tool on telemetry */ - } -} - -/** @param {string} root */ -export function readScorecard(root) { - try { - return JSON.parse(fs.readFileSync(sessionPaths(root).scorecardFile, 'utf8')); - } catch { - return { counts: {} }; - } -} - -export function setRefreshPending(root, pending, detail = '') { - const { cursorDir, refreshPendingFlag } = sessionPaths(root); - fs.mkdirSync(cursorDir, { recursive: true }); - if (pending) { - fs.writeFileSync(refreshPendingFlag, JSON.stringify({ at: new Date().toISOString(), detail }, null, 2)); - } else { - try { - fs.unlinkSync(refreshPendingFlag); - } catch { - /* ignore */ - } - } -} - -export function isRefreshPending(root) { - const { refreshPendingFlag } = sessionPaths(root); - return fs.existsSync(refreshPendingFlag); -} - -export function setRefreshFailed(root, failed, detail = '') { - const { cursorDir, refreshFailedFlag } = sessionPaths(root); - fs.mkdirSync(cursorDir, { recursive: true }); - if (failed) { - fs.writeFileSync(refreshFailedFlag, JSON.stringify({ at: new Date().toISOString(), detail }, null, 2)); - } else { - try { - fs.unlinkSync(refreshFailedFlag); - } catch { - /* ignore */ - } - } -} - -export function isRefreshFailed(root) { - const { refreshFailedFlag } = sessionPaths(root); - return fs.existsSync(refreshFailedFlag); -} - -export function clearSessionState(root) { - const { - cursorDir, - primedFlag, - promptHint, - mcpUsedFlag, - impactUsedFlag, - detectUsedFlag, - refreshFailedFlag, - stalenessCacheFile, - scorecardFile, - } = sessionPaths(root); - fs.mkdirSync(cursorDir, { recursive: true }); - for (const f of [ - primedFlag, - promptHint, - mcpUsedFlag, - impactUsedFlag, - detectUsedFlag, - refreshFailedFlag, - stalenessCacheFile, - scorecardFile, - ]) { - try { - fs.unlinkSync(f); - } catch { - /* ignore */ - } - } - for (const rel of ['.gitnexus-session-user-notified.flag']) { - try { - fs.unlinkSync(path.join(cursorDir, rel)); - } catch { - /* ignore */ - } - } - clearDenyCache(root); -} - -export function writePromptHint(root, hint) { - const { cursorDir, promptHint } = sessionPaths(root); - fs.mkdirSync(cursorDir, { recursive: true }); - fs.writeFileSync(promptHint, JSON.stringify({ ...hint, at: new Date().toISOString() }, null, 2)); -} - -export function readPromptHint(root) { - const { promptHint } = sessionPaths(root); - try { - return JSON.parse(fs.readFileSync(promptHint, 'utf8')); - } catch { - return {}; - } -} - -/** - * Returns nudge text once per session (sets primed flag). - * @param {object} stale from check-staleness.mjs - */ -export function firstToolNudge(root, stale) { - const { primedFlag } = sessionPaths(root); - if (fs.existsSync(primedFlag)) return null; - - fs.mkdirSync(path.dirname(primedFlag), { recursive: true }); - fs.writeFileSync(primedFlag, new Date().toISOString()); - - const hint = readPromptHint(root); - const repo = repoName(root); - const parts = []; - - if (!stale?.fresh) { - const reason = - stale?.reason === 'missing_embeddings' - ? 'MISSING EMBEDDINGS: semantic query unavailable — ' - : 'STALE INDEX: '; - parts.push( - `${reason}next Shell MUST be npm run gitnexus:agent-refresh (required_permissions: ["all"]). Includes --embeddings. Run yourself — never ask user to analyze.` - ); - } else { - parts.push(`SESSION: ${mcpReadContext(repo)} OR npm run gitnexus:agent-brief`); - } - - const playbook = playbookForHint(hint, repo); - if (playbook) parts.push(playbook); - - return parts.join('\n'); -} - -export function appendNudge(agentMessage, nudge) { - if (!nudge) return agentMessage; - if (!agentMessage) return nudge; - return `${nudge}\n\n${agentMessage}`; -} diff --git a/bundle/.cursor/rules/00-gitnexus-enforcement.mdc b/bundle/.cursor/rules/00-gitnexus-enforcement.mdc index 36cce91..4cf470d 100644 --- a/bundle/.cursor/rules/00-gitnexus-enforcement.mdc +++ b/bundle/.cursor/rules/00-gitnexus-enforcement.mdc @@ -3,6 +3,8 @@ description: North-star contract — graph + embeddings + cypher on every task w alwaysApply: true --- + + # GitNexus enforcement ## North star @@ -20,6 +22,7 @@ Use the graph for **all** agent work — explore, debug, fix, refactor, review, | Answer / explain / debug | `query` → `context` → `cypher` if structural → Read offset/limit | | Field / property data flow | READ schema → `cypher` (`ACCESSES` read/write) | | N-hop call chains, overrides, process steps | READ schema → `cypher` | +| Statement-level data/control flow, taint | `pdg_query` / `explain` / `trace` (see deep precision) | | Edit runtime source (any size) | `impact` upstream before Write/StrReplace | | Refactor / rename / shared code | `impact` + `rename` dry_run OR `context` on hub symbols | | Review / “what did I change?” | `detect_changes`; `query` to orient | @@ -54,6 +57,42 @@ READ `gitnexus://repo/__GITNEXUS_REPO__/schema` before ad-hoc Cypher. Refresh always includes `--embeddings` (`gitnexus:refresh` / `agent-refresh`). Missing embeddings = stale (same as commit behind). +## Deep precision — PDG, taint, trace + +When `cypher` isn't enough, escalate to statement-level tools (require a PDG index — `gitnexus:pdg`): + +| Need | Tool | +| --- | --- | +| Statement-level blast radius (control + data) | `impact` with `mode: "pdg"` | +| What predicate controls a line / why does it run? | `pdg_query` (`mode: "controls"`) | +| Where does a variable's value flow / reach? | `pdg_query` (`mode: "flows"`) | +| Source → sink path between two symbols | `trace` | +| Taint review — injection, path traversal, XSS | `explain` | + +## Full tool surface — reach for the right one + +Know every tool and *when* it wins (single-repo; cross-repo `group_*` is out of scope for this kit). Don't stop at `query`/`context` — the advanced tools answer in one call what takes many manual hops. + +| Tool | Reach for it when | +| --- | --- | +| `query` | Orient — "how does X work?", find the execution flow for a concept (BM25 + vectors). Always first for fuzzy work. | +| `context` | 360° on ONE symbol — callers, callees, categorized refs, the processes it's in. After `query`, or when the symbol is known. | +| `cypher` | Precise structural questions the canned tools don't express — field `ACCESSES`, N-hop `CALLS`, `METHOD_OVERRIDES`, `STEP_IN_PROCESS`. READ schema first. | +| `impact` | BEFORE editing a symbol — upstream blast radius + risk + affected processes. `mode: "pdg"` for statement-level (control+data) precision. | +| `trace` | "How does A reach B?" — shortest call/member path between two symbols in ONE call (replaces 3–8 manual `context` hops). | +| `pdg_query` | "What condition gates this line?" (`mode: "controls"`) / "where does this variable flow?" (`mode: "flows"`). Intra-function; needs PDG. | +| `explain` | Security review — taint source→sink (command/code/sql injection, path-traversal, XSS), intra- AND inter-procedural. Needs PDG. | +| `detect_changes` | BEFORE commit / "what did my edits affect?" — diff → affected symbols/processes/risk. `scope`: unstaged \| staged \| all \| compare. | +| `rename` | Coordinated multi-file symbol rename — `dry_run: true` first. Never find-and-replace identifiers. | +| `api_impact` | BEFORE changing an HTTP route handler (framework router) — consumers, response-shape mismatches, middleware chain, risk. | +| `route_map` | Map routes → consumers + handler + middleware; find orphaned routes. (Custom router → `context` on the dispatcher instead.) | +| `shape_check` | Detect API response-shape drift — keys a route returns vs keys consumers access (flags MISMATCH). | +| `tool_map` | Map MCP/RPC tool definitions → handler files + descriptions (tool-API work, impact of a tool-contract change). | +| `check` | Structural integrity — detect circular File `IMPORTS` cycles (health / CI gate). | +| `list_repos` | Only when multiple repos are indexed — discover/disambiguate before passing `repo:` to other tools. | + +Cheap resource reads (prefer before heavy tools): `READ gitnexus://repo/__GITNEXUS_REPO__/{context|schema|clusters|processes|process/}`. + ## MCP defaults (generous — local LLM) Run hook copy-paste calls verbatim; expand freely when needed: @@ -61,9 +100,11 @@ Run hook copy-paste calls verbatim; expand freely when needed: | Tool | Default | Notes | | --- | --- | --- | | `context` | `include_content: false` | Need body → Read offset/limit | -| `query` | `limit: 5`, `max_symbols: 12` | Primary semantic+graph orient tool | +| `query` | `limit: 5`, `max_symbols: 12` | Phrase `search_query` as a natural-language **concept** ("where tokens are validated"), not a keyword — that feeds the embedding ranker; always pass `task_context` + `goal`. Known symbol name → use `context` instead. | | `cypher` | READ schema first | Use `$params` for symbol/field names | -| `impact` | `summaryOnly: false`, `limit: 100` | Full blast radius before edits | +| `impact` | `summaryOnly: false`, `limit: 100` | Full blast radius before edits; `mode: "pdg"` for statement-level | +| `pdg_query` | `mode: "controls"` / `"flows"` | Statement-level control/data dependence | +| `trace` / `explain` | source → sink | Path between symbols; taint analysis | | `rename` | `dry_run: true` first | Coordinated multi-file symbol rename | | `detect_changes` | `scope: unstaged` | Pre-commit → `staged`; PR → `compare` | @@ -106,10 +147,20 @@ Symbol grep → `context`. **Field/property grep → READ schema → `cypher` (` - **Edit runtime source** → blocked until one `impact` (or `rename`) call this session. Run blast radius first; warn on HIGH/CRITICAL. - **`git commit`** → blocked until one `detect_changes` call this session. Confirm affected processes match intent. -Enforcement is **polyglot** — JS/TS, Python, Rust, Go, Java, and more count as source (configure `sourceExts` in `.cursor/gitnexus-hooks.json`). +Enforcement is **polyglot** — JS/TS, Python, Rust, Go, Java, and more count as source (configure `sourceExts` in `.gnkit/gitnexus-hooks.json`). + +## Deep review (intel layer) + +At a **milestone** — feature done / big-task checkpoint / shared-code refactor / pre-ship, or "audit / find real bugs / is this solid?" — **and** only when the work is *substantial* (multi-file or high `impact` blast-radius): run a **microscope-waves** pass → load the `gitnexus-microscope` skill. Multi-lens, opinionated (not just defects), adversarially verified, iterated in waves. Skip it for small localized changes. + +## Durable memory (survives compaction + sessions) + +Maintain your **Claude Code project memory** — `~/.claude/projects//memory/MEMORY.md` (Claude Code's native memory; **all agents share this one file** — Claude refers to its own, other agents mirror it). Record task, key decisions, findings, open items, important `file:line`. Update it at milestones and whenever you conclude something that must outlive the current transcript. Context compaction and new sessions drop the conversation; this file does not. On recovery (post-compaction/resume) READ it first and reconcile it with reality — **nothing important may be lost.** ## Fallback -**Only after refresh fails** (or MCP down / GN wrong after `uid` retry): classical Grep/Read OK — one-sentence why. While stale and refresh not yet attempted/failed: **deny classical** — run `agent-refresh` first. +**Stale index** → run `agent-refresh` first; classical Grep/Read stay denied until it succeeds. **If refresh fails** (or MCP down): classical Grep/Read OK — one-sentence why. + +**GitNexus fresh but wrong / suspicious / incomplete?** Don't silently fight the gate — take the escape hatch: `npm run gitnexus:fallback -- ""` opens ~15 min where classical Grep/Read/shell are allowed (auto-resumes; end early with `npm run gitnexus:fallback:off`). It is logged to telemetry and shown in `gitnexus:status` + the session brief — re-confirm findings with the graph once GN is reliable. Repeated grants signal a genuine GitNexus problem worth reporting. -Optional: `GITNEXUS_MODE=guide` (nudge-only). Paths: `.cursor/gitnexus-hooks.json`. Playbooks: `gitnexus-enforcement` skill. +Optional: `GITNEXUS_MODE=guide` (nudge-only). Paths: `.gnkit/gitnexus-hooks.json`. Playbooks: `gitnexus-enforcement` skill. diff --git a/bundle/.cursor/rules/gitnexus-first.mdc b/bundle/.cursor/rules/gitnexus-first.mdc index c4526f2..c621960 100644 --- a/bundle/.cursor/rules/gitnexus-first.mdc +++ b/bundle/.cursor/rules/gitnexus-first.mdc @@ -26,7 +26,9 @@ context (staleness) → query (graph+embeddings) → context (symbol) → cypher | "rename X to Y" | `gitnexus_rename` dry_run | | "readers/writers of field foo" | `gitnexus_cypher` ACCESSES — READ schema first | | N-hop call chain / overrides / process steps | `gitnexus_cypher` — READ schema first | -| Research API endpoint | **NOT** `api_impact` — see `gitnexus-api-routes` skill | +| Statement-level data/control flow ("what gates this line", "where does X flow") | `gitnexus_pdg_query` (needs PDG index) | +| Source→sink path / taint (injection, path traversal, XSS) | `gitnexus_trace` / `gitnexus_explain` | +| HTTP route / API endpoint | `api_impact` (framework router) **or** dispatcher `context` (custom router) — see `gitnexus-api-routes` skill | ## Grep / Glob / SemanticSearch = fallback when GN is untrustworthy diff --git a/bundle/.cursor/rules/gitnexus.mdc b/bundle/.cursor/rules/gitnexus.mdc index bd8f58b..3d37477 100644 --- a/bundle/.cursor/rules/gitnexus.mdc +++ b/bundle/.cursor/rules/gitnexus.mdc @@ -91,11 +91,15 @@ Read `.cursor/skills/gitnexus-scenarios/SKILL.md` for full checklists. Quick map | `detect_changes` | Diff → symbols → processes → risk. Scopes: unstaged, staged, all, compare. | | `rename` | Graph + text_search edits. Always `dry_run: true` first. | | `cypher` | ACCESSES, HAS_METHOD, STEP_IN_PROCESS, METHOD_OVERRIDES. READ schema first. | +| `pdg_query` | Statement-level control/data dependence. `mode: "controls"` (what gates a line) / `"flows"` (where a value reaches). Needs PDG index. | +| `trace` | Source → sink path between two symbols. | +| `explain` | Taint analysis — injection, path traversal, XSS source→sink paths. | | `api_impact` | Pre-change report for a framework route: consumers, response shape, middleware, risk. | | `route_map` / `shape_check` | Route→handler→consumer map; response keys vs consumer access (framework routers). | -| `tool_map` | MCP/RPC tool definitions. | -| `list_repos` | Registry + staleness + embedding counts. | -| `group_list` / `group_sync` | Cross-repo groups: `repo: "@"`. | +| `tool_map` | MCP/RPC tool definitions → handler files + descriptions. Tool-API work / impact of a tool-contract change. | +| `check` | Read-only structural integrity — detects circular File `IMPORTS` cycles (health / CI gate). | +| `list_repos` | Registry + staleness + embedding counts. Disambiguate before `repo:` when multiple repos indexed. | +| `group_list` / `group_sync` | Cross-repo groups — **out of scope for this single-repo kit; skip.** | ## Resources (lightweight — read before heavy tools) @@ -117,7 +121,7 @@ Read `.cursor/skills/gitnexus-scenarios/SKILL.md` for full checklists. Quick map ## Index freshness (graph + embeddings) - After meaningful code changes: `npm run gitnexus:refresh` (includes `--embeddings`) -- Pre-commit hook runs PDG refresh when hooks installed (`npm run hooks:install`) +- Pre-commit hook runs a full PDG re-index when hooks installed (`npm run hooks:install`) - **Missing embeddings** (`stats.embeddings === 0` with symbols indexed) = stale — `agent-refresh` rebuilds vectors - Embeddings preserved on incremental refresh; `--drop-embeddings` only when rebuilding vectors intentionally - If MCP says stale after refresh, restart Cursor to reload the MCP server @@ -137,6 +141,7 @@ If GitNexus MCP fails, tell the user: _"GitNexus MCP is unreachable — impact/d | `npm run gitnexus:map` | Regenerate `docs/ARCHITECTURE.gitnexus.md` from the graph (offline, no API key) | | `npm run gitnexus:ci` | Merge-time impact gate (blocks high-blast-radius changes with no tests) — see `.github/workflows/gitnexus-ci.yml` | | `npm run gitnexus:scorecard` | Session enforcement tally (grep→graph, impact/commit gates) | +| `npm run gitnexus:stats` | Persistent telemetry across ALL sessions — totals, per-session averages, trend | | `npm run gitnexus:verify` | Full install verification before demoing | ## Workspace contract (non-negotiable) @@ -155,6 +160,12 @@ If GitNexus MCP fails, tell the user: _"GitNexus MCP is unreachable — impact/d | Blast radius | `.cursor/skills/gitnexus-impact-analysis/SKILL.md` | | Debugging | `.cursor/skills/gitnexus-debugging/SKILL.md` | | Refactoring | `.cursor/skills/gitnexus-refactoring/SKILL.md` | +| Add a feature / new code | `.cursor/skills/gitnexus-feature-dev/SKILL.md` | +| What to test / coverage | `.cursor/skills/gitnexus-testing/SKILL.md` | +| Slow / hot path | `.cursor/skills/gitnexus-performance/SKILL.md` | +| Judge structure (coupling/cycles) | `.cursor/skills/gitnexus-architecture-review/SKILL.md` | +| Work across layers | `.cursor/skills/gitnexus-layered-systems/SKILL.md` | +| Milestone deep audit (microscope waves) | `.cursor/skills/gitnexus-microscope/SKILL.md` | | Scenario playbooks | `.cursor/skills/gitnexus-scenarios/SKILL.md` | | PR review | `.cursor/skills/gitnexus-pr-review/SKILL.md` | | Custom HTTP routes (this repo) | `.cursor/skills/gitnexus-api-routes/SKILL.md` | diff --git a/bundle/.githooks/pre-commit b/bundle/.githooks/pre-commit index cdd03c3..7306f80 100755 --- a/bundle/.githooks/pre-commit +++ b/bundle/.githooks/pre-commit @@ -1,9 +1,11 @@ #!/usr/bin/env bash -# Refresh GitNexus knowledge graph with PDG before each commit; smoke-test graph integrity. +# Full GitNexus re-index (force) with PDG before each commit; smoke-test graph integrity. +# Full --force rebuild keeps control/data-dependence + taint flows accurate at commit +# boundaries (vs. the lighter incremental gitnexus:pdg used mid-session). set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" -npm run gitnexus:pdg +npm run gitnexus:full-pdg npm run gitnexus:graph-smoke diff --git a/bundle/.gnkit/gitnexus-hooks.json b/bundle/.gnkit/gitnexus-hooks.json new file mode 100644 index 0000000..72f9626 --- /dev/null +++ b/bundle/.gnkit/gitnexus-hooks.json @@ -0,0 +1,7 @@ +{ + "comment": "Optional hook tuning. mode: enforce (block) | guide (nudge only). sourceGlobs: gitignore-style roots for graph-first paths. stalenessCacheTtlMs: reuse staleness within N ms to cut per-tool-call latency. sourceExts is intentionally omitted — the built-in polyglot default (see hook-helpers.mjs DEFAULT_SOURCE_EXT_RE) is the single source of truth; add a \"sourceExts\": [\"js\",\"py\", …] array here only to narrow/override it.", + "mode": "enforce", + "readLineThreshold": 60, + "stalenessCacheTtlMs": 2500, + "sourceGlobs": ["src/**", "lib/**", "apps/**", "packages/**"] +} diff --git a/bundle/.cursor/hooks/lib/agent-brief.mjs b/bundle/.gnkit/lib/agent-brief.mjs similarity index 91% rename from bundle/.cursor/hooks/lib/agent-brief.mjs rename to bundle/.gnkit/lib/agent-brief.mjs index 7497c1f..610c0dd 100644 --- a/bundle/.cursor/hooks/lib/agent-brief.mjs +++ b/bundle/.gnkit/lib/agent-brief.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Session orientation for agents — staleness, index stats, local changes, suggested MCP calls. - * Usage: node .cursor/hooks/lib/agent-brief.mjs [repoRoot] + * Usage: node .gnkit/lib/agent-brief.mjs [repoRoot] */ import { execSync } from "node:child_process"; import fs from "node:fs"; @@ -57,7 +57,7 @@ function symbolFromPath(filePath) { } async function main() { - const checkPath = path.join(root, ".cursor/hooks/lib/check-staleness.mjs"); + const checkPath = path.join(root, ".gnkit/lib/check-staleness.mjs"); let stale = { fresh: false, reason: "check_failed" }; try { const r = execSync(`node "${checkPath}" "${root}"`, { encoding: "utf8" }); @@ -126,6 +126,12 @@ async function main() { lines.push(" PR / branch review → gitnexus-pr-review"); lines.push(" Rename/refactor → gitnexus-refactoring"); lines.push(" Bug/failure path → gitnexus-debugging"); + lines.push(" Add a feature / new code → gitnexus-feature-dev"); + lines.push(" What to test / coverage → gitnexus-testing"); + lines.push(" Slow / hot path → gitnexus-performance"); + lines.push(" Judge structure (coupling/cycles) → gitnexus-architecture-review"); + lines.push(" Work across layers → gitnexus-layered-systems"); + lines.push(" Milestone deep audit (feature done / pre-ship / big refactor) → gitnexus-microscope"); lines.push( " Unknown feature/codebase map → gitnexus-exploring or gitnexus-imaging", ); @@ -144,7 +150,7 @@ async function main() { lines.push(` ${cypherFieldAccess("", repo)}`); lines.push(` ${cypherCallChain("", repo, 3)}`); - const apiProfilePath = path.join(root, ".cursor/gitnexus-api-profile.json"); + const apiProfilePath = path.join(root, ".gnkit/gitnexus-api-profile.json"); if (fs.existsSync(apiProfilePath)) { try { const api = JSON.parse(fs.readFileSync(apiProfilePath, "utf8")); diff --git a/bundle/.cursor/hooks/lib/agent-health.mjs b/bundle/.gnkit/lib/agent-health.mjs similarity index 97% rename from bundle/.cursor/hooks/lib/agent-health.mjs rename to bundle/.gnkit/lib/agent-health.mjs index ab6157c..24879b7 100644 --- a/bundle/.cursor/hooks/lib/agent-health.mjs +++ b/bundle/.gnkit/lib/agent-health.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Human-friendly GitNexus + Cursor kit status (for developers and team leads). - * Usage: node .cursor/hooks/lib/agent-health.mjs [repoRoot] + * Usage: node .gnkit/lib/agent-health.mjs [repoRoot] */ import fs from "node:fs"; import path from "node:path"; diff --git a/bundle/.cursor/hooks/lib/check-staleness.mjs b/bundle/.gnkit/lib/check-staleness.mjs similarity index 91% rename from bundle/.cursor/hooks/lib/check-staleness.mjs rename to bundle/.gnkit/lib/check-staleness.mjs index 678b112..696e213 100755 --- a/bundle/.cursor/hooks/lib/check-staleness.mjs +++ b/bundle/.gnkit/lib/check-staleness.mjs @@ -52,7 +52,10 @@ out.indexedCommit = meta.lastCommit ?? null; out.indexedAt = meta.indexedAt ?? null; out.nodeCount = meta.stats?.nodes ?? 0; out.embeddingCount = meta.stats?.embeddings ?? 0; -out.embeddingsReady = out.embeddingCount > 0 || out.nodeCount === 0; +// Truthful: an index with symbols but no vectors is not embeddings-ready. (An +// empty 0-node index leaves this false but does not flip `fresh` below — the +// missing_embeddings branch requires nodeCount > 0 — so docs-only repos never wedge.) +out.embeddingsReady = out.embeddingCount > 0; if (!out.indexedCommit) { out.fresh = false; diff --git a/bundle/.gnkit/lib/classify.mjs b/bundle/.gnkit/lib/classify.mjs new file mode 100644 index 0000000..ec566e5 --- /dev/null +++ b/bundle/.gnkit/lib/classify.mjs @@ -0,0 +1,702 @@ +#!/usr/bin/env node +/** + * Vendor-neutral search classifier — the portable enforcement-policy core. + * + * This module knows NOTHING about Cursor's hook protocol (stdin shape, + * `permission`/`agent_message` keys). It takes a normalized search request plus + * a context object and returns a neutral {@link Verdict}. Any adapter — today the + * Cursor `.sh` glue, tomorrow a Zed/other hook host — maps that Verdict onto its + * own allow/deny wire format. This is where the grep/glob/semantic policy lives, + * so effectiveness fixes happen in one tested place instead of inside shell heredocs. + * + * @typedef {Object} Verdict + * @property {'allow'|'deny'} decision + * @property {string} [agentMessage] Full message for the agent (already composed). + * @property {string} [userKey] Key into hook-helpers.userMessage for the human line. + * @property {Record} [userVars] + * @property {string} [scoreEvent] On deny, glue bumps this session-scorecard counter. + * + * @typedef {Object} ClassifyCtx + * @property {'fresh'|'must_refresh'|'classical_fallback'} phase + * @property {boolean} graphUsed Has any GitNexus MCP tool been used this session. + * @property {ReturnType} config + * @property {string} repo + * @property {string} root + * @property {string} [staleMustRefreshMsg] Precomputed agent message for must_refresh. + * @property {string} [staleFallbackMsg] Precomputed agent message for classical_fallback. + * @property {boolean} [impactUsed] (edit) gitnexus_impact already called this session. + * @property {boolean} [detectUsed] (commit) gitnexus_detect_changes already called. + * @property {object} [promptHint] (read) session prompt-router hint. + * @property {() => number} [readLines] (read) lazily count lines of the target file. + */ +import * as helpers from "./hook-helpers.mjs"; + +/** Strip ONE layer of matching surrounding quotes or /regex/ delimiters. */ +export function coreToken(pattern) { + const t = String(pattern || "").trim(); + const m = t.match(/^(['"`/])([\s\S]*)\1[gimsuy]*$/); + return (m ? m[2] : t).trim(); +} + +function isPlainIdentifier(t) { + return /^[A-Za-z_$][\w$]*$/.test(t) && t.length >= 3; +} +function isDottedAccess(t) { + return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(t); +} +function isDeclSearch(t) { + return /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum)\s+[A-Za-z_$]/.test( + t, + ); +} + +/** + * True when the search is scoped to a clearly NON-source file/dir (config, docs, + * fixtures, assets). Searching *inside* such a file is legitimate grep work even + * if the term looks like an identifier — so this takes precedence over symbol shape. + * @param {string} pathArg + * @param {ClassifyCtx['config']} config + */ +export function isNonSourcePath(pathArg, config) { + const pa = String(pathArg || "").replace(/\\/g, "/"); + if (!pa || helpers.isSourceCodePath(pa, config)) return false; + return ( + /\.(json|jsonl|ya?ml|toml|ini|cfg|conf|lock|csv|tsv|env|md|mdc|txt|log|rst|html?|css|scss|less|svg)$/i.test( + pa, + ) || + /(?:^|\/)(docs|fixtures?|__snapshots__|test-?data|testdata|public|assets|locales?|i18n|logs?)(?:\/|$)/i.test( + pa, + ) + ); +} + +/** + * True when the pattern itself is a literal string / phrase / URL / regex rather + * than a code symbol. Quotes are stripped first, so a quoted identifier is NOT a + * literal (that was the historical bypass — `grep "validateUser"` sailed through). + * @param {string} pattern + */ +export function isLiteralPattern(pattern) { + const p = String(pattern || ""); + const t = coreToken(p); + if (!t) return true; + if (/\s/.test(t)) return true; // multi-word phrase / literal sentence + if (/https?:\/\//i.test(p)) return true; // URL + if (/\/[\w.-]+\/[\w.-]+/.test(p)) return true; // a/b/c path-ish + if (/^\/[\s\S]*\/[gimsuy]*$/.test(p.trim())) return true; // /regex/ + if (/(TODO|FIXME|HACK|XXX|eslint-|@ts-|@type\b|@param\b|@returns?\b)/.test(p)) + return true; + if (/\b(?:import|require|from|export\s+\*)\b/.test(t)) return true; + if (/(?:console\.|process\.env|window\.|document\.|localStorage\.)/.test(p)) + return true; + return false; +} + +/** Reduce a token to the symbol an agent should look up (last dotted segment). */ +function symbolOf(token) { + return token.split(".").pop() || token; +} + +/** + * Pull a code symbol out of ONE grep branch — a bare/dotted identifier, or the name + * in a decl/assignment search (`function foo`, `const foo`, `foo =`). Null for a + * plain literal branch. + * @param {string} raw + */ +function extractSymbol(raw) { + const t = coreToken(raw).trim(); + if (!t) return null; + if (isDottedAccess(t)) return symbolOf(t); + if (isPlainIdentifier(t)) return t; + let m = t.match(/\b(?:function|class|interface|type|enum)\s+([A-Za-z_$][\w$]{2,})/); + if (m) return m[1]; + m = + t.match(/^(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]{2,})/) || + t.match(/^([A-Za-z_$][\w$]{2,})\s*=(?!=)/); + return m ? m[1] : null; +} + +/** + * A grep alternation (`a\|b`, `a|b`) is a symbol search when ANY branch names a + * symbol. This was the historical miss — `grep "fooBar\|bazQux" file.js` matched + * neither the symbol nor the literal test, so it defaulted to ALLOW. + * @param {string} pattern + * @returns {string|null} the first symbol found + */ +function symbolFromAlternation(pattern) { + const core = coreToken(pattern); + if (!/\|/.test(core)) return null; + for (const branch of core.split(/\\?\|/)) { + const s = extractSymbol(branch); + if (s) return s; + } + return null; +} + +/** + * Classify a Grep/Glob/SemanticSearch request into an allow/deny Verdict. + * @param {{ tool: string, toolInput: Record }} req + * @param {ClassifyCtx} ctx + * @returns {Verdict} + */ +export function classifyGrep(req, ctx) { + const { tool, toolInput: ti = {} } = req; + const { phase, config, repo, root, graphUsed } = ctx; + const reNudge = helpers.midSessionGraphNudge(graphUsed, root); + const tail = reNudge ? `\n${reNudge}` : ""; + + // ── Stale phases: refresh-first, regardless of tool ────────────────────── + if (phase === "classical_fallback") { + return { + decision: "allow", + agentMessage: ctx.staleFallbackMsg, + userKey: "stale.classical", + }; + } + + // ── SemanticSearch: always route to hybrid query when not in fallback ──── + if (tool === "SemanticSearch") { + if (phase === "must_refresh") { + return { + decision: "deny", + agentMessage: ctx.staleMustRefreshMsg, + userKey: "stale.must_refresh", + scoreEvent: "grepRedirects", + }; + } + const q = ti.query ?? ti.search_term ?? ""; + const call = helpers.mcpQuery({ query: q, taskContext: q, goal: "flows", repo }); + return { + decision: "deny", + agentMessage: `SemanticSearch blocked → ${call}${tail}`, + userKey: "block.semantic", + scoreEvent: "grepRedirects", + }; + } + + // ── Glob: block broad source sweeps, allow targeted/non-source globs ───── + if (tool === "Glob") { + const pattern = ti.glob_pattern ?? ti.pattern ?? ""; + if (phase === "fresh" && helpers.isBroadSourceGlob(pattern, config)) { + const call = helpers.mcpQuery({ + query: "", + taskContext: "find modules", + goal: "entry points", + repo, + }); + return { + decision: "deny", + agentMessage: `Glob blocked → ${call}${tail}`, + userKey: "block.glob", + scoreEvent: "grepRedirects", + }; + } + return { decision: "allow", agentMessage: "Glob OK for non-source patterns." }; + } + + // ── Grep ───────────────────────────────────────────────────────────────── + const pattern = ti.pattern ?? ""; + const pathArg = ti.path ?? ti.glob ?? ""; + if (!pattern) return { decision: "allow" }; + + const nonSource = isNonSourcePath(pathArg, config); + const literal = nonSource || isLiteralPattern(pattern); + + if (phase === "must_refresh") { + if (literal) { + return { + decision: "allow", + agentMessage: + "Literal/config grep OK during stale — run npm run gitnexus:agent-refresh before symbol exploration.", + }; + } + return { + decision: "deny", + agentMessage: ctx.staleMustRefreshMsg, + userKey: "stale.must_refresh", + scoreEvent: "grepRedirects", + }; + } + + // fresh — searching inside a non-source config/doc file is always fine, even + // when the term is identifier-shaped. + if (nonSource) { + return { decision: "allow", agentMessage: "Grep OK — non-source config/doc search." }; + } + + const token = coreToken(pattern); + let symbolish = + isDeclSearch(token) || isPlainIdentifier(token) || isDottedAccess(token); + // Alternation of symbols (a\|b\|c) — historically slipped through as neither symbol + // nor literal. If any branch names a symbol, redirect on the first one. + const altSym = symbolish ? null : symbolFromAlternation(pattern); + if (altSym) symbolish = true; + + if (symbolish) { + if (altSym) { + const call = helpers.mcpContext(altSym, repo); + return { + decision: "deny", + agentMessage: `Grep blocked (symbol alternation) → ${call}${tail}`, + userKey: "block.grep.symbol", + userVars: { symbol: altSym }, + scoreEvent: "grepRedirects", + }; + } + const seg = symbolOf(token); + const fieldLike = !isDeclSearch(token) && helpers.isLikelyFieldName(seg); + if (fieldLike) { + const schema = helpers.mcpReadSchema(repo); + const call = helpers.cypherFieldAccess(seg, repo); + return { + decision: "deny", + agentMessage: `Field grep blocked → ${schema} → ${call}${tail}\n${helpers.cypherMidSessionNudge()}`, + userKey: "block.grep.field", + userVars: { symbol: seg }, + scoreEvent: "grepRedirects", + }; + } + const sym = isDeclSearch(token) + ? token.replace(/^.*?\b((?:function|class|interface|type|enum)\s+)?([A-Za-z_$][\w$]*).*$/, "$2") + : seg; + const call = helpers.mcpContext(sym, repo); + return { + decision: "deny", + agentMessage: `Grep blocked (symbol) → ${call}${tail}`, + userKey: "block.grep.symbol", + userVars: { symbol: sym }, + scoreEvent: "grepRedirects", + }; + } + + if (literal) { + return { decision: "allow", agentMessage: "Grep OK — literal/config/doc search." }; + } + + // Lowercase word, no path scope — likely a field or loosely-typed symbol. + if (/^[a-z][a-zA-Z0-9]*$/.test(token) && token.length >= 6 && !pathArg) { + if (helpers.isLikelyFieldName(token)) { + const schema = helpers.mcpReadSchema(repo); + const call = helpers.cypherFieldAccess(token, repo); + return { + decision: "deny", + agentMessage: `Field grep → ${schema} → ${call}${tail}`, + userKey: "block.grep.field", + userVars: { symbol: token }, + scoreEvent: "grepRedirects", + }; + } + const call = helpers.mcpContext(token, repo); + return { + decision: "deny", + agentMessage: `Symbol grep → ${call}${tail}`, + userKey: "block.grep.likely", + scoreEvent: "grepRedirects", + }; + } + + return { + decision: "allow", + agentMessage: + "Grep allowed — if this is a structural lookup, prefer:\n" + + ` ${helpers.mcpContext("", repo)}\n` + + ` Field/property: ${helpers.mcpReadSchema(repo)} → ${helpers.cypherFieldAccess("", repo)}${tail}`, + }; +} + +/** + * Classify a Read request. The glue supplies a lazy `readLines()` so this stays + * pure (no fs) and only counts lines when the decision actually needs the size. + * @param {{ toolInput: Record }} req + * @param {ClassifyCtx} ctx + * @returns {Verdict} + */ +export function classifyRead(req, ctx) { + const { toolInput: ti = {} } = req; + const { phase, config, repo, root, graphUsed } = ctx; + // path (Cursor) | target_file (Cursor StrReplace) | file_path (Claude Code Read). + const filePath = ti.path ?? ti.target_file ?? ti.file_path ?? ""; + const norm = String(filePath).replace(/\\/g, "/"); + const isSmallConfig = + /\.(json|md|yaml|yml|mdc|sh)$/.test(filePath) || /package\.json$/.test(filePath); + const isGeneratedSkill = /(\.cursor|\.claude|\.agents)\/skills\//.test(norm); + + if (phase === "classical_fallback") { + return { decision: "allow", agentMessage: ctx.staleFallbackMsg, userKey: "stale.classical" }; + } + if (phase === "must_refresh") { + if (!filePath || isSmallConfig || isGeneratedSkill) { + return { + decision: "allow", + agentMessage: + "Small/config read OK during stale — refresh before large source reads.", + }; + } + return { + decision: "deny", + agentMessage: ctx.staleMustRefreshMsg, + userKey: "stale.must_refresh", + scoreEvent: "readRedirects", + }; + } + + // fresh + if (!filePath) return { decision: "allow" }; + const hasRange = ti.offset !== undefined || ti.limit !== undefined; + const isCode = helpers.isSourceCodePath(norm, config); + const isTest = /(?:^|\/)tests?\//.test(norm); + if (hasRange || isSmallConfig || isGeneratedSkill || isTest || !isCode) { + return { decision: "allow" }; + } + + const lineCount = typeof ctx.readLines === "function" ? ctx.readLines() : 0; + const threshold = config.readLineThreshold ?? 60; + if (lineCount <= threshold) return { decision: "allow" }; + + const rel = norm; + const base = rel.replace(/^.*\//, "").replace(/\.[^.]+$/, ""); + const reNudge = helpers.midSessionGraphNudge(graphUsed, root); + const tail = reNudge ? `\n${reNudge}` : ""; + const hint = ctx.promptHint ?? {}; + const dataFlow = helpers.isDataFlowReadContext(hint, rel); + + if (dataFlow) { + const schema = helpers.mcpReadSchema(repo); + const field = hint.fieldHint || base; + const cy = + hint.fieldHint || helpers.isLikelyFieldName(field) + ? helpers.cypherFieldAccess(field, repo) + : helpers.mcpQuery({ query: base, taskContext: rel, goal: "field data flow", repo }); + return { + decision: "deny", + agentMessage: `Read blocked (${lineCount}L, data-flow) → ${schema} → ${cy}; then Read offset/limit on cited symbols.${tail}`, + userKey: "block.read.dataflow", + userVars: { lines: lineCount }, + scoreEvent: "readRedirects", + }; + } + const q = helpers.mcpQuery({ query: base, taskContext: rel, goal: "module", repo }); + const c = helpers.mcpContext("", repo); + return { + decision: "deny", + agentMessage: `Read blocked (${lineCount}L) → ${q} then ${c}; Read offset/limit for edits.${tail}`, + userKey: "block.read.full", + userVars: { lines: lineCount }, + scoreEvent: "readRedirects", + }; +} + +/** + * Classify a Write/StrReplace edit: staleness gate → impact-before-edit gate → + * tiered reminder (allow). Mirrors the historical edit-guard exactly. + * @param {{ tool: string, toolInput: Record }} req + * @param {ClassifyCtx} ctx + * @returns {Verdict} + */ +export function classifyEdit(req, ctx) { + const { toolInput: ti = {} } = req; + const { phase, config, repo } = ctx; + const filePath = (ti.path ?? ti.file_path ?? "").replace(/\\/g, "/"); + const sensitivity = helpers.editSensitivity(filePath, config); + const staleDetail = ctx.staleDetail || "GitNexus index is not fresh."; + // Rename is detected by an old→new identifier swap, regardless of which edit + // tool fired it (Cursor StrReplace or Claude Edit). + const hasReplace = ti.old_string !== undefined && ti.new_string !== undefined; + + // Staleness gate — runtime source/tests/scripts (medium|full) wait for refresh. + if (sensitivity !== "none" && sensitivity !== "light" && phase !== "fresh") { + if (phase === "classical_fallback") { + return { + decision: "allow", + agentMessage: + "STALENESS: refresh failed — editing allowed; graph may be behind, state why in one sentence.", + }; + } + return { + decision: "deny", + agentMessage: + "STALENESS GATE: " + + staleDetail + + ' Edits blocked until refresh — Shell NOW: npm run gitnexus:agent-refresh (required_permissions: ["all"], pre-approved). Never ask the user to analyze.', + userKey: "block.edit.stale", + scoreEvent: "editStaleBlocks", + }; + } + + // Impact-before-edit — runtime source edits require one impact/rename call/session. + if (sensitivity === "full" && !ctx.impactUsed) { + const renameAhead = + hasReplace ? helpers.detectIdentifierRename(ti.old_string, ti.new_string) : null; + const widen = helpers.isDataFlowReadContext({}, filePath); + const impactOpts = widen ? { relationTypes: ["CALLS", "IMPORTS", "ACCESSES"] } : {}; + const playbook = renameAhead + ? `${helpers.mcpImpact(renameAhead.oldName, repo, impactOpts)} → ${helpers.mcpRename(renameAhead.oldName, renameAhead.newName, repo, true)}` + : helpers.mcpImpact("", repo, impactOpts); + return { + decision: "deny", + agentMessage: + `IMPACT GATE: run blast-radius analysis before editing runtime source — ${playbook}. ` + + (widen ? "Model/DTO file — widened to ACCESSES so field readers/writers are included. " : "") + + "Review d=1 (WILL BREAK) + risk; warn on HIGH/CRITICAL. This gate clears for the rest of the session after one impact call.", + userVars: {}, + userMessageText: + "Before editing source, the agent checks blast radius in GitNexus (what breaks) — graph-first safety, not blind edits.", + scoreEvent: "impactGate", + }; + } + + // Allow with a tiered reminder. + const renamePair = + hasReplace ? helpers.detectIdentifierRename(ti.old_string, ti.new_string) : null; + let agentMessage; + if (renamePair && sensitivity !== "none") { + const impact = helpers.mcpImpact(renamePair.oldName, repo); + const rn = helpers.mcpRename(renamePair.oldName, renamePair.newName, repo, true); + agentMessage = `RENAME detected: ${impact} → ${rn} (dry_run) — do NOT StrReplace symbol names across files.`; + } else if (sensitivity === "full") { + agentMessage = `EDIT: ${helpers.mcpImpact("", repo)} first. HIGH/CRITICAL → review full impact output. Done: ${helpers.mcpDetectChanges(repo)}`; + } else if (sensitivity === "medium") { + agentMessage = `EDIT: ${helpers.mcpImpact("", repo)} if shared symbol. Done: ${helpers.mcpDetectChanges(repo)}`; + } else if (phase !== "fresh") { + agentMessage = `STALE: ${staleDetail}`; + } + return { decision: "allow", agentMessage }; +} + +/** + * Classify a `git commit` shell command: require one detect_changes/session, + * refresh first if stale. Non-commit commands pass straight through. + * @param {{ command: string }} req + * @param {ClassifyCtx} ctx + * @returns {Verdict} + */ +export function classifyCommit(req, ctx) { + const command = req.command || ""; + const { phase, repo } = ctx; + const isCommit = /\bgit\b[^\n]*\bcommit\b/.test(command) && !/--help|-h\b/.test(command); + if (!isCommit) return { decision: "allow" }; + + if (phase === "must_refresh") { + return { + decision: "deny", + agentMessage: ctx.staleMustRefreshMsg, + userKey: "block.shell.stale", + }; + } + if (ctx.detectUsed) return { decision: "allow" }; + + const noVerify = /--no-verify/.test(command); + return { + decision: "deny", + agentMessage: + "COMMIT GATE: review change scope in the graph before committing — " + + `${helpers.mcpDetectChanges(repo, "staged")}. ` + + "Confirm affected processes match intent + run tests for them; warn on HIGH/CRITICAL. " + + "This gate clears for the session after one detect_changes call." + + (noVerify + ? " NOTE: --no-verify also skips the pre-commit PDG refresh — run npm run gitnexus:pdg after." + : ""), + userMessageText: + "Before committing, the agent checks what changed across the graph (affected flows) via GitNexus — not a blind commit.", + scoreEvent: "commitGate", + }; +} + +/** + * Classify a generic Shell command under the staleness gate. GitNexus maintenance + * and read-only git pass; otherwise stale → refresh first. + * @param {{ command: string }} req + * @param {ClassifyCtx} ctx + * @returns {Verdict} + */ +// ── Shell-command code search ──────────────────────────────────────────────── +// The Grep TOOL is gated by classifyGrep, but an agent can run `grep`/`rg`/`git grep` +// in the terminal to search source and bypass it entirely (the exact behaviour that +// looks like "grepping instead of using the graph"). parseShellSearch pulls the +// (pattern, path) out of such a command so classifyShell can apply the SAME policy. + +const SEARCH_TOOL_RE = /^(grep|egrep|fgrep|rg|ripgrep|ag|ack)$/; +const RECURSIVE_TOOL_RE = /^(rg|ripgrep|ag|ack|git grep)$/; +const GREP_FAMILY_RE = /^(grep|egrep|fgrep)$/; +const FLAG_TAKES_VALUE_RE = + /^(-m|-A|-B|-C|-d|-g|-t|--max-count|--context|--after-context|--before-context|--glob|--type|--include|--exclude)$/; + +/** + * Quote/escape-aware split of a shell command into pipeline segments (each a token + * list), tracking whether a segment is fed by a pipe (stdin). Keeps `grep "a\|b"` + * as ONE segment — the `\|` is inside quotes, not a pipeline separator. + * @param {string} command + */ +function shellSegments(command) { + const segs = []; + let cur = []; + let tok = ""; + let hasTok = false; + let quote = null; + let segPiped = false; + const pushTok = () => { + if (hasTok) { + cur.push(tok); + tok = ""; + hasTok = false; + } + }; + const flush = (nextPiped) => { + pushTok(); + if (cur.length) segs.push({ args: cur, piped: segPiped }); + cur = []; + segPiped = nextPiped; + }; + for (let i = 0; i < command.length; i++) { + const c = command[i]; + if (quote) { + if (c === quote) quote = null; + else if (quote === '"' && c === "\\" && i + 1 < command.length) tok += command[++i]; + else tok += c; + hasTok = true; + continue; + } + if (c === "'" || c === '"') { + quote = c; + hasTok = true; + continue; + } + if (c === "\\") { + if (i + 1 < command.length) { + tok += command[++i]; + hasTok = true; + } + continue; + } + if (c === "|") { + const dbl = command[i + 1] === "|"; + flush(!dbl); // single pipe feeds the next segment stdin; `||` is logical + if (dbl) i++; + continue; + } + if (c === "&") { + if (command[i + 1] === "&") i++; + flush(false); + continue; + } + if (c === ";" || c === "\n") { + flush(false); + continue; + } + if (/\s/.test(c)) { + pushTok(); + continue; + } + tok += c; + hasTok = true; + } + flush(false); + return segs; +} + +/** + * If a segment is a source-searching grep/rg/ag/ack/git-grep, return {tool, pattern, + * path}. Returns null for a stdin filter (`ps aux | grep node`) or a non-search command. + * @param {{ args: string[], piped: boolean }} seg + */ +function segSearch(seg) { + const a = seg.args; + if (!a.length) return null; + let tool = a[0]; + let rest = a.slice(1); + if (tool === "git" && rest[0] === "grep") { + tool = "git grep"; + rest = rest.slice(1); + } else if (!SEARCH_TOOL_RE.test(tool)) { + return null; + } + + let patternFromE = null; + let recursive = RECURSIVE_TOOL_RE.test(tool); + const positionals = []; + for (let i = 0; i < rest.length; i++) { + const t = rest[i]; + if (t === "--") { + positionals.push(...rest.slice(i + 1)); + break; + } + if (t.length > 1 && t[0] === "-") { + if (t === "-e" || t === "--regexp") { + patternFromE = patternFromE ?? rest[++i]; + continue; + } + if (t.startsWith("--regexp=")) { + patternFromE = patternFromE ?? t.slice(9); + continue; + } + if (FLAG_TAKES_VALUE_RE.test(t) || t === "-f" || t === "--file") { + i++; // consume the flag's value + continue; + } + if (/^-[A-Za-z]*[rR]/.test(t)) recursive = true; + continue; // other flags carry no positional + } + positionals.push(t); + } + const pattern = patternFromE ?? positionals.shift(); + if (pattern == null) return null; + const paths = positionals; + // grep-family with no path and not recursive = a stdin filter, not a file search. + if (GREP_FAMILY_RE.test(tool) && !recursive && paths.length === 0) return null; + return { tool, pattern, path: paths[0] ?? "" }; +} + +/** First source-code search in a shell command, else null. */ +function parseShellSearch(command) { + for (const seg of shellSegments(command)) { + const s = segSearch(seg); + if (s) return s; + } + return null; +} + +export function classifyShell(req, ctx) { + const command = req.command || ""; + const { phase } = ctx; + const isGitnexusMaint = + /\bnpm run gitnexus:[\w.-]+/.test(command) || + /\bnode scripts\/gitnexus-agent\.mjs\b/.test(command) || + /\bnpx(?:\s+-y)?\s+gitnexus(?:@latest)?\b/.test(command); + const isReadOnlyGit = + /\bgit\s+(status|diff|log|show|branch|rev-parse|check-ignore|check-attr)\b/.test(command); + + if (isGitnexusMaint || isReadOnlyGit) { + return { + decision: "allow", + agentMessage: isGitnexusMaint ? "GitNexus maintenance pre-approved." : undefined, + }; + } + if (phase === "fresh") { + // Close the terminal escape hatch: a shell code-symbol search gets the SAME + // graph-first redirect as the Grep tool. Piped filters / non-source / literal + // searches fall through to allow (classifyGrep decides). + const s = parseShellSearch(command); + if (s) { + const g = classifyGrep( + { tool: "Grep", toolInput: { pattern: s.pattern, path: s.path } }, + ctx, + ); + if (g.decision === "deny") { + return { + ...g, + userKey: "block.shell.search", + agentMessage: `Shell \`${s.tool}\` for a code symbol bypasses the graph → ${g.agentMessage}`, + }; + } + } + return { decision: "allow" }; + } + if (phase === "classical_fallback") { + return { decision: "allow", agentMessage: ctx.staleFallbackMsg }; + } + return { + decision: "deny", + agentMessage: ctx.staleMustRefreshMsg, + userKey: "block.shell.stale", + }; +} diff --git a/bundle/.gnkit/lib/claude-emit.mjs b/bundle/.gnkit/lib/claude-emit.mjs new file mode 100644 index 0000000..5ee66b7 --- /dev/null +++ b/bundle/.gnkit/lib/claude-emit.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Claude Code hook-protocol adapter — the analog of cursor-emit.mjs. + * + * Maps a vendor-neutral {@link import('./classify.mjs').Verdict} onto Claude + * Code's PreToolUse JSON (`hookSpecificOutput.permissionDecision` + reason), and + * provides the shared context Claude guard scripts need (staleness, policy, repo, + * config). The policy itself lives in classify.mjs — this file only knows Claude's + * wire format, so the same core drives Cursor and Claude Code identically. + * + * Lives under .gnkit/lib because that dir is the shared, always-shipped + * hook library; nothing here depends on Cursor. + */ +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as helpers from "./hook-helpers.mjs"; +import { bumpScore } from "./session-primer.mjs"; +import { evaluateStalePolicy, staleRefreshAgentMessage } from "./stale-policy.mjs"; + +const LIB = path.dirname(fileURLToPath(import.meta.url)); + +/** Read the hook's JSON stdin payload (Claude passes the event object). */ +export async function readStdin() { + let raw = ""; + for await (const chunk of process.stdin) raw += chunk; + try { + return JSON.parse(raw || "{}"); + } catch { + return {}; + } +} + +/** Resolve the project root for a hook invocation. */ +export function hookRoot(input = {}) { + return process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); +} + +/** Shared classify context: staleness phase, config, repo, precomputed messages. */ +export function gnContext(root) { + const r = spawnSync(process.execPath, [path.join(LIB, "load-staleness.mjs"), root], { + encoding: "utf8", + }); + let stale = { fresh: false, reason: "check_failed" }; + try { + stale = JSON.parse(r.stdout.trim() || "{}"); + } catch { + /* keep fail-closed default */ + } + const policy = evaluateStalePolicy(stale, root); + const staleMsg = staleRefreshAgentMessage(stale, policy); + return { + root, + stale, + config: helpers.loadHookConfig(root), + repo: helpers.repoName(root), + phase: policy.phase, + staleMustRefreshMsg: staleMsg, + staleFallbackMsg: staleMsg, + staleDetail: stale.detail, + graphUsed: existsFlag(root, ".gitnexus-mcp-used.flag"), + }; +} + +function existsFlag(root, name) { + return fs.existsSync(path.join(root, ".gnkit", name)); +} + +/** + * Map a Verdict to Claude's PreToolUse output. deny → permissionDecision deny + * (reason shown to the model). allow → exit 0 silently so the tool follows the + * normal permission flow; any reminder is surfaced on stderr (visible to the user). + * @param {import('./classify.mjs').Verdict} verdict + * @param {{ root: string, mode: import('./hook-helpers.mjs').HookMode, event?: string }} opts + */ +export function emitVerdict(verdict, { root, mode, event = "PreToolUse" }) { + if (verdict.decision === "deny" && verdict.scoreEvent) { + bumpScore(root, verdict.scoreEvent); + } + const applied = helpers.applyHookMode( + { permission: verdict.decision, agent_message: verdict.agentMessage }, + mode, + ); + if (applied.permission === "deny") { + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: event, + permissionDecision: "deny", + permissionDecisionReason: + applied.agent_message || "Blocked by GitNexus enforcement — use the graph.", + }, + }), + ); + } else if (applied.agent_message) { + process.stderr.write(`${applied.agent_message}\n`); + } +} + +/** Inject additional context (SessionStart / UserPromptSubmit). */ +export function emitContext(text, event = "SessionStart") { + if (!text) return; + process.stdout.write( + JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }), + ); +} diff --git a/bundle/.cursor/hooks/lib/clear-session.mjs b/bundle/.gnkit/lib/clear-session.mjs similarity index 83% rename from bundle/.cursor/hooks/lib/clear-session.mjs rename to bundle/.gnkit/lib/clear-session.mjs index 5eee56c..2fba879 100644 --- a/bundle/.cursor/hooks/lib/clear-session.mjs +++ b/bundle/.gnkit/lib/clear-session.mjs @@ -4,7 +4,7 @@ import path from 'node:path'; const root = process.argv[2] ?? process.cwd(); const lib = pathToFileURL( - path.join(root, '.cursor/hooks/lib/session-primer.mjs') + path.join(root, '.gnkit/lib/session-primer.mjs') ).href; const { clearSessionState, setRefreshPending } = await import(lib); clearSessionState(root); diff --git a/bundle/.cursor/hooks/lib/commit-message.mjs b/bundle/.gnkit/lib/commit-message.mjs similarity index 100% rename from bundle/.cursor/hooks/lib/commit-message.mjs rename to bundle/.gnkit/lib/commit-message.mjs diff --git a/bundle/.gnkit/lib/cursor-emit.mjs b/bundle/.gnkit/lib/cursor-emit.mjs new file mode 100644 index 0000000..4f8faae --- /dev/null +++ b/bundle/.gnkit/lib/cursor-emit.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * Cursor-protocol adapter: maps a vendor-neutral {@link import('./classify.mjs').Verdict} + * onto Cursor's hook wire format and prints it. This is the ONLY place that knows + * Cursor's `permission`/`agent_message`/`user_message` shape + guide-mode + nudges, + * so the guard `.sh` files stay thin and the policy lives in classify.mjs. + */ +import * as helpers from "./hook-helpers.mjs"; +import { appendNudge, bumpScore } from "./session-primer.mjs"; + +/** + * @param {import('./classify.mjs').Verdict} verdict + * @param {{ root: string, mode: import('./hook-helpers.mjs').HookMode, nudge?: string }} opts + */ +export function emitVerdict(verdict, { root, mode, nudge = "" }) { + if (verdict.decision === "deny" && verdict.scoreEvent) { + bumpScore(root, verdict.scoreEvent); + } + const user_message = verdict.userKey + ? helpers.userMessage(verdict.userKey, verdict.userVars || {}) + : verdict.userMessageText; + const applied = helpers.applyHookMode( + { + permission: verdict.decision, + agent_message: verdict.agentMessage, + user_message, + }, + mode, + ); + if (applied.agent_message && nudge) { + applied.agent_message = appendNudge(applied.agent_message, nudge); + } + process.stdout.write(JSON.stringify(applied)); +} diff --git a/bundle/.cursor/hooks/lib/cypher-cli.mjs b/bundle/.gnkit/lib/cypher-cli.mjs similarity index 100% rename from bundle/.cursor/hooks/lib/cypher-cli.mjs rename to bundle/.gnkit/lib/cypher-cli.mjs diff --git a/bundle/.cursor/hooks/lib/cypher-helpers.mjs b/bundle/.gnkit/lib/cypher-helpers.mjs similarity index 100% rename from bundle/.cursor/hooks/lib/cypher-helpers.mjs rename to bundle/.gnkit/lib/cypher-helpers.mjs diff --git a/bundle/.cursor/hooks/lib/detect-api-router.mjs b/bundle/.gnkit/lib/detect-api-router.mjs similarity index 98% rename from bundle/.cursor/hooks/lib/detect-api-router.mjs rename to bundle/.gnkit/lib/detect-api-router.mjs index a7fa0b4..3da71a1 100644 --- a/bundle/.cursor/hooks/lib/detect-api-router.mjs +++ b/bundle/.gnkit/lib/detect-api-router.mjs @@ -8,7 +8,7 @@ import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { repoName } from './hook-helpers.mjs'; -export const API_PROFILE_FILE = '.cursor/gitnexus-api-profile.json'; +export const API_PROFILE_FILE = '.gnkit/gitnexus-api-profile.json'; const FRAMEWORK_RES = [ /\bexpress\s*\(/, diff --git a/bundle/.gnkit/lib/first-nudge.mjs b/bundle/.gnkit/lib/first-nudge.mjs new file mode 100644 index 0000000..ede8609 --- /dev/null +++ b/bundle/.gnkit/lib/first-nudge.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** Print first-tool nudge once per session (stdout); empty if already primed. */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const root = process.argv[2] ?? process.cwd(); +const here = path.dirname(fileURLToPath(import.meta.url)); + +// Reuse the staleness the calling guard already computed (exported as +// GITNEXUS_STALENESS) to avoid a second load-staleness spawn per tool call. +// Only fall back to computing it when invoked standalone. +let stale = { fresh: false, reason: 'check_failed' }; +if (process.env.GITNEXUS_STALENESS) { + try { + stale = JSON.parse(process.env.GITNEXUS_STALENESS); + } catch { + /* fall through to spawn */ + } +} +if (stale.reason === 'check_failed' && !process.env.GITNEXUS_STALENESS) { + const r = spawnSync(process.execPath, [path.join(here, 'load-staleness.mjs'), root], { + encoding: 'utf8', + }); + try { + stale = JSON.parse(r.stdout.trim() || '{}'); + } catch { + stale = { fresh: false, reason: 'check_failed' }; + } +} + +const { firstToolNudge } = await import(pathToFileURL(path.join(here, 'session-primer.mjs')).href); +const nudge = firstToolNudge(root, stale); +process.stdout.write(nudge ?? ''); diff --git a/bundle/.cursor/hooks/lib/generate-arch-doc.mjs b/bundle/.gnkit/lib/generate-arch-doc.mjs similarity index 100% rename from bundle/.cursor/hooks/lib/generate-arch-doc.mjs rename to bundle/.gnkit/lib/generate-arch-doc.mjs diff --git a/bundle/.cursor/hooks/lib/graph-smoke.mjs b/bundle/.gnkit/lib/graph-smoke.mjs similarity index 97% rename from bundle/.cursor/hooks/lib/graph-smoke.mjs rename to bundle/.gnkit/lib/graph-smoke.mjs index 03a2db6..adb52a6 100644 --- a/bundle/.cursor/hooks/lib/graph-smoke.mjs +++ b/bundle/.gnkit/lib/graph-smoke.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Post-index graph smoke test — verifies Cypher works and graph has expected structure. - * Usage: node .cursor/hooks/lib/graph-smoke.mjs [repoRoot] + * Usage: node .gnkit/lib/graph-smoke.mjs [repoRoot] * Exit 0 = OK (warnings allowed); exit 1 = graph/Cypher broken. */ import fs from 'node:fs'; diff --git a/bundle/.cursor/hooks/lib/hook-helpers.mjs b/bundle/.gnkit/lib/hook-helpers.mjs similarity index 94% rename from bundle/.cursor/hooks/lib/hook-helpers.mjs rename to bundle/.gnkit/lib/hook-helpers.mjs index fa81fbd..ff2573e 100644 --- a/bundle/.cursor/hooks/lib/hook-helpers.mjs +++ b/bundle/.gnkit/lib/hook-helpers.mjs @@ -41,7 +41,7 @@ export { playbookRenameForHint, } from "./rename-helpers.mjs"; -export const CONFIG_FILE = ".cursor/gitnexus-hooks.json"; +export const CONFIG_FILE = ".gnkit/gitnexus-hooks.json"; /** @typedef {'enforce' | 'guide'} HookMode */ /** @typedef {'none' | 'light' | 'medium' | 'full'} EditSensitivity */ @@ -64,7 +64,7 @@ const DEFAULT_BROAD_GLOB_RES = [ ]; // Polyglot: GitNexus indexes many languages — enforcement should not be JS/TS-only. -// Override in .cursor/gitnexus-hooks.json via "sourceExts": ["js","py","rs", …]. +// Override in .gnkit/gitnexus-hooks.json via "sourceExts": ["js","py","rs", …]. const DEFAULT_SOURCE_EXT_RE = /\.(js|mjs|cjs|jsx|ts|tsx|mts|cts|py|pyi|rb|go|rs|java|kt|kts|swift|php|cs|cpp|cc|cxx|hpp|hh|c|h|cu|cuh|scala|m|mm|dart|lua|ex|exs|clj)$/i; @@ -85,7 +85,6 @@ export function loadHookConfig(root) { const cfg = { mode: hookModeFromEnv(), readLineThreshold: 60, - graceCommitsBehind: 2, sourcePathRes: DEFAULT_SOURCE_RES, broadGlobRes: DEFAULT_BROAD_GLOB_RES, sourceExtRe: DEFAULT_SOURCE_EXT_RE, @@ -100,8 +99,6 @@ export function loadHookConfig(root) { if (file.mode) cfg.mode = file.mode === "guide" ? "guide" : "enforce"; if (typeof file.readLineThreshold === "number") cfg.readLineThreshold = file.readLineThreshold; - if (typeof file.graceCommitsBehind === "number") - cfg.graceCommitsBehind = file.graceCommitsBehind; if (typeof file.stalenessCacheTtlMs === "number") cfg.stalenessCacheTtlMs = file.stalenessCacheTtlMs; if (Array.isArray(file.sourceGlobs) && file.sourceGlobs.length) { @@ -178,7 +175,7 @@ export function editSensitivity(filePath, config) { ) { return "light"; } - if (/\.cursor\/hooks\//.test(norm) || /(?:^|\/)bundle\//.test(norm)) + if (/(\.cursor\/hooks|\.claude\/hooks|\.gnkit)\//.test(norm) || /(?:^|\/)bundle\//.test(norm)) return "light"; if (/(?:^|\/)tests?\//.test(norm)) return "medium"; if (/(?:^|\/)scripts\//.test(norm)) return "medium"; @@ -280,7 +277,7 @@ const DENY_CACHE_FILE = ".gitnexus-deny-cache.json"; /** @param {string} root */ function denyCachePath(root) { - return path.join(root, ".cursor", DENY_CACHE_FILE); + return path.join(root, ".gnkit", DENY_CACHE_FILE); } /** @param {string} root */ @@ -332,17 +329,6 @@ export function midSessionGraphNudge(graphUsedThisSession, root = "") { ); } -/** - * @param {object} stale from check-staleness - * @param {ReturnType} config - */ -export function isGraceStale(stale, config) { - if (stale?.fresh) return false; - if (stale?.reason !== "behind") return false; - const n = stale.commitsBehind ?? 0; - return n > 0 && n <= (config.graceCommitsBehind ?? 0); -} - /** * 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 diff --git a/bundle/.cursor/hooks/lib/load-staleness.mjs b/bundle/.gnkit/lib/load-staleness.mjs similarity index 89% rename from bundle/.cursor/hooks/lib/load-staleness.mjs rename to bundle/.gnkit/lib/load-staleness.mjs index e5bf3e8..606f30a 100644 --- a/bundle/.cursor/hooks/lib/load-staleness.mjs +++ b/bundle/.gnkit/lib/load-staleness.mjs @@ -4,7 +4,7 @@ * stdout: JSON from check-staleness.mjs, or { fresh: false, reason: 'check_failed', detail } * * Perf: a single tool call triggers staleness twice (guard + first-nudge). A short TTL cache - * (.cursor/.gitnexus-staleness-cache.json) collapses those + rapid tool loops into one git pass. + * (.gnkit/.gitnexus-staleness-cache.json) collapses those + rapid tool loops into one git pass. * The cache is invalidated on refresh (gitnexus-agent) and on session start (clear-session). */ import fs from 'node:fs'; @@ -24,11 +24,11 @@ const FAIL = { 'Agent MUST run npm run gitnexus:agent-refresh autonomously (required_permissions: ["all"]).', }; -const cachePath = path.join(root, '.cursor', '.gitnexus-staleness-cache.json'); +const cachePath = path.join(root, '.gnkit', '.gitnexus-staleness-cache.json'); function ttlMs() { try { - const cfg = JSON.parse(fs.readFileSync(path.join(root, '.cursor/gitnexus-hooks.json'), 'utf8')); + const cfg = JSON.parse(fs.readFileSync(path.join(root, '.gnkit/gitnexus-hooks.json'), 'utf8')); if (typeof cfg.stalenessCacheTtlMs === 'number') return cfg.stalenessCacheTtlMs; } catch { /* default */ diff --git a/bundle/.cursor/hooks/lib/persistence-health.mjs b/bundle/.gnkit/lib/persistence-health.mjs similarity index 100% rename from bundle/.cursor/hooks/lib/persistence-health.mjs rename to bundle/.gnkit/lib/persistence-health.mjs diff --git a/bundle/.cursor/hooks/lib/rename-helpers.mjs b/bundle/.gnkit/lib/rename-helpers.mjs similarity index 100% rename from bundle/.cursor/hooks/lib/rename-helpers.mjs rename to bundle/.gnkit/lib/rename-helpers.mjs diff --git a/bundle/.cursor/hooks/lib/session-health-audit.mjs b/bundle/.gnkit/lib/session-health-audit.mjs similarity index 95% rename from bundle/.cursor/hooks/lib/session-health-audit.mjs rename to bundle/.gnkit/lib/session-health-audit.mjs index a1b828b..edf8e5f 100644 --- a/bundle/.cursor/hooks/lib/session-health-audit.mjs +++ b/bundle/.gnkit/lib/session-health-audit.mjs @@ -16,7 +16,7 @@ export const SESSION_USER_NOTIFIED_FLAG = * @param {string} root */ export function loadStaleness(root) { - const checkPath = path.join(root, ".cursor/hooks/lib/check-staleness.mjs"); + const checkPath = path.join(root, ".gnkit/lib/check-staleness.mjs"); try { const r = spawnSync(process.execPath, [checkPath, root], { encoding: "utf8", @@ -94,8 +94,8 @@ export function auditKitHealth(root) { }); const helpersOk = - fs.existsSync(path.join(root, ".cursor/hooks/lib/hook-helpers.mjs")) && - fs.existsSync(path.join(root, ".cursor/hooks/lib/cypher-helpers.mjs")); + fs.existsSync(path.join(root, ".gnkit/lib/hook-helpers.mjs")) && + fs.existsSync(path.join(root, ".gnkit/lib/cypher-helpers.mjs")); checks.push({ id: "hook_libs", ok: helpersOk, @@ -212,7 +212,7 @@ export function agentContextForSession(audit) { * @param {ReturnType} audit */ export function writeSessionHealthFile(root, audit) { - const p = path.join(root, ".cursor", SESSION_HEALTH_FILE); + const p = path.join(root, ".gnkit", SESSION_HEALTH_FILE); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(audit, null, 2) + "\n"); } diff --git a/bundle/.cursor/hooks/lib/session-health-context.mjs b/bundle/.gnkit/lib/session-health-context.mjs similarity index 88% rename from bundle/.cursor/hooks/lib/session-health-context.mjs rename to bundle/.gnkit/lib/session-health-context.mjs index b10eee0..00e690a 100644 --- a/bundle/.cursor/hooks/lib/session-health-context.mjs +++ b/bundle/.gnkit/lib/session-health-context.mjs @@ -12,7 +12,7 @@ import { const root = process.argv[2] ?? process.cwd(); try { - fs.unlinkSync(path.join(root, '.cursor', SESSION_USER_NOTIFIED_FLAG)); + fs.unlinkSync(path.join(root, '.gnkit', SESSION_USER_NOTIFIED_FLAG)); } catch { /* ignore */ } diff --git a/bundle/.gnkit/lib/session-primer.mjs b/bundle/.gnkit/lib/session-primer.mjs new file mode 100644 index 0000000..181e7fe --- /dev/null +++ b/bundle/.gnkit/lib/session-primer.mjs @@ -0,0 +1,448 @@ +#!/usr/bin/env node +/** + * Session-first-tool nudge + flag management for GitNexus hooks. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { playbookForHint, mcpReadContext, repoName, clearDenyCache } from './hook-helpers.mjs'; + +export function sessionPaths(root) { + const stateDir = path.join(root, '.gnkit'); + return { + stateDir, + primedFlag: path.join(stateDir, '.gitnexus-session-primed.flag'), + promptHint: path.join(stateDir, '.gitnexus-prompt-hint.json'), + refreshPendingFlag: path.join(stateDir, '.gitnexus-refresh-pending.flag'), + refreshFailedFlag: path.join(stateDir, '.gitnexus-refresh-failed.flag'), + mcpUsedFlag: path.join(stateDir, '.gitnexus-mcp-used.flag'), + impactUsedFlag: path.join(stateDir, '.gitnexus-impact-used.flag'), + detectUsedFlag: path.join(stateDir, '.gitnexus-detect-used.flag'), + stalenessCacheFile: path.join(stateDir, '.gitnexus-staleness-cache.json'), + scorecardFile: path.join(stateDir, '.gitnexus-scorecard.json'), + fallbackFlag: path.join(stateDir, '.gitnexus-fallback.json'), + }; +} + +// ── Classical fallback escape hatch ────────────────────────────────────────── +// When GitNexus returns wrong / suspicious / incomplete info while the index is +// FRESH, graph-first enforcement would otherwise trap the agent. grantClassicalFallback +// opens a BOUNDED, REASONED, LOGGED window where classical Grep/Read/shell are allowed +// (evaluateStalePolicy honours it → phase classical_fallback). It is surfaced in the +// session brief + `gitnexus:status`, so it can never be a silent lazy bypass. + +const FALLBACK_TTL_MS = 15 * 60 * 1000; // 15 min, then enforcement auto-resumes + +/** @param {string} root @param {string} reason @param {number} [ttlMs] */ +export function grantClassicalFallback(root, reason = '', ttlMs = FALLBACK_TTL_MS) { + const { stateDir, fallbackFlag } = sessionPaths(root); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + fallbackFlag, + JSON.stringify( + { at: new Date().toISOString(), reason: String(reason).slice(0, 300), ttlMs }, + null, + 2, + ), + ); +} + +/** @param {string} root */ +export function revokeClassicalFallback(root) { + try { + fs.unlinkSync(sessionPaths(root).fallbackFlag); + } catch { + /* ignore */ + } +} + +/** + * Active classical-fallback grant, or null. Auto-expires (and self-cleans) after ttlMs. + * @param {string} root + * @returns {{ reason: string, remainingMs: number, expiresAt: string } | null} + */ +export function fallbackGrant(root) { + let rec; + try { + rec = JSON.parse(fs.readFileSync(sessionPaths(root).fallbackFlag, 'utf8')); + } catch { + return null; + } + const startedAt = Date.parse(rec.at); + if (!Number.isFinite(startedAt)) return null; + const ttl = typeof rec.ttlMs === 'number' ? rec.ttlMs : FALLBACK_TTL_MS; + const remainingMs = startedAt + ttl - Date.now(); + if (remainingMs <= 0) { + revokeClassicalFallback(root); + return null; + } + return { + reason: rec.reason || '', + remainingMs, + expiresAt: new Date(startedAt + ttl).toISOString(), + }; +} + +/** + * Record which GitNexus MCP tool the agent used, so edit/commit guards can enforce + * "impact before edit" and "detect_changes before commit" once per session. + * @param {string} root + * @param {string} toolName e.g. "gitnexus_impact" / "mcp_gitnexus_detect_changes" + */ +export function setMcpToolUsed(root, toolName) { + const { stateDir, mcpUsedFlag, impactUsedFlag, detectUsedFlag } = sessionPaths(root); + fs.mkdirSync(stateDir, { recursive: true }); + const stamp = new Date().toISOString(); + try { + fs.writeFileSync(mcpUsedFlag, stamp); + if (/impact|rename/i.test(toolName)) fs.writeFileSync(impactUsedFlag, stamp); + if (/detect_changes|detect-changes/i.test(toolName)) fs.writeFileSync(detectUsedFlag, stamp); + } catch { + /* best effort */ + } +} + +/** @param {string} root */ +export function isImpactUsed(root) { + return fs.existsSync(sessionPaths(root).impactUsedFlag); +} + +/** @param {string} root */ +export function isDetectUsed(root) { + return fs.existsSync(sessionPaths(root).detectUsedFlag); +} + +/** Invalidate the short-TTL staleness cache (after refresh / on session start). */ +export function clearStalenessCache(root) { + try { + fs.unlinkSync(sessionPaths(root).stalenessCacheFile); + } catch { + /* ignore */ + } +} + +/** + * Lightweight enforcement scorecard — counts how often the kit redirected the agent + * from a lazy pattern to the graph. Surfaced in agent-brief / `gitnexus:scorecard`. + * @param {string} root + * @param {string} key + */ +export function bumpScore(root, key) { + const { stateDir, scorecardFile } = sessionPaths(root); + try { + fs.mkdirSync(stateDir, { recursive: true }); + let card = {}; + try { + card = JSON.parse(fs.readFileSync(scorecardFile, 'utf8')); + } catch { + card = {}; + } + card.counts ??= {}; + card.counts[key] = (card.counts[key] ?? 0) + 1; + card.startedAt ??= new Date().toISOString(); + card.updatedAt = new Date().toISOString(); + fs.writeFileSync(scorecardFile, JSON.stringify(card, null, 2)); + } catch { + /* best effort — never block a tool on telemetry */ + } +} + +/** @param {string} root */ +export function readScorecard(root) { + try { + return JSON.parse(fs.readFileSync(sessionPaths(root).scorecardFile, 'utf8')); + } catch { + return { counts: {} }; + } +} + +// ── Persistent telemetry ───────────────────────────────────────────────────── +// The scorecard is per-session (cleared on session start). Before clearing, we +// archive each finished session's tally to an append-only .jsonl so aggregate +// trends survive across sessions. Read/aggregate via `npm run gitnexus:stats`. + +const TELEMETRY_FILE = '.gitnexus-telemetry.jsonl'; + +/** @param {string} root — append-only telemetry log (gitignored, never cleared). */ +export function telemetryPath(root) { + return path.join(root, '.gnkit', TELEMETRY_FILE); +} + +/** Best-effort index stats snapshot for context on a telemetry record. */ +function indexSnapshot(root) { + try { + const s = JSON.parse(fs.readFileSync(path.join(root, '.gitnexus/meta.json'), 'utf8')).stats || {}; + return { + files: s.files ?? null, + nodes: s.nodes ?? null, + embeddings: s.embeddings ?? null, + processes: s.processes ?? null, + }; + } catch { + return null; + } +} + +/** + * Archive the finished session's scorecard to the persistent telemetry log. + * No-op when the session recorded nothing. Never throws (telemetry must not + * block session start). + * @param {string} root + * @returns {boolean} whether a record was written + */ +export function flushScorecardToTelemetry(root) { + const card = readScorecard(root); + if (!card?.counts || Object.keys(card.counts).length === 0) return false; + const startedAt = card.startedAt ?? null; + const endedAt = card.updatedAt ?? null; + const durationMs = + startedAt && endedAt ? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt)) : null; + const rec = { startedAt, endedAt, durationMs, counts: card.counts, index: indexSnapshot(root) }; + try { + fs.mkdirSync(path.join(root, '.gnkit'), { recursive: true }); + fs.appendFileSync(telemetryPath(root), JSON.stringify(rec) + '\n'); + return true; + } catch { + return false; + } +} + +/** Parse the telemetry log into records (skips blank/malformed lines). */ +export function readTelemetry(root) { + let text = ''; + try { + text = fs.readFileSync(telemetryPath(root), 'utf8'); + } catch { + return []; + } + const out = []; + for (const line of text.split('\n')) { + const t = line.trim(); + if (!t) continue; + try { + out.push(JSON.parse(t)); + } catch { + /* skip malformed line */ + } + } + return out; +} + +/** Aggregate telemetry records into totals / per-session averages / recent. */ +export function summarizeTelemetry(records) { + const sessions = records.length; + const totals = {}; + let totalDurationMs = 0; + let durCount = 0; + let firstAt = null; + let lastAt = null; + for (const r of records) { + for (const [k, v] of Object.entries(r.counts || {})) { + totals[k] = (totals[k] ?? 0) + (Number(v) || 0); + } + if (typeof r.durationMs === 'number') { + totalDurationMs += r.durationMs; + durCount++; + } + if (r.startedAt && (!firstAt || r.startedAt < firstAt)) firstAt = r.startedAt; + if (r.endedAt && (!lastAt || r.endedAt > lastAt)) lastAt = r.endedAt; + } + const avgPerSession = {}; + for (const [k, v] of Object.entries(totals)) { + avgPerSession[k] = sessions ? Math.round((v / sessions) * 100) / 100 : 0; + } + return { + sessions, + firstAt, + lastAt, + totals, + avgPerSession, + avgDurationMs: durCount ? Math.round(totalDurationMs / durCount) : null, + recent: records.slice(-5), + }; +} + +export function setRefreshPending(root, pending, detail = '') { + const { stateDir, refreshPendingFlag } = sessionPaths(root); + fs.mkdirSync(stateDir, { recursive: true }); + if (pending) { + fs.writeFileSync(refreshPendingFlag, JSON.stringify({ at: new Date().toISOString(), detail }, null, 2)); + } else { + try { + fs.unlinkSync(refreshPendingFlag); + } catch { + /* ignore */ + } + } +} + +export function isRefreshPending(root) { + const { refreshPendingFlag } = sessionPaths(root); + return fs.existsSync(refreshPendingFlag); +} + +export function setRefreshFailed(root, failed, detail = '') { + const { stateDir, refreshFailedFlag } = sessionPaths(root); + fs.mkdirSync(stateDir, { recursive: true }); + if (failed) { + fs.writeFileSync(refreshFailedFlag, JSON.stringify({ at: new Date().toISOString(), detail }, null, 2)); + } else { + try { + fs.unlinkSync(refreshFailedFlag); + } catch { + /* ignore */ + } + } +} + +export function isRefreshFailed(root) { + const { refreshFailedFlag } = sessionPaths(root); + return fs.existsSync(refreshFailedFlag); +} + +// ── Durable memory + compaction recovery ──────────────────────────────────── +// Context compaction (auto or manual) drops the middle of a conversation. The +// per-session gate flags + a running memory file must survive it, so the agent +// doesn't re-run cleared gates or lose task state after a compaction. + +const MEMORY_FILE = 'MEMORY.md'; + +/** + * Claude Code's NATIVE per-project memory file — `~/.claude/projects//memory/MEMORY.md`, + * where is the project's absolute path with "/" → "-". We reuse it (not a kit-specific + * file) so Claude Code refers to its own memory and every other agent mirrors the same file. + * Lives outside the repo, so it is never committed/gitignored. + * @param {string} root project root (absolute) + */ +export function memoryPath(root) { + const home = process.env.HOME || os.homedir(); + const slug = path.resolve(root).replace(/\//g, '-'); + return path.join(home, '.claude', 'projects', slug, 'memory', MEMORY_FILE); +} + +/** + * Clear per-session state ONLY on a genuinely new session. A compaction/resume + * is the SAME task continuing — clearing there would wipe satisfied gates and + * re-block the agent mid-task. + * @param {string} [source] Claude SessionStart source: startup|clear|compact|resume + */ +export function shouldClearOnSource(source) { + return source !== 'compact' && source !== 'resume'; +} + +/** Append a lightweight state breadcrumb to the memory file (best-effort). */ +export function appendMemoryCheckpoint(root, note = '') { + const p = memoryPath(root); + try { + fs.mkdirSync(path.dirname(p), { recursive: true }); + if (!fs.existsSync(p)) { + fs.writeFileSync( + p, + `# Project working memory (GitNexus kit)\n\n` + + `> Durable across compaction + sessions. Keep this current: task, decisions, ` + + `findings, open items, key file:line. Nothing important should live only in the volatile transcript.\n`, + ); + } + fs.appendFileSync(p, `\n\n${note}\n`); + return true; + } catch { + return false; + } +} + +export function clearSessionState(root) { + const { + stateDir, + primedFlag, + promptHint, + mcpUsedFlag, + impactUsedFlag, + detectUsedFlag, + refreshFailedFlag, + stalenessCacheFile, + scorecardFile, + fallbackFlag, + } = sessionPaths(root); + fs.mkdirSync(stateDir, { recursive: true }); + // Archive the finishing session's tally BEFORE wiping the scorecard. + flushScorecardToTelemetry(root); + for (const f of [ + primedFlag, + promptHint, + mcpUsedFlag, + impactUsedFlag, + detectUsedFlag, + refreshFailedFlag, + stalenessCacheFile, + scorecardFile, + fallbackFlag, + ]) { + try { + fs.unlinkSync(f); + } catch { + /* ignore */ + } + } + for (const rel of ['.gitnexus-session-user-notified.flag']) { + try { + fs.unlinkSync(path.join(stateDir, rel)); + } catch { + /* ignore */ + } + } + clearDenyCache(root); +} + +export function writePromptHint(root, hint) { + const { stateDir, promptHint } = sessionPaths(root); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(promptHint, JSON.stringify({ ...hint, at: new Date().toISOString() }, null, 2)); +} + +export function readPromptHint(root) { + const { promptHint } = sessionPaths(root); + try { + return JSON.parse(fs.readFileSync(promptHint, 'utf8')); + } catch { + return {}; + } +} + +/** + * Returns nudge text once per session (sets primed flag). + * @param {object} stale from check-staleness.mjs + */ +export function firstToolNudge(root, stale) { + const { primedFlag } = sessionPaths(root); + if (fs.existsSync(primedFlag)) return null; + + fs.mkdirSync(path.dirname(primedFlag), { recursive: true }); + fs.writeFileSync(primedFlag, new Date().toISOString()); + + const hint = readPromptHint(root); + const repo = repoName(root); + const parts = []; + + if (!stale?.fresh) { + const reason = + stale?.reason === 'missing_embeddings' + ? 'MISSING EMBEDDINGS: semantic query unavailable — ' + : 'STALE INDEX: '; + parts.push( + `${reason}next Shell MUST be npm run gitnexus:agent-refresh (required_permissions: ["all"]). Includes --embeddings. Run yourself — never ask user to analyze.` + ); + } else { + parts.push(`SESSION: ${mcpReadContext(repo)} OR npm run gitnexus:agent-brief`); + } + + const playbook = playbookForHint(hint, repo); + if (playbook) parts.push(playbook); + + return parts.join('\n'); +} + +export function appendNudge(agentMessage, nudge) { + if (!nudge) return agentMessage; + if (!agentMessage) return nudge; + return `${nudge}\n\n${agentMessage}`; +} diff --git a/bundle/.cursor/hooks/lib/set-refresh-pending.mjs b/bundle/.gnkit/lib/set-refresh-pending.mjs similarity index 92% rename from bundle/.cursor/hooks/lib/set-refresh-pending.mjs rename to bundle/.gnkit/lib/set-refresh-pending.mjs index 3436e50..8ceb97e 100644 --- a/bundle/.cursor/hooks/lib/set-refresh-pending.mjs +++ b/bundle/.gnkit/lib/set-refresh-pending.mjs @@ -8,7 +8,7 @@ const action = process.argv[3] ?? 'status'; const detail = process.argv[4] ?? ''; const { setRefreshPending, isRefreshPending, setRefreshFailed, isRefreshFailed } = await import( - pathToFileURL(path.join(root, '.cursor/hooks/lib/session-primer.mjs')).href + pathToFileURL(path.join(root, '.gnkit/lib/session-primer.mjs')).href ); if (action === 'set') { diff --git a/bundle/.cursor/hooks/lib/stale-policy.mjs b/bundle/.gnkit/lib/stale-policy.mjs similarity index 62% rename from bundle/.cursor/hooks/lib/stale-policy.mjs rename to bundle/.gnkit/lib/stale-policy.mjs index 86a2e68..325f2a1 100644 --- a/bundle/.cursor/hooks/lib/stale-policy.mjs +++ b/bundle/.gnkit/lib/stale-policy.mjs @@ -4,15 +4,29 @@ * Phases: * fresh — graph trusted; hooks enforce graph-first tools * must_refresh — stale; deny classical + MCP until agent-refresh runs - * classical_fallback — refresh attempted and failed; classical OK with reason + * classical_fallback — refresh failed OR agent granted a fallback; classical OK with reason */ -import { isRefreshFailed, isRefreshPending } from './session-primer.mjs'; +import { isRefreshFailed, isRefreshPending, fallbackGrant } from './session-primer.mjs'; /** * @param {object} stale from check-staleness / load-staleness * @param {string} root repo root */ export function evaluateStalePolicy(stale, root) { + // Explicit escape hatch: the agent/user declared GitNexus untrustworthy here + // (`npm run gitnexus:fallback ""`) → classical fallback even on a FRESH index. + // Bounded (auto-expires), logged, and surfaced so it can't be a silent bypass. + const grant = fallbackGrant(root); + if (grant) { + return { + phase: 'classical_fallback', + forceRefresh: false, + allowClassical: true, + allowGraphTools: true, + override: grant, + }; + } + if (stale?.fresh) { return { phase: 'fresh', @@ -57,6 +71,15 @@ export function staleRefreshAgentMessage(stale, policy) { } if (policy.phase === 'classical_fallback') { + if (policy.override) { + const mins = Math.max(1, Math.round(policy.override.remainingMs / 60000)); + const why = policy.override.reason || 'GitNexus distrusted'; + return ( + `CLASSICAL FALLBACK active (${why}) — classical Grep/Read/shell OK for ~${mins} min. ` + + 'Re-confirm with the graph once GitNexus is reliable; ' + + 'end early with npm run gitnexus:fallback:off.' + ); + } return ( `GN FALLBACK (${detail}): agent-refresh failed or graph unavailable. ` + 'Classical Grep/Read OK — state why refresh failed in one sentence.' diff --git a/bundle/.cursor/hooks/lib/verify-kit.mjs b/bundle/.gnkit/lib/verify-kit.mjs similarity index 100% rename from bundle/.cursor/hooks/lib/verify-kit.mjs rename to bundle/.gnkit/lib/verify-kit.mjs diff --git a/bundle/docs/GITNEXUS-CURSOR-GUIDE.md b/bundle/docs/GITNEXUS-CURSOR-GUIDE.md index 89f0750..14ca623 100644 --- a/bundle/docs/GITNEXUS-CURSOR-GUIDE.md +++ b/bundle/docs/GITNEXUS-CURSOR-GUIDE.md @@ -81,8 +81,8 @@ What did my local changes affect? Am I done? | Task | Command | |------|---------| -| Install kit into a repo | `cursor-gitnexus-kit/bin/install.sh /path/to/repo` | -| Update after kit release | `cursor-gitnexus-kit/bin/update.sh /path/to/repo` | +| Install kit into a repo | `gitnexus-agent-kit/bin/install.sh /path/to/repo` | +| Update after kit release | `gitnexus-agent-kit/bin/update.sh /path/to/repo` | | Human status | `npm run gitnexus:health` | | Re-index (humans / CI) | `npm run gitnexus:refresh` | | Agent re-index | `npm run gitnexus:agent-refresh` (agents run this autonomously) | diff --git a/bundle/docs/GITNEXUS-SKILLS.md b/bundle/docs/GITNEXUS-SKILLS.md index 6d0cb17..63f86b6 100644 --- a/bundle/docs/GITNEXUS-SKILLS.md +++ b/bundle/docs/GITNEXUS-SKILLS.md @@ -1,6 +1,6 @@ # GitNexus agent skills -Use this index to route agent work to the right reusable playbook. The canonical skill store is installed into this repo at `.gitnexus/agent-kit/skills/` and symlinked into Cursor (`.cursor/skills/`) and Zed (`.agents/skills/`) based on runtime. +Use this index to route agent work to the right reusable playbook. The canonical skill store is installed into target repos at `.gnkit/skills/` and symlinked into Cursor (`.cursor/skills/`) and Zed (`.agents/skills/`) based on runtime. | Skill | Use when | Minimum graph path | | --- | --- | --- | diff --git a/bundle/docs/GITNEXUS-TEAM-BUNDLE.md b/bundle/docs/GITNEXUS-TEAM-BUNDLE.md index fd2b253..bf2dca9 100644 --- a/bundle/docs/GITNEXUS-TEAM-BUNDLE.md +++ b/bundle/docs/GITNEXUS-TEAM-BUNDLE.md @@ -1,8 +1,8 @@ -# GitNexus Cursor teaching bundle — team install +# GitNexus agent teaching bundle — team install -Portable **rules + hooks + skills + scripts** for graph-first Cursor agents. Built for this repo; reusable on other projects with one rename step. +Portable **rules + hooks + skills + scripts** for graph-first agents (Cursor hooks, Zed profiles). Built for this repo; reusable on other projects with one rename step. -> **Standalone installer:** [`cursor-gitnexus-kit`](https://github.com/ReidenXerx/cursor-gitnexus-kit) — `install` / `update` / `uninstall` scripts for any repo (upstream for this teaching bundle). +> **Standalone installer:** [`gitnexus-agent-kit`](https://github.com/ReidenXerx/gitnexus-agent-kit) — `install` / `update` / `uninstall` scripts for any repo (upstream for this teaching bundle). Updates **migrate** legacy `cursor-gitnexus-kit` layouts automatically. > **Team-facing guide:** `docs/GITNEXUS-CURSOR-GUIDE.md` — plain language for developers (what enforcement feels like, `npm run gitnexus:health`). @@ -10,12 +10,13 @@ Portable **rules + hooks + skills + scripts** for graph-first Cursor agents. Bui | Included | Purpose | | --- | --- | -| `.cursor/rules/gitnexus*.mdc` | Always-on agent contract | +| `.cursor/rules/gitnexus*.mdc` | Always-on agent contract (Cursor) | | `.cursor/hooks.json` + `.cursor/hooks/**` | Block grep-first; field grep → Cypher; staleness gate; **auto-refresh on session start** | -| `.cursor/hooks/lib/cypher-helpers.mjs` | Copy-paste Cypher recipes (ACCESSES, CALLS, overrides) | -| `.claude/skills/gitnexus*` | Playbooks (imaging, enforcement, scenarios, …) | +| `.gnkit/lib/cypher-helpers.mjs` | Copy-paste Cypher recipes (ACCESSES, CALLS, overrides) | +| `.gnkit/skills/` + symlinks | Playbooks (enforcement, scenarios, exploring, …) | | `scripts/gitnexus-setup.sh` | One-shot team installer | -| `scripts/sync-cursor-gitnexus-teaching.sh` | Re-sync after pull | +| `scripts/sync-cursor-gitnexus-teaching.sh` | Re-sync skills symlinks after pull | +| `scripts/gitnexus-verify.mjs` | Runtime-aware kit verification | | `scripts/gitnexus-agent.mjs` | Agent CLI (`agent-status` / `agent-refresh`) | | `scripts/install-git-hooks.sh` + `.githooks/pre-commit` | PDG index refresh on commit | | `.vscode/settings.json` | npm task settings (optional) | @@ -28,9 +29,9 @@ Portable **rules + hooks + skills + scripts** for graph-first Cursor agents. Bui | Excluded | Why | | --- | --- | -| `.gitnexus/` index | Built locally via `npm run gitnexus:refresh`; pre-commit upgrades it with `npm run gitnexus:pdg` | -| `.claude/skills/generated/` | Area skills from `gitnexus analyze --skills` on **that** codebase | -| `.cursor/skills/` | Generated by `gitnexus:sync-teaching` | +| `.gitnexus/` index | Built locally via `npm run gitnexus:refresh`; pre-commit upgrades it with `npm run gitnexus:full-pdg` | +| `.cursor/skills/generated/` | Area skills from `gitnexus analyze --skills` on **that** codebase | +| IDE skill symlinks | Created by install/update from canonical store | ## Large generated caches (recommended) @@ -76,15 +77,15 @@ cd /path/to/their-repo GITNEXUS_REPO_NAME=their-repo-name bash scripts/gitnexus-teaching/install-from-bundle.sh ``` -Or after extract, merge scripts manually and run: +Or use the standalone kit (recommended): ```bash -npm run gitnexus:setup +/path/to/gitnexus-agent-kit/bin/install.sh /path/to/their-repo --runtime both ``` ## After install (every dev) -1. **Restart Cursor** (MCP + hooks) +1. **Restart your IDE** (MCP + hooks / Zed profile) 2. `npm run gitnexus:agent-status` — index fresh? 3. Start Agent chats with: *"Read gitnexus-workspace skill, then …"* @@ -93,6 +94,7 @@ npm run gitnexus:setup ## Daily commands ```bash +npm run gitnexus:verify # full kit check npm run gitnexus:agent-status # staleness (agent runs autonomously) npm run gitnexus:agent-refresh # re-index when stale npm run gitnexus:sync-teaching # after pulling rule/skill updates @@ -103,4 +105,4 @@ npm run gitnexus:setup -- --quick # hooks/skills only, skip index - Node.js >= 22.9.0 - git -- Cursor with Hooks + MCP enabled +- Cursor and/or Zed with MCP enabled diff --git a/bundle/scripts/gitnexus-agent.mjs b/bundle/scripts/gitnexus-agent.mjs index 39bf434..0379886 100644 --- a/bundle/scripts/gitnexus-agent.mjs +++ b/bundle/scripts/gitnexus-agent.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Agent-facing GitNexus maintenance CLI (no MCP required). - * Usage: node scripts/gitnexus-agent.mjs status|refresh|brief|health|verify|doctor|review [base]|pr-impact [base]|branch-status [base]|commit-msg|map|scorecard|graph-smoke|detect-api + * Usage: node scripts/gitnexus-agent.mjs status|refresh|brief|health|verify|doctor|review [base]|pr-impact [base]|branch-status [base]|commit-msg|map|scorecard|stats [--json]|graph-smoke|detect-api|fallback ""|fallback:off */ import { spawnSync } from "node:child_process"; import fs from "node:fs"; @@ -15,14 +15,22 @@ const { withProjectTmpEnv, tmpSpaceReport, enospcHelp } = await import( pathToFileURL(path.join(ROOT, "scripts/lib/project-tmp.mjs")).href ); const { inspectPersistence, classifyPersistenceOutput } = await import( - pathToFileURL(path.join(ROOT, ".cursor/hooks/lib/persistence-health.mjs")) + pathToFileURL(path.join(ROOT, ".gnkit/lib/persistence-health.mjs")) .href ); +const { + grantClassicalFallback, + revokeClassicalFallback, + fallbackGrant, + bumpScore, +} = await import( + pathToFileURL(path.join(ROOT, ".gnkit/lib/session-primer.mjs")).href +); function loadStaleness() { const r = spawnSync( process.execPath, - [path.join(ROOT, ".cursor/hooks/lib/check-staleness.mjs"), ROOT], + [path.join(ROOT, ".gnkit/lib/check-staleness.mjs"), ROOT], { encoding: "utf8", env: withProjectTmpEnv(ROOT), @@ -51,7 +59,42 @@ function run(cmd, args, opts = {}) { const cmd = process.argv[2] ?? "status"; +if (cmd === "fallback") { + const reason = process.argv.slice(3).join(" ").trim(); + if (!reason) { + console.error( + 'Usage: npm run gitnexus:fallback -- ""\n' + + ' or: node scripts/gitnexus-agent.mjs fallback ""', + ); + process.exit(2); + } + grantClassicalFallback(ROOT, reason); + bumpScore(ROOT, "classicalFallbackGranted"); + const g = fallbackGrant(ROOT); + const mins = g ? Math.max(1, Math.round(g.remainingMs / 60000)) : 15; + console.log(`⚠ Classical fallback GRANTED for ~${mins} min — reason: ${reason}`); + console.log( + " Classical Grep/Read/shell are now allowed. Re-confirm findings with the graph once GitNexus is reliable.", + ); + console.log(" End early: npm run gitnexus:fallback:off"); + process.exit(0); +} + +if (cmd === "fallback:off" || cmd === "unfallback") { + revokeClassicalFallback(ROOT); + console.log("Classical fallback ended — graph-first enforcement re-armed."); + process.exit(0); +} + if (cmd === "status") { + const grant = fallbackGrant(ROOT); + if (grant) { + const mins = Math.max(1, Math.round(grant.remainingMs / 60000)); + console.log( + `⚠ CLASSICAL FALLBACK active (${grant.reason || "GitNexus distrusted"}) — classical tools allowed for ~${mins} min more.`, + ); + console.log(" End early: npm run gitnexus:fallback:off\n"); + } const stale = loadStaleness(); const systemTmp = tmpSpaceReport(ROOT); if (stale.fresh) { @@ -80,7 +123,7 @@ if (cmd === "status") { function markRefreshOutcome(success, detail = "") { const setPending = path.join( ROOT, - ".cursor/hooks/lib/set-refresh-pending.mjs", + ".gnkit/lib/set-refresh-pending.mjs", ); spawnSync( process.execPath, @@ -93,17 +136,22 @@ function markRefreshOutcome(success, detail = "") { ); // Invalidate the short-TTL staleness cache so the next tool call sees fresh state. try { - fs.unlinkSync(path.join(ROOT, ".cursor/.gitnexus-staleness-cache.json")); + fs.unlinkSync(path.join(ROOT, ".gnkit/.gitnexus-staleness-cache.json")); } catch { /* ignore */ } } if (cmd === "refresh") { - console.log("==> GitNexus agent refresh (analyze + sync teaching bundle)"); + console.log( + "==> GitNexus agent refresh (full analyze --force + embeddings + PDG + sync teaching bundle)", + ); console.log(tmpSpaceReport(ROOT)); try { - run("npm", ["run", "gitnexus:refresh"], { stdio: "inherit" }); + // Full --force + PDG: guarantees a complete control/data-dependence + taint + // layer (pdg_query/explain/impact(mode:pdg)) on every autonomous refresh, same + // as the pre-commit hook — no partial-incremental PDG risk. + run("npm", ["run", "gitnexus:full-pdg"], { stdio: "inherit" }); if ( fs.existsSync(path.join(ROOT, "scripts/sync-cursor-gitnexus-teaching.sh")) ) { @@ -123,7 +171,7 @@ if (cmd === "refresh") { try { const { generateArchDoc } = await import( pathToFileURL( - path.join(ROOT, ".cursor/hooks/lib/generate-arch-doc.mjs"), + path.join(ROOT, ".gnkit/lib/generate-arch-doc.mjs"), ).href ); const res = generateArchDoc(ROOT, undefined, withProjectTmpEnv(ROOT)); @@ -144,7 +192,7 @@ if (cmd === "refresh") { if (cmd === "brief") { const r = spawnSync( process.execPath, - [path.join(ROOT, ".cursor/hooks/lib/agent-brief.mjs"), ROOT], + [path.join(ROOT, ".gnkit/lib/agent-brief.mjs"), ROOT], { encoding: "utf8", env: withProjectTmpEnv(ROOT), @@ -158,7 +206,7 @@ if (cmd === "brief") { if (cmd === "health") { const r = spawnSync( process.execPath, - [path.join(ROOT, ".cursor/hooks/lib/agent-health.mjs"), ROOT], + [path.join(ROOT, ".gnkit/lib/agent-health.mjs"), ROOT], { encoding: "utf8", env: withProjectTmpEnv(ROOT), @@ -172,7 +220,7 @@ if (cmd === "health") { if (cmd === "graph-smoke") { const r = spawnSync( process.execPath, - [path.join(ROOT, ".cursor/hooks/lib/graph-smoke.mjs"), ROOT], + [path.join(ROOT, ".gnkit/lib/graph-smoke.mjs"), ROOT], { encoding: "utf8", env: withProjectTmpEnv(ROOT), @@ -185,7 +233,7 @@ if (cmd === "graph-smoke") { if (cmd === "detect-api") { const { writeApiRouterProfile } = await import( - pathToFileURL(path.join(ROOT, ".cursor/hooks/lib/detect-api-router.mjs")) + pathToFileURL(path.join(ROOT, ".gnkit/lib/detect-api-router.mjs")) .href ); const profile = writeApiRouterProfile(ROOT); @@ -203,7 +251,7 @@ if (cmd === "detect-api") { if (cmd === "verify") { const verifyPath = path.join(ROOT, "scripts/gitnexus-verify.mjs"); - const fallback = path.join(ROOT, ".cursor/hooks/lib/verify-kit.mjs"); + const fallback = path.join(ROOT, ".gnkit/lib/verify-kit.mjs"); const script = fs.existsSync(verifyPath) ? verifyPath : fallback; const r = spawnSync( process.execPath, @@ -432,7 +480,7 @@ if (cmd === "doctor") { if (cmd === "map") { const { generateArchDoc } = await import( - pathToFileURL(path.join(ROOT, ".cursor/hooks/lib/generate-arch-doc.mjs")) + pathToFileURL(path.join(ROOT, ".gnkit/lib/generate-arch-doc.mjs")) .href ); const res = generateArchDoc(ROOT, undefined, withProjectTmpEnv(ROOT)); @@ -446,7 +494,7 @@ if (cmd === "map") { if (cmd === "commit-msg") { const { draftCommitMessage } = await import( - pathToFileURL(path.join(ROOT, ".cursor/hooks/lib/commit-message.mjs")).href + pathToFileURL(path.join(ROOT, ".gnkit/lib/commit-message.mjs")).href ); const { message } = draftCommitMessage( ROOT, @@ -459,7 +507,7 @@ if (cmd === "commit-msg") { if (cmd === "scorecard") { const { readScorecard } = await import( - pathToFileURL(path.join(ROOT, ".cursor/hooks/lib/session-primer.mjs")).href + pathToFileURL(path.join(ROOT, ".gnkit/lib/session-primer.mjs")).href ); const card = readScorecard(ROOT); const counts = card.counts ?? {}; @@ -470,6 +518,8 @@ if (cmd === "scorecard") { impactGate: "Impact-before-edit gates", commitGate: "detect_changes-before-commit gates", editStaleBlocks: "Stale-edit blocks", + compactions: "Context compactions", + classicalFallbackGranted: "Classical-fallback grants (GN distrusted)", }; console.log("GitNexus enforcement scorecard (this session)"); console.log( @@ -486,7 +536,67 @@ if (cmd === "scorecard") { process.exit(0); } +if (cmd === "stats") { + const { readTelemetry, summarizeTelemetry, readScorecard } = await import( + pathToFileURL(path.join(ROOT, ".gnkit/lib/session-primer.mjs")).href + ); + const records = readTelemetry(ROOT); + // Fold in the current (not-yet-archived) session so nothing is missing. + const live = readScorecard(ROOT); + if (live?.counts && Object.keys(live.counts).length) { + records.push({ + startedAt: live.startedAt ?? null, + endedAt: live.updatedAt ?? null, + counts: live.counts, + live: true, + }); + } + const labels = { + graphCalls: "GitNexus MCP calls", + grepRedirects: "Grep → graph redirects", + readRedirects: "Large Read → graph redirects", + impactGate: "Impact-before-edit gates", + commitGate: "detect_changes-before-commit gates", + editStaleBlocks: "Stale-edit blocks", + compactions: "Context compactions", + }; + const s = summarizeTelemetry(records); + if (process.argv.includes("--json")) { + const latestIndex = [...records].reverse().find((r) => r.index)?.index ?? null; + process.stdout.write(JSON.stringify({ ...s, latestIndex }, null, 2) + "\n"); + process.exit(0); + } + console.log("GitNexus telemetry — all sessions"); + if (!s.sessions) { + console.log(" No sessions recorded yet. A session is archived on the NEXT"); + console.log(" session start; run some tools + start a new chat to accrue data."); + process.exit(0); + } + console.log(` sessions: ${s.sessions} | ${s.firstAt ?? "?"} → ${s.lastAt ?? "?"}`); + if (s.avgDurationMs != null) { + console.log(` avg session length: ${Math.round(s.avgDurationMs / 1000)}s`); + } + console.log(" metric".padEnd(38) + "total avg/session"); + const keys = Object.keys(labels).filter((k) => s.totals[k]); + if (!keys.length) { + console.log(" (no enforcement events across recorded sessions)"); + } else { + for (const k of keys) { + console.log( + ` ${labels[k].padEnd(36)}${String(s.totals[k]).padEnd(8)}${s.avgPerSession[k]}`, + ); + } + } + const gate = (s.totals.impactGate ?? 0) + (s.totals.commitGate ?? 0); + const redir = (s.totals.grepRedirects ?? 0) + (s.totals.readRedirects ?? 0); + console.log( + `\n Value: ${redir} lazy-search redirect(s) to the graph, ${gate} pre-edit/commit gate(s) fired.`, + ); + console.log(` Log: ${path.join(".gnkit", ".gitnexus-telemetry.jsonl")}`); + process.exit(0); +} + console.error( - `Unknown command: ${cmd}. Use: status | refresh | brief | health | verify | doctor | review [base] | pr-impact [base] | branch-status [base] | commit-msg | map | scorecard | graph-smoke | detect-api`, + `Unknown command: ${cmd}. Use: status | refresh | brief | health | verify | doctor | review [base] | pr-impact [base] | branch-status [base] | commit-msg | map | scorecard | stats | graph-smoke | detect-api`, ); process.exit(2); diff --git a/bundle/scripts/gitnexus-ci.mjs b/bundle/scripts/gitnexus-ci.mjs index 79c84f9..ab37379 100644 --- a/bundle/scripts/gitnexus-ci.mjs +++ b/bundle/scripts/gitnexus-ci.mjs @@ -44,7 +44,7 @@ function fail(msg) { async function main() { const { runCypher, parseCount } = await import( - pathToFileURL(path.join(ROOT, '.cursor/hooks/lib/cypher-cli.mjs')).href + pathToFileURL(path.join(ROOT, '.gnkit/lib/cypher-cli.mjs')).href ); const repo = repoName(); diff --git a/bundle/scripts/gitnexus-setup.sh b/bundle/scripts/gitnexus-setup.sh index 190cb3f..3f4be9a 100755 --- a/bundle/scripts/gitnexus-setup.sh +++ b/bundle/scripts/gitnexus-setup.sh @@ -56,6 +56,12 @@ done export GITNEXUS_RUNTIME +# Runtime membership — GITNEXUS_RUNTIME may be cursor|zed|claude|both|all or a +# comma-list (e.g. "cursor,claude"). both = cursor+zed; all = every adapter. +wants_cursor() { case "$GITNEXUS_RUNTIME" in *cursor*|*both*|*all*) return 0;; esac; return 1; } +wants_zed() { case "$GITNEXUS_RUNTIME" in *zed*|*both*|*all*) return 0;; esac; return 1; } +wants_claude() { case "$GITNEXUS_RUNTIME" in *claude*|*all*) return 0;; esac; return 1; } + info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } ok() { printf '\033[1;32m ✓\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m !\033[0m %s\n' "$*"; } @@ -109,16 +115,16 @@ CORE_SOURCES=( "scripts/gitnexus-gate-hint.mjs" "scripts/gitnexus-teaching/script-gates.mjs" "scripts/lib/setup-ui.mjs" - ".gitnexus/agent-kit/skills/gitnexus-workspace/SKILL.md" - ".gitnexus/agent-kit/skills/gitnexus-enforcement/SKILL.md" + ".gnkit/skills/gitnexus-workspace/SKILL.md" + ".gnkit/skills/gitnexus-enforcement/SKILL.md" ) CURSOR_SOURCES=( ".cursor/rules/00-gitnexus-enforcement.mdc" ".cursor/hooks.json" ".cursor/hooks/gitnexus-grep-guard.sh" - ".cursor/hooks/lib/hook-helpers.mjs" - ".cursor/hooks/lib/stale-policy.mjs" + ".gnkit/lib/hook-helpers.mjs" + ".gnkit/lib/stale-policy.mjs" ) ZED_SOURCES=( @@ -126,13 +132,17 @@ ZED_SOURCES=( "AGENTS.md" ) +CLAUDE_SOURCES=( + ".mcp.json" + ".claude/settings.json" + "CLAUDE.md" + ".gnkit/lib/classify.mjs" +) + for f in "${CORE_SOURCES[@]}"; do require_file "$f"; done -if [[ "$GITNEXUS_RUNTIME" != "zed" ]]; then - for f in "${CURSOR_SOURCES[@]}"; do require_file "$f"; done -fi -if [[ "$GITNEXUS_RUNTIME" != "cursor" ]]; then - for f in "${ZED_SOURCES[@]}"; do require_file "$f"; done -fi +if wants_cursor; then for f in "${CURSOR_SOURCES[@]}"; do require_file "$f"; done; fi +if wants_zed; then for f in "${ZED_SOURCES[@]}"; do require_file "$f"; done; fi +if wants_claude; then for f in "${CLAUDE_SOURCES[@]}"; do require_file "$f"; done; fi ok "Teaching sources OK (runtime: ${GITNEXUS_RUNTIME})" # ── 4. teaching bundle (skills symlinks + manifest) ───────────────────────── @@ -143,7 +153,7 @@ bash scripts/sync-cursor-gitnexus-teaching.sh # ── 4b. Cursor MCP (when runtime includes cursor) ─────────────────────────── -if [[ "$GITNEXUS_RUNTIME" != "zed" ]]; then +if wants_cursor; then info "Ensuring GitNexus MCP in .cursor/mcp.json" node <<'NODE' @@ -161,12 +171,12 @@ fi # ── 5. global MCP (optional, Cursor) ───────────────────────────────────────── -if [[ "$GITNEXUS_RUNTIME" != "zed" ]] && [[ "$SKIP_GLOBAL_MCP" == false ]]; then +if wants_cursor && [[ "$SKIP_GLOBAL_MCP" == false ]]; then info "Global GitNexus MCP (optional — all Cursor projects)" "${GITNEXUS_CLI[@]}" setup 2>/dev/null && ok "Global MCP configured" \ || warn "Global setup skipped — project .cursor/mcp.json is sufficient" -elif [[ "$GITNEXUS_RUNTIME" == "zed" ]]; then - ok "Skipped global Cursor MCP (zed runtime)" +elif ! wants_cursor; then + ok "Skipped global Cursor MCP (runtime: ${GITNEXUS_RUNTIME})" else ok "Skipped global MCP (--skip-global-mcp)" fi diff --git a/bundle/scripts/gitnexus-teaching/install-from-bundle.sh b/bundle/scripts/gitnexus-teaching/install-from-bundle.sh index 6f74300..43fefaa 100755 --- a/bundle/scripts/gitnexus-teaching/install-from-bundle.sh +++ b/bundle/scripts/gitnexus-teaching/install-from-bundle.sh @@ -16,15 +16,15 @@ REPO_NAME="${GITNEXUS_REPO_NAME:-$(basename "$ROOT")}" info "Target repo: $REPO_NAME" -if grep -rq '__GITNEXUS_REPO__' .cursor/rules .cursor/hooks .gitnexus/agent-kit/skills/gitnexus-workspace .gitnexus/agent-kit/skills/gitnexus-enforcement 2>/dev/null; then +if grep -rq '__GITNEXUS_REPO__' .cursor/rules .cursor/hooks .gnkit/skills/gitnexus-workspace .gnkit/skills/gitnexus-enforcement 2>/dev/null; then warn "Bundle still references __GITNEXUS_REPO__ — set GITNEXUS_REPO_NAME and re-run substitution:" warn " GITNEXUS_REPO_NAME=$REPO_NAME bash scripts/gitnexus-teaching/install-from-bundle.sh" if [[ "${GITNEXUS_SKIP_RENAME:-}" != "1" ]]; then info "Replacing __GITNEXUS_REPO__ → $REPO_NAME in rules/hooks/skills" - find .cursor/rules .cursor/hooks .gitnexus/agent-kit/skills/gitnexus-workspace .gitnexus/agent-kit/skills/gitnexus-enforcement \ + find .cursor/rules .cursor/hooks .gnkit/skills/gitnexus-workspace .gnkit/skills/gitnexus-enforcement \ -type f \( -name '*.mdc' -o -name '*.sh' -o -name '*.mjs' -o -name 'SKILL.md' \) \ -exec sed -i '' "s/__GITNEXUS_REPO__/$REPO_NAME/g" {} + 2>/dev/null \ - || find .cursor/rules .cursor/hooks .gitnexus/agent-kit/skills/gitnexus-workspace .gitnexus/agent-kit/skills/gitnexus-enforcement \ + || find .cursor/rules .cursor/hooks .gnkit/skills/gitnexus-workspace .gnkit/skills/gitnexus-enforcement \ -type f \( -name '*.mdc' -o -name '*.sh' -o -name '*.mjs' -o -name 'SKILL.md' \) \ -exec sed -i "s/__GITNEXUS_REPO__/$REPO_NAME/g" {} + ok "Repo name substituted" diff --git a/bundle/scripts/gitnexus-teaching/script-gates.mjs b/bundle/scripts/gitnexus-teaching/script-gates.mjs index 643d66c..797f6c7 100644 --- a/bundle/scripts/gitnexus-teaching/script-gates.mjs +++ b/bundle/scripts/gitnexus-teaching/script-gates.mjs @@ -35,7 +35,10 @@ export const GITNEXUS_SCRIPT_GATES = [ "gitnexus:graph-smoke": "node scripts/gitnexus-agent.mjs graph-smoke", "gitnexus:detect-api": "node scripts/gitnexus-agent.mjs detect-api", "gitnexus:scorecard": "node scripts/gitnexus-agent.mjs scorecard", + "gitnexus:stats": "node scripts/gitnexus-agent.mjs stats", "gitnexus:map": "node scripts/gitnexus-agent.mjs map", + "gitnexus:fallback": "node scripts/gitnexus-agent.mjs fallback", + "gitnexus:fallback:off": "node scripts/gitnexus-agent.mjs fallback:off", }, }, { diff --git a/bundle/scripts/gitnexus-verify.mjs b/bundle/scripts/gitnexus-verify.mjs index 7134733..f37ee29 100644 --- a/bundle/scripts/gitnexus-verify.mjs +++ b/bundle/scripts/gitnexus-verify.mjs @@ -12,7 +12,7 @@ const jsonOut = process.argv.includes('--json'); const ZED_PROFILE_KEY = 'zed-gitnexus'; const ZED_PROFILE_NAME = 'Zed + GitNexus'; -const SKILLS_STORE = '.gitnexus/agent-kit/skills'; +const SKILLS_STORE = '.gnkit/skills'; function readRuntime() { for (const rel of ['.gitnexus/agent-kit-manifest.json', '.cursor/gn-kit-manifest.json']) { @@ -164,8 +164,8 @@ function checkZed() { const CURSOR_CRITICAL = [ '.cursor/rules/00-gitnexus-enforcement.mdc', '.cursor/hooks.json', - '.cursor/hooks/lib/hook-helpers.mjs', - '.cursor/hooks/lib/stale-policy.mjs', + '.gnkit/lib/hook-helpers.mjs', + '.gnkit/lib/stale-policy.mjs', 'scripts/gitnexus-agent.mjs', 'scripts/gitnexus-verify.mjs', ]; @@ -206,7 +206,7 @@ export async function verifyInstall(repoRoot) { let health = { healthy: true, checks: [] }; try { - const auditPath = path.join(repoRoot, '.cursor/hooks/lib/session-health-audit.mjs'); + const auditPath = path.join(repoRoot, '.gnkit/lib/session-health-audit.mjs'); if (fs.existsSync(auditPath)) { const mod = await import(pathToFileURL(auditPath).href); health = mod.auditKitHealth(repoRoot); diff --git a/bundle/scripts/pack-gitnexus-teaching.sh b/bundle/scripts/pack-gitnexus-teaching.sh index 5c843ad..2dd6858 100755 --- a/bundle/scripts/pack-gitnexus-teaching.sh +++ b/bundle/scripts/pack-gitnexus-teaching.sh @@ -57,28 +57,30 @@ BUNDLE_PATHS=( .cursor/hooks/gitnexus-commit-guard.sh .cursor/hooks/gitnexus-mcp-allowlist.sh .cursor/hooks/gitnexus-after-git-commit.sh - .cursor/hooks/lib/check-staleness.mjs - .cursor/hooks/lib/load-staleness.mjs - .cursor/hooks/lib/graph-session.mjs - .cursor/hooks/lib/session-primer.mjs - .cursor/hooks/lib/first-nudge.mjs - .cursor/hooks/lib/clear-session.mjs - .cursor/hooks/lib/set-refresh-pending.mjs - .cursor/hooks/lib/hook-helpers.mjs - .cursor/hooks/lib/cypher-helpers.mjs - .cursor/hooks/lib/rename-helpers.mjs - .cursor/hooks/lib/stale-policy.mjs - .cursor/hooks/lib/cypher-cli.mjs - .cursor/hooks/lib/generate-arch-doc.mjs - .cursor/hooks/lib/commit-message.mjs - .cursor/hooks/lib/detect-api-router.mjs - .cursor/hooks/lib/graph-smoke.mjs - .cursor/hooks/lib/agent-brief.mjs - .cursor/hooks/lib/agent-health.mjs - .cursor/hooks/lib/session-health-audit.mjs - .cursor/hooks/lib/session-health-context.mjs - .cursor/hooks/lib/verify-kit.mjs - .cursor/gitnexus-hooks.json + .gnkit/lib/check-staleness.mjs + .gnkit/lib/load-staleness.mjs + .gnkit/lib/classify.mjs + .gnkit/lib/cursor-emit.mjs + .gnkit/lib/claude-emit.mjs + .gnkit/lib/session-primer.mjs + .gnkit/lib/first-nudge.mjs + .gnkit/lib/clear-session.mjs + .gnkit/lib/set-refresh-pending.mjs + .gnkit/lib/hook-helpers.mjs + .gnkit/lib/cypher-helpers.mjs + .gnkit/lib/rename-helpers.mjs + .gnkit/lib/stale-policy.mjs + .gnkit/lib/cypher-cli.mjs + .gnkit/lib/generate-arch-doc.mjs + .gnkit/lib/commit-message.mjs + .gnkit/lib/detect-api-router.mjs + .gnkit/lib/graph-smoke.mjs + .gnkit/lib/agent-brief.mjs + .gnkit/lib/agent-health.mjs + .gnkit/lib/session-health-audit.mjs + .gnkit/lib/session-health-context.mjs + .gnkit/lib/verify-kit.mjs + .gnkit/gitnexus-hooks.json scripts/gitnexus-verify.mjs scripts/gitnexus-setup.sh scripts/sync-cursor-gitnexus-teaching.sh @@ -127,19 +129,19 @@ cat > "$BUNDLE_ROOT/gitignore.snippet" <<'SNIP' .cursor/gitnexus-teaching-bundle.json .cursor/gn-kit-manifest.json .gitnexus/agent-kit-manifest.json -.cursor/.gitnexus-session-edits.flag -.cursor/.gitnexus-session-primed.flag -.cursor/.gitnexus-prompt-hint.json -.cursor/.gitnexus-refresh-pending.flag -.cursor/.gitnexus-refresh-failed.flag -.cursor/.gitnexus-mcp-used.flag -.cursor/.gitnexus-impact-used.flag -.cursor/.gitnexus-detect-used.flag -.cursor/.gitnexus-staleness-cache.json -.cursor/.gitnexus-scorecard.json -.cursor/.gitnexus-deny-cache.json -.cursor/.gitnexus-session-health.json -.cursor/.gitnexus-session-user-notified.flag +.gnkit/.gitnexus-session-edits.flag +.gnkit/.gitnexus-session-primed.flag +.gnkit/.gitnexus-prompt-hint.json +.gnkit/.gitnexus-refresh-pending.flag +.gnkit/.gitnexus-refresh-failed.flag +.gnkit/.gitnexus-mcp-used.flag +.gnkit/.gitnexus-impact-used.flag +.gnkit/.gitnexus-detect-used.flag +.gnkit/.gitnexus-staleness-cache.json +.gnkit/.gitnexus-scorecard.json +.gnkit/.gitnexus-deny-cache.json +.gnkit/.gitnexus-session-health.json +.gnkit/.gitnexus-session-user-notified.flag .cursor/gitnexus-api-profile.json SNIP diff --git a/bundle/scripts/sync-cursor-gitnexus-teaching.sh b/bundle/scripts/sync-cursor-gitnexus-teaching.sh index 30ca0e1..a1342c6 100755 --- a/bundle/scripts/sync-cursor-gitnexus-teaching.sh +++ b/bundle/scripts/sync-cursor-gitnexus-teaching.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Sync GitNexus teaching bundle into Cursor-native paths (.cursor/skills). -# Source of truth: .gitnexus/agent-kit/skills/ + .cursor/rules/ + .cursor/hooks/ +# Source of truth: .gnkit/skills/ + .cursor/rules/ + .cursor/hooks/ # Run via: npm run gitnexus:setup (or directly) set -euo pipefail @@ -28,28 +28,30 @@ HOOK_SCRIPTS=( ) HOOK_LIBS=( - ".cursor/hooks/lib/check-staleness.mjs" - ".cursor/hooks/lib/load-staleness.mjs" - ".cursor/hooks/lib/graph-session.mjs" - ".cursor/hooks/lib/session-primer.mjs" - ".cursor/hooks/lib/first-nudge.mjs" - ".cursor/hooks/lib/clear-session.mjs" - ".cursor/hooks/lib/set-refresh-pending.mjs" - ".cursor/hooks/lib/hook-helpers.mjs" - ".cursor/hooks/lib/cypher-helpers.mjs" - ".cursor/hooks/lib/rename-helpers.mjs" - ".cursor/hooks/lib/stale-policy.mjs" - ".cursor/hooks/lib/cypher-cli.mjs" - ".cursor/hooks/lib/generate-arch-doc.mjs" - ".cursor/hooks/lib/commit-message.mjs" - ".cursor/hooks/lib/detect-api-router.mjs" - ".cursor/hooks/lib/graph-smoke.mjs" - ".cursor/hooks/lib/agent-brief.mjs" - ".cursor/hooks/lib/agent-health.mjs" - ".cursor/hooks/lib/session-health-audit.mjs" - ".cursor/hooks/lib/session-health-context.mjs" - ".cursor/hooks/lib/verify-kit.mjs" - ".cursor/gitnexus-hooks.json" + ".gnkit/lib/check-staleness.mjs" + ".gnkit/lib/load-staleness.mjs" + ".gnkit/lib/classify.mjs" + ".gnkit/lib/cursor-emit.mjs" + ".gnkit/lib/claude-emit.mjs" + ".gnkit/lib/session-primer.mjs" + ".gnkit/lib/first-nudge.mjs" + ".gnkit/lib/clear-session.mjs" + ".gnkit/lib/set-refresh-pending.mjs" + ".gnkit/lib/hook-helpers.mjs" + ".gnkit/lib/cypher-helpers.mjs" + ".gnkit/lib/rename-helpers.mjs" + ".gnkit/lib/stale-policy.mjs" + ".gnkit/lib/cypher-cli.mjs" + ".gnkit/lib/generate-arch-doc.mjs" + ".gnkit/lib/commit-message.mjs" + ".gnkit/lib/detect-api-router.mjs" + ".gnkit/lib/graph-smoke.mjs" + ".gnkit/lib/agent-brief.mjs" + ".gnkit/lib/agent-health.mjs" + ".gnkit/lib/session-health-audit.mjs" + ".gnkit/lib/session-health-context.mjs" + ".gnkit/lib/verify-kit.mjs" + ".gnkit/gitnexus-hooks.json" "scripts/gitnexus-agent.mjs" "scripts/gitnexus-gate-hint.mjs" "scripts/gitnexus-teaching/script-gates.mjs" @@ -166,7 +168,7 @@ const manifest = { mcp: '.cursor/mcp.json', masterSkill: '.agents/skills/gitnexus-workspace/SKILL.md', enforcementSkill: '.agents/skills/gitnexus-enforcement/SKILL.md', - gitnexusSkills: listSkills('.gitnexus/agent-kit/skills').filter((n) => n.startsWith('gitnexus-')), + gitnexusSkills: listSkills('.gnkit/skills').filter((n) => n.startsWith('gitnexus-')), generatedAreaSkills: listSkills('.cursor/skills/generated'), }, workflowChain: [ @@ -212,7 +214,7 @@ done ok "${#HOOK_SCRIPTS[@]} hook scripts + ${#HOOK_LIBS[@]} lib(s) ready" info " [3/5] Link skills (symlinks from canonical store)" -STORE=".gitnexus/agent-kit/skills" +STORE=".gnkit/skills" if [[ ! -d "$STORE" ]]; then fail "Missing $STORE — run gn-agent-kit install or update first" fi @@ -233,13 +235,11 @@ link_skills() { ok "$label → $dest_root ($count skills symlinked)" } +# Runtime may be cursor|zed|claude|both|all or a comma-list. both = cursor+zed. RUNTIME="${GITNEXUS_RUNTIME:-both}" -case "$RUNTIME" in - cursor) link_skills ".cursor/skills" "Cursor skills" ;; - zed) link_skills ".agents/skills" "Zed skills" ;; - *) link_skills ".cursor/skills" "Cursor skills" - link_skills ".agents/skills" "Zed skills" ;; -esac +case "$RUNTIME" in *cursor*|*both*|*all*) link_skills ".cursor/skills" "Cursor skills" ;; esac +case "$RUNTIME" in *zed*|*both*|*all*) link_skills ".agents/skills" "Zed skills" ;; esac +case "$RUNTIME" in *claude*|*all*) link_skills ".claude/skills" "Claude skills" ;; esac info " [4/5] Teaching bundle manifest" write_manifest diff --git a/bundle/skills/gitnexus-architecture-review/SKILL.md b/bundle/skills/gitnexus-architecture-review/SKILL.md new file mode 100644 index 0000000..b67126a --- /dev/null +++ b/bundle/skills/gitnexus-architecture-review/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-architecture-review +description: "Use to JUDGE structure — coupling, cohesion, layering violations, import cycles, god objects. Exploring/imaging help you understand; this produces an assessment with evidence. Examples: \"review the architecture\", \"where's the coupling\", \"are there layering violations\", \"find import cycles / god objects\"." +--- + +# Architecture review with GitNexus + +`gitnexus-exploring`/`imaging` help you *understand* a codebase; this skill *judges* it — and backs every claim with a graph query, not vibes. + +## When to Use + +- "Review the architecture / call out structural problems" +- "Where is the coupling / are layers respected?" +- "Find import cycles, god objects, dead seams" +- Pre-refactor scoping, or a design-health pass + +## Workflow + +``` +1. READ gitnexus://repo/{name}/clusters → functional areas + cohesion scores (low = poorly factored) +2. READ gitnexus://repo/{name}/processes → long/tangled flows = candidate hotspots +3. check({cycles: true}) → circular File IMPORTS (hard structural smell) +4. cypher: cross-cluster CALLS → coupling + layering violations (lower layer calling higher) +5. cypher: god objects → classes with many HAS_METHOD AND high fan-in +6. impact on hub symbols → load-bearing nodes (high blast radius = architectural risk) +``` + +> Stale index → `npm run gitnexus:agent-refresh` (autonomous). + +## What to assess (and how) + +| Finding | How to detect | Why it matters | +| --- | --- | --- | +| **Import cycles** | `check({cycles: true})` | Cyclic deps block modularity, slow builds, break reasoning | +| **Low cohesion** | `clusters` cohesion score | An area doing too many unrelated things | +| **High coupling / layering violation** | `cypher` cross-cluster `CALLS` (e.g. `core` → `ui`, `repo` → `controller`) | Wrong-direction dependency erodes the architecture | +| **God object** | `cypher` classes with many `HAS_METHOD` + many callers | Single point that everything touches | +| **Load-bearing hub** | `impact` upstream (huge d=1) | Change here is high-risk; candidate to split/stabilize | +| **Dead seam** | `cypher` symbols with 0 callers / `route_map` orphan routes | Cruft to remove | + +## Cypher starters (READ schema first) + +```cypher +// Cross-area CALLS — coupling between functional areas +MATCH (a)-[:CodeRelation {type:'CALLS'}]->(b) +WHERE a.community <> b.community +RETURN a.community AS from, b.community AS to, count(*) AS edges +ORDER BY edges DESC + +// God-object candidates — wide classes with high fan-in +MATCH (c:Class)-[:CodeRelation {type:'HAS_METHOD'}]->(m) +WITH c, count(m) AS methods +MATCH (caller)-[:CodeRelation {type:'CALLS'}]->(c) +RETURN c.name, methods, count(caller) AS callers +ORDER BY methods + callers DESC +``` + +## Checklist + +``` +- [ ] clusters → note low-cohesion areas +- [ ] check(cycles) → list every import cycle (cite the file ring) +- [ ] cypher cross-cluster CALLS → coupling hotspots + wrong-direction (layering) edges +- [ ] cypher → god objects / wide high-fan-in classes +- [ ] impact on top hubs → quantify blast radius (the refactor priority list) +- [ ] Report: each finding + the query that proves it + a concrete remediation +``` + +## Example output + +``` +Findings (evidence-backed): +- CYCLE: payments/index.ts → billing/tax.ts → payments/index.ts (check cycles) +- LAYERING: data/UserRepo CALLS api/UserController (cross-cluster, wrong direction) +- GOD OBJECT: AppContext — 41 methods, 180 callers (split by concern) +- HUB: validateRequest — impact d=1 = 63 (stabilize before touching) +Priority: break the payments↔billing cycle first (blocks independent testing). +``` diff --git a/bundle/skills/gitnexus-feature-dev/SKILL.md b/bundle/skills/gitnexus-feature-dev/SKILL.md new file mode 100644 index 0000000..9172f59 --- /dev/null +++ b/bundle/skills/gitnexus-feature-dev/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-feature-dev +description: "Use when ADDING new code — implement a feature, add an endpoint/handler, wire in a new module. The graph finds existing patterns to reuse and the right place to integrate. Examples: \"add a feature that does X\", \"where do I wire this in\", \"implement X like the existing Y\", \"add a new endpoint\"." +--- + +# Feature development with GitNexus + +Refactoring changes existing code; **this is about adding it well** — reuse the codebase's patterns instead of reinventing, and wire into the *right* place. The graph is how you find both. + +## When to Use + +- "Implement a feature that does X" +- "Where should this new code live / wire in?" +- "Is there existing logic I should reuse?" +- "Add a new handler/service/job following the existing style" + +## Workflow + +``` +1. query({search_query: "", goal: "pattern to reuse"}) → find prior art +2. READ gitnexus://repo/{name}/clusters → pick the right functional area +3. context({name: ""}) → copy its shape (deps, signature, error handling) +4. context({name: ""}) → where you'll hook in (router, registry, factory) +5. impact({target: "", direction: "upstream"}) BEFORE wiring → who else uses it; don't break them +6. implement following the reused pattern +7. detect_changes({scope: "unstaged"}) + impact on the new wiring → confirm scope matches intent +``` + +> Stale index → `npm run gitnexus:agent-refresh` (autonomous, never ask the user). + +## Reuse before reinvent + +| Question | Tool | +| --- | --- | +| "Has someone already solved this?" | `query` (semantic — finds conceptually similar code grep misses) | +| "What's the existing pattern for a handler/service/job?" | `context` on the closest example — mirror its deps + signature | +| "Which functional area does this belong to?" | READ `clusters` — add to the cohesive area, not a random file | +| "What's the integration/extension point?" | `context` on the dispatcher/registry/factory symbol | +| "Who else wires into that point?" | `impact` upstream — match the call convention; avoid breaking siblings | + +## Checklist + +``` +- [ ] query for existing similar features — REUSE, don't reinvent +- [ ] context the closest example; mirror its structure + error handling +- [ ] READ clusters → put new code in the right functional area +- [ ] context the integration point; impact upstream BEFORE wiring in +- [ ] implement to the reused pattern (same deps, naming, conventions) +- [ ] detect_changes + impact on new wiring → verify nothing unexpected moved +``` + +## Example: "add a CSV export endpoint" + +``` +1. query({search_query: "export endpoint download", goal: "existing export pattern"}) + → found: JsonExportHandler (the existing export shape to mirror) +2. context({name: "JsonExportHandler"}) + → deps: AuthGuard, StreamWriter, ExportRegistry.register(...) +3. context({name: "ExportRegistry"}) → the integration point +4. impact({target: "ExportRegistry", direction: "upstream"}) + → 6 existing exporters register here → follow the same register() call +5. Implement CsvExportHandler mirroring JsonExportHandler; register it. +6. detect_changes → only ExportFlow affected, as intended. +``` diff --git a/bundle/skills/gitnexus-guide/SKILL.md b/bundle/skills/gitnexus-guide/SKILL.md index 4659788..14638f0 100644 --- a/bundle/skills/gitnexus-guide/SKILL.md +++ b/bundle/skills/gitnexus-guide/SKILL.md @@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact 2. **Match your task to a skill below** and **read that skill file** 3. **Follow the skill's workflow and checklist** -> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. +> If step 1 warns the index is stale, run `npm run gitnexus:agent-refresh` (autonomous, hook-pre-approved) — never ask the user to analyze. ## Skills @@ -25,24 +25,52 @@ For any task involving code understanding, debugging, impact analysis, or refact | Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | | Trace bugs / "Why is X failing?" | `gitnexus-debugging` | | Rename / extract / split / refactor | `gitnexus-refactoring` | +| Add a feature / new code (reuse + wire in) | `gitnexus-feature-dev` | +| What to test / coverage gaps | `gitnexus-testing` | +| Slow / hot path / cost optimization | `gitnexus-performance` | +| Judge structure (coupling, cycles, god objs) | `gitnexus-architecture-review` | +| Work across layers (controller→repo→model) | `gitnexus-layered-systems` | +| Milestone deep audit (opinionated, verified) | `gitnexus-microscope` | | Tools, resources, schema reference | `gitnexus-guide` (this file) | | Security / taint / injection review | `gitnexus-security-review` | | Index, status, clean, wiki CLI commands | `gitnexus-cli` | -## Tools Reference +## Tools Reference (full surface — `group_list`/`group_sync` cross-repo are out of scope) -| Tool | What it gives you | +**Core navigation & safety** + +| Tool | What it gives you / when to reach for it | | ---------------- | ------------------------------------------------------------------------ | -| `query` | Process-grouped code intelligence — execution flows related to a concept | -| `context` | 360-degree symbol view — categorized refs, processes it participates in | -| `impact` | Symbol blast radius; `mode: "pdg"` for precise control/data affectedness | -| `detect_changes` | Git-diff impact — what do your current changes affect | -| `trace` | Shortest directed call path between two symbols | -| `pdg_query` | Control/data dependence (`controls`, `flows`) when indexed with `--pdg` | -| `explain` | Persisted taint findings/source→sink paths | -| `rename` | Multi-file coordinated rename with confidence-tagged edits | -| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | -| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) | +| `query` | Orient — process-grouped execution flows for a concept (BM25 + vectors). First move for fuzzy work. | +| `context` | 360° on one symbol — callers, callees, categorized refs, processes. After `query`, or when symbol is known. | +| `cypher` | Raw structural traversals the canned tools can't express — `ACCESSES`, N-hop `CALLS`, `METHOD_OVERRIDES`, `STEP_IN_PROCESS`. READ schema first. | +| `impact` | Pre-edit blast radius + risk + affected processes. `mode: "pdg"` for statement-level control/data affectedness. | +| `detect_changes` | Git-diff impact — what your current/staged/compared changes affect. Pre-commit + PR review. | +| `rename` | Multi-file coordinated rename, confidence-tagged. `dry_run: true` first — never find-and-replace. | + +**Deep precision (need `analyze --pdg`)** + +| Tool | What it gives you / when | +| ------------- | --------------------------------------------------------------------------- | +| `trace` | Shortest directed call/member path between two symbols — "how does A reach B?" in one call. | +| `pdg_query` | Control dependence (`mode:"controls"` — what gates a line) / data dependence (`mode:"flows"` — where a variable flows). Anchored to a function. | +| `explain` | Persisted taint findings — source→sink (injection, path-traversal, XSS), intra- and inter-procedural. Security review. | + +**HTTP API (framework routers)** + +| Tool | What it gives you / when | +| ------------- | --------------------------------------------------------------------------- | +| `api_impact` | Pre-change report for a route handler — consumers, response-shape mismatches, middleware, risk. BEFORE editing a route. | +| `route_map` | Routes → consumers + handler + middleware chain; find orphaned routes. (Custom router → `context` on the dispatcher.) | +| `shape_check` | Response-shape drift — keys a route returns vs keys consumers access (flags MISMATCH). | + +**Meta / health** + +| Tool | What it gives you / when | +| ------------- | --------------------------------------------------------------------------- | +| `tool_map` | MCP/RPC tool definitions → handler files + descriptions. Tool-API work, impact of a tool-contract change. | +| `check` | Read-only structural integrity — detects circular File `IMPORTS` cycles (health / CI). | +| `list_repos` | Discover/disambiguate indexed repos (paginated — `limit`/`offset`). Only when multiple repos are indexed. | ### Paginating `list_repos` @@ -90,10 +118,18 @@ Lightweight reads (~100-500 tokens) for navigation: ## Graph Schema -**Nodes:** File, Function, Class, Interface, Method, Community, Process -**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS +Always `READ gitnexus://repo/{name}/schema` before writing Cypher — it's authoritative for this repo. + +**Nodes:** File, Function, Class, Interface, Method, Community, Process (PDG layer adds BasicBlock). +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, HAS_METHOD, METHOD_OVERRIDES, STEP_IN_PROCESS, **ACCESSES** (field read/write — carries `reason: "read"|"write"`). PDG layer adds CONTROL_DEP, REACHING_DEF, TAINTED. ```cypher +// Who calls myFunc? MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) RETURN caller.name, caller.filePath + +// Who writes the `balance` field? (use ACCESSES, not field grep) +MATCH (s)-[r:CodeRelation {type: 'ACCESSES'}]->(field {name: "balance"}) +WHERE r.reason = "write" +RETURN s.name, s.filePath, s.kind ``` diff --git a/bundle/skills/gitnexus-imaging/SKILL.md b/bundle/skills/gitnexus-imaging/SKILL.md index 43961e9..763035e 100644 --- a/bundle/skills/gitnexus-imaging/SKILL.md +++ b/bundle/skills/gitnexus-imaging/SKILL.md @@ -50,7 +50,7 @@ Always tell the user in one sentence when bypassing graph-first imaging. READ gitnexus://repo/__GITNEXUS_REPO__/context → staleness first ``` -If stale → **Cursor agents:** `npm run gitnexus:agent-refresh` autonomously. **Humans/CI:** `npm run gitnexus:refresh` (`gitnexus:pdg` for pre-commit precision). Hooks block runtime edits until fresh. +If stale → **Cursor agents:** `npm run gitnexus:agent-refresh` autonomously. **Humans/CI:** `npm run gitnexus:refresh` (`gitnexus:full-pdg` for pre-commit precision). Hooks block runtime edits until fresh. --- diff --git a/bundle/skills/gitnexus-layered-systems/SKILL.md b/bundle/skills/gitnexus-layered-systems/SKILL.md new file mode 100644 index 0000000..8af542e --- /dev/null +++ b/bundle/skills/gitnexus-layered-systems/SKILL.md @@ -0,0 +1,72 @@ +--- +name: gitnexus-layered-systems +description: "Use when WORKING IN a complex, multi-layered architecture — trace a request through layers (controller→service→repo→model), change at the right layer, and respect boundaries/contracts between layers. For monoliths, hexagonal/onion, and monorepos with packages. Examples: \"trace this request through the layers\", \"which layer should I change\", \"what crosses this boundary\", \"change the DTO between service and API safely\"." +--- + +# Working across layered systems + +Layered systems (controller → service → repository → model; or hexagonal/onion; or monorepo packages) defeat grep because a single feature is **smeared vertically across layers** and behind interfaces. The graph re-connects them: `trace` and process flows turn "how does the HTTP handler reach the DB write?" into one answer, and cross-layer `impact`/`cypher` keep a change from silently breaking a *different* layer. + +This is the *operate* counterpart to `gitnexus-architecture-review` (which *judges* structure). + +## When to Use + +- "Trace this request/event through the layers end-to-end" +- "Which layer should this change go in?" +- "What crosses this boundary (interface / DTO / port)?" +- "Change the contract between two layers safely" +- Monorepo: "what depends on this package across the others?" + +## Workflow + +``` +1. Map the layers: + READ gitnexus://repo/{name}/clusters → functional areas ≈ layers/modules + (HTTP? check .gnkit/gitnexus-api-profile.json → framework vs custom router) +2. Trace one feature top-to-bottom: + query({search_query:""}) → READ process/ → the cross-layer chain + step order + trace({from:"", to:""}) → exact path through every layer +3. Locate the right layer to change: + context({name:""}) → its module/area = its layer; change at the layer that OWNS the concern +4. Check what crosses the boundary BEFORE changing an interface/DTO/port: + impact({target:"", direction:"upstream", relationTypes:["CALLS","IMPORTS","ACCESSES"]}) + cypher: who in OTHER layers/areas CALLS or ACCESSES it + (HTTP boundary → api_impact + shape_check ; field/DTO boundary → cypher ACCESSES on its fields) +5. Edit at the owning layer; detect_changes → confirm the ripple stayed within intended layers. +``` + +> Stale index → `npm run gitnexus:agent-refresh` (autonomous). PDG/taint steps need `analyze --pdg`. + +## Moves for layered work + +| Need | Tool | Note | +| --- | --- | --- | +| See a feature across ALL layers | READ `process/` | The ordered cross-layer chain — the single best layered-systems read | +| "How does controller reach the DB?" | `trace({from, to})` | One call vs 5–8 manual `context` hops up/down the stack | +| Which layer owns a symbol | `context` → its `module`/community | Change the concern where it lives; don't leak logic up/down | +| What crosses a boundary (interface/port) | `impact` widened + `cypher` cross-area `CALLS`/`ACCESSES` | The other layers depending on this seam | +| Layer contract = HTTP response | `api_impact` → `shape_check` | Consumers in the client layer + shape mismatches | +| Layer contract = a DTO/model field | `cypher` `ACCESSES` (read vs write) on the field | Every layer reading/writing the field | +| Cross-package deps (monorepo) | `cypher` `IMPORTS` across areas + `check({cycles:true})` | Package coupling + import cycles between packages | + +## Anti-patterns (layered) + +- Editing a symbol without knowing its layer → logic leaks into the wrong tier. `context` first. +- Changing an interface/DTO/port from one side only → the other layer breaks silently. `impact` widened across layers BEFORE editing. +- Re-implementing a lower-layer concern in a higher layer because grep didn't surface it → `query` for the existing lower-layer logic and call down instead. +- Treating a cross-layer change as local → `detect_changes` to confirm which layers actually moved. + +## Example: "add a `currency` field end-to-end" + +``` +1. clusters → layers: api / service / repo / model +2. query("order total currency") → READ process/CheckoutFlow + trace({from:"OrderController.create", to:"OrderRepo.insert"}) + → Controller → OrderService.build → OrderMapper.toRow → OrderRepo.insert +3. The field is a MODEL/DTO concern → owning layer = model + mapper. +4. Boundary check before touching the DTO: + cypher ACCESSES on Order.fields → who reads/writes order shape across layers + api_impact on POST /orders + shape_check → client consumers of the response +5. Add `currency` at model → mapper → repo → expose in API response; update the + one client consumer flagged by shape_check. detect_changes → api+service+repo+model moved, as intended. +``` diff --git a/bundle/skills/gitnexus-local/SKILL.md b/bundle/skills/gitnexus-local/SKILL.md index ffdc33b..aeaabf2 100644 --- a/bundle/skills/gitnexus-local/SKILL.md +++ b/bundle/skills/gitnexus-local/SKILL.md @@ -10,7 +10,7 @@ disable-model-invocation: false ## Rules (short) -1. **Orient:** `query({ query, task_context, goal, repo: "__GITNEXUS_REPO__", limit: 3, max_symbols: 8 })` +1. **Orient:** `query({ search_query, task_context, goal, repo: "__GITNEXUS_REPO__", limit: 3, max_symbols: 8 })` 2. **Symbol:** `context({ name, repo: "__GITNEXUS_REPO__", include_content: false })` 3. **Before edit:** `impact({ target, direction: "upstream", repo: "__GITNEXUS_REPO__", summaryOnly: true })` 4. **Path:** `trace({from, to})` when both endpoints are known diff --git a/bundle/skills/gitnexus-microscope/SKILL.md b/bundle/skills/gitnexus-microscope/SKILL.md new file mode 100644 index 0000000..d951b9d --- /dev/null +++ b/bundle/skills/gitnexus-microscope/SKILL.md @@ -0,0 +1,73 @@ +--- +name: gitnexus-microscope +description: "Deep multi-lens audit ('microscope waves') for MILESTONE moments — feature done / big-task checkpoint / shared-code refactor / pre-ship, or when asked to 'audit / find real bugs / is this solid'. NOT for small localized changes. Goes beyond cascade code-review: it opinionates (relevance, soundness, over-engineering) as a senior domain expert, verifies findings adversarially, and iterates in waves. Examples: \"microscope this\", \"audit before we ship\", \"find the real oversights\", \"deep review this refactor\"." +--- + +# Microscope waves — deep, opinionated, verified audit + +This is **not** a cascade code review or a linter pass. A microscope wave scrutinizes a target from many independent angles, **has real opinions** (is this even needed? is this the right approach? is it over-engineered?), verifies every finding **against real logic — not "does it run"**, and iterates in numbered **waves** until clean. It's the power-composition of the whole GitNexus toolset. + +## When to run (trigger) — and when NOT (scope gate) + +Fire at **milestone boundaries**: a feature is "done" / pre-PR / pre-ship · a checkpoint in a large multi-step task · after a refactor touching shared/hub code · the user asks to "audit / review deeply / find real bugs / is this solid?". + +**Scope gate (avoid harm):** run the full waves only when the work is *substantial* — multi-file, OR touches a hub (check with `impact` blast-radius), OR high-risk path. A small localized change → **skip**, or run one quick lens. Don't fan out six agents on a one-file fix. This is a **capability you invoke**, not a mandatory gate — use judgment. + +## Two KINDS of lenses (not a fixed list) + +You **spawn concrete lenses dynamically from the map** — one per meaningful flow / layer / architectural surface / seam — and each lens is one of two KINDS. Important slices get **both** kinds. + +| KIND | The question | Sub-angles | +| --- | --- | --- | +| **A — Correctness** ("is it right?") | Does this slice actually work + do the right computation? | logic/formula correctness · null/empty/boundary edge-cases · state/env-threading/data-freshness/races · cross-surface consistency & contract agreement · security/taint · performance/cost | +| **B — Judgment / opinion** ("is it the *right thing*, and worth it?") | Abstract, evaluative — a senior expert's *taste*, not a defect list | **necessity/relevance** (should this exist? dead weight? YAGNI?) · **soundness of approach** (right way? simpler design?) · **intent alignment** (achieves the real goal, not just "runs") · **proportionality** (complexity vs value; over/under-engineered) · **conceptual integrity** (fits the mental model? abstraction boundaries + naming right?) | + +Kind B is what separates this from cascade review — a linter never asks *"why does this exist?"* or *"wrong abstraction — do X instead."* + +## The routine (one wave) + +``` +0. SCOPE-GATE: substantial? (impact blast-radius). If not → skip or one lens. +1. PERSONA: adopt "senior engineer" (see Domain persona). +2. MAP: GitNexus enumerates the lenses for you — + READ clusters (layers/areas) + processes (flows) + impact/detect_changes (changed surface) + → the list of slices/seams to scrutinize. +3. SPAWN: one lens per meaningful slice, tagged KIND A or B (both on core slices); + + cross-cutting lenses (security, performance) where relevant. + Parallel agents IF the runtime has multi-agent orchestration; else run sequentially. +4. EACH LENS: verify against REAL logic (trace the value, read the branch), not plausibility. + Kind-B lenses OPINIONATE — argue necessity/soundness/proportionality with the WHY. +5. VERIFY: adversarially re-check each finding — try to REFUTE it; keep only what survives. Cite file:line. +6. SYNTHESIZE: one report — deduped across lenses, severity-ranked (CRITICAL/HIGH/MEDIUM/LOW), + each item = a defect OR an opinion, with the WHY + file:line + a concrete recommendation. +7. WAVES: fix criticals → fold the remainder + any new/user findings → run the next NUMBERED pass → + repeat until clean. Record each pass to memory as a handoff. +``` + +> Stale index → `npm run gitnexus:agent-refresh` first (the map depends on a fresh graph + PDG). + +## Domain persona (generalize, don't hardcode) + +The judgment lenses need a domain expert, not a generic reviewer. **Adopt "a senior engineer expert in *this project's* domain."** + +- **Pinned?** If `.gnkit/domain.json` exists (e.g. `{ "domain": "payments", "persona": "staff payments/ledger engineer" }`), use it. +- **Else infer** the domain from `README`, `package.json` description, `CLAUDE.md`, and the GitNexus `clusters`/`processes` names — then state the persona you adopted in one line before reviewing. + +An expert in the domain catches *semantic* wrongness ("this fee is computed on gross, should be net") and *taste* issues ("this whole abstraction is unnecessary") that a language-only reviewer never sees. + +## Guardrails (don't harm strong models) + +- **Scaffold the stance, not the answers.** Have *real* opinions — push back, question necessity, propose better designs, defend them with the *why*. Don't emit shallow, safe, generic takes to fill a rubric. +- **Verify before asserting.** Every finding survives an adversarial refutation attempt and cites file:line. No "looks risky" without proof. +- **Proportional effort.** Lens count scales with the target; the scope gate keeps small tasks cheap. + +## Output shape + +``` +# Microscope Pass #N — (persona: senior engineer) +## CRITICAL +- — file:line — +## HIGH / MEDIUM / LOW … (same shape) +## Verified-correct (high-value confirmations) +## Bottom line + next wave +``` diff --git a/bundle/skills/gitnexus-performance/SKILL.md b/bundle/skills/gitnexus-performance/SKILL.md new file mode 100644 index 0000000..7289828 --- /dev/null +++ b/bundle/skills/gitnexus-performance/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-performance +description: "Use when optimizing a slow path or reasoning about cost/hot paths. GitNexus reveals call STRUCTURE (depth, fan-in, repeated work) — pair with a real profiler for runtime numbers. Examples: \"this endpoint is slow\", \"find the hot path\", \"why is this expensive\", \"reduce redundant work\"." +--- + +# Performance work with GitNexus + +GitNexus does **not** profile runtime — it exposes the *structure* that makes code expensive: deep call chains, high fan-in hubs, work repeated across a flow, and values recomputed instead of reused. Use it to **localize** the cost, then confirm with a profiler/benchmark. + +## When to Use + +- "This request/job is slow — where's the cost?" +- "Find the hot path for X" +- "Is this called in a loop / on every iteration?" +- "Reduce redundant computation" + +## Workflow + +``` +1. query({search_query: "", goal: "hot path"}) → orient on the flow +2. READ gitnexus://repo/{name}/process/ → see the chain + step order +3. trace({from: "", to: ""}) → exact call path (depth = cost proxy) +4. cypher (CALLS variable-length / fan-in) → deep chains + high-fan-in hubs +5. pdg_query({mode: "flows", target}) → values recomputed vs reused +6. impact({target, direction: "upstream"}) BEFORE optimizing → don't break callers +7. confirm with a profiler/benchmark, then detect_changes → verify the win + scope +``` + +> Stale index → `npm run gitnexus:agent-refresh` (autonomous). PDG steps need `analyze --pdg`. + +## Structural cost signals (what to look for) + +| Signal | Graph query | Why it's expensive | +| --- | --- | --- | +| **Deep chain** | `trace`, or `cypher` `CALLS*` variable-length path | Long synchronous call depth on a hot flow | +| **High fan-in hub** | `impact` upstream (many d=1 callers) / `cypher` count callers | Called from everywhere — small cost × huge frequency | +| **Repeated work** | `pdg_query flows` — same def reaching many uses | A value recomputed instead of hoisted/cached | +| **Work inside a flow step** | READ `process/` — a heavy symbol mid-loop-ish flow | Per-item cost on a collection flow | +| **Cross-layer chatter** | `cypher` cross-cluster `CALLS` | N+1 / round-trips across a boundary (e.g. per-row DB call) | + +## Checklist + +``` +- [ ] query the slow concept; READ the process flow for step order +- [ ] trace entry → suspected expensive sink (chain depth = first cost proxy) +- [ ] cypher: find deepest CALLS chains + highest-fan-in symbols on the path +- [ ] pdg_query flows: is a value recomputed where one computation would do? +- [ ] impact upstream on the symbol BEFORE changing its signature/behavior +- [ ] Benchmark/profile to CONFIRM (graph localizes; it does not measure) +- [ ] detect_changes after the fix → re-check affected flows +``` + +## Example: "the report endpoint is slow" + +``` +1. query({search_query: "report generation", goal: "hot path"}) + → process: ReportFlow (buildReport → fetchRows → formatRow → serialize) +2. trace({from: "buildReport", to: "fetchRows"}) + → buildReport → enrich → formatRow → fetchRows (fetchRows is 1 hop under a per-row formatter) +3. cypher: callers of fetchRows + → formatRow CALLS fetchRows → classic N+1 (a DB call per row) +4. Fix: hoist fetchRows out of formatRow (batch). impact({target:"fetchRows"}) first. +5. Benchmark before/after; detect_changes to confirm only ReportFlow moved. +``` diff --git a/bundle/skills/gitnexus-scenarios/SKILL.md b/bundle/skills/gitnexus-scenarios/SKILL.md index 79f7520..ff31d4b 100644 --- a/bundle/skills/gitnexus-scenarios/SKILL.md +++ b/bundle/skills/gitnexus-scenarios/SKILL.md @@ -28,7 +28,7 @@ Cross-module flows / architecture questions → also read **`gitnexus-imaging`** - [ ] Review changed_symbols + affected_processes - [ ] Unexpected cross-module hits? → split commit or narrow scope - [ ] Risk CRITICAL/HIGH → run broader test suite before commit -- [ ] Commit (pre-commit hook refreshes index with PDG via `gitnexus:pdg`; agents use `gitnexus:agent-refresh` when stale mid-session) +- [ ] Commit (pre-commit hook refreshes index with PDG via `gitnexus:full-pdg` (full --force rebuild); agents use `gitnexus:agent-refresh` when stale mid-session) ``` ## 3. PR / branch review diff --git a/bundle/skills/gitnexus-testing/SKILL.md b/bundle/skills/gitnexus-testing/SKILL.md new file mode 100644 index 0000000..03423ee --- /dev/null +++ b/bundle/skills/gitnexus-testing/SKILL.md @@ -0,0 +1,74 @@ +--- +name: gitnexus-testing +description: "Use when deciding WHAT to test or checking coverage. The graph turns blast radius into a precise test target list and surfaces untested symbols. Examples: \"what should I test for this change\", \"is this covered\", \"write tests for X\", \"which flows need tests\"." +--- + +# Test targeting with GitNexus + +Don't guess what to test — let the graph turn a change's **blast radius into the exact test surface**, and find the symbols with no test coverage. + +## When to Use + +- "What should I test for this change?" +- "Is X covered by tests?" +- "Write tests for this feature/fix" +- "Which execution flows does my change put at risk?" + +## Workflow + +``` +1. impact({target: "", direction: "upstream"}) → everything that could break = the test surface +2. READ gitnexus://repo/{name}/processes (or detect_changes) → which execution FLOWS the change touches → integration tests +3. cypher: callers of that live in test files → existing coverage vs gaps +4. query({search_query: " tests", goal: "test pattern"}) → mirror the repo's existing test style +5. write tests for: d=1 callers (unit) + each affected process (integration) + the gap symbols +6. detect_changes({scope: "staged"}) after → confirm every affected process has a test +``` + +> Stale index → `npm run gitnexus:agent-refresh` (autonomous). + +## Blast radius → test plan + +| Graph result | Test to write | +| --- | --- | +| `impact` d=1 callers (WILL BREAK) | Unit test each caller's contract with the changed symbol | +| Affected **processes** (from `impact` / `detect_changes`) | One integration test per flow end-to-end | +| Changed symbol itself | Unit tests for new/changed branches (pair with `pdg_query controls` to enumerate guards) | +| HIGH/CRITICAL risk path | Regression test before the change; assert behavior is preserved | + +## Coverage gaps (find untested code) + +```cypher +// Symbols with callers but NONE from a test file → likely untested +MATCH (s:Function) +WHERE NOT EXISTS { + MATCH (t)-[:CodeRelation {type:'CALLS'}]->(s) + WHERE t.filePath CONTAINS 'test' OR t.filePath CONTAINS 'spec' +} +RETURN s.name, s.filePath +``` + +(READ `gitnexus://repo/{name}/schema` first — adapt node/edge names to this repo.) + +## Checklist + +``` +- [ ] impact upstream on the changed symbol → list d=1 (unit) + affected processes (integration) +- [ ] pdg_query controls (if PDG) → enumerate the guards/branches to cover +- [ ] cypher → which affected symbols already have test-file callers vs gaps +- [ ] query for the existing test pattern; mirror it +- [ ] write unit (callers) + integration (each flow) + gap tests +- [ ] detect_changes(staged) → every affected process has a test before commit +``` + +## Example: "what should I test after changing computeDiscount?" + +``` +1. impact({target: "computeDiscount", direction: "upstream"}) + → d=1: CheckoutTotal, InvoiceBuilder ; processes: CheckoutFlow, BillingFlow +2. pdg_query({mode:"controls", target:"computeDiscount"}) + → 3 guards (member?, coupon?, minSpend?) → 3 branch cases to cover +3. cypher → CheckoutTotal has tests; InvoiceBuilder has NONE (gap) +4. Write: unit for the 3 discount branches, integration for CheckoutFlow + BillingFlow, + and fill the InvoiceBuilder gap. +``` diff --git a/bundle/skills/gitnexus-workspace/SKILL.md b/bundle/skills/gitnexus-workspace/SKILL.md index bac60cd..f66c8e4 100644 --- a/bundle/skills/gitnexus-workspace/SKILL.md +++ b/bundle/skills/gitnexus-workspace/SKILL.md @@ -52,7 +52,13 @@ Run `npm run gitnexus:detect-api` to refresh the profile after major server chan | Before editing / blast radius | `gitnexus-impact-analysis` | | Bug / failure / wrong behavior | `gitnexus-debugging` | | Rename / extract / refactor | `gitnexus-refactoring` + **`rename` MCP** | +| Add a feature / new code (reuse + wire in) | `gitnexus-feature-dev` | +| What to test / coverage gaps | `gitnexus-testing` | +| Slow path / hot path / cost | `gitnexus-performance` | +| Judge structure (coupling, cycles, god objects) | `gitnexus-architecture-review` | +| Work across layers (controller→service→repo→model) | `gitnexus-layered-systems` | | Structured task (pre-commit, PR, cross-module) | `gitnexus-scenarios` | +| Milestone deep audit (feature done, pre-ship, big refactor) | **`gitnexus-microscope`** — multi-lens, opinionated, verified | | PR or branch review | `gitnexus-pr-review` | | Security / taint / injection review | `gitnexus-security-review` | | Research HTTP API change | See **HTTP API routing** above | @@ -61,23 +67,25 @@ Run `npm run gitnexus:detect-api` to refresh the profile after major server chan | Hook blocked Grep/Read | `gitnexus-enforcement` (staleness + suspicion fallback) | | Full agent contract | `.cursor/rules/gitnexus.mdc` + `00-gitnexus-enforcement.mdc` | -## Smart query habits +## Smart query habits (use the embeddings, don't grep-in-disguise) -Always pass context to rank results better: +`query` is **hybrid**: BM25 keyword + **embedding vectors** (RRF). The embedding half is the point — it matches *meaning*, so it finds code that a keyword search misses. + +- **Phrase `search_query` as a natural-language concept, not a symbol/keyword.** `"where auth tokens are validated"` ✓ — not `"validateToken"` ✗ (that's a `context` lookup). Concept phrasing feeds the vector ranker. +- **Always pass `task_context` + `goal`** — they steer the embedding ranking, not just keyword match. +- **Embeddings win when:** you don't know the symbol name · you want "code that *does* X even if named differently" · fuzzy/conceptual exploration. (Exact known symbol → skip to `context`.) ```javascript query({ - search_query: "", - task_context: "what you are doing in this chat", - goal: "what you need to find", + search_query: "how retry/backoff is applied to outbound requests", // concept, not a keyword + task_context: "adding a circuit breaker", + goal: "find existing retry logic to reuse", repo: "__GITNEXUS_REPO__", limit: 5, max_symbols: 12 }) ``` -Pass `task_context` + `goal` — they improve **embedding** ranking, not just keyword match. - ## Anti-patterns (grep is wrong tool) - Symbol lookup → `context`, not Grep @@ -96,7 +104,7 @@ Installed by `npm run gitnexus:setup`: - **Enforcement rule** — `.cursor/rules/00-gitnexus-enforcement.mdc` (only `alwaysApply: true` contract) - **Reference rules** — `.cursor/rules/gitnexus.mdc` + `gitnexus-first.mdc` (load on demand) - **Hooks** — GN-first when fresh; **refresh-first when stale** (classical only after refresh fails); field grep → Cypher; large data-flow Read → Cypher -- **Skills** — canonical store in `.gitnexus/agent-kit/skills/`, symlinked to `.cursor/skills/` (and `.agents/skills/` on Zed) +- **Skills** — canonical store in `.gnkit/skills/`, symlinked to `.cursor/skills/` (and `.agents/skills/` on Zed) - **MCP** — `gitnexus` in `.cursor/mcp.json` Restart Cursor after setup so MCP + hooks load. diff --git a/bundle/templates/AGENTS.gitnexus.md b/bundle/templates/AGENTS.gitnexus.md index fd509f2..7b0b1b4 100644 --- a/bundle/templates/AGENTS.gitnexus.md +++ b/bundle/templates/AGENTS.gitnexus.md @@ -1,25 +1,164 @@ + + # GitNexus agent kit — always-on instructions ## North star -> **GitNexus is the default reasoning layer for every task.** Prefer graph + embeddings when the index is fresh. Use `query` to orient. Use `cypher` for precise structural questions. Refresh autonomously when stale. Classical grep/read only **after refresh fails** — say why in one sentence. +> **GitNexus is the default reasoning layer for every task — not a fallback when code is unfamiliar.** Prefer graph + embeddings when the index is fresh. Use `query` to orient (BM25 + vectors). Use `cypher` for precise structural graph questions. Refresh autonomously when stale or embeddings are missing. Classical tools only **after refresh fails** or GN is wrong — say why. + +**Model tiers:** the graph + gates improve **every** agent — budget/local models gain the most *relative* lift; flagship models waste fewer tokens and follow the same enforced loop. Local LLM / zero API cost: rebuild context freely; do not skip gates for speed. + +## Every task (not “unfamiliar code only”) + +Use the graph for **all** agent work — explore, debug, fix, refactor, review, rename, commit — not only architecture questions. + +| Task type | Graph role | +| --- | --- | +| Answer / explain / debug | `query` → `context` → `cypher` if structural → Read offset/limit | +| Field / property data flow | READ schema → `cypher` (`ACCESSES` read/write) | +| N-hop call chains, overrides, process steps | READ schema → `cypher` | +| Statement-level data/control flow, taint | `pdg_query` / `explain` / `trace` (see deep precision) | +| Edit runtime source (any size) | `impact` upstream before Write/StrReplace | +| Refactor / rename / shared code | `impact` + `rename` dry_run OR `context` on hub symbols | +| Review / “what did I change?” | `detect_changes`; `query` to orient | +| Session start | `agent-brief` or repo context; confirm kit health | + +**Anti-patterns:** reserving GitNexus for big exploratory prompts; grep/read from memory on “familiar” files; grepping field names instead of `cypher`; **StrReplace/find-and-replace for symbol renames** instead of `rename` dry_run; skipping `impact` on “small” edits; jumping to `context`/`impact`/`grep` without `query` first (skips embeddings). `SemanticSearch` is blocked — use `query`. + +## Graph + embeddings + cypher (layered) + +| Need | Tool | Why | +| --- | --- | --- | +| Orient — any fuzzy or grounding step | `query` | Hybrid BM25 + **embedding** vectors (RRF) | +| One symbol, callers, 360° | `context` | Structural graph (canned API) | +| **Precise structural graph questions** | **`cypher`** | Raw traversals the canned tools don't express | +| Pre-edit blast radius | `impact` | Graph traversal | +| Pre-commit / done | `detect_changes` | Diff → processes | -## Tool loop (every task) +### When to escalate to `cypher` (after `query` / `context`) + +READ `gitnexus://repo/__GITNEXUS_REPO__/schema` before ad-hoc Cypher. + +| Question | Cypher edge / pattern | +| --- | --- | +| Who reads/writes field/property X? | `ACCESSES` with `reason: read` / `write` | +| Custom N-hop call chain | `CALLS` variable-length path | +| Method override chain | `METHOD_OVERRIDES` | +| Ordered steps in a process | `STEP_IN_PROCESS` + `r.step` | +| All methods on a class | `HAS_METHOD` | +| Diamond / multi-inheritance | `EXTENDS` multi-path MATCH | + +**Order:** `query` (orient) → `context` (symbol) → **`cypher`** (structural precision) → `impact` (before edits). Do not start with `cypher` for fuzzy questions — that's what `query` + embeddings are for. + +Refresh always includes `--embeddings` (`gitnexus:refresh` / `agent-refresh`). Missing embeddings = stale (same as commit behind). + +## Deep precision — PDG, taint, trace + +When `cypher` isn't enough, escalate to statement-level tools (require a PDG index — `gitnexus:pdg`): | Need | Tool | | --- | --- | -| Orient / fuzzy flow | `query` (BM25 + embeddings) | -| One symbol, callers | `context` | -| Field access, N-hop, overrides | READ schema → `cypher` | -| Before edits | `impact` upstream | -| Before commit / done | `detect_changes` | -| Symbol rename | `rename` dry_run first | - -## Session - -1. `npm run gitnexus:agent-brief` or READ `gitnexus://repo/__GITNEXUS_REPO__/context` -2. **Stale graph or missing embeddings → run `npm run gitnexus:agent-refresh` before any graph MCP call** (Shell, `required_permissions: ["all"]`). Do not grep or read source as a workaround while stale. -3. Never ask the user to run analyze — refresh autonomously. If refresh fails, say why and only then use classical tools. +| Statement-level blast radius (control + data) | `impact` with `mode: "pdg"` | +| What predicate controls a line / why does it run? | `pdg_query` (`mode: "controls"`) | +| Where does a variable's value flow / reach? | `pdg_query` (`mode: "flows"`) | +| Source → sink path between two symbols | `trace` | +| Taint review — injection, path traversal, XSS | `explain` | + +## Full tool surface — reach for the right one + +Know every tool and *when* it wins (single-repo; cross-repo `group_*` is out of scope for this kit). Don't stop at `query`/`context` — the advanced tools answer in one call what takes many manual hops. + +| Tool | Reach for it when | +| --- | --- | +| `query` | Orient — "how does X work?", find the execution flow for a concept (BM25 + vectors). Always first for fuzzy work. | +| `context` | 360° on ONE symbol — callers, callees, categorized refs, the processes it's in. After `query`, or when the symbol is known. | +| `cypher` | Precise structural questions the canned tools don't express — field `ACCESSES`, N-hop `CALLS`, `METHOD_OVERRIDES`, `STEP_IN_PROCESS`. READ schema first. | +| `impact` | BEFORE editing a symbol — upstream blast radius + risk + affected processes. `mode: "pdg"` for statement-level (control+data) precision. | +| `trace` | "How does A reach B?" — shortest call/member path between two symbols in ONE call (replaces 3–8 manual `context` hops). | +| `pdg_query` | "What condition gates this line?" (`mode: "controls"`) / "where does this variable flow?" (`mode: "flows"`). Intra-function; needs PDG. | +| `explain` | Security review — taint source→sink (command/code/sql injection, path-traversal, XSS), intra- AND inter-procedural. Needs PDG. | +| `detect_changes` | BEFORE commit / "what did my edits affect?" — diff → affected symbols/processes/risk. `scope`: unstaged \| staged \| all \| compare. | +| `rename` | Coordinated multi-file symbol rename — `dry_run: true` first. Never find-and-replace identifiers. | +| `api_impact` | BEFORE changing an HTTP route handler (framework router) — consumers, response-shape mismatches, middleware chain, risk. | +| `route_map` | Map routes → consumers + handler + middleware; find orphaned routes. (Custom router → `context` on the dispatcher instead.) | +| `shape_check` | Detect API response-shape drift — keys a route returns vs keys consumers access (flags MISMATCH). | +| `tool_map` | Map MCP/RPC tool definitions → handler files + descriptions (tool-API work, impact of a tool-contract change). | +| `check` | Structural integrity — detect circular File `IMPORTS` cycles (health / CI gate). | +| `list_repos` | Only when multiple repos are indexed — discover/disambiguate before passing `repo:` to other tools. | + +Cheap resource reads (prefer before heavy tools): `READ gitnexus://repo/__GITNEXUS_REPO__/{context|schema|clusters|processes|process/}`. + +## MCP defaults (generous — local LLM) + +Run hook copy-paste calls verbatim; expand freely when needed: + +| Tool | Default | Notes | +| --- | --- | --- | +| `context` | `include_content: false` | Need body → Read offset/limit | +| `query` | `limit: 5`, `max_symbols: 12` | Phrase `search_query` as a natural-language **concept** ("where tokens are validated"), not a keyword — that feeds the embedding ranker; always pass `task_context` + `goal`. Known symbol name → use `context` instead. | +| `cypher` | READ schema first | Use `$params` for symbol/field names | +| `impact` | `summaryOnly: false`, `limit: 100` | Full blast radius before edits; `mode: "pdg"` for statement-level | +| `pdg_query` | `mode: "controls"` / `"flows"` | Statement-level control/data dependence | +| `trace` / `explain` | source → sink | Path between symbols; taint analysis | +| `rename` | `dry_run: true` first | Coordinated multi-file symbol rename | +| `detect_changes` | `scope: unstaged` | Pre-commit → `staged`; PR → `compare` | + +## Session (autonomous Shell) + +New chat: run session health ritual if injected — `npm run gitnexus:agent-status`, one-sentence confirm to user. + +`npm run gitnexus:agent-brief` or READ `gitnexus://repo/__GITNEXUS_REPO__/context`. Stale or missing embeddings → **`npm run gitnexus:agent-refresh` first** (`required_permissions: ["all"]`). Hooks **block** Grep/Read/MCP/shell until refresh succeeds; classical tools only if refresh **fails** (say why). Never ask user to analyze. + +## Stale loop (mandatory) + +``` +stale → agent-refresh (Shell, pre-approved) + → fresh → query / context / cypher / impact + → still stale after refresh → agent-refresh retry once if plausible + → refresh failed → classical fallback OK (one sentence why) +``` + +Session start runs auto-refresh when stale. Do **not** grep/read “while refreshing” — refresh is the next tool, not a background hint. + +## Gates (do not skip — every task) + +``` +1. brief OR context — session start +2. query — orient / ground (graph + embeddings) before reasoning or edits +3. context → process — drill into symbols +4. cypher — structural precision (field ACCESSES, N-hop CALLS, overrides, process steps) +5. impact upstream — before runtime source edits +6. rename dry_run — before coordinated symbol renames (not StrReplace across files) +7. detect_changes — before commit / done +``` + +HIGH/CRITICAL impact → warn before proceeding. + +## When fresh — hooks block (enforced, not advisory) + +Symbol grep → `context`. **Field/property grep → READ schema → `cypher` (`ACCESSES`).** SemanticSearch/broad Glob → `query`. Large source Read → `query` → `context` → Read offset/limit; **data-flow / model reads → `cypher` first.** Symbol **StrReplace rename** → `rename` dry_run. + +**Hard gates (deny until satisfied, once per session):** +- **Edit runtime source** → blocked until one `impact` (or `rename`) call this session. Run blast radius first; warn on HIGH/CRITICAL. +- **`git commit`** → blocked until one `detect_changes` call this session. Confirm affected processes match intent. + +Enforcement is **polyglot** — JS/TS, Python, Rust, Go, Java, and more count as source (configure `sourceExts` in `.gnkit/gitnexus-hooks.json`). + +## Deep review (intel layer) + +At a **milestone** — feature done / big-task checkpoint / shared-code refactor / pre-ship, or "audit / find real bugs / is this solid?" — **and** only when the work is *substantial* (multi-file or high `impact` blast-radius): run a **microscope-waves** pass → load the `gitnexus-microscope` skill. Multi-lens, opinionated (not just defects), adversarially verified, iterated in waves. Skip it for small localized changes. + +## Durable memory (survives compaction + sessions) + +Maintain your **Claude Code project memory** — `~/.claude/projects//memory/MEMORY.md` (Claude Code's native memory; **all agents share this one file** — Claude refers to its own, other agents mirror it). Record task, key decisions, findings, open items, important `file:line`. Update it at milestones and whenever you conclude something that must outlive the current transcript. Context compaction and new sessions drop the conversation; this file does not. On recovery (post-compaction/resume) READ it first and reconcile it with reality — **nothing important may be lost.** + +## Fallback + +**Stale index** → run `agent-refresh` first; classical Grep/Read stay denied until it succeeds. **If refresh fails** (or MCP down): classical Grep/Read OK — one-sentence why. + +**GitNexus fresh but wrong / suspicious / incomplete?** Don't silently fight the gate — take the escape hatch: `npm run gitnexus:fallback -- ""` opens ~15 min where classical Grep/Read/shell are allowed (auto-resumes; end early with `npm run gitnexus:fallback:off`). It is logged to telemetry and shown in `gitnexus:status` + the session brief — re-confirm findings with the graph once GN is reliable. Repeated grants signal a genuine GitNexus problem worth reporting. + +Optional: `GITNEXUS_MODE=guide` (nudge-only). Paths: `.gnkit/gitnexus-hooks.json`. Playbooks: `gitnexus-enforcement` skill. ## Zed + local models (Ollama) diff --git a/bundle/templates/CLAUDE.gitnexus.md b/bundle/templates/CLAUDE.gitnexus.md new file mode 100644 index 0000000..03ecd56 --- /dev/null +++ b/bundle/templates/CLAUDE.gitnexus.md @@ -0,0 +1,172 @@ + + +# GitNexus agent kit — always-on instructions + +## North star + +> **GitNexus is the default reasoning layer for every task — not a fallback when code is unfamiliar.** Prefer graph + embeddings when the index is fresh. Use `query` to orient (BM25 + vectors). Use `cypher` for precise structural graph questions. Refresh autonomously when stale or embeddings are missing. Classical tools only **after refresh fails** or GN is wrong — say why. + +**Model tiers:** the graph + gates improve **every** agent — budget/local models gain the most *relative* lift; flagship models waste fewer tokens and follow the same enforced loop. Local LLM / zero API cost: rebuild context freely; do not skip gates for speed. + +## Every task (not “unfamiliar code only”) + +Use the graph for **all** agent work — explore, debug, fix, refactor, review, rename, commit — not only architecture questions. + +| Task type | Graph role | +| --- | --- | +| Answer / explain / debug | `query` → `context` → `cypher` if structural → Read offset/limit | +| Field / property data flow | READ schema → `cypher` (`ACCESSES` read/write) | +| N-hop call chains, overrides, process steps | READ schema → `cypher` | +| Statement-level data/control flow, taint | `pdg_query` / `explain` / `trace` (see deep precision) | +| Edit runtime source (any size) | `impact` upstream before Write/StrReplace | +| Refactor / rename / shared code | `impact` + `rename` dry_run OR `context` on hub symbols | +| Review / “what did I change?” | `detect_changes`; `query` to orient | +| Session start | `agent-brief` or repo context; confirm kit health | + +**Anti-patterns:** reserving GitNexus for big exploratory prompts; grep/read from memory on “familiar” files; grepping field names instead of `cypher`; **StrReplace/find-and-replace for symbol renames** instead of `rename` dry_run; skipping `impact` on “small” edits; jumping to `context`/`impact`/`grep` without `query` first (skips embeddings). `SemanticSearch` is blocked — use `query`. + +## Graph + embeddings + cypher (layered) + +| Need | Tool | Why | +| --- | --- | --- | +| Orient — any fuzzy or grounding step | `query` | Hybrid BM25 + **embedding** vectors (RRF) | +| One symbol, callers, 360° | `context` | Structural graph (canned API) | +| **Precise structural graph questions** | **`cypher`** | Raw traversals the canned tools don't express | +| Pre-edit blast radius | `impact` | Graph traversal | +| Pre-commit / done | `detect_changes` | Diff → processes | + +### When to escalate to `cypher` (after `query` / `context`) + +READ `gitnexus://repo/__GITNEXUS_REPO__/schema` before ad-hoc Cypher. + +| Question | Cypher edge / pattern | +| --- | --- | +| Who reads/writes field/property X? | `ACCESSES` with `reason: read` / `write` | +| Custom N-hop call chain | `CALLS` variable-length path | +| Method override chain | `METHOD_OVERRIDES` | +| Ordered steps in a process | `STEP_IN_PROCESS` + `r.step` | +| All methods on a class | `HAS_METHOD` | +| Diamond / multi-inheritance | `EXTENDS` multi-path MATCH | + +**Order:** `query` (orient) → `context` (symbol) → **`cypher`** (structural precision) → `impact` (before edits). Do not start with `cypher` for fuzzy questions — that's what `query` + embeddings are for. + +Refresh always includes `--embeddings` (`gitnexus:refresh` / `agent-refresh`). Missing embeddings = stale (same as commit behind). + +## Deep precision — PDG, taint, trace + +When `cypher` isn't enough, escalate to statement-level tools (require a PDG index — `gitnexus:pdg`): + +| Need | Tool | +| --- | --- | +| Statement-level blast radius (control + data) | `impact` with `mode: "pdg"` | +| What predicate controls a line / why does it run? | `pdg_query` (`mode: "controls"`) | +| Where does a variable's value flow / reach? | `pdg_query` (`mode: "flows"`) | +| Source → sink path between two symbols | `trace` | +| Taint review — injection, path traversal, XSS | `explain` | + +## Full tool surface — reach for the right one + +Know every tool and *when* it wins (single-repo; cross-repo `group_*` is out of scope for this kit). Don't stop at `query`/`context` — the advanced tools answer in one call what takes many manual hops. + +| Tool | Reach for it when | +| --- | --- | +| `query` | Orient — "how does X work?", find the execution flow for a concept (BM25 + vectors). Always first for fuzzy work. | +| `context` | 360° on ONE symbol — callers, callees, categorized refs, the processes it's in. After `query`, or when the symbol is known. | +| `cypher` | Precise structural questions the canned tools don't express — field `ACCESSES`, N-hop `CALLS`, `METHOD_OVERRIDES`, `STEP_IN_PROCESS`. READ schema first. | +| `impact` | BEFORE editing a symbol — upstream blast radius + risk + affected processes. `mode: "pdg"` for statement-level (control+data) precision. | +| `trace` | "How does A reach B?" — shortest call/member path between two symbols in ONE call (replaces 3–8 manual `context` hops). | +| `pdg_query` | "What condition gates this line?" (`mode: "controls"`) / "where does this variable flow?" (`mode: "flows"`). Intra-function; needs PDG. | +| `explain` | Security review — taint source→sink (command/code/sql injection, path-traversal, XSS), intra- AND inter-procedural. Needs PDG. | +| `detect_changes` | BEFORE commit / "what did my edits affect?" — diff → affected symbols/processes/risk. `scope`: unstaged \| staged \| all \| compare. | +| `rename` | Coordinated multi-file symbol rename — `dry_run: true` first. Never find-and-replace identifiers. | +| `api_impact` | BEFORE changing an HTTP route handler (framework router) — consumers, response-shape mismatches, middleware chain, risk. | +| `route_map` | Map routes → consumers + handler + middleware; find orphaned routes. (Custom router → `context` on the dispatcher instead.) | +| `shape_check` | Detect API response-shape drift — keys a route returns vs keys consumers access (flags MISMATCH). | +| `tool_map` | Map MCP/RPC tool definitions → handler files + descriptions (tool-API work, impact of a tool-contract change). | +| `check` | Structural integrity — detect circular File `IMPORTS` cycles (health / CI gate). | +| `list_repos` | Only when multiple repos are indexed — discover/disambiguate before passing `repo:` to other tools. | + +Cheap resource reads (prefer before heavy tools): `READ gitnexus://repo/__GITNEXUS_REPO__/{context|schema|clusters|processes|process/}`. + +## MCP defaults (generous — local LLM) + +Run hook copy-paste calls verbatim; expand freely when needed: + +| Tool | Default | Notes | +| --- | --- | --- | +| `context` | `include_content: false` | Need body → Read offset/limit | +| `query` | `limit: 5`, `max_symbols: 12` | Phrase `search_query` as a natural-language **concept** ("where tokens are validated"), not a keyword — that feeds the embedding ranker; always pass `task_context` + `goal`. Known symbol name → use `context` instead. | +| `cypher` | READ schema first | Use `$params` for symbol/field names | +| `impact` | `summaryOnly: false`, `limit: 100` | Full blast radius before edits; `mode: "pdg"` for statement-level | +| `pdg_query` | `mode: "controls"` / `"flows"` | Statement-level control/data dependence | +| `trace` / `explain` | source → sink | Path between symbols; taint analysis | +| `rename` | `dry_run: true` first | Coordinated multi-file symbol rename | +| `detect_changes` | `scope: unstaged` | Pre-commit → `staged`; PR → `compare` | + +## Session (autonomous Shell) + +New chat: run session health ritual if injected — `npm run gitnexus:agent-status`, one-sentence confirm to user. + +`npm run gitnexus:agent-brief` or READ `gitnexus://repo/__GITNEXUS_REPO__/context`. Stale or missing embeddings → **`npm run gitnexus:agent-refresh` first** (`required_permissions: ["all"]`). Hooks **block** Grep/Read/MCP/shell until refresh succeeds; classical tools only if refresh **fails** (say why). Never ask user to analyze. + +## Stale loop (mandatory) + +``` +stale → agent-refresh (Shell, pre-approved) + → fresh → query / context / cypher / impact + → still stale after refresh → agent-refresh retry once if plausible + → refresh failed → classical fallback OK (one sentence why) +``` + +Session start runs auto-refresh when stale. Do **not** grep/read “while refreshing” — refresh is the next tool, not a background hint. + +## Gates (do not skip — every task) + +``` +1. brief OR context — session start +2. query — orient / ground (graph + embeddings) before reasoning or edits +3. context → process — drill into symbols +4. cypher — structural precision (field ACCESSES, N-hop CALLS, overrides, process steps) +5. impact upstream — before runtime source edits +6. rename dry_run — before coordinated symbol renames (not StrReplace across files) +7. detect_changes — before commit / done +``` + +HIGH/CRITICAL impact → warn before proceeding. + +## When fresh — hooks block (enforced, not advisory) + +Symbol grep → `context`. **Field/property grep → READ schema → `cypher` (`ACCESSES`).** SemanticSearch/broad Glob → `query`. Large source Read → `query` → `context` → Read offset/limit; **data-flow / model reads → `cypher` first.** Symbol **StrReplace rename** → `rename` dry_run. + +**Hard gates (deny until satisfied, once per session):** +- **Edit runtime source** → blocked until one `impact` (or `rename`) call this session. Run blast radius first; warn on HIGH/CRITICAL. +- **`git commit`** → blocked until one `detect_changes` call this session. Confirm affected processes match intent. + +Enforcement is **polyglot** — JS/TS, Python, Rust, Go, Java, and more count as source (configure `sourceExts` in `.gnkit/gitnexus-hooks.json`). + +## Deep review (intel layer) + +At a **milestone** — feature done / big-task checkpoint / shared-code refactor / pre-ship, or "audit / find real bugs / is this solid?" — **and** only when the work is *substantial* (multi-file or high `impact` blast-radius): run a **microscope-waves** pass → load the `gitnexus-microscope` skill. Multi-lens, opinionated (not just defects), adversarially verified, iterated in waves. Skip it for small localized changes. + +## Durable memory (survives compaction + sessions) + +Maintain your **Claude Code project memory** — `~/.claude/projects//memory/MEMORY.md` (Claude Code's native memory; **all agents share this one file** — Claude refers to its own, other agents mirror it). Record task, key decisions, findings, open items, important `file:line`. Update it at milestones and whenever you conclude something that must outlive the current transcript. Context compaction and new sessions drop the conversation; this file does not. On recovery (post-compaction/resume) READ it first and reconcile it with reality — **nothing important may be lost.** + +## Fallback + +**Stale index** → run `agent-refresh` first; classical Grep/Read stay denied until it succeeds. **If refresh fails** (or MCP down): classical Grep/Read OK — one-sentence why. + +**GitNexus fresh but wrong / suspicious / incomplete?** Don't silently fight the gate — take the escape hatch: `npm run gitnexus:fallback -- ""` opens ~15 min where classical Grep/Read/shell are allowed (auto-resumes; end early with `npm run gitnexus:fallback:off`). It is logged to telemetry and shown in `gitnexus:status` + the session brief — re-confirm findings with the graph once GN is reliable. Repeated grants signal a genuine GitNexus problem worth reporting. + +Optional: `GITNEXUS_MODE=guide` (nudge-only). Paths: `.gnkit/gitnexus-hooks.json`. Playbooks: `gitnexus-enforcement` skill. + +## Claude Code + +- The `gitnexus` MCP server is configured in `.mcp.json` — approve it on first run. +- Hooks in `.claude/settings.json` enforce the loop: symbol Grep → `gitnexus_context`, large source Read → `gitnexus_query`, edits gated on `gitnexus_impact`, `git commit` gated on `gitnexus_detect_changes`, and stale shell commands blocked until refresh. +- Skills live in `.claude/skills/` — invoke `/gitnexus-enforcement` or `/gitnexus-workspace` on hard tasks. +- Stale index or missing embeddings → run `npm run gitnexus:agent-refresh` (Bash, pre-approved); never ask the user to analyze. + +## npm gates + +Run gated scripts from `package.json` when hooks remind you: `gitnexus.__gate.*` — they document the enforced playbook for this repo. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2c06bf7..d5045fe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -193,7 +193,7 @@ flowchart TD flowchart TB I["bin/install.sh"] --> M[migrate legacy skills/manifest/zed profile] M --> B[Copy bundle] - B --> SK[".gitnexus/agent-kit/skills + symlinks"] + B --> SK[".gnkit/skills + symlinks"] SK --> MC[Merge Cursor hooks + MCP] SK --> MZ[Merge Zed profile + AGENTS.md] MC --> S[gitnexus-setup.sh] @@ -272,9 +272,9 @@ Agents still **`query` first** for fuzzy work — Cypher is gate #4, not a grep | `AGENTS.md` (kit block) | Always-on instructions for Zed agents | | Session health hooks | New chat audit + agent confirms kit on first reply | | Cypher integration | `cypher-helpers.mjs`; field grep → ACCESSES | -| `.gitnexus/agent-kit/skills/` + symlinks | Canonical skill store → `.cursor/skills/`, `.agents/skills/` | +| `.gnkit/skills/` + symlinks | Canonical skill store → `.cursor/skills/`, `.agents/skills/` | | `scripts/gitnexus-*` | Setup, sync, agent CLI, pack, git hooks | -| `.githooks/pre-commit` | Optional PDG index refresh on commit (`gitnexus:pdg`) | +| `.githooks/pre-commit` | Full PDG re-index on commit (`gitnexus:full-pdg`) | | `.cursor/mcp.json` | Merges `gitnexus` MCP server (Cursor) | | Gated `package.json` scripts | `gitnexus:health`, `gitnexus:verify`, gate docs | @@ -294,7 +294,7 @@ Per-target repo (built locally): `.gitnexus/` index, `.cursor/skills/generated/` ``` bundle/ -├── skills/ # canonical flat skill store (copied to .gitnexus/agent-kit/skills) +├── skills/ # canonical flat skill store (copied to .gnkit/skills) ├── .cursor/rules/ hooks.json hooks/ │ └── hooks/lib/ # cypher, rename, verify, graph-smoke, … ├── templates/ # AGENTS.gitnexus.md fragment diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 3e547fc..e1ce2ce 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -63,7 +63,7 @@ bin/install.sh → stepped banner UI (validate → migrate legacy → copy → merge → manifest → setup) → migrate legacy cursor-gitnexus-kit layout (rsync skills, old manifest, zed profile) → copy bundle (rules, hooks, skills store, scripts, team guide) - → materialize .gitnexus/agent-kit/skills/ + symlink into .cursor/ and/or .agents/ + → materialize .gnkit/skills/ + symlink into .cursor/ and/or .agents/ → merge gated package.json gitnexus:* scripts + .cursor/mcp.json (Cursor) → merge .zed/settings.json + AGENTS.md (Zed) → gitnexus-setup.sh (--skip-global-mcp) @@ -71,7 +71,7 @@ bin/install.sh → npm run gitnexus:verify ``` -Skills live once in `.gitnexus/agent-kit/skills/` and are **symlinked** — not copied — into IDE skill paths. Updates replace the store and refresh symlinks. +Skills live once in `.gnkit/skills/` and are **symlinked** — not copied — into IDE skill paths. Updates replace the store and refresh symlinks. ## Update @@ -107,7 +107,8 @@ npm run gitnexus:agent-status # staleness (agents) npm run gitnexus:agent-refresh # re-index when stale npm run gitnexus:branch-status # branch/base summary + branch-aware MCP calls npm run gitnexus:pr-impact # branch-aware PR review playbook -npm run gitnexus:pdg # embeddings + skills + PDG (pre-commit uses this) +npm run gitnexus:pdg # incremental embeddings + skills + PDG (mid-session) +npm run gitnexus:full-pdg # full --force rebuild + PDG (pre-commit hook uses this) npm run gitnexus:graph-smoke # Cypher / ACCESSES sanity (CI) npm run gitnexus:detect-api # HTTP router profile npm run gitnexus:sync-teaching # after pulling kit updates @@ -133,7 +134,7 @@ Source: `scripts/gitnexus-teaching/script-gates.mjs` | **`rename` MCP** | Graph-coordinated rename — `edit-guard`, prompt-router | | **API router profile** | `npm run gitnexus:detect-api` → `.cursor/gitnexus-api-profile.json` | | **Branch-aware PR review** | `npm run gitnexus:branch-status -- main`; `npm run gitnexus:pr-impact -- main` | -| **PDG pre-commit refresh** | `.githooks/pre-commit` runs `npm run gitnexus:pdg` before `gitnexus:graph-smoke` | +| **PDG pre-commit refresh** | `.githooks/pre-commit` runs `npm run gitnexus:full-pdg` before `gitnexus:graph-smoke` | | **Graph smoke test** | `npm run gitnexus:graph-smoke`; pre-commit after PDG refresh | | **Zed + Ollama** | See [ZED.md](./ZED.md) — **Zed + GitNexus** profile, local model hints | diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 4b5edb2..876e0f2 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -69,7 +69,7 @@ In a real target repo after update: ## 6. GitNexus v1.6.8 capability smoke - [ ] Agent brief shows routing for `trace`, `pdg_query`, `explain`, and Cypher. -- [ ] Pre-commit hook calls `npm run gitnexus:pdg` before `gitnexus:graph-smoke`. +- [ ] Pre-commit hook calls `npm run gitnexus:full-pdg` before `gitnexus:graph-smoke`. - [ ] Security review skill warns that no taint/PDG layer is not proof of safety. - [ ] MCP snippets use current parameter names: - `gitnexus_query({ search_query: ... })` diff --git a/docs/SKILLS.md b/docs/SKILLS.md index cd0a3a4..798bee3 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -1,6 +1,6 @@ # GitNexus agent skills -Use this index to route agent work to the right reusable playbook. The canonical skill store is installed into target repos at `.gitnexus/agent-kit/skills/` and symlinked into Cursor (`.cursor/skills/`) and Zed (`.agents/skills/`) based on runtime. +Use this index to route agent work to the right reusable playbook. The canonical skill store is installed into target repos at `.gnkit/skills/` and symlinked into Cursor (`.cursor/skills/`) and Zed (`.agents/skills/`) based on runtime. | Skill | Use when | Minimum graph path | | --- | --- | --- | @@ -12,6 +12,12 @@ Use this index to route agent work to the right reusable playbook. The canonical | `gitnexus-api-routes` | API handler or payload shape changes | `api_impact` before route edits; `shape_check` for payload drift | | `gitnexus-debugging` | Bugs, failing flows, “how did we reach this?” | `query` symptom → `context` suspect → `trace`/process/PDG as needed | | `gitnexus-refactoring` | Rename/extract/split/move work | `impact` → `context` → `rename({ dry_run: true })` or manual plan | +| `gitnexus-feature-dev` | Adding a feature / new code — reuse + wire in | `query` existing pattern → `context` integration point → `impact` before wiring | +| `gitnexus-testing` | What to test / coverage gaps | `impact` → affected processes = test surface; `cypher` for untested symbols | +| `gitnexus-performance` | Slow / hot path / cost | `query`/process → `trace` depth → `cypher` fan-in → `pdg_query flows` | +| `gitnexus-architecture-review` | Judge coupling/cohesion/cycles/god objects | `clusters` → `check(cycles)` → `cypher` cross-area `CALLS` → `impact` hubs | +| `gitnexus-layered-systems` | Working across layers (controller→service→repo→model) | `process`/`trace` through layers → `impact` widened across boundaries | +| `gitnexus-microscope` | Milestone deep audit — opinionated multi-lens, verified, waves | map (`clusters`/`processes`) → spawn per-slice lenses (2 kinds) → adversarial verify → synthesize | | `gitnexus-exploring` | Learning an unfamiliar codebase or feature | READ context → `query({ search_query })` → process/resource reads | | `gitnexus-imaging` | Producing architectural maps or mental models | clusters/processes → query → context on hubs | | `gitnexus-scenarios` | Checklist-style common workflows | Use the scenario checklist matching the task | @@ -25,6 +31,12 @@ Use this index to route agent work to the right reusable playbook. The canonical - API route or response payload → `gitnexus-api-routes` - PR/branch review → `gitnexus-pr-review` - Rename/refactor → `gitnexus-refactoring` +- Add a feature / new code → `gitnexus-feature-dev` +- What to test / coverage → `gitnexus-testing` +- Slow / hot path → `gitnexus-performance` +- Judge structure (coupling/cycles/god objects) → `gitnexus-architecture-review` +- Work across layers (controller→service→repo→model) → `gitnexus-layered-systems` +- Milestone deep audit (feature done / pre-ship / big refactor) → `gitnexus-microscope` - Bug trace/failure path → `gitnexus-debugging` - Unknown codebase/feature → `gitnexus-exploring` or `gitnexus-imaging` - Hook blocked an action → `gitnexus-enforcement` diff --git a/docs/TEAM-BUNDLE.md b/docs/TEAM-BUNDLE.md index d749cb0..bf2dca9 100644 --- a/docs/TEAM-BUNDLE.md +++ b/docs/TEAM-BUNDLE.md @@ -12,8 +12,8 @@ Portable **rules + hooks + skills + scripts** for graph-first agents (Cursor hoo | --- | --- | | `.cursor/rules/gitnexus*.mdc` | Always-on agent contract (Cursor) | | `.cursor/hooks.json` + `.cursor/hooks/**` | Block grep-first; field grep → Cypher; staleness gate; **auto-refresh on session start** | -| `.cursor/hooks/lib/cypher-helpers.mjs` | Copy-paste Cypher recipes (ACCESSES, CALLS, overrides) | -| `.gitnexus/agent-kit/skills/` + symlinks | Playbooks (enforcement, scenarios, exploring, …) | +| `.gnkit/lib/cypher-helpers.mjs` | Copy-paste Cypher recipes (ACCESSES, CALLS, overrides) | +| `.gnkit/skills/` + symlinks | Playbooks (enforcement, scenarios, exploring, …) | | `scripts/gitnexus-setup.sh` | One-shot team installer | | `scripts/sync-cursor-gitnexus-teaching.sh` | Re-sync skills symlinks after pull | | `scripts/gitnexus-verify.mjs` | Runtime-aware kit verification | @@ -29,18 +29,18 @@ Portable **rules + hooks + skills + scripts** for graph-first agents (Cursor hoo | Excluded | Why | | --- | --- | -| `.gitnexus/` index | Built locally via `npm run gitnexus:refresh`; pre-commit upgrades it with `npm run gitnexus:pdg` | +| `.gitnexus/` index | Built locally via `npm run gitnexus:refresh`; pre-commit upgrades it with `npm run gitnexus:full-pdg` | | `.cursor/skills/generated/` | Area skills from `gitnexus analyze --skills` on **that** codebase | | IDE skill symlinks | Created by install/update from canonical store | ## Large generated caches (recommended) -If the repo has thousands of non-source files (e.g. OHLCV candle shards, backtest reports), add them to **both**: +If the repo has thousands of non-source files (e.g. large data shards, generated reports, fixtures), add them to **both**: - **`.gitignore`** — keep git clean - **`.gitnexusignore`** — same gitignore syntax; keeps `gitnexus analyze` fast -This repo ignores `data/candles/` and `reports/` in both files. After changing ignores, re-index: +Example: ignore `data/` and `reports/` in both files. After changing ignores, re-index: ```bash npm run gitnexus:agent-refresh diff --git a/docs/ZED.md b/docs/ZED.md index 56843b6..c6e31f2 100644 --- a/docs/ZED.md +++ b/docs/ZED.md @@ -11,7 +11,7 @@ Install with: | Artifact | Purpose | | --- | --- | -| `.agents/skills/*` | Symlinks → `.gitnexus/agent-kit/skills/` (13 playbooks incl. `gitnexus-local`) | +| `.agents/skills/*` | Symlinks → `.gnkit/skills/` (20 playbooks incl. `gitnexus-local`) | | `.zed/settings.json` | `context_servers.gitnexus` + **Zed + GitNexus** agent profile (grep off) | | `AGENTS.md` | Always-on graph-first instructions | | npm `gitnexus:*` gates | Same playbook as Cursor installs | diff --git a/eval/README.md b/eval/README.md index 7e86dce..644626d 100644 --- a/eval/README.md +++ b/eval/README.md @@ -1,12 +1,13 @@ -# Eval harness (internal / WIP) +# Eval harness -A developer tool for experimenting with the kit's effect on agent behavior. It runs the same -task twice — **kit ON** vs **kit OFF** — through a pluggable agent runner and reports pass-rate -and token usage. +Two complementary approaches to measuring GitNexus value: -> Status: exploratory. These are not published benchmarks — they're for finding the -> scenarios where graph-first enforcement clearly helps. Treat any local numbers as -> direction, not claims. +1. **Real-repo benchmark** (`bench-realrepo.mjs`) — tests **your kit** (hooks + rules + skills + MCP + PDG) +2. **SWE-bench Verified** (`swebench/`) — tests **GitNexus MCP** alone (industry-standard comparison) + +> ⚠️ These measure different things. The real-repo benchmark proves your kit's enforcement +> adds value ON TOP of MCP. SWE-bench proves MCP helps agents explore code. See the +> [two-tier breakdown](#what-each-tier-proves) below. ## Quick start @@ -92,21 +93,109 @@ explaining the expected kit advantage. Add your own — keep them small and obje Toy fixtures are too easy for capable models. To probe real lift, run against a real codebase: ```bash +# 2-arm (OFF vs KIT — proves total kit value): +node eval/bench-realrepo.mjs \ + --task eval/realrepo-tasks/transitive-callers-impact.json \ + --repo /path/to/your/repo \ + --model composer-2.5-fast --trials 2 + +# 3-arm (OFF vs MCP vs KIT — proves kit enforcement adds value ON TOP of MCP): node eval/bench-realrepo.mjs \ - --task eval/realrepo-tasks/.json \ - --model --trials 2 + --task eval/realrepo-tasks/transitive-callers-impact.json \ + --repo /path/to/your/repo \ + --model composer-2.5-fast --trials 2 --arms 3 +``` + +**Choosing the repo.** The `repo` field in each task JSON is only an example/default +(it points at one author's local clone). Override it for your machine with **either**: + +```bash +node eval/bench-realrepo.mjs --task --repo /path/to/your/repo ... +# or +GITNEXUS_BENCH_REPO=/path/to/your/repo node eval/bench-realrepo.mjs --task ... ``` +`--repo` takes precedence over `GITNEXUS_BENCH_REPO`, which takes precedence over the +task JSON's `repo` field. The ground-truth sets in the bundled tasks were derived against +the example repo, so for a different repo you'll need to regenerate `groundTruth` (via +`gitnexus_impact` / `gitnexus_cypher`) for the scores to be meaningful. + This is **read-only** — it never mutates your project (the task only writes a small answer file, deleted after each trial): -- **ON** = your original repo, used in place. It's already kit-installed and indexed, so the - graph is reused as-is — *no copy, no re-index* (only a one-time refresh if the index is stale). - GitNexus bakes an absolute `repoPath` into the graph, so the indexed dir must never be moved. -- **OFF** = a quick source-only copy with `.cursor`/`.gitnexus` stripped — a clean grep-only - baseline. No index needed. +Every arm runs against an isolated `rsync` copy of `repo` under `~/.cache/gn-bench/` — +the original repo is never touched. The graph for the MCP/KIT arms is (re)built inside a +Docker container. If a graph build fails, the run **aborts** rather than letting that arm +silently score against an empty graph (which would masquerade as the OFF baseline). + +- **OFF** = a source-only copy with `.cursor`/`.agents`/`.gitnexus` stripped — a clean grep-only + baseline. No graph built. +- **MCP** = a source copy with `.cursor`/`.agents` stripped; the graph is built in Docker — the + agent has graph access but no hooks, rules, or skills. Proves MCP-only value. +- **KIT** = a source copy with `.cursor`/`.agents` kept and the graph built in Docker — the agent + has the graph **plus** kit enforcement (hooks/rules/skills). Proves total kit value. + +Stale `gn-bench-*` copies and leftover `gn-bench-*` Docker containers from crashed prior +runs are best-effort pruned at startup (skipped silently if Docker isn't installed). Scoring is recall (name mode) or precision/recall/F1 (`"scoreBy": "path"`) against a graph-derived ground-truth set baked into the task spec. Write a task spec (see `eval/realrepo-tasks/`) with: `repo`, `prompt`, `answerFile`, `threshold`, and a `groundTruth` list (derive it once via `gitnexus_impact` / `gitnexus_cypher`). + +### Available real-repo tasks + +The bundled tasks were authored against a `crypto-trading-bot` repo (the `repo` field in each +JSON is just that example/default path). Point them at your own repo with `--repo` / +`GITNEXUS_BENCH_REPO` as described above. + +| Task | What it tests | grep ceiling | graph ceiling | **kit ceiling** | +|------|-------------|-------------|---------------|-----------------| +| `enter-control-useauth-impact` | Transitive callers in mirrored monorepo | 0.87 | 1.0 | Same — MCP-only | +| `transitive-callers-impact` | 3-level call chain (formatResearchApiError) | ~86% | 1.0 | Same — MCP-only | +| `call-chain-impact` | Call chain tracing (calculateCurrentExposure) | ~80% | 1.0 | Same — MCP-only | +| `field-write-access` | ACCESSES write edges (planExecution vars) | Can't distinguish read/write | 1.0 | Same — MCP-only | +| `pdg-control-dependence` | PDG control dependence (exposure limits) | Can't determine control | 1.0 | Same — PDG-only | +| **`safe-rename-kit-enforced`** | Coordinated rename (formatResearchApiError) | Misses indirect refs | 1.0 via `rename` | **Kit forces `rename` instead of StrReplace** | +| **`detect-changes-before-commit`** | Pre-commit blast radius (calculateCurrentExposure) | No enforcement | Agent *can* run it | **Kit blocks commit until `detect_changes` runs** | + +The **bold** rows are where your kit adds value on top of MCP. These are the ones to focus on for proving the kit's contribution. + +## What each tier proves + +| Tier | Harness | Measures | Proves | +|------|---------|----------|--------| +| **Kit value** | `bench-realrepo.mjs` + `cursor-agent` | Hooks + rules + skills + MCP + PDG vs bare agent | **Your product** adds measurable value on top of raw MCP | +| **MCP value** | `swebench/` (SWE-bench) | Agent + MCP vs agent alone (no hooks) | GitNexus graph helps agents explore code (d3thshot proved: 30% fewer tokens) | + +The kit value tier is what you need for your product claims. The MCP value tier reproduces d3thshot's result. + +### 3-arm design (OFF / MCP / KIT) + +The 3-arm design lets you decompose the kit's contribution: + +``` +KIT lift = KIT score − MCP score (hooks + rules + skills on top of MCP) +MCP lift = MCP score − OFF score (graph alone, no enforcement) +Total lift = KIT score − OFF score (everything vs nothing) +``` + +- **MCP-only tasks** (transitive callers, PDG, field-write) should show MCP lift ≈ total lift + (kit adds little because the task doesn't require enforcement). +- **Kit enforcement tasks** (safe-rename, detect-changes) should show KIT lift > MCP lift + (the hooks force behavior that an ungated agent skips). + +## SWE-bench Verified benchmark + +Industry-standard comparison: run SWE-bench Verified with and without GitNexus MCP. +This measures **MCP value** (graph helps agents explore), NOT kit enforcement value. + +```bash +# Quick test (50 instances) +./eval/swebench/run-benchmark.sh --model deepseek/deepseek-chat-v3-0324 --instances 50 + +# Full SWE-bench Verified (500 instances) +./eval/swebench/run-benchmark.sh --model deepseek/deepseek-chat-v3-0324 +``` + +See [`eval/swebench/README.md`](swebench/README.md) for full documentation. \ No newline at end of file diff --git a/eval/bench-realrepo.mjs b/eval/bench-realrepo.mjs index d64755f..0176d20 100644 --- a/eval/bench-realrepo.mjs +++ b/eval/bench-realrepo.mjs @@ -2,115 +2,367 @@ /** * Real-repository benchmark: measures the kit's lift on a large, real codebase. * - * READ-ONLY task → it only writes a small answer file (deleted after each trial). + * Three conditions (3-arm design proves incremental kit value): + * - OFF = source-only copy, no graph, no kit. Agent has grep/read/shell only. + * - MCP = source + gitnexus graph (in Docker), no kit enforcement. + * - KIT = source + gitnexus graph (in Docker) + kit enforcement (.cursor hooks). * - * - ON = the ORIGINAL repo itself. It's already kit-installed + indexed, so we - * reuse its graph as-is — NO copy, NO re-index (we only refresh once if - * the index is stale). GitNexus bakes an absolute repoPath into the graph, - * so the indexed dir must never be moved — running in place is the only - * correct (and fastest) option. - * - OFF = a cheap, source-only copy with the kit + graph stripped, so the agent - * has nothing but grep/read. No indexing needed (there's no graph by - * design), so this copy takes seconds. - * - * Scoring: recall against a graph-derived ground-truth caller set baked into the - * task spec. pass = recall >= task.threshold. + * ARCHITECTURE: + * - cursor-agent runs on the HOST (has Cursor auth via keyring) + * - gitnexus MCP runs INSIDE a Docker container (isolated, no host pollution) + * - Repo copies are mounted at the SAME path inside and outside Docker + * so gitnexus_rename edits files at paths that cursor-agent can access + * - The ORIGINAL repo is NEVER touched — only rsync copies are used + * - Docker containers are destroyed after the benchmark * * Usage: * node eval/bench-realrepo.mjs --task eval/realrepo-tasks/.json --model composer-2.5-fast --trials 2 + * node eval/bench-realrepo.mjs --task eval/realrepo-tasks/.json --model composer-2.5-fast --trials 2 --arms 3 */ -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { fileURLToPath } from 'node:url'; -import { execSync, spawn, spawnSync } from 'node:child_process'; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath } from "node:url"; +import { execSync, spawn, spawnSync } from "node:child_process"; const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DOCKER_IMAGE = "gn-bench"; function parseArgs(argv) { - const a = { task: null, model: '', trials: 2, timeoutMs: 420000 }; + const a = { + task: null, + model: "", + trials: 2, + timeoutMs: 420000, + arms: 2, + repo: process.env.GITNEXUS_BENCH_REPO || "", + }; for (let i = 0; i < argv.length; i++) { - if (argv[i] === '--task') a.task = argv[++i]; - else if (argv[i] === '--model') a.model = argv[++i]; - else if (argv[i] === '--trials') a.trials = Math.max(1, Number(argv[++i]) || 1); - else if (argv[i] === '--timeout-ms') a.timeoutMs = Number(argv[++i]) || a.timeoutMs; + if (argv[i] === "--task") a.task = argv[++i]; + else if (argv[i] === "--model") a.model = argv[++i]; + else if (argv[i] === "--repo") a.repo = argv[++i]; + else if (argv[i] === "--trials") + a.trials = Math.max(1, Number(argv[++i]) || 1); + else if (argv[i] === "--timeout-ms") + a.timeoutMs = Number(argv[++i]) || a.timeoutMs; + else if (argv[i] === "--arms") a.arms = Number(argv[++i]) === 3 ? 3 : 2; } return a; } -const log = (...m) => process.stderr.write(`[bench] ${m.join(' ')}\n`); +const log = (...m) => process.stderr.write(`[bench] ${m.join(" ")}\n`); -function resolveLocalGitnexus() { - const r = spawnSync('which', ['gitnexus'], { encoding: 'utf8' }); - return r.status === 0 ? (r.stdout || '').trim() : ''; +/** Run a command in a Docker container. */ +function dockerExec(container, cmd, opts = {}) { + const args = ["exec"]; + if (opts.cwd) args.push("-w", opts.cwd); + args.push(container, ...cmd); + return spawnSync("docker", args, { + encoding: "utf8", + stdio: opts.stdio || "pipe", + timeout: opts.timeout || 60000, + }); } -/** Cheap, source-only copy with kit + graph stripped → a clean grep-only baseline. */ -function prepOff(repo, offDir) { - fs.rmSync(offDir, { recursive: true, force: true }); - fs.mkdirSync(offDir, { recursive: true }); - const excludes = [ - 'node_modules', - '.git', - 'dist', - 'build', - '.next', - 'coverage', - '.gitnexus', - '.cursor', - '.turbo', - '.nx', - 'out', +/** Check if Docker is available and the image exists. */ +function ensureDocker() { + const r = spawnSync("docker", ["images", "-q", DOCKER_IMAGE], { + encoding: "utf8", + }); + if (r.status !== 0 || !(r.stdout || "").trim()) { + log(`Docker image '${DOCKER_IMAGE}' not found. Building…`); + execSync(`docker build -t ${DOCKER_IMAGE} ${path.join(HERE, "docker")}/`, { + stdio: "inherit", + timeout: 300000, + }); + } +} + +// Common rsync excludes — large/irrelevant dirs stripped from ALL copies. +const RSYNC_EXCLUDES = [ + "node_modules", + "dist", + "build", + ".next", + "coverage", + ".turbo", + ".nx", + "out", + "reports", + "data", + "*.tar.gz", + "*.zip", + "*.db", + "*.sqlite", +]; + +/** rsync a copy of the repo with specified extra excludes. */ +function rsyncCopy(repo, destDir, extraExcludes = []) { + fs.rmSync(destDir, { recursive: true, force: true }); + fs.mkdirSync(destDir, { recursive: true }); + const excludes = [...RSYNC_EXCLUDES, ...extraExcludes]; + const args = [ + "-a", + ...excludes.flatMap((e) => ["--exclude", e]), + `${repo}/`, + `${destDir}/`, ]; - const args = ['-a', ...excludes.flatMap((e) => ['--exclude', e]), `${repo}/`, `${offDir}/`]; - const r = spawnSync('rsync', args, { stdio: 'ignore' }); - if (r.status !== 0) throw new Error('rsync failed'); + const r = spawnSync("rsync", args, { encoding: "utf8" }); + if (r.status !== 0) { + log(`rsync failed (exit ${r.status}): ${(r.stderr || "").slice(0, 500)}`); + throw new Error(`rsync failed: ${r.status}`); + } +} + +/** OFF: source + .git, no .gitnexus, no .cursor, no .agents. */ +function prepOff(repo, offDir) { + rsyncCopy(repo, offDir, [".gitnexus", ".cursor", ".agents"]); +} + +/** MCP: source + .git, no .cursor, no .agents. Graph built in Docker. */ +function prepMcp(repo, mcpDir, containerName) { + rsyncCopy(repo, mcpDir, [".gitnexus", ".cursor", ".agents"]); + removeKitScripts(mcpDir); + createDockerMcpConfig(mcpDir, containerName); + // Container must be started before buildGraphInDocker (see main) } -/** True when the original's graph is up to date with HEAD (no refresh needed). */ -function isFresh(repo) { +/** KIT: source + .git + .cursor + .agents. Graph built in Docker. */ +function prepKit(repo, kitDir, containerName) { + rsyncCopy(repo, kitDir, [".gitnexus"]); + patchMcpConfigForDocker(kitDir, containerName); + // Container must be started before buildGraphInDocker (see main) +} + +/** Remove kit-related npm scripts so the agent can't discover hooks. */ +function removeKitScripts(dir) { + const pkgPath = path.join(dir, "package.json"); + if (!fs.existsSync(pkgPath)) return; try { - const meta = JSON.parse(fs.readFileSync(path.join(repo, '.gitnexus/meta.json'), 'utf8')); - const head = execSync('git rev-parse HEAD', { - cwd: repo, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - return Boolean(meta.lastCommit) && meta.lastCommit === head; + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); + let removed = 0; + if (pkg.scripts) { + for (const key of Object.keys(pkg.scripts)) { + if (key.startsWith("gitnexus")) { + delete pkg.scripts[key]; + removed++; + } + } + } + if (removed > 0) + fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); } catch { - return false; + /* keep package.json as-is */ } } -/** Ensure the ORIGINAL repo has a fresh graph — index/refresh in place only if needed. */ -function ensureIndexed(repo) { - const hasGraph = fs.existsSync(path.join(repo, '.gitnexus/meta.json')); - if (hasGraph && isFresh(repo)) { - log('ON = original repo: graph already fresh — reusing as-is (no copy, no re-index) ✓'); +/** Create .cursor/mcp.json that runs gitnexus MCP via docker exec. */ +function createDockerMcpConfig(dir, containerName) { + const mcpDir = path.join(dir, ".cursor"); + fs.mkdirSync(mcpDir, { recursive: true }); + const config = { + mcpServers: { + gitnexus: { + command: "docker", + args: ["exec", "-i", containerName, "gitnexus", "mcp"], + }, + }, + }; + fs.writeFileSync( + path.join(mcpDir, "mcp.json"), + JSON.stringify(config, null, 2) + "\n", + ); + log( + `${path.basename(dir)}: created Docker MCP config (container: ${containerName})`, + ); +} + +/** Patch existing .cursor/mcp.json to use docker exec for gitnexus. */ +function patchMcpConfigForDocker(dir, containerName) { + const mcpPath = path.join(dir, ".cursor", "mcp.json"); + if (!fs.existsSync(mcpPath)) { + createDockerMcpConfig(dir, containerName); return; } - log(hasGraph ? 'ON: original graph stale — refreshing once in place …' : 'ON: original not indexed — indexing once in place …'); - const bin = resolveLocalGitnexus(); - const idxCmd = bin || 'npx'; - const idxArgs = bin ? ['analyze', '--embeddings'] : ['-y', 'gitnexus@latest', 'analyze', '--embeddings']; - const idx = spawnSync(idxCmd, idxArgs, { cwd: repo, stdio: 'ignore', timeout: 900000 }); - if (idx.status !== 0) log('WARNING: indexing failed — ON degraded'); + try { + const config = JSON.parse(fs.readFileSync(mcpPath, "utf8")); + if (config.mcpServers?.gitnexus) { + config.mcpServers.gitnexus = { + command: "docker", + args: ["exec", "-i", containerName, "gitnexus", "mcp"], + }; + fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n"); + log(`${path.basename(dir)}: patched MCP config for Docker`); + } + } catch { + createDockerMcpConfig(dir, containerName); + } +} + +/** Build gitnexus graph inside a Docker container. + * The repo dir is mounted at the same path so graph paths match the host. + * Adds safe.directory to git config to avoid dubious ownership errors. */ +function buildGraphInDocker(containerName, repoDir) { + log(`${path.basename(repoDir)}: building graph in Docker ...`); + // Fix git dubious ownership (container runs as root, files owned by host user) + dockerExec( + containerName, + ["git", "config", "--global", "--add", "safe.directory", repoDir], + { stdio: "ignore" }, + ); + const r = dockerExec( + containerName, + [ + "gitnexus", + "analyze", + "--embeddings", + "--pdg", + "--skip-agents-md", + "--skip-skills", + ], + { + cwd: repoDir, + stdio: "inherit", + timeout: 300000, + }, + ); + if (r.status !== 0) { + log( + `${path.basename(repoDir)}: ERROR — graph build failed (exit ${r.status})`, + ); + // A silent empty graph makes the MCP/KIT arm score like OFF, corrupting the + // comparison. Abort the run so a degraded arm can never masquerade as a real + // result. (Containers are already registered for cleanup in main's finally.) + throw new Error( + `graph build failed for ${path.basename(repoDir)} (gitnexus analyze exit ${r.status}); ` + + `aborting so the arm cannot silently score against an empty graph`, + ); + } + log(`${path.basename(repoDir)}: graph built in Docker ✓`); +} + +/** Disable git push on a workspace (safety guard). */ +function disableGitPush(dir) { + try { + execSync( + "git remote set-url --push origin no-push://benchmark-safety-guard", + { + cwd: dir, + stdio: "ignore", + }, + ); + } catch { + /* no origin remote — push already impossible */ + } +} + +/** Start a Docker container with the repo dir mounted at the same path. */ +function startContainer(containerName, mountDir) { + const r = spawnSync( + "docker", + [ + "run", + "-d", + "--name", + containerName, + "-v", + `${mountDir}:${mountDir}`, + "-e", + "HOME=/tmp", // Isolated HOME — no host registry pollution + DOCKER_IMAGE, + ], + { encoding: "utf8" }, + ); + if (r.status !== 0) { + log(`Failed to start container ${containerName}: ${r.stderr}`); + throw new Error(`docker run failed: ${r.status}`); + } + log(`started Docker container: ${containerName}`); +} + +/** Stop and remove a Docker container. */ +function removeContainer(containerName) { + spawnSync("docker", ["rm", "-f", containerName], { + encoding: "utf8", + stdio: "ignore", + timeout: 30000, + }); + log(`removed Docker container: ${containerName}`); +} + +/** Best-effort prune of stale gn-bench-* copies left by crashed prior runs. + * Skips the current run's dir. Never throws. */ +function pruneStaleTempDirs(benchTmpBase, currentDir) { + try { + if (!fs.existsSync(benchTmpBase)) return; + for (const name of fs.readdirSync(benchTmpBase)) { + if (!name.startsWith("gn-bench-")) continue; + const full = path.join(benchTmpBase, name); + if (full === currentDir) continue; + try { + fs.rmSync(full, { recursive: true, force: true }); + log(`pruned stale temp dir: ${full}`); + } catch (e) { + log(`could not prune ${full}: ${e.message}`); + } + } + } catch (e) { + log(`temp-dir prune skipped: ${e.message}`); + } +} + +/** Best-effort sweep of leftover gn-bench-* containers from crashed runs. + * Guarded so it never throws on a machine without docker. */ +function pruneStaleContainers() { + try { + const r = spawnSync( + "docker", + ["ps", "-aq", "--filter", "name=gn-bench-"], + { encoding: "utf8", timeout: 30000 }, + ); + if (r.status !== 0) return; + const ids = (r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean); + if (!ids.length) return; + spawnSync("docker", ["rm", "-f", ...ids], { + encoding: "utf8", + stdio: "ignore", + timeout: 60000, + }); + log(`swept ${ids.length} leftover gn-bench container(s)`); + } catch (e) { + log(`container sweep skipped: ${e.message}`); + } } function runAgent(ws, prompt, model, timeoutMs) { return new Promise((resolve) => { - const streamFile = path.join(os.tmpdir(), `gn-bench-stream-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`); - const fd = fs.openSync(streamFile, 'w'); - const args = ['-p', '--output-format', 'stream-json', '--force', '--trust', '--approve-mcps', '--workspace', ws]; - if (model) args.push('--model', model); + const streamFile = path.join( + os.tmpdir(), + `gn-bench-stream-${Date.now()}-${Math.random().toString(36).slice(2)}.jsonl`, + ); + const fd = fs.openSync(streamFile, "w"); + const args = [ + "-p", + "--output-format", + "stream-json", + "--force", + "--trust", + "--approve-mcps", + "--workspace", + ws, + ]; + if (model) args.push("--model", model); args.push(prompt); - const child = spawn('cursor-agent', args, { cwd: ws, stdio: ['ignore', fd, 'inherit'] }); + const child = spawn("cursor-agent", args, { + cwd: ws, + stdio: ["ignore", fd, "inherit"], + }); let killed = false; const killer = setTimeout(() => { killed = true; - child.kill('SIGKILL'); + child.kill("SIGKILL"); }, timeoutMs); - child.on('exit', () => { + child.on("exit", () => { clearTimeout(killer); try { fs.closeSync(fd); @@ -119,17 +371,36 @@ function runAgent(ws, prompt, model, timeoutMs) { } let tokens = 0; try { - const lines = fs.readFileSync(streamFile, 'utf8').split('\n').filter(Boolean); + const lines = fs + .readFileSync(streamFile, "utf8") + .split("\n") + .filter(Boolean); for (let i = lines.length - 1; i >= 0; i--) { - if (!lines[i].includes('usage')) continue; + if (!lines[i].includes("usage")) continue; const o = JSON.parse(lines[i]); - const u = o.usage || {}; + const u = o.usage || o.message?.usage || {}; const t = (u.inputTokens || 0) + (u.outputTokens || 0); - if (t > 0 || o.type === 'result') { + if (t > 0 || o.type === "result") { tokens = t; break; } } + if (tokens === 0 && lines.length > 0) { + let assistantEvents = 0, + toolCalls = 0; + for (const line of lines) { + try { + const ev = JSON.parse(line); + if (ev.type === "assistant") assistantEvents++; + if (ev.type === "tool_call" && ev.subtype === "completed") + toolCalls++; + } catch { + /* skip */ + } + } + if (assistantEvents > 0) + tokens = assistantEvents * 300 + toolCalls * 200; + } } catch { /* noop */ } @@ -141,64 +412,114 @@ function runAgent(ws, prompt, model, timeoutMs) { function readAnswer(ws, task) { try { - return fs.readFileSync(path.join(ws, task.answerFile), 'utf8'); + return fs.readFileSync(path.join(ws, task.answerFile), "utf8"); } catch { - return ''; + return ""; } } -/** - * Path-mode scoring (precision/recall/F1). Robustly extracts repo-relative paths - * from the answer (even from prose) and compares as a set against ground truth. - * Paths keep their clients/web vs clients/desktop prefix, so a vague basename - * (which can't disambiguate the mirror packages) never scores a true positive. - */ +/** Path-mode scoring — normalizes ./ prefixes and leading slashes. */ function scorePath(raw, task) { const truth = new Set(task.groundTruth.map((s) => s.toLowerCase())); - const re = /[\w./@-]*clients\/(?:web|desktop)\/[\w./-]+\.(?:tsx?|jsx?)/gi; + const srcRe = + /\.?\/?(?:src|lib|apps|packages|test|tests)\/[\w./-]+\.(?:tsx?|jsx?|js|py|rb|go|rs|java|kt|swift|php|cs|cpp|c|scala|mjs|cjs)/gi; const answered = new Set(); - for (const m of raw.matchAll(re)) { - const norm = m[0].slice(m[0].indexOf('clients/')).toLowerCase(); - answered.add(norm); + for (const m of raw.matchAll(srcRe)) { + answered.add(m[0].replace(/^\.?\/+/, "").toLowerCase()); + } + for (const line of raw.split(/[\n\r]+/)) { + const trimmed = line + .replace(/^[\s`"'-]+/, "") + .replace(/[\s`"',;]+$/, "") + .trim(); + const normalized = trimmed.replace(/^\.?\/+/, ""); + if ( + normalized.includes("/") && + /\.(?:tsx?|jsx?|js|py|rb|go|rs|java|kt|swift|php|cs|cpp|c|scala|mjs|cjs)$/.test( + normalized, + ) + ) { + answered.add(normalized.toLowerCase()); + } } let tp = 0; for (const a of answered) if (truth.has(a)) tp++; const precision = answered.size ? tp / answered.size : 0; const recall = truth.size ? tp / truth.size : 0; - const f1 = precision + recall ? (2 * precision * recall) / (precision + recall) : 0; - return { precision, recall, f1, tp, answered: answered.size, total: truth.size }; + const f1 = + precision + recall ? (2 * precision * recall) / (precision + recall) : 0; + return { + precision, + recall, + f1, + tp, + answered: answered.size, + total: truth.size, + }; } -/** Name-mode scoring (recall only) — kept for simpler tasks. */ +/** Name-mode scoring (recall only). */ function scoreName(raw, task) { const truth = task.groundTruth.map((s) => s.toLowerCase()); const names = new Set( raw .split(/[\s,]+/) - .map((s) => s.replace(/[`'"()]/g, '').trim().toLowerCase()) - .filter(Boolean) + .map((s) => + s + .replace(/[`'"()]/g, "") + .trim() + .toLowerCase(), + ) + .filter(Boolean), ); const found = truth.filter((t) => names.has(t)).length; const recall = found / truth.length; - return { precision: recall, recall, f1: recall, tp: found, answered: names.size, total: truth.length }; + return { + precision: recall, + recall, + f1: recall, + tp: found, + answered: names.size, + total: truth.length, + }; } function scoreAnswer(ws, task) { const raw = readAnswer(ws, task); - if (!raw) return { precision: 0, recall: 0, f1: 0, tp: 0, answered: 0, total: task.groundTruth.length }; - return task.scoreBy === 'path' ? scorePath(raw, task) : scoreName(raw, task); + if (!raw || !raw.trim()) { + log(`scoreAnswer: empty answer file in ${ws}`); + return { + precision: 0, + recall: 0, + f1: 0, + tp: 0, + answered: 0, + total: task.groundTruth.length, + }; + } + log( + `scoreAnswer: ${raw.split(/\n/).length} lines, first 100 chars: ${raw.slice(0, 100)}`, + ); + return task.scoreBy === "path" ? scorePath(raw, task) : scoreName(raw, task); } async function trials(ws, task, model, n, timeoutMs, label) { - const metric = task.scoreMetric || (task.scoreBy === 'path' ? 'f1' : 'recall'); - let passes = 0; - let pSum = 0; - let rSum = 0; - let fSum = 0; - let tokenSum = 0; - let tokenRuns = 0; + const metric = + task.scoreMetric || (task.scoreBy === "path" ? "f1" : "recall"); + let passes = 0, + pSum = 0, + rSum = 0, + fSum = 0, + tokenSum = 0, + tokenRuns = 0; for (let i = 0; i < n; i++) { fs.rmSync(path.join(ws, task.answerFile), { force: true }); + try { + execSync("git checkout -- .", { cwd: ws, stdio: "ignore" }); + execSync("git clean -fd", { cwd: ws, stdio: "ignore" }); + } catch { + /* no git repo or no changes — fine */ + } const { tokens } = await runAgent(ws, task.prompt, model, timeoutMs); const s = scoreAnswer(ws, task); const score = s[metric]; @@ -213,7 +534,8 @@ async function trials(ws, task, model, n, timeoutMs, label) { } log( `${label} trial ${i + 1}/${n}: ${metric}=${(score * 100).toFixed(0)}% ` + - `(P=${(s.precision * 100).toFixed(0)}% R=${(s.recall * 100).toFixed(0)}% tp=${s.tp}/${s.total} answered=${s.answered}) tokens=${tokens}` + `(P=${(s.precision * 100).toFixed(0)}% R=${(s.recall * 100).toFixed(0)}% ` + + `tp=${s.tp}/${s.total} answered=${s.answered}) tokens=${tokens}`, ); } return { @@ -230,55 +552,172 @@ async function trials(ws, task, model, n, timeoutMs, label) { async function main() { const args = parseArgs(process.argv.slice(2)); if (!args.task) { - console.error('Usage: node eval/bench-realrepo.mjs --task [--model M] [--trials N]'); + console.error( + "Usage: node eval/bench-realrepo.mjs --task [--model M] [--trials N] [--arms 2|3]", + ); process.exit(1); } - const task = JSON.parse(fs.readFileSync(path.resolve(args.task), 'utf8')); - if (!fs.existsSync(task.repo)) { - console.error(`Repo not found: ${task.repo}`); + const task = JSON.parse(fs.readFileSync(path.resolve(args.task), "utf8")); + // The task JSON's `repo` is only a documented default/example. Allow override + // via --repo or GITNEXUS_BENCH_REPO so the suite is runnable anywhere. + if (args.repo) task.repo = path.resolve(args.repo); + if (!task.repo || !fs.existsSync(task.repo)) { + console.error( + `Repo not found: ${task.repo || "(unset)"}\n` + + `Set it with --repo or GITNEXUS_BENCH_REPO, or edit the task JSON's "repo" field.`, + ); process.exit(1); } - const onDir = path.resolve(task.repo); - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-bench-off-')); - const offDir = path.join(tmp, 'off'); + ensureDocker(); + + // Temp directory for all copies — on host, mounted into Docker at same path. + const benchTmpBase = path.join(os.homedir(), ".cache", "gn-bench"); + fs.mkdirSync(benchTmpBase, { recursive: true }); + // Sweep crashed-run leftovers before we start (both guarded against no-docker). + pruneStaleContainers(); + const tmp = fs.mkdtempSync(path.join(benchTmpBase, "gn-bench-")); + pruneStaleTempDirs(benchTmpBase, tmp); + const offDir = path.join(tmp, "off"); + const mcpDir = path.join(tmp, "mcp"); + const kitDir = path.join(tmp, "kit"); + const threeArm = args.arms === 3; + const containers = []; try { - log('preparing OFF baseline (source-only copy, no kit/graph) …'); + // ── OFF arm (no Docker needed — no graph) ── + log("preparing OFF baseline (source-only copy, no kit/graph) …"); prepOff(task.repo, offDir); - ensureIndexed(onDir); + disableGitPush(offDir); + + // ── MCP arm (needs Docker for gitnexus) ── + let mcpContainer = null; + if (threeArm) { + log("preparing MCP-only copy (graph in Docker, no kit hooks) …"); + mcpContainer = `gn-bench-mcp-${Date.now()}`; + prepMcp(task.repo, mcpDir, mcpContainer); + disableGitPush(mcpDir); + startContainer(mcpContainer, mcpDir); + containers.push(mcpContainer); + buildGraphInDocker(mcpContainer, mcpDir); + } + + // ── KIT arm (needs Docker for gitnexus) ── + log("preparing KIT copy (graph in Docker + kit enforcement) …"); + const kitContainer = `gn-bench-kit-${Date.now()}`; + prepKit(task.repo, kitDir, kitContainer); + disableGitPush(kitDir); + startContainer(kitContainer, kitDir); + containers.push(kitContainer); + buildGraphInDocker(kitContainer, kitDir); + // ── Run trials ── log(`running OFF (${args.trials}×) …`); - const off = await trials(offDir, task, args.model, args.trials, args.timeoutMs, 'OFF'); - log(`running ON (${args.trials}×) …`); - const on = await trials(onDir, task, args.model, args.trials, args.timeoutMs, 'ON'); + const off = await trials( + offDir, + task, + args.model, + args.trials, + args.timeoutMs, + "OFF", + ); + + let mcp = null; + if (threeArm) { + log(`running MCP (${args.trials}×) …`); + mcp = await trials( + mcpDir, + task, + args.model, + args.trials, + args.timeoutMs, + "MCP", + ); + } + log(`running KIT (${args.trials}×) …`); + const on = await trials( + kitDir, + task, + args.model, + args.trials, + args.timeoutMs, + "KIT", + ); + + // ── Report ── + const metric = on.metric; const md = []; - md.push('# GitNexus kit — real-repo benchmark'); - md.push(''); + md.push("# GitNexus kit — real-repo benchmark"); + md.push(""); md.push(`Task: ${task.title}`); - md.push(`Repo: \`${path.basename(task.repo)}\` · Model: ${args.model || '(default)'} · Trials: ${args.trials} · ${new Date().toISOString()}`); - md.push(''); - const metric = on.metric; - md.push(`| Condition | Pass (${metric} ≥ ${Math.round(task.threshold * 100)}%) | Avg precision | Avg recall | Avg ${metric} | Avg tokens |`); - md.push('| --- | --- | --- | --- | --- | --- |'); md.push( - `| Kit OFF (grep) | ${off.passes}/${off.n} | ${(off.avgPrecision * 100).toFixed(0)}% | ${(off.avgRecall * 100).toFixed(0)}% | ${(off.avgF1 * 100).toFixed(0)}% | ${off.avgTokens || '—'} |` + `Repo: \`${path.basename(task.repo)}\` · Model: ${args.model || "(default)"} · ` + + `Trials: ${args.trials} · Arms: ${threeArm ? "3 (OFF/MCP/KIT)" : "2 (OFF/KIT)"} · ` + + `${new Date().toISOString()}`, ); + md.push(""); md.push( - `| Kit ON (graph) | ${on.passes}/${on.n} | ${(on.avgPrecision * 100).toFixed(0)}% | ${(on.avgRecall * 100).toFixed(0)}% | ${(on.avgF1 * 100).toFixed(0)}% | ${on.avgTokens || '—'} |` + `| Condition | Pass (${metric} ≥ ${Math.round(task.threshold * 100)}%) | Avg precision | Avg recall | Avg ${metric} | Avg tokens |`, ); - md.push(''); - md.push(`Ground truth: ${task.groundTruth.length} files (graph-derived closure, depth ≤ 2).`); - md.push(''); - const outPath = path.join(HERE, 'BENCHMARK-realrepo.md'); - fs.writeFileSync(outPath, md.join('\n') + '\n'); - - console.log('\n' + md.join('\n')); + md.push("| --- | --- | --- | --- | --- | --- |"); + md.push( + `| OFF (grep only) | ${off.passes}/${off.n} | ${(off.avgPrecision * 100).toFixed(0)}% | ` + + `${(off.avgRecall * 100).toFixed(0)}% | ${(off.avgF1 * 100).toFixed(0)}% | ${off.avgTokens || "—"} |`, + ); + if (mcp) { + md.push( + `| MCP (graph, no hooks) | ${mcp.passes}/${mcp.n} | ${(mcp.avgPrecision * 100).toFixed(0)}% | ` + + `${(mcp.avgRecall * 100).toFixed(0)}% | ${(mcp.avgF1 * 100).toFixed(0)}% | ${mcp.avgTokens || "—"} |`, + ); + } + md.push( + `| KIT (graph + hooks) | ${on.passes}/${on.n} | ${(on.avgPrecision * 100).toFixed(0)}% | ` + + `${(on.avgRecall * 100).toFixed(0)}% | ${(on.avgF1 * 100).toFixed(0)}% | ${on.avgTokens || "—"} |`, + ); + md.push(""); + md.push(`Ground truth: ${task.groundTruth.length} items (graph-derived).`); + if (task.grepInvisible?.length) { + md.push( + `Grep-invisible items: ${task.grepInvisible.length} (${((1 - task.grepInvisible.length / task.groundTruth.length) * 100).toFixed(0)}% grep ceiling on recall).`, + ); + } + md.push(""); + const outPath = path.join(HERE, "BENCHMARK-realrepo.md"); + fs.writeFileSync(outPath, md.join("\n") + "\n"); + console.log("\n" + md.join("\n")); console.log(`\nReport: ${path.relative(process.cwd(), outPath)}`); } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - fs.rmSync(path.join(onDir, task.answerFile), { force: true }); + // Docker creates files as root inside the container, so the host user can't + // delete them afterward. Chown the mounted dirs back to the ACTUAL host + // uid/gid (not a hard-coded 1000:1000 — that fails on hosts where the user + // isn't uid 1000 and leaves root-owned GB-scale copies behind). + const uid = typeof process.getuid === "function" ? process.getuid() : null; + const gid = typeof process.getgid === "function" ? process.getgid() : null; + if (uid !== null && gid !== null) { + const owner = `${uid}:${gid}`; + for (const c of containers) { + // Each container only mounts its own dir; chown that one back to host. + for (const dir of [mcpDir, kitDir]) { + if (fs.existsSync(dir)) { + dockerExec(c, ["chown", "-R", owner, dir], { + stdio: "ignore", + timeout: 30000, + }); + } + } + } + } + // Stop and remove all Docker containers + for (const c of containers) removeContainer(c); + // Delete all temp copies (now chowned to host user) + try { + fs.rmSync(tmp, { recursive: true, force: true }); + } catch (e) { + log(`cleanup warning — could not delete temp dir: ${e.message}`); + log(`run: sudo rm -rf ${tmp}`); + } + log("cleanup complete — containers removed, copies deleted"); } } diff --git a/eval/docker/Dockerfile b/eval/docker/Dockerfile new file mode 100644 index 0000000..963f6a0 --- /dev/null +++ b/eval/docker/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-slim + +# Install git (needed for gitnexus to track commits and for agent tools) +RUN apt-get update -qq && apt-get install -y -qq git rsync ca-certificates && rm -rf /var/lib/apt/lists/* + +# Install gitnexus globally +RUN npm install -g gitnexus@latest + +# Create a working directory +WORKDIR /workspace + +# Default command — will be overridden by docker exec +CMD ["sleep", "infinity"] diff --git a/eval/realrepo-tasks/call-chain-impact.json b/eval/realrepo-tasks/call-chain-impact.json new file mode 100644 index 0000000..af7fca1 --- /dev/null +++ b/eval/realrepo-tasks/call-chain-impact.json @@ -0,0 +1,20 @@ +{ + "id": "call-chain-impact", + "title": "Trace the full call chain from calculateCurrentExposure to the backtest entry point", + "repo": "/home/duduphudu/Projects/crypto-trading-bot", + "_repoNote": "EXAMPLE/DEFAULT ONLY — override at runtime with --repo or GITNEXUS_BENCH_REPO. The suite does not require this exact path.", + "answerFile": "call-chain.txt", + "scoreBy": "name", + "prompt": "Trace the complete call chain from `calculateCurrentExposure` (in `src/future/core/risk/exposureLimits.js`) to the top-level backtest entry point. List every function name in the chain, in order from callee to caller: calculateCurrentExposure → ... → top-level entry point. Write one function name per line to `call-chain.txt` in the repo root.", + "threshold": 0.85, + "scoreMetric": "recall", + "groundTruth": [ + "calculateCurrentExposure", + "sizeAndCheckExposure", + "evaluateRisk", + "runBacktest", + "runBacktestFromSource" + ], + "grepInvisible": ["runBacktestFromSource"], + "hypothesis": "Call chains with transitive depth > 2 are hard to trace with grep — you need to read each intermediate function to find the next caller. `gitnexus_impact(upstream, depth 3)` or `gitnexus_context` on each symbol gives the full chain in one query. Grep finds direct callers but misses the chain's end (runBacktestFromSource at depth 3) because that function never mentions 'calculateCurrentExposure'." +} diff --git a/eval/realrepo-tasks/detect-changes-before-commit.json b/eval/realrepo-tasks/detect-changes-before-commit.json new file mode 100644 index 0000000..90165fd --- /dev/null +++ b/eval/realrepo-tasks/detect-changes-before-commit.json @@ -0,0 +1,14 @@ +{ + "id": "detect-changes-before-commit", + "title": "Change calculateCurrentExposure and report what processes are affected (kit enforcement test)", + "repo": "/home/duduphudu/Projects/crypto-trading-bot", + "_repoNote": "EXAMPLE/DEFAULT ONLY — override at runtime with --repo or GITNEXUS_BENCH_REPO. The suite does not require this exact path.", + "answerFile": "changes-affected.txt", + "scoreBy": "name", + "prompt": "In `src/future/core/risk/exposureLimits.js`, change the return value of `calculateCurrentExposure` so that it adds a new property `timestamp: Date.now()` to the returned object (just append `, timestamp: Date.now()` before the closing brace of the return statement). Before committing, run `gitnexus_detect_changes` and list all the process names that your change affects. Write each process name on its own line to `changes-affected.txt` in the repo root.", + "threshold": 0.8, + "scoreMetric": "recall", + "groundTruth": ["runBacktest", "RunBacktest"], + "grepInvisible": [], + "hypothesis": "Kit enforcement test: the commit-guard hook should block `git commit` until `detect_changes` has been called. Without the kit, agents often commit without checking what their change affects. With the kit, the hook enforces the pre-commit ritual and `detect_changes` returns the exact blast radius (runBacktest process). calculateCurrentExposure is a good target because it's in the core risk engine with a clear 3-hop call chain: calculateCurrentExposure → sizeAndCheckExposure → evaluateRisk → runBacktest." +} diff --git a/eval/realrepo-tasks/enter-control-useauth.json b/eval/realrepo-tasks/enter-control-useauth.json index 28854e5..e24c7fe 100644 --- a/eval/realrepo-tasks/enter-control-useauth.json +++ b/eval/realrepo-tasks/enter-control-useauth.json @@ -1,41 +1,107 @@ { - "id": "enter-control-useauth-impact", - "title": "Pinpoint the exact web-useAuth dependency closure in a mirrored monorepo", - "repo": "/Users/vk/Projects/enter-control", - "answerFile": "web-useauth-files.txt", - "scoreBy": "path", - "prompt": "This monorepo has two near-identical client packages: `clients/web` and `clients/desktop`. EACH has its OWN separate `useAuth` hook, and many component files share the SAME name across both packages. Your job: list the repo-relative file path of every module in `clients/web` that depends on the WEB `useAuth` hook (the one exported from `clients/web/src/auth/auth-provider.tsx`). Include BOTH files that call it directly AND files that depend on it transitively up to 2 levels (a component that renders another component which uses useAuth is also affected, even if it never mentions `useAuth` itself). STRICT RULES: (1) ONLY `clients/web/...` paths — exclude every `clients/desktop/...` file, it has its own unrelated useAuth. (2) Exclude the hook's own definition/re-export files (auth-provider.tsx, auth/index.ts). Write one repo-relative path per line (e.g. `clients/web/src/components/account-panel.tsx`) to `web-useauth-files.txt` in the repo root.", - "threshold": 0.95, - "scoreMetric": "f1", + "id": "downstream-callees-impact", + "title": "Find all functions called by runBacktest (downstream impact analysis)", + "repo": "/home/duduphudu/Projects/crypto-trading-bot", + "_repoNote": "EXAMPLE/DEFAULT ONLY — override at runtime with --repo or GITNEXUS_BENCH_REPO. The suite does not require this exact path.", + "answerFile": "downstream-callees.txt", + "scoreBy": "name", + "prompt": "Find every function that `runBacktest` (in `src/future/core/backtest/replayEngine.js`) calls, either directly or through one level of indirection (functions that runBacktest calls, which themselves call other functions). Use `gitnexus_impact` with direction downstream and depth 2. Write one function name per line to `downstream-callees.txt` in the repo root. Do NOT include `runBacktest` itself.", + "threshold": 0.85, + "scoreMetric": "recall", "groundTruth": [ - "clients/web/src/App.tsx", - "clients/web/src/components/account-panel.tsx", - "clients/web/src/components/admin-panel.tsx", - "clients/web/src/components/billing-panel.tsx", - "clients/web/src/components/chat-gate.tsx", - "clients/web/src/components/dev-menu.tsx", - "clients/web/src/components/embedded-pricing-table.tsx", - "clients/web/src/components/login-register-panel.tsx", - "clients/web/src/components/manage-team-panel.tsx", - "clients/web/src/hooks/use-baa-signing.ts", - "clients/web/src/hooks/use-widget-token.ts", - "clients/web/src/components/onboarding-flow.tsx", - "clients/web/src/components/organization-settings.tsx", - "clients/web/src/components/payment-prompt.tsx", - "clients/web/src/components/personalization-panel.tsx", - "clients/web/src/components/refresh-user-button.tsx", - "clients/web/src/components/subscription-details-card.tsx", - "clients/web/src/components/toolbar.tsx", - "clients/web/src/services/chat-availability-service.tsx", - "clients/web/src/views/settings-view.tsx", - "clients/web/src/components/agreement-prompt.tsx", - "clients/web/src/pages/chat.tsx", - "clients/web/src/views/chat-view/chat-view.tsx" + "resolveIntrabarConflictMode", + "resolveSlippageRate", + "isBracketValidationError", + "planLiquidationExit", + "planIntrabarExit", + "planExecution", + "executePlan", + "resolveSimulationMode", + "calculateUnrealizedPnl", + "isFuturesPosition", + "resolveFundingRatePerInterval", + "resolveFundingIntervalMs", + "calculateFundingPayment", + "buyAndHoldReturnPct", + "buildBacktestConfigSnapshot", + "buildReport", + "createPortfolioState", + "markEquity", + "snapshotPortfolio", + "resolvePendingDecisionSymbol", + "shouldQueuePendingDecision", + "barObserved", + "logStrategyDecision", + "logExecFill", + "normalizeCandle", + "evaluateRisk", + "assertValidDecision", + "applySlippage", + "computeFee", + "validateBracketForOpen", + "detectIntrabarExit", + "assertMarketOrder", + "resolveQuantity", + "assertSpotCashAffordable", + "assertSpotLongOnly", + "resolveLeverage", + "resolveMaintenanceMarginRate", + "validateLeverageMaintenance", + "calculateInitialMargin", + "calculateApproxLiquidationPrice", + "isLiquidated", + "maxDrawdown", + "winRate", + "profitFactor", + "expectancy", + "totalFees", + "tradePnlStdDev", + "sharpeLikeRatio", + "returnPerRisk", + "resolveMarkPrice", + "applyOpenFill", + "applyIncreaseFill", + "applyCloseFill", + "toNumber", + "toOptionalNumber", + "exceedsMaxOpenPositions", + "riskResult", + "sizeAndCheckExposure", + "assertValidOrder" ], "grepInvisible": [ - "clients/web/src/components/agreement-prompt.tsx", - "clients/web/src/pages/chat.tsx", - "clients/web/src/views/chat-view/chat-view.tsx" + "applySlippage", + "computeFee", + "validateBracketForOpen", + "detectIntrabarExit", + "assertMarketOrder", + "resolveQuantity", + "assertSpotCashAffordable", + "assertSpotLongOnly", + "resolveLeverage", + "resolveMaintenanceMarginRate", + "validateLeverageMaintenance", + "calculateInitialMargin", + "calculateApproxLiquidationPrice", + "isLiquidated", + "maxDrawdown", + "winRate", + "profitFactor", + "expectancy", + "totalFees", + "tradePnlStdDev", + "sharpeLikeRatio", + "returnPerRisk", + "resolveMarkPrice", + "applyOpenFill", + "applyIncreaseFill", + "applyCloseFill", + "toNumber", + "toOptionalNumber", + "exceedsMaxOpenPositions", + "riskResult", + "sizeAndCheckExposure", + "assertValidOrder" ], - "hypothesis": "Graph-only task. 3 of 23 truth files (agreement-prompt.tsx, pages/chat.tsx, chat-view.tsx) are transitive dependents that NEVER contain the text 'useAuth', so grep cannot find them — a flawless grep run ceilings at 20/23 = 0.87 recall, F1 <= 0.93. Meanwhile `grep useAuth` floods with clients/desktop mirror files + web non-callers, dragging precision down further. gitnexus_impact(upstream, depth 2) returns the exact 23-file closure → F1 = 1.0. Pass bar F1 >= 0.95 is reachable only with the graph." + "hypothesis": "Downstream impact is fundamentally different from grep — grep can find direct callers of runBacktest (depth 1) but misses the 32 functions at depth 2 that are called by runBacktest's callees. `gitnexus_impact(downstream, depth 2)` returns all 59 callees in one query. Grep finds the 27 direct callees by searching for their names in runBacktest's file, but the 32 transitive callees (depth 2) are invisible — they're called by intermediate functions, not by runBacktest directly. This gives grep a recall ceiling of ~46%." } diff --git a/eval/realrepo-tasks/field-write-access.json b/eval/realrepo-tasks/field-write-access.json new file mode 100644 index 0000000..86983e6 --- /dev/null +++ b/eval/realrepo-tasks/field-write-access.json @@ -0,0 +1,14 @@ +{ + "id": "field-write-access", + "title": "Find all functions that write to the notional/liquidationPrice variables in planExecution", + "repo": "/home/duduphudu/Projects/crypto-trading-bot", + "_repoNote": "EXAMPLE/DEFAULT ONLY — override at runtime with --repo or GITNEXUS_BENCH_REPO. The suite does not require this exact path.", + "answerFile": "field-writers.txt", + "scoreBy": "path", + "prompt": "Find every function that contains a WRITE access to the `notional` or `liquidationPrice` variables inside the `planExecution` function (in `src/future/core/backtest/executionSimulator.js`). Only include functions that assign to or mutate these variables — NOT functions that only read them. Use `gitnexus_cypher` with ACCESSES edges filtered by `reason` containing 'write' to get the exact writers. Write one repo-relative path per line to `field-writers.txt` in the repo root.", + "threshold": 0.9, + "scoreMetric": "f1", + "groundTruth": ["src/future/core/backtest/executionSimulator.js"], + "grepInvisible": [], + "hypothesis": "Field writes are hard to distinguish from reads with grep — `notional = ...` vs `const x = notional` both contain the variable name. `gitnexus_cypher` with ACCESSES edges and `reason` containing 'write' returns exactly the writer function(s). Grep over-includes (finds both reads and writes in the same file), making precision suffer. The ground truth is small (only planExecution writes these consts) but grep would return many false positives from the same file's reads, or from other files that merely reference the names." +} diff --git a/eval/realrepo-tasks/pdg-control-dependence.json b/eval/realrepo-tasks/pdg-control-dependence.json new file mode 100644 index 0000000..4fbc1fe --- /dev/null +++ b/eval/realrepo-tasks/pdg-control-dependence.json @@ -0,0 +1,19 @@ +{ + "id": "pdg-control-dependence", + "title": "Find all code paths guarded by the exposure limit conditions in the risk engine (PDG-powered)", + "repo": "/home/duduphudu/Projects/crypto-trading-bot", + "_repoNote": "EXAMPLE/DEFAULT ONLY — override at runtime with --repo or GITNEXUS_BENCH_REPO. The suite does not require this exact path.", + "answerFile": "control-dependence.txt", + "scoreBy": "path", + "prompt": "Find every function that is control-dependent on the condition checks in `src/future/core/risk/exposureLimits.js`. The file has three guard functions: `exceedsSymbolExposure`, `exceedsTotalExposure`, and `exceedsMaxOpenPositions` — each returns false (guard) or true. Use `gitnexus_pdg_query(mode='controls')` to find what statements these conditions gate. Write one repo-relative path per line to `control-dependence.txt` in the repo root.", + "threshold": 0.85, + "scoreMetric": "f1", + "groundTruth": [ + "src/future/core/risk/exposureLimits.js", + "src/future/core/risk/riskEngine.js", + "src/future/core/backtest/replayEngine.js", + "src/future/core/backtest/backtestRunner.js" + ], + "grepInvisible": ["src/future/core/backtest/backtestRunner.js"], + "hypothesis": "Control dependence is invisible to grep — you can't tell which if-branch a function call sits under without understanding the program's control flow. `gitnexus_pdg_query(mode='controls')` on exposureLimits.js shows that exceedsSymbolExposure/exceedsTotalExposure/exceedsMaxOpenPositions guard code in riskEngine.js, and their callers in replayEngine.js and backtestRunner.js are transitively affected. Grep finds the function names but cannot determine what they control. The transitive caller backtestRunner.js (depth 3) is invisible to grep." +} diff --git a/eval/realrepo-tasks/safe-rename-kit-enforced.json b/eval/realrepo-tasks/safe-rename-kit-enforced.json new file mode 100644 index 0000000..cf0d9d0 --- /dev/null +++ b/eval/realrepo-tasks/safe-rename-kit-enforced.json @@ -0,0 +1,45 @@ +{ + "id": "safe-rename-kit-enforced", + "title": "Rename formatResearchApiError to formatApiError across the repo (kit enforcement test)", + "repo": "/home/duduphudu/Projects/crypto-trading-bot", + "_repoNote": "EXAMPLE/DEFAULT ONLY — override at runtime with --repo or GITNEXUS_BENCH_REPO. The suite does not require this exact path.", + "answerFile": "rename-result.txt", + "scoreBy": "path", + "prompt": "Rename the function `formatResearchApiError` (defined in `apps/research-dashboard/src/api/researchApi.ts`) to `formatApiError` across the entire repository. You MUST actually perform the rename in every file — edit each source file to replace `formatResearchApiError` with `formatApiError`. After completing all edits, write the list of all files you changed (repo-relative paths, one per line) to `rename-result.txt` in the repo root. Do NOT use find-and-replace — use the `gitnexus_rename` MCP tool to perform a coordinated rename that catches all transitive references. If you use find-and-replace or manual edits on each file, you will miss indirect references.", + "threshold": 0.95, + "scoreMetric": "f1", + "groundTruth": [ + "apps/research-dashboard/src/api/researchApi.ts", + "apps/research-dashboard/src/components/CandleCachePanel.tsx", + "apps/research-dashboard/src/components/ReportExportPanel.tsx", + "apps/research-dashboard/src/components/RunRobustnessSection.tsx", + "apps/research-dashboard/src/components/auth/AuthGate.tsx", + "apps/research-dashboard/src/components/charts/ExperimentChartsSection.tsx", + "apps/research-dashboard/src/components/charts/SymbolMarketChartSection.tsx", + "apps/research-dashboard/src/components/home/RecentSpikeRunsPanel.tsx", + "apps/research-dashboard/src/components/home/TradingOverviewPanel.tsx", + "apps/research-dashboard/src/components/live/AutoTradePositionsTable.tsx", + "apps/research-dashboard/src/components/live/AutoTradeRulesPanel.tsx", + "apps/research-dashboard/src/components/live/AutoTradeSettingsPanel.tsx", + "apps/research-dashboard/src/components/live/LiveDecisionViewer.tsx", + "apps/research-dashboard/src/components/live/LiveLogPanel.tsx", + "apps/research-dashboard/src/components/spike/SpikeAlertsPanel.tsx", + "apps/research-dashboard/src/pages/ExperimentRunDetailPage.tsx", + "apps/research-dashboard/src/pages/LiveModePage.tsx", + "apps/research-dashboard/src/pages/MatrixGeneratorPage.tsx", + "apps/research-dashboard/src/pages/NewRunPage.tsx", + "apps/research-dashboard/src/pages/PlansPage.tsx", + "apps/research-dashboard/src/pages/PresetsPage.tsx", + "apps/research-dashboard/src/pages/RobustnessExperimentPage.tsx", + "apps/research-dashboard/src/pages/RunDetailPage.tsx", + "apps/research-dashboard/src/pages/RunsPage.tsx", + "apps/research-dashboard/src/pages/SymbolReportPage.tsx", + "apps/research-dashboard/src/pages/WindowDetailPage.tsx", + "apps/research-dashboard/src/pages/DashboardPage.tsx", + "apps/research-dashboard/src/app/router.tsx", + "apps/research-dashboard/src/components/charts/ExperimentMarketChartsPanel.tsx", + "apps/research-dashboard/src/main.tsx" + ], + "grepInvisible": [], + "hypothesis": "Kit enforcement test: the edit-guard hook should force the agent to run `impact` before editing, and the prompt-router should route symbol renames to `gitnexus_rename` instead of StrReplace. Without the kit, agents typically do find-and-replace which misses 4 indirect references (DashboardPage.tsx, router.tsx, ExperimentMarketChartsPanel.tsx, main.tsx — transitive callers at depth 2-3 that never mention 'formatResearchApiError' directly). With the kit, `gitnexus_rename` does coordinated multi-file rename via the graph, catching all 30 files." +} diff --git a/eval/realrepo-tasks/transitive-callers-impact.json b/eval/realrepo-tasks/transitive-callers-impact.json new file mode 100644 index 0000000..561796d --- /dev/null +++ b/eval/realrepo-tasks/transitive-callers-impact.json @@ -0,0 +1,49 @@ +{ + "id": "transitive-callers-impact", + "title": "Find all transitive callers of formatResearchApiError across the dashboard app", + "repo": "/home/duduphudu/Projects/crypto-trading-bot", + "_repoNote": "EXAMPLE/DEFAULT ONLY — override at runtime with --repo or GITNEXUS_BENCH_REPO. The suite does not require this exact path.", + "answerFile": "transitive-callers.txt", + "scoreBy": "path", + "prompt": "Find every file that transitively depends on the function `formatResearchApiError` (defined in `apps/research-dashboard/src/api/researchApi.ts`). Include both direct callers and files that call functions which themselves call `formatResearchApiError` (up to 3 levels of transitivity). Write one repo-relative path per line to `transitive-callers.txt` in the repo root. Do NOT include the definition file itself.", + "threshold": 0.9, + "scoreMetric": "f1", + "groundTruth": [ + "apps/research-dashboard/src/components/CandleCachePanel.tsx", + "apps/research-dashboard/src/components/ReportExportPanel.tsx", + "apps/research-dashboard/src/components/RunRobustnessSection.tsx", + "apps/research-dashboard/src/components/auth/AuthGate.tsx", + "apps/research-dashboard/src/components/charts/ExperimentChartsSection.tsx", + "apps/research-dashboard/src/components/charts/SymbolMarketChartSection.tsx", + "apps/research-dashboard/src/components/home/RecentSpikeRunsPanel.tsx", + "apps/research-dashboard/src/components/home/TradingOverviewPanel.tsx", + "apps/research-dashboard/src/components/live/AutoTradePositionsTable.tsx", + "apps/research-dashboard/src/components/live/AutoTradeRulesPanel.tsx", + "apps/research-dashboard/src/components/live/AutoTradeSettingsPanel.tsx", + "apps/research-dashboard/src/components/live/LiveDecisionViewer.tsx", + "apps/research-dashboard/src/components/live/LiveLogPanel.tsx", + "apps/research-dashboard/src/components/spike/SpikeAlertsPanel.tsx", + "apps/research-dashboard/src/pages/ExperimentRunDetailPage.tsx", + "apps/research-dashboard/src/pages/LiveModePage.tsx", + "apps/research-dashboard/src/pages/MatrixGeneratorPage.tsx", + "apps/research-dashboard/src/pages/NewRunPage.tsx", + "apps/research-dashboard/src/pages/PlansPage.tsx", + "apps/research-dashboard/src/pages/PresetsPage.tsx", + "apps/research-dashboard/src/pages/RobustnessExperimentPage.tsx", + "apps/research-dashboard/src/pages/RunDetailPage.tsx", + "apps/research-dashboard/src/pages/RunsPage.tsx", + "apps/research-dashboard/src/pages/SymbolReportPage.tsx", + "apps/research-dashboard/src/pages/WindowDetailPage.tsx", + "apps/research-dashboard/src/pages/DashboardPage.tsx", + "apps/research-dashboard/src/app/router.tsx", + "apps/research-dashboard/src/components/charts/ExperimentMarketChartsPanel.tsx", + "apps/research-dashboard/src/main.tsx" + ], + "grepInvisible": [ + "apps/research-dashboard/src/pages/DashboardPage.tsx", + "apps/research-dashboard/src/components/charts/ExperimentMarketChartsPanel.tsx", + "apps/research-dashboard/src/components/auth/AuthGate.tsx", + "apps/research-dashboard/src/main.tsx" + ], + "hypothesis": "Transitive callers are invisible to grep — files at depth 2+ call formatResearchApiError's direct callers but never mention 'formatResearchApiError' directly. `gitnexus_impact(upstream, depth 3)` returns the exact 29-file closure. Grep finds the 25 direct callers but misses 4 transitive dependents (router.tsx, AuthGate.tsx, ExperimentMarketChartsPanel.tsx, DashboardPage.tsx) plus main.tsx at depth 3, capping recall at ~86%. The graph achieves F1 ≥ 0.9; grep alone cannot." +} diff --git a/eval/swebench/README.md b/eval/swebench/README.md new file mode 100644 index 0000000..c81da94 --- /dev/null +++ b/eval/swebench/README.md @@ -0,0 +1,190 @@ +# SWE-bench Verified benchmark with GitNexus + +> **⚠️ Important caveat**: This harness measures **GitNexus MCP** (the graph engine), +> not **gitnexus-agent-kit** (the enforcement layer). See [What this tests](#what-this-tests-vs-what-proves-your-kit) below. + +Industry-standard benchmark comparing agents with and without GitNexus MCP access. +Based on d3thshot7777's approach (mini-swe-agent + GitNexus MCP). + +## What this tests vs what proves your kit + +| Comparison | What it measures | What it proves | +|------------|-------------------|----------------| +| Agent + MCP vs Agent alone | GitNexus graph helps agents explore code | GitNexus MCP adds value (d3thshot proved: 30% fewer tokens) | +| Agent + MCP + **Kit** vs Agent + MCP alone | Hooks, skills, enforcement add value **on top of** MCP | **This is your kit's unique contribution** — NOT tested here | + +This harness proves the first row (MCP value). To prove the second row (kit value on top of MCP), +you need your `bench-realrepo.mjs` with `cursor-agent`, which can actually exercise hooks and skills. + +**Recommendation**: Use this SWE-bench harness for the MCP comparison (matching d3thshot's numbers), +and use `bench-realrepo.mjs` with the task specs in `realrepo-tasks/` for the kit-specific value. + +## What it measures + +| Metric | Meaning | +|--------|---------| +| **Solve rate** | % of instances where the generated patch passes the test suite | +| **Total tokens** | Cumulative input+output tokens across all instances | +| **API calls** | Number of model completions | +| **Avg tokens/instance** | Efficiency per task | + +## Architecture + +``` +swebench/ +├── README.md ← you are here +├── run-benchmark.sh Main entry point: run both arms, produce report +├── install-gitnexus-mcp.sh Per-instance GitNexus setup inside Docker (with PDG) +├── gitnexus_swe_tools.py Tool schemas + CLI client + agent integration +├── configs/ +│ ├── baseline.yaml mini-swe-agent config (bash only) +│ └── gitnexus.yaml mini-swe-agent config (bash + 8 GitNexus tools + PDG) +├── scoring/ +│ └── score-pairs.py Pair instances, compute deltas, produce report +└── results/ Created at runtime; trajectories + patches + scores +``` + +## Quick start + +```bash +# Install dependencies +pip install mini-swe-agent swebench + +# Run 50 instances with DeepSeek V4 (or any litellm model) +./eval/swebench/run-benchmark.sh \ + --model deepseek/deepseek-chat-v3-0324 \ + --instances 50 + +# Full SWE-bench Verified (500 instances) +./eval/swebench/run-benchmark.sh \ + --model deepseek/deepseek-chat-v3-0324 +``` + +## How it works + +### 1. Baseline arm (no GitNexus) + +Standard mini-swe-agent with bash-only tool. Each instance: +- Spins up a Docker container with the repo at the right commit +- Agent explores via grep/find/cat + edits + test +- Produces a patch + +### 2. GitNexus arm (with MCP + PDG) + +Same Docker setup, **plus**: +- `gitnexus analyze --embeddings --pdg` runs inside the container after setup + - **Embeddings layer**: BM25 + vector search for `query` + - **PDG layer**: Control dependence (CDG), data dependence (REACHING_DEF), taint flows +- mini-swe-agent gets 8 additional GitNexus MCP tools alongside bash: + - `gitnexus_query` — hybrid BM25 + embedding search + - `gitnexus_context` — symbol-level callers/callees/references + - `gitnexus_impact` — blast radius (with `mode=pdg` for statement-level) + - `gitnexus_cypher` — raw graph queries (ACCESSES, CALLS, METHOD_OVERRIDES) + - `gitnexus_pdg_query` — control/data dependence at statement level + - `gitnexus_explain` — taint analysis (command-injection, path-traversal, etc.) + - `gitnexus_detect_changes` — affected processes from uncommitted changes +- The PDG layer gives the agent precision that the base graph alone cannot: + - **What condition gates this line?** → `pdg_query mode=controls` + - **Where does this variable's value flow?** → `pdg_query mode=flows` + - **Is there a taint path from user input to this sink?** → `explain` + - **Statement-level impact of this change** → `impact mode=pdg` + +### 3. Scoring + +SWE-bench's standard Docker-based test evaluation checks if each patch passes the gold test suite. The scoring script pairs instances and computes: + +- Solve rate delta (GitNexus − baseline) +- Token savings percentage +- API call savings percentage +- Per-instance diff (regressed / improved / tied) + +## Proving your kit's value (the real goal) + +This SWE-bench harness proves MCP value. To prove **kit enforcement** value, use `bench-realrepo.mjs`: + +```bash +# 3-arm comparison on your real repo: +# OFF = bare agent (no MCP, no hooks) +# MCP = agent + gitnexus MCP (no hooks) +# KIT = agent + gitnexus MCP + hooks + rules + skills + PDG + +node eval/bench-realrepo.mjs \ + --task eval/realrepo-tasks/enter-control-useauth-impact.json \ + --model composer-2.5-fast --trials 3 +``` + +The task specs in `realrepo-tasks/` are designed to show where each layer adds value: + +| Task | What it tests | grep ceiling | graph ceiling | kit ceiling | +|------|--------------|-------------|---------------|-------------| +| `enter-control-useauth-impact` | Transitive callers | 0.87 (3 invisible) | 1.0 | Same as graph — this is MCP-only | +| `transitive-callers-impact` | Multi-level call chains | Misses transitive | 1.0 | Same — MCP-only | +| `field-write-access` | ACCESSES write edges | Can't distinguish read/write | 1.0 | Same — MCP-only | +| `safe-rename-kit-enforced` | Coordinated rename | Misses indirect refs | 1.0 via `rename` | **Kit forces `rename` MCP instead of StrReplace** | +| `detect-changes-before-commit` | Pre-commit blast radius | No enforcement | Agent *can* run it | **Kit blocks commit until `detect_changes` runs** | +| `pdg-control-dependence` | PDG control flow | Can't determine control | 1.0 via `pdg_query` | Same — PDG-only | + +The last two rows are where your **kit** (enforcement) adds value on top of MCP: + +1. **`safe-rename-kit-enforced`**: Without the kit, an agent can use StrReplace (find-and-replace) and miss indirect references. With the kit, the edit-guard hook blocks the edit until `impact` is run, and the prompt-router steers renames to the `rename` MCP tool. + +2. **`detect-changes-before-commit`**: Without the kit, an agent can commit without checking blast radius. With the kit, the commit-guard hook blocks `git commit` until `detect_changes` has been called. + +## Methodology notes + +- **Fair comparison**: Both arms use the same model, same prompt structure, same step/cost limits. The only difference is GitNexus MCP availability. +- **Instance filtering**: Exclude instances that fail due to infra/container issues (not agent-related) from both arms, matching d3thshot's fair-pair methodology. +- **Paired analysis**: Every instance appears in both arms, so we compare within-instance deltas rather than aggregate percentages alone. + +## PDG cache generation + +The Docker setup script (`install-gitnexus-mcp.sh`) runs two passes: + +1. `gitnexus analyze --embeddings` — builds the symbol graph + BM25/vector index +2. `gitnexus analyze --pdg` — builds the PDG layer on top (CDG + REACHING_DEF + taint) + +This gives the agent PDG-powered tools that d3thshot's original run didn't have: +- **Control dependence** — what predicate controls each statement +- **Data dependence** — where each variable's definition reaches +- **Taint flows** — source→sink paths for security-sensitive data + +## Agent wiring status + +`gitnexus_swe_tools.py` contains the full integration: + +- **`GitNexusClient`** — Routes `gitnexus_*` tool calls to the `gitnexus` CLI subprocess +- **`GitNexusLitellmModel`** — Wraps `LitellmModel` to inject all 8 GitNexus tools into the LLM's tool list +- **`GitNexusSweBenchAgent`** — Owns the agent loop: parses tool calls from LLM responses, routes `bash` calls to the Docker environment and `gitnexus_*` calls to `GitNexusClient`, feeds observations back +- **`create_gitnexus_agent()`** — Factory that creates the right agent (baseline or gitnexus) based on the `--gitnexus` flag + +The agent loop: +1. Send messages + tools to LLM +2. Parse tool calls from response (bash or gitnexus_*) +3. Execute: bash → Docker env, gitnexus_* → `GitNexusClient.call()` +4. Feed observation back as tool result messages +5. Repeat until no more tool calls or step/cost limit + +### Running + +```bash +# Install dependencies +pip install mini-swe-agent swebench litellm + +# Run a single instance with GitNexus (treatment arm) +python eval/swebench/gitnexus_swe_tools.py \ + --model deepseek/deepseek-chat-v3-0324 \ + --gitnexus \ + --instances 1 + +# Run baseline (no GitNexus) +python eval/swebench/gitnexus_swe_tools.py \ + --model deepseek/deepseek-chat-v3-0324 \ + --instances 1 +``` + +## Credit + +Benchmark approach inspired by d3thshot7777's initial SWE-bench Verified run with DeepSeek V4 + GitNexus MCP via mini-swe-agent. + +SWE-bench: [Jimenez et al., ICLR 2024](https://arxiv.org/abs/2310.06781) +mini-swe-agent: [Yang et al., NeurIPS 2024](https://arxiv.org/abs/2405.15793) \ No newline at end of file diff --git a/eval/swebench/configs/baseline.yaml b/eval/swebench/configs/baseline.yaml new file mode 100644 index 0000000..eafc4c3 --- /dev/null +++ b/eval/swebench/configs/baseline.yaml @@ -0,0 +1,127 @@ +# Baseline mini-swe-agent config for SWE-bench Verified +# Standard bash-only agent — NO GitNexus. +# This is the control arm. + +agent: + system_template: | + You are a helpful assistant that can interact with a computer shell to solve programming tasks. + instance_template: | + + Consider the following PR description: + {{task}} + + + + # Task Instructions + + ## Overview + + You're a software engineer interacting continuously with a computer by submitting commands. + You'll be helping implement necessary changes to meet requirements in the PR description. + Your task is specifically to make changes to non-test files in the current directory in order to fix the issue described in the PR description in a way that is general and consistent with the codebase. + + This is an interactive process where you will think and issue AT LEAST ONE command, see the result, then think and issue your next command(s). + + For each response: + + 1. Include a THOUGHT section explaining your reasoning and what you're trying to accomplish + 2. Provide one or more bash tool calls to execute + + ## Important Boundaries + + - MODIFY: Regular source code files in /testbed (this is the working directory for all your subsequent commands) + - DO NOT MODIFY: Tests, configuration files (pyproject.toml, setup.cfg, etc.) + + ## Recommended Workflow + + 1. Analyze the codebase by finding and reading relevant files + 2. Create a script to reproduce the issue + 3. Edit the source code to resolve the issue + 4. Verify your fix works by running your script again + 5. Test edge cases to ensure your fix is robust + + ## Command Execution Rules + + You are operating in an environment where: + + 1. You issue at least one command + 2. The system executes the command(s) in a subshell + 3. You see the result(s) + 4. You write your next command(s) + + Each response should include: + + 1. **Reasoning text** where you explain your analysis and plan + 2. At least one tool call with your command + + + {{system}} {{release}} {{version}} {{machine}} + + + step_limit: 250 + cost_limit: 3.0 + +environment: + cwd: "/testbed" + timeout: 60 + interpreter: ["bash", "-c"] + env: + PAGER: cat + MANPAGER: cat + LESS: -R + PIP_PROGRESS_BAR: "off" + TQDM_DISABLE: "1" + BASH_ENV: /root/.bashrc + environment_class: docker + +model: + observation_template: | + {% if output.exception_info -%} + {{output.exception_info}} + {% endif -%} + {{output.returncode}} + {% if output.output | length < 10000 -%} + + {{ output.output -}} + + {%- else -%} + + The output of your last command was too long. + Please try a different command that produces less output. + If you're looking at a file you can try use head, tail or sed to view a smaller number of lines selectively. + If you're using grep or find and it produced too much output, you can use a more selective search pattern. + If you really need to see something from the full command's output, you can redirect output to a file and then search in that file. + + {%- set elided_chars = output.output | length - 10000 -%} + + {{ output.output[:5000] }} + + + {{ elided_chars }} characters elided + + + {{ output.output[-5000:] }} + + {%- endif -%} + format_error_template: | + {% if finish_reason is defined and finish_reason in ["length", "tool_calls"] -%} + Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with exactly one bash tool call. If you need to think more, do so briefly. + {%- else -%} + Tool call error: + + + {{error}} + + + Every response needs to use the 'bash' tool at least once to execute commands. + + Call the bash tool with your command as the argument: + - Tool: bash + - Arguments: {"command": "your_command_here"} + + If you have completed your assignment, please submit your patch as described in the task instructions. + {%- endif %} + model_name: "anthropic/claude-sonnet-4-5-20250929" + model_kwargs: + drop_params: true + parallel_tool_calls: true diff --git a/eval/swebench/configs/gitnexus.yaml b/eval/swebench/configs/gitnexus.yaml new file mode 100644 index 0000000..84955cc --- /dev/null +++ b/eval/swebench/configs/gitnexus.yaml @@ -0,0 +1,199 @@ +# GitNexus-enhanced mini-swe-agent config for SWE-bench Verified +# Bash + GitNexus MCP tools (with PDG layer). This is the treatment arm. +# +# The agent gets both bash and GitNexus tools. The system prompt instructs +# it to prefer graph tools for code exploration, falling back to bash +# for editing, testing, and git operations. +# +# PDG-powered tools give the agent: +# - pdg_query: statement-level control/data dependence +# - explain: taint analysis (security-sensitive flows) +# - impact --mode pdg: statement-level blast-radius slicing +# +# The Docker setup script runs `gitnexus analyze --embeddings --pdg` +# to build the full graph before the agent starts. + +agent: + system_template: | + You are a helpful assistant that can interact with a computer shell and a code knowledge graph (GitNexus) to solve programming tasks. + instance_template: | + + Consider the following PR description: + {{task}} + + + + # Task Instructions + + ## Overview + + You're a software engineer with access to both a shell and a code knowledge graph (GitNexus). + You'll be helping implement necessary changes to meet requirements in the PR description. + Your task is specifically to make changes to non-test files in the current directory in order to fix the issue described in the PR description in a way that is general and consistent with the codebase. + + This is an interactive process where you will think and issue AT LEAST ONE command, see the result, then think and issue your next command(s). + + For each response: + + 1. Include a THOUGHT section explaining your reasoning and what you're trying to accomplish + 2. Provide one or more tool calls to execute + + ## Important Boundaries + + - MODIFY: Regular source code files in /testbed (this is the working directory for all your subsequent commands) + - DO NOT MODIFY: Tests, configuration files (pyproject.toml, setup.cfg, etc.) + + ## GitNexus Tool Usage (PREFERRED for code exploration) + + A knowledge graph of this repository has been built WITH PDG (Program Dependence Graph). + This means the graph includes: + - Symbol-level data: callers, callees, overrides, communities, processes + - Control dependence: what condition gates each statement + - Data dependence: where variables flow (REACHING_DEF edges) + - Taint flows: source→sink for security-sensitive data + + **Prefer GitNexus tools over grep/cat for code exploration.** + + ### Tool: gitnexus_query + Use this FIRST to orient yourself on any task. Search for relevant code, processes, or concepts. + - Example: `{"search_query": "how does authentication work", "limit": 5}` + + ### Tool: gitnexus_context + Use this to get a 360-degree view of a specific symbol (callers, callees, references). + - Example: `{"name": "UserModel", "kind": "Class"}` + + ### Tool: gitnexus_impact + Use this BEFORE editing shared code to understand blast radius. + - Example: `{"target": "validate_user", "direction": "upstream"}` + - For PDG-powered statement-level impact: `{"target": "validate_user", "direction": "upstream", "mode": "pdg"}` + + ### Tool: gitnexus_cypher + Use this for precise structural questions — field read/write, N-hop call chains, method overrides. + Read the schema first: `{"statement": "READ gitnexus://repo/__REPO__/schema"}` + - Example: `{"statement": "MATCH (f:Function)-[:CALLS]->(t:Function {name: 'authenticate'}) RETURN f.name, f.filePath"}` + + ### Tool: gitnexus_pdg_query + Use this for control/data dependence questions at statement level. + - `mode: "controls"` — what condition gates a statement? What runs under true/false branches? + - `mode: "flows"` — where does a variable's value flow? + - Example: `{"mode": "controls", "target": "src/auth/login.py"}` + + ### Tool: gitnexus_explain + Use this for security-sensitive code changes. Shows taint flows (source→sink) for the code you're modifying. + - Categories: command-injection, path-traversal, sql-injection, xss + - Example: `{"target": "src/auth/login.py"}` + + ### Tool: gitnexus_detect_changes + Use this BEFORE submitting to verify what your changes affect. + - Example: `{"scope": "unstaged"}` + + ### Decision guide + + | Need | Tool | Why | + |------|------|-----| + | Orient / find relevant code | gitnexus_query | Hybrid BM25 + embedding search | + | Understand a symbol | gitnexus_context | Callers, callees, references | + | Before editing shared code | gitnexus_impact | Blast radius analysis | + | Field/property data flow | gitnexus_cypher | ACCESSES edges | + | What condition gates a line? | gitnexus_pdg_query (controls) | Control dependence | + | Where does a variable flow? | gitnexus_pdg_query (flows) | Data dependence | + | Security-sensitive changes | gitnexus_explain | Taint analysis | + | Before final submit | gitnexus_detect_changes | Verify affected processes | + + **Workflow**: gitnexus_query → gitnexus_context → gitnexus_pdg_query (if structural) → bash (edit/test) → gitnexus_detect_changes (before submit) + + ## Bash Tool Usage + + Use bash for: + - Editing files (sed, patch, etc.) + - Running tests / reproduction scripts + - Git operations (diff, log, etc.) + - Any command-line operation + + ## Recommended Workflow + + 1. Start with gitnexus_query to find relevant code areas + 2. Use gitnexus_context on key symbols to understand dependencies + 3. Use gitnexus_impact on code you plan to modify + 4. Use gitnexus_pdg_query for control/data flow if the bug involves conditionals or variable flow + 5. Create a script to reproduce the issue (bash) + 6. Edit the source code to resolve the issue (bash) + 7. Verify your fix works (bash) + 8. Use gitnexus_detect_changes to verify impact before submitting + 9. Submit your patch + + + {{system}} {{release}} {{version}} {{machine}} + + + step_limit: 250 + cost_limit: 3.0 + +environment: + cwd: "/testbed" + timeout: 60 + interpreter: ["bash", "-c"] + env: + PAGER: cat + MANPAGER: cat + LESS: -R + PIP_PROGRESS_BAR: "off" + TQDM_DISABLE: "1" + BASH_ENV: /root/.bashrc + environment_class: docker + +model: + observation_template: | + {% if output.exception_info -%} + {{output.exception_info}} + {% endif -%} + {{output.returncode}} + {% if output.output | length < 10000 -%} + + {{ output.output -}} + + {%- else -%} + + The output of your last command was too long. + Please try a different command that produces less output. + If you're looking at a file you can try use head, tail or sed to view a smaller number of lines selectively. + If you're using grep or find and it produced too much output, you can use a more selective search pattern. + If you really need to see something from the full command's the output, you can redirect output to a file and then search in that file. + + {%- set elided_chars = output.output | length - 10000 -%} + + {{ output.output[:5000] }} + + + {{ elided_chars }} characters elided + + + {{ output.output[-5000:] }} + + {%- endif -%} + format_error_template: | + {% if finish_reason is defined and finish_reason in ["length", "tool_calls"] -%} + Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with exactly one tool call. If you need to think more, do so briefly. + {%- else -%} + Tool call error: + + + {{error}} + + + You have access to the following tools: + - bash: Execute a bash command + - gitnexus_query: Search the code knowledge graph + - gitnexus_context: Get symbol context (callers, callees, references) + - gitnexus_impact: Analyze blast radius of changes + - gitnexus_cypher: Run Cypher queries on the graph + - gitnexus_pdg_query: Query PDG for control/data dependence + - gitnexus_explain: Taint analysis for security-sensitive code + - gitnexus_detect_changes: Detect affected code from uncommitted changes + + Every response needs at least one tool call. Prefer GitNexus tools for code exploration. + {%- endif %} + model_name: "anthropic/claude-sonnet-4-5-20250929" + model_kwargs: + drop_params: true + parallel_tool_calls: true diff --git a/eval/swebench/gitnexus_swe_tools.py b/eval/swebench/gitnexus_swe_tools.py new file mode 100644 index 0000000..bcc848b --- /dev/null +++ b/eval/swebench/gitnexus_swe_tools.py @@ -0,0 +1,832 @@ +""" +GitNexus MCP tools for mini-swe-agent (v2.4.2+). + +Extends mini-swe-agent with 8 GitNexus tools alongside bash, including +PDG-powered tools (pdg_query, explain, impact --mode pdg). + +Architecture (matching mini-swe-agent v2.4.2 API): + - LitellmModel._query() calls litellm.completion() with tools=[BASH_TOOL] + - LitellmModel._parse_actions() calls parse_toolcall_actions() which ONLY + accepts "bash" tool calls — any other tool name raises FormatError + - DefaultAgent.execute_actions() calls env.execute(action) for each parsed action + + Our integration: + - GitNexusLitellmModel(LitellmModel): overrides _query() to pass + tools=ALL_TOOLS (bash + 8 gitnexus), and overrides _parse_actions() to + accept both "bash" and "gitnexus_*" tool calls, routing gitnexus calls + through GitNexusClient + - GitNexusSweBenchAgent(DefaultAgent): overrides execute_actions() to route + gitnexus actions through GitNexusClient and bash actions through env + + This matches the approach described by d3thshot7777 who ran SWE-bench Verified + with GitNexus MCP via mini-swe-agent. + +Usage: + # Run SWE-bench Verified with GitNexus: + python eval/swebench/gitnexus_swe_tools.py run \ + --model deepseek/deepseek-chat-v3-0324 \ + --instance-ids django__django-11149 + + # Or via the shell script: + ./eval/swebench/run-benchmark.sh --model deepseek/deepseek-chat-v3-0324 --instances 50 +""" + +from __future__ import annotations + +import json +import logging +import os +import subprocess +from pathlib import Path +from typing import Any + +logger = logging.getLogger("gitnexus_swe_tools") + +# ────────────────────────────────────────────────────────────── +# GitNexus MCP tool definitions (OpenAI function-calling schema) +# ────────────────────────────────────────────────────────────── + +GITNEXUS_TOOLS = [ + { + "type": "function", + "function": { + "name": "gitnexus_query", + "description": ( + "Search the code knowledge graph using hybrid BM25 + embedding vectors. " + "Use this FIRST to orient yourself on any task — find relevant code, " + "processes, or concepts. Returns ranked execution flows with symbol locations." + ), + "parameters": { + "type": "object", + "properties": { + "search_query": { + "type": "string", + "description": "Natural language or keyword search query", + }, + "limit": { + "type": "integer", + "description": "Max processes to return (default 5)", + "default": 5, + }, + "max_symbols": { + "type": "integer", + "description": "Max symbols per process (default 10)", + "default": 10, + }, + "repo": { + "type": "string", + "description": "Repository name (auto-detected if omitted)", + }, + }, + "required": ["search_query"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "gitnexus_context", + "description": ( + "Get a 360-degree view of a code symbol: callers, callees, references, " + "process participation, and file location. Use after query to drill into " + "specific symbols." + ), + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Symbol name (e.g. 'validate_user', 'UserModel')", + }, + "kind": { + "type": "string", + "description": "Kind hint: Function, Class, Method, Interface, etc.", + }, + "file_path": { + "type": "string", + "description": "File path hint to disambiguate common names", + }, + "include_content": { + "type": "boolean", + "description": "Include full symbol source code (default false)", + "default": False, + }, + "repo": { + "type": "string", + "description": "Repository name (auto-detected if omitted)", + }, + }, + "required": ["name"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "gitnexus_impact", + "description": ( + "Analyze the blast radius of changing a code symbol. Returns affected symbols " + "grouped by depth, risk assessment, and affected execution flows. " + "ALWAYS run this before editing shared/runtime code. " + "Use mode='pdg' for statement-level impact with control/data dependence." + ), + "parameters": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "Name of function, class, or file to analyze", + }, + "direction": { + "type": "string", + "enum": ["upstream", "downstream"], + "description": "upstream = what depends on this; downstream = what this depends on", + "default": "upstream", + }, + "mode": { + "type": "string", + "enum": ["callgraph", "pdg"], + "description": "callgraph = symbol-level impact (default); pdg = statement-level with control/data dependence", + "default": "callgraph", + }, + "summaryOnly": { + "type": "boolean", + "description": "Return only summary counts (default false)", + "default": False, + }, + "repo": { + "type": "string", + "description": "Repository name (auto-detected if omitted)", + }, + }, + "required": ["target"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "gitnexus_cypher", + "description": ( + "Run a Cypher query on the code knowledge graph. Use for precise structural " + "questions: field read/write (ACCESSES), N-hop call chains (CALLS), " + "method overrides (METHOD_OVERRIDES), process steps (STEP_IN_PROCESS). " + "READ the schema first: gitnexus://repo/{name}/schema" + ), + "parameters": { + "type": "object", + "properties": { + "statement": { + "type": "string", + "description": "Cypher query statement", + }, + "repo": { + "type": "string", + "description": "Repository name (auto-detected if omitted)", + }, + }, + "required": ["statement"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "gitnexus_pdg_query", + "description": ( + "Query the Program Dependence Graph for statement-level control and data dependence. " + "mode='controls': what condition gates a statement? What runs under true/false branches? " + "mode='flows': where does a variable's value flow (REACHING_DEF edges)? " + "Requires the repo to be indexed with --pdg." + ), + "parameters": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["controls", "flows"], + "description": "'controls' = control dependence (CDG); 'flows' = data dependence (REACHING_DEF)", + }, + "target": { + "type": "string", + "description": "File path or symbol name to anchor the query", + }, + "variable": { + "type": "string", + "description": "Variable name (for flows mode only): filter REACHING_DEF to this variable", + }, + "repo": { + "type": "string", + "description": "Repository name (auto-detected if omitted)", + }, + }, + "required": ["mode", "target"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "gitnexus_explain", + "description": ( + "Explain taint findings — source→sink data flows for security review. " + "Shows intra-procedural taint (statement-level hops) and cross-function flows. " + "Categories: command-injection, path-traversal, sql-injection, xss. " + "Requires the repo to be indexed with --pdg." + ), + "parameters": { + "type": "object", + "properties": { + "target": { + "type": "string", + "description": "File path or symbol/function name to anchor the query. Omit to list all findings.", + }, + "limit": { + "type": "integer", + "description": "Max findings returned (default 50, max 200)", + "default": 50, + }, + "repo": { + "type": "string", + "description": "Repository name (auto-detected if omitted)", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "gitnexus_detect_changes", + "description": ( + "Analyze uncommitted git changes and find affected execution flows. " + "Use BEFORE submitting your patch to verify what your changes affect." + ), + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "enum": ["unstaged", "staged", "all", "compare"], + "description": "What to analyze (default: unstaged)", + "default": "unstaged", + }, + "repo": { + "type": "string", + "description": "Repository name (auto-detected if omitted)", + }, + }, + "required": [], + }, + }, + }, + { + "type": "function", + "function": { + "name": "gitnexus_rename", + "description": ( + "Coordinated multi-file rename using the code knowledge graph. " + "Finds all references (direct + indirect) and renames them atomically. " + "Use dry_run=true first to preview changes." + ), + "parameters": { + "type": "object", + "properties": { + "symbol_name": { + "type": "string", + "description": "Current symbol name to rename", + }, + "new_name": { + "type": "string", + "description": "New name for the symbol", + }, + "file_path": { + "type": "string", + "description": "File path hint to disambiguate common names", + }, + "dry_run": { + "type": "boolean", + "description": "Preview changes without applying (default true)", + "default": True, + }, + "repo": { + "type": "string", + "description": "Repository name (auto-detected if omitted)", + }, + }, + "required": ["symbol_name", "new_name"], + }, + }, + }, +] + +BASH_TOOL = { + "type": "function", + "function": { + "name": "bash", + "description": "Execute a bash command", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute", + }, + }, + "required": ["command"], + }, + }, +} + +ALL_TOOLS = [BASH_TOOL] + GITNEXUS_TOOLS +BASELINE_TOOLS = [BASH_TOOL] +GITNEXUS_TOOL_NAMES = {t["function"]["name"] for t in GITNEXUS_TOOLS} + + +# ────────────────────────────────────────────────────────────── +# GitNexus MCP client — calls gitnexus CLI as subprocess +# ────────────────────────────────────────────────────────────── + + +class GitNexusClient: + """Calls GitNexus MCP tools via the CLI (subprocess).""" + + TOOL_TO_CLI = { + "gitnexus_query": "query", + "gitnexus_context": "context", + "gitnexus_impact": "impact", + "gitnexus_cypher": "cypher", + "gitnexus_pdg_query": "pdg-query", + "gitnexus_explain": "explain", + "gitnexus_detect_changes": "detect-changes", + "gitnexus_rename": "rename", + } + + def __init__(self, repo_path: str, repo_name: str | None = None): + self.repo_path = repo_path + self.repo_name = repo_name or Path(repo_path).name + self._gitnexus_bin = self._find_gitnexus() + + def _find_gitnexus(self) -> str: + for candidate in ["gitnexus", "npx"]: + try: + result = subprocess.run( + ["which", candidate], capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + return result.stdout.strip() + except Exception: + continue + return "npx" + + def call(self, tool_name: str, arguments: dict[str, Any]) -> str: + tool_args = {**arguments} + if "repo" not in tool_args: + tool_args["repo"] = self.repo_name + cmd = self._build_command(tool_name, tool_args) + try: + result = subprocess.run( + cmd, cwd=self.repo_path, capture_output=True, text=True, timeout=120 + ) + output = result.stdout + if result.returncode != 0: + stderr = result.stderr[:2000] + output = f"ERROR: gitnexus {tool_name} failed (exit {result.returncode})\n{stderr}\n{output}" + return output[:50000] + except subprocess.TimeoutExpired: + return "ERROR: gitnexus call timed out after 120s" + except Exception as e: + return f"ERROR: gitnexus call failed: {e}" + + def _build_command(self, tool_name: str, args: dict[str, Any]) -> list[str]: + if self._gitnexus_bin == "npx": + base = ["npx", "-y", "gitnexus@latest"] + else: + base = [self._gitnexus_bin] + subcmd = self.TOOL_TO_CLI.get(tool_name, tool_name.replace("gitnexus_", "")) + cmd = base + [subcmd] + for key, value in args.items(): + if key in ("search_query", "statement"): + cmd.append(str(value)) + elif isinstance(value, bool): + if value: + cmd.append(f"--{key}") + elif isinstance(value, (int, float)): + cmd.extend([f"--{key}", str(value)]) + else: + cmd.extend([f"--{key}", str(value)]) + return cmd + + +# ────────────────────────────────────────────────────────────── +# mini-swe-agent integration (v2.4.2+) +# ────────────────────────────────────────────────────────────── + + +class GitNexusLitellmModel: + """Wraps LitellmModel to add GitNexus tools to the LLM's tool list. + + Overrides _query() to include ALL_TOOLS (bash + gitnexus_*) and overrides + _parse_actions() to handle gitnexus tool calls alongside bash. + """ + + def __init__(self, model_name: str, **kwargs): + from minisweagent.models.litellm_model import LitellmModel + + self._model = LitellmModel(model_name=model_name, **kwargs) + self.model_name = model_name + self._gitnexus_tools = ALL_TOOLS + + def query(self, messages, **kwargs): + """Delegate to the wrapped model's query (which calls _query then _parse_actions).""" + return self._model.query(messages, **kwargs) + + def _query(self, messages, **kwargs): + """Override to inject GitNexus tools into the litellm completion call.""" + import litellm + + return litellm.completion( + model=self._model.config.model_name, + messages=messages, + tools=self._gitnexus_tools, + **(self._model.config.model_kwargs | kwargs), + ) + + def _parse_actions(self, response): + """Override to handle both bash and gitnexus tool calls. + + The base parse_toolcall_actions() only accepts 'bash' — it raises + FormatError for any other tool name. We parse gitnexus calls ourselves + and delegate bash calls to the original parser. + """ + from minisweagent.models.litellm_model import ( + FormatError, + parse_toolcall_actions, + ) + + tool_calls = response.choices[0].message.tool_calls or [] + bash_calls = [] + gitnexus_actions = [] + + for tc in tool_calls: + fn = tc.function + name = fn.name + try: + args = json.loads(fn.arguments) if fn.arguments else {} + except json.JSONDecodeError: + args = {} + + tool_call_id = tc.id or "" + + if name == "bash": + bash_calls.append(tc) + elif name in GITNEXUS_TOOL_NAMES: + gitnexus_actions.append( + { + "command": f"gitnexus {self._gitnexus_subcmd(name)} {json.dumps(args)}", + "tool_call_id": tool_call_id, + "_gitnexus_action": True, + "_gitnexus_tool": name, + "_gitnexus_args": args, + } + ) + # Unknown tools are ignored (will be caught by bash parser if they slip through) + + # Parse bash calls using the original parser + try: + bash_actions = parse_toolcall_actions( + bash_calls, + format_error_template=self._model.config.format_error_template, + template_kwargs={"finish_reason": response.choices[0].finish_reason}, + ) + except FormatError: + # If there are no bash calls but we have gitnexus calls, that's fine + if gitnexus_actions: + bash_actions = [] + else: + raise + + return bash_actions + gitnexus_actions + + @staticmethod + def _gitnexus_subcmd(tool_name: str) -> str: + """Map gitnexus MCP tool names to CLI subcommands.""" + mapping = { + "gitnexus_query": "query", + "gitnexus_context": "context", + "gitnexus_impact": "impact", + "gitnexus_cypher": "cypher", + "gitnexus_pdg_query": "pdg-query", + "gitnexus_explain": "explain", + "gitnexus_detect_changes": "detect-changes", + "gitnexus_rename": "rename", + } + return mapping.get(tool_name, tool_name.replace("gitnexus_", "")) + + @property + def config(self): + return self._model.config + + @property + def cost(self): + return self._model.cost + + @property + def n_calls(self): + return self._model.n_calls + + +class GitNexusSweBenchAgent: + """Agent that handles both bash and GitNexus tool calls. + + Wraps DefaultAgent and overrides execute_actions to route gitnexus_* + tool calls to GitNexusClient instead of the bash environment. + """ + + def __init__(self, model, gitnexus_client: GitNexusClient, **kwargs): + from minisweagent.agents.default import DefaultAgent + + self.model = model + self.gitnexus_client = gitnexus_client + # We need to patch the model's _query and _parse_actions + # to handle GitNexus tools + if isinstance(model, GitNexusLitellmModel): + # Already patched + pass + else: + # Patch an existing LitellmModel to add GitNexus tools + model._query = lambda messages, **kw: self._gitnexus_query(messages, **kw) + model._parse_actions = lambda response: self._gitnexus_parse_actions( + response + ) + + self.agent = DefaultAgent(model=model, **kwargs) + + def _gitnexus_query(self, messages, **kwargs): + """Patch for non-GitNexusLitellmModel models to inject GitNexus tools.""" + import litellm + + return litellm.completion( + model=self.model.config.model_name, + messages=messages, + tools=ALL_TOOLS, + **(self.model.config.model_kwargs | kwargs), + ) + + def _gitnexus_parse_actions(self, response): + """Patch for non-GitNexusLitellmModel models to handle GitNexus tool calls.""" + from minisweagent.models.litellm_model import ( + FormatError, + parse_toolcall_actions, + ) + + tool_calls = response.choices[0].message.tool_calls or [] + bash_calls = [] + gitnexus_actions = [] + + for tc in tool_calls: + fn = tc.function + name = fn.name + try: + args = json.loads(fn.arguments) if fn.arguments else {} + except json.JSONDecodeError: + args = {} + tool_call_id = tc.id or "" + + if name == "bash": + bash_calls.append(tc) + elif name in GITNEXUS_TOOL_NAMES: + gitnexus_actions.append( + { + "command": f"gitnexus {GitNexusLitellmModel._gitnexus_subcmd(name)} {json.dumps(args)}", + "tool_call_id": tool_call_id, + "_gitnexus_action": True, + "_gitnexus_tool": name, + "_gitnexus_args": args, + } + ) + + try: + bash_actions = parse_toolcall_actions( + bash_calls, + format_error_template=self.model.config.format_error_template, + template_kwargs={"finish_reason": response.choices[0].finish_reason}, + ) + except FormatError: + if gitnexus_actions: + bash_actions = [] + else: + raise + + return bash_actions + gitnexus_actions + + def run(self, task: str = "", **kwargs) -> dict: + """Run the agent loop with GitNexus tool routing.""" + # Monkey-patch execute_actions to handle gitnexus actions + original_execute = self.agent.execute_actions + + def patched_execute(message): + actions = message.get("extra", {}).get("actions", []) + outputs = [] + for action in actions: + if action.get("_gitnexus_action"): + result_text = self.gitnexus_client.call( + action["_gitnexus_tool"], + action["_gitnexus_args"], + ) + outputs.append( + { + "output": result_text, + "returncode": 0 + if not result_text.startswith("ERROR") + else 1, + "exception_info": "" + if not result_text.startswith("ERROR") + else "gitnexus_error", + } + ) + else: + obs = self.agent.env.execute(action) + outputs.append(obs) + return self.agent.model.format_observation_messages( + message, outputs, self.agent.get_template_vars() + ) + + self.agent.execute_actions = patched_execute + return self.agent.run(task=task, **kwargs) + + def save(self, path, **kwargs): + """Save trajectory.""" + return self.agent.save(path, **kwargs) + + +# ────────────────────────────────────────────────────────────── +# Convenience factory +# ────────────────────────────────────────────────────────────── + + +def create_gitnexus_agent( + model_name: str, + config_path: str, + gitnexus: bool = False, + repo_path: str = "/testbed", + step_limit: int = 250, + cost_limit: float = 3.0, +): + """Create a mini-swe-agent with optional GitNexus tool support.""" + import yaml + from minisweagent.agents.default import DefaultAgent + from minisweagent.models.litellm_model import LitellmModel + + with open(config_path) as f: + config = yaml.safe_load(f) + + model_config = config.get("model", {}) + model_config["model_name"] = model_name + agent_config = config.get("agent", {}) + env_config = config.get("environment", {}) + + if gitnexus: + model = GitNexusLitellmModel(**model_config) + gitnexus_client = GitNexusClient(repo_path) + agent = GitNexusSweBenchAgent( + model=model, + gitnexus_client=gitnexus_client, + step_limit=step_limit, + cost_limit=cost_limit, + **{ + k: v + for k, v in agent_config.items() + if k not in ("step_limit", "cost_limit") + }, + ) + else: + model = LitellmModel(**model_config) + agent = DefaultAgent( + model=model, + step_limit=step_limit, + cost_limit=cost_limit, + **{ + k: v + for k, v in agent_config.items() + if k not in ("step_limit", "cost_limit") + }, + ) + + return agent + + +# ────────────────────────────────────────────────────────────── +# CLI entry point +# ────────────────────────────────────────────────────────────── + + +def main(): + """CLI entry point for running SWE-bench with GitNexus.""" + import argparse + + parser = argparse.ArgumentParser(description="SWE-bench Verified with GitNexus") + parser.add_argument("--model", required=True, help="litellm model name") + parser.add_argument("--instance-ids", help="Comma-separated instance IDs") + parser.add_argument("--instances", type=int, help="Run first N instances") + parser.add_argument("--config", default=None, help="Path to YAML config") + parser.add_argument("--output-dir", default="results", help="Output directory") + parser.add_argument( + "--gitnexus", + action="store_true", + default=False, + help="Enable GitNexus tools (treatment arm)", + ) + parser.add_argument( + "--repo-path", default="/testbed", help="Repo path in container" + ) + parser.add_argument("--setup-script", default=None, help="Path to setup script") + parser.add_argument("--step-limit", type=int, default=250, help="Max agent steps") + parser.add_argument( + "--cost-limit", type=float, default=3.0, help="Max cost per instance (USD)" + ) + args = parser.parse_args() + + # Determine instance IDs + if args.instance_ids: + instance_ids = [x.strip() for x in args.instance_ids.split(",")] + elif args.instances: + from datasets import load_dataset + + ds = load_dataset("princeton-nlp/SWE-bench_Verified", split="test") + instance_ids = [item["instance_id"] for item in ds][: args.instances] + else: + from datasets import load_dataset + + ds = load_dataset("princeton-nlp/SWE-bench_Verified", split="test") + instance_ids = [item["instance_id"] for item in ds] + + # Determine config + script_dir = Path(__file__).parent + if args.config is None: + args.config = str( + script_dir + / "configs" + / ("gitnexus.yaml" if args.gitnexus else "baseline.yaml") + ) + + print( + f"Running {len(instance_ids)} instances (gitnexus={'ON' if args.gitnexus else 'OFF'})" + ) + + # Run each instance + results = [] + for iid in instance_ids: + print(f"\n{'=' * 60}") + print(f"Instance: {iid}") + print(f"{'=' * 60}") + try: + agent = create_gitnexus_agent( + model_name=args.model, + config_path=args.config, + gitnexus=args.gitnexus, + repo_path=args.repo_path, + step_limit=args.step_limit, + cost_limit=args.cost_limit, + ) + + from datasets import load_dataset + + ds = load_dataset("princeton-nlp/SWE-bench_Verified", split="test") + instance = None + for item in ds: + if item["instance_id"] == iid: + instance = item + break + + if instance is None: + raise ValueError(f"Instance {iid} not found in SWE-bench Verified") + + task = instance.get("problem_statement", "") + result = agent.run(task=task) + + # Normalize result to a dict + if not isinstance(result, dict): + result = {"raw_result": str(result)} + result["instance_id"] = iid + results.append(result) + + # Save trajectory + output_path = Path(args.output_dir) / f"{iid}.json" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(result, indent=2, default=str)) + + except Exception as e: + logger.exception(f"Error running instance {iid}") + results.append({"instance_id": iid, "status": "error", "error": str(e)}) + + # Summary + print(f"\n{'=' * 60}") + print(f"Results: {len(results)} instances") + print(f" OK: {sum(1 for r in results if r.get('status') != 'error')}") + print(f" Error: {sum(1 for r in results if r.get('status') == 'error')}") + + +if __name__ == "__main__": + main() diff --git a/eval/swebench/install-gitnexus-mcp.sh b/eval/swebench/install-gitnexus-mcp.sh new file mode 100755 index 0000000..40ae3b6 --- /dev/null +++ b/eval/swebench/install-gitnexus-mcp.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# install-gitnexus-mcp.sh — Install and index GitNexus inside a SWE-bench Docker container +# +# This script runs INSIDE each Docker container before the agent starts. +# It installs GitNexus, builds the knowledge graph WITH PDG (Program Dependence +# Graph), and makes the MCP server available. +# +# PDG gives the agent access to: +# - Control dependence (CDG): what condition gates a statement +# - Data dependence (REACHING_DEF): where a variable flows +# - Taint analysis: source→sink data flows for security review +# - Statement-level impact slicing for precise blast-radius +# +# Called by the GitNexus arm's environment setup: +# bash install-gitnexus-mcp.sh [--skip-index] [--no-pdg] +# +# Environment: +# TESTBED — path to the repo in the container (default: /testbed) +# GITNEXUS_BIN — path to gitnexus binary (default: auto-detect) +set -euo pipefail + +TESTBED="${TESTBED:-/testbed}" +SKIP_INDEX=false +NO_PDG=false + +for arg in "$@"; do + case "$arg" in + --skip-index) SKIP_INDEX=true ;; + --no-pdg) NO_PDG=true ;; + *) echo "Unknown argument: $arg"; exit 1 ;; + esac +done + +echo "[gitnexus-setup] Installing GitNexus in container..." + +# ── 1. Install Node.js (required by gitnexus) ────────────── +if ! command -v node &>/dev/null; then + echo "[gitnexus-setup] Installing Node.js..." + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y nodejs +fi + +echo "[gitnexus-setup] Node version: $(node --version)" + +# ── 2. Install GitNexus ───────────────────────────────────── +if [[ -n "${GITNEXUS_BIN:-}" && -x "${GITNEXUS_BIN}" ]]; then + echo "[gitnexus-setup] Using pre-installed gitnexus: $GITNEXUS_BIN" + GITNEXUS_CMD="$GITNEXUS_BIN" +else + echo "[gitnexus-setup] Installing gitnexus via npm..." + npm install -g gitnexus@latest 2>/dev/null || { + echo "[gitnexus-setup] npm global install failed, trying npx fallback..." + # npx will be used per-call instead + } + GITNEXUS_CMD="gitnexus" +fi + +# Verify +if command -v gitnexus &>/dev/null; then + echo "[gitnexus-setup] GitNexus version: $(gitnexus --version 2>/dev/null || echo 'unknown')" +else + echo "[gitnexus-setup] WARNING: gitnexus command not found; will use npx" +fi + +# ── 3. Build the knowledge graph WITH PDG ─────────────────── +if [[ "$SKIP_INDEX" == false ]]; then + echo "[gitnexus-setup] Building knowledge graph for: $TESTBED" + cd "$TESTBED" + + REPO_NAME="$(basename "$TESTBED")" + + # Step 3a: Base analysis + embeddings + # This builds the symbol graph, communities, processes, and embeddings + echo "[gitnexus-setup] Step 1/2: Building symbol graph + embeddings..." + if command -v gitnexus &>/dev/null; then + gitnexus analyze --embeddings --repo "$REPO_NAME" 2>&1 | tail -5 + else + npx -y gitnexus@latest analyze --embeddings --repo "$REPO_NAME" 2>&1 | tail -5 + fi + + # Step 3b: PDG layer (control + data dependence + taint) + # This adds CDG edges, REACHING_DEF edges, and taint flows on top of the + # base graph. PDG is what enables: + # - pdg_query: statement-level control/data dependence + # - explain: taint analysis (command-injection, path-traversal, etc.) + # - impact mode pdg: statement-level affected-code slicing + # - Precise "what condition gates this line?" answers + if [[ "$NO_PDG" == false ]]; then + echo "[gitnexus-setup] Step 2/2: Building PDG layer (control + data dependence + taint)..." + if command -v gitnexus &>/dev/null; then + gitnexus analyze --pdg --repo "$REPO_NAME" 2>&1 | tail -5 + else + npx -y gitnexus@latest analyze --pdg --repo "$REPO_NAME" 2>&1 | tail -5 + fi + echo "[gitnexus-setup] PDG layer built. Agent has access to:" + echo "[gitnexus-setup] - Control dependence (CDG): what guards each statement" + echo "[gitnexus-setup] - Data dependence (REACHING_DEF): where variables flow" + echo "[gitnexus-setup] - Taint analysis: source→sink data flows" + echo "[gitnexus-setup] - Statement-level impact slicing" + else + echo "[gitnexus-setup] Skipping PDG layer (--no-pdg). Graph has embeddings only." + fi + + echo "[gitnexus-setup] Knowledge graph built at: $TESTBED/.gitnexus/" + + # Verify the graph + echo "[gitnexus-setup] Verifying graph..." + if [[ -f "$TESTBED/.gitnexus/meta.json" ]]; then + NODES=$(python3 -c "import json; d=json.load(open('$TESTBED/.gitnexus/meta.json')); print(d.get('nodeCount', 'unknown'))" 2>/dev/null || echo "unknown") + echo "[gitnexus-setup] Graph nodes: $NODES" + fi + if [[ -d "$TESTBED/.gitnexus/pdg" ]]; then + echo "[gitnexus-setup] PDG layer: present" + else + echo "[gitnexus-setup] PDG layer: absent (agent falls back to graph-only tools)" + fi +fi + +echo "[gitnexus-setup] Done. GitNexus MCP ready with PDG-powered cache." diff --git a/eval/swebench/results/.gitkeep b/eval/swebench/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/eval/swebench/run-benchmark.sh b/eval/swebench/run-benchmark.sh new file mode 100755 index 0000000..1192513 --- /dev/null +++ b/eval/swebench/run-benchmark.sh @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +# run-benchmark.sh — SWE-bench Verified with and without GitNexus +# +# Runs both arms (baseline + gitnexus), scores with swebench, and produces +# a paired comparison report. +# +# Usage: +# ./eval/swebench/run-benchmark.sh --model [options] +# +# Options: +# --model MODEL litellm model string (required) +# --instances N Run first N instances from SWE-bench Verified (default: all) +# --instance-ids IDS Comma-separated instance IDs (overrides --instances) +# --step-limit N Max agent steps per instance (default: 250) +# --cost-limit FLOAT Max cost per instance in USD (default: 3.0) +# --timeout N Per-command timeout in seconds (default: 60) +# --workers N Parallel Docker workers (default: 1) +# --results-dir DIR Output directory (default: eval/swebench/results) +# --skip-baseline Skip baseline arm (use existing results) +# --skip-gitnexus Skip gitnexus arm (use existing results) +# --skip-score Skip scoring (just run agent trajectories) +# --skip-index Skip gitnexus analyze step (use existing index) +# -h, --help Show this help +# +# Environment: +# ANTHROPIC_API_KEY, OPENAI_API_KEY, DEEPSEEK_API_KEY, etc. — set for your model +# GITNEXUS_REPO GitNexus MCP server repo name override (default: auto-detect) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +KIT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +RESULTS_DIR="$SCRIPT_DIR/results" + +# Defaults +MODEL="" +INSTANCES="" +INSTANCE_IDS="" +STEP_LIMIT=250 +COST_LIMIT=3.0 +TIMEOUT=60 +WORKERS=1 +SKIP_BASELINE=false +SKIP_GITNEXUS=false +SKIP_SCORE=false +SKIP_INDEX=false + +usage() { + sed -n '2,/^set -euo pipefail/p' "$0" | head -n -1 | sed 's/^# //' | sed 's/^#//' + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --model) MODEL="$2"; shift 2 ;; + --instances) INSTANCES="$2"; shift 2 ;; + --instance-ids) INSTANCE_IDS="$2"; shift 2 ;; + --step-limit) STEP_LIMIT="$2"; shift 2 ;; + --cost-limit) COST_LIMIT="$2"; shift 2 ;; + --timeout) TIMEOUT="$2"; shift 2 ;; + --workers) WORKERS="$2"; shift 2 ;; + --results-dir) RESULTS_DIR="$2"; shift 2 ;; + --skip-baseline) SKIP_BASELINE=true; shift ;; + --skip-gitnexus) SKIP_GITNEXUS=true; shift ;; + --skip-score) SKIP_SCORE=true; shift ;; + --skip-index) SKIP_INDEX=true; shift ;; + -h|--help) usage ;; + *) echo "Unknown option: $1"; usage ;; + esac +done + +if [[ -z "$MODEL" ]]; then + echo "ERROR: --model is required" + usage +fi + +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ SWE-bench Verified — GitNexus Benchmark Runner ║" +echo "╚══════════════════════════════════════════════════════════╝" +echo "" +echo "Model: $MODEL" +echo "Step limit: $STEP_LIMIT" +echo "Cost limit: \$$COST_LIMIT" +echo "Workers: $WORKERS" +echo "Results: $RESULTS_DIR" +echo "" + +# ── Check dependencies ────────────────────────────────────── +command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required"; exit 1; } +command -v docker >/dev/null 2>&1 || { echo "ERROR: docker required"; exit 1; } +python3 -c "import minisweagent" 2>/dev/null || { echo "ERROR: mini-swe-agent not installed. Run: pip install mini-swe-agent"; exit 1; } +python3 -c "import swebench" 2>/dev/null || { echo "ERROR: swebench not installed. Run: pip install swebench"; exit 1; } + +# ── Determine instance IDs ────────────────────────────────── +if [[ -n "$INSTANCE_IDS" ]]; then + IDS_ARG="--instance_ids $INSTANCE_IDS" +elif [[ -n "$INSTANCES" ]]; then + IDS_ARG="--instance_ids $(python3 -c " +from datasets import load_dataset +ds = load_dataset('princeton-nlp/SWE-bench_Verified', split='test') +print(','.join(ds['instance_id'][:int('$INSTANCES')])) +")" +else + IDS_ARG="" +fi + +# ── Create results directory ───────────────────────────────── +mkdir -p "$RESULTS_DIR/baseline" "$RESULTS_DIR/gitnexus" + +# ── Helper: update config with model name ─────────────────── +patch_config_model() { + local config="$1" + local model="$2" + # Update model_name in the yaml config + if command -v yq >/dev/null 2>&1; then + yq -i ".model.model_name = \"$model\"" "$config" + else + # Fallback: sed-based replacement + sed -i "s/model_name: .*/model_name: \"$model\"/" "$config" + fi +} + +# ── Helper: run mini-swe-agent on SWE-bench ────────────────── +run_arm() { + local arm="$1" # "baseline" or "gitnexus" + local config="$2" + local output_dir="$RESULTS_DIR/$arm" + local run_id="${arm}-$(date +%Y%m%d-%H%M%S)" + + echo "" + echo "═══════════════════════════════════════════════════════════" + echo " Running $arm arm" + echo "═══════════════════════════════════════════════════════════" + echo "" + + # Create a per-run copy of the config with the right model + local run_config="$output_dir/config.yaml" + cp "$config" "$run_config" + patch_config_model "$run_config" "$MODEL" + + # Update step/cost limits + if command -v yq >/dev/null 2>&1; then + yq -i ".agent.step_limit = $STEP_LIMIT" "$run_config" + yq -i ".agent.cost_limit = $COST_LIMIT" "$run_config" + yq -i ".environment.timeout = $TIMEOUT" "$run_config" + else + sed -i "s/step_limit: .*/step_limit: $STEP_LIMIT/" "$run_config" + sed -i "s/cost_limit: .*/cost_limit: $COST_LIMIT/" "$run_config" + fi + + # Run mini-swe-agent batch inference + # mini-swe-agent handles Docker container setup and SWE-bench instance management + python3 -m minisweagent.run.from_swe_bench \ + --config "$run_config" \ + --output_dir "$output_dir/trajectories" \ + $IDS_ARG \ + --max_workers "$WORKERS" \ + --run_id "$run_id" \ + ${EXTRA_MINI_ARGS:-} + + echo "" + echo " $arm arm complete. Trajectories in: $output_dir/trajectories" +} + +# ══════════════════════════════════════════════════════════════ +# ARM 1: BASELINE (no GitNexus) +# ══════════════════════════════════════════════════════════════ +if [[ "$SKIP_BASELINE" == false ]]; then + run_arm "baseline" "$SCRIPT_DIR/configs/baseline.yaml" +fi + +# ══════════════════════════════════════════════════════════════ +# ARM 2: GITNEXUS (with GitNexus MCP) +# ══════════════════════════════════════════════════════════════ +if [[ "$SKIP_GITNEXUS" == false ]]; then + echo "" + echo "═══════════════════════════════════════════════════════════" + echo " Preparing GitNexus arm: installing MCP into agent config" + echo "═══════════════════════════════════════════════════════════" + + # The GitNexus arm uses the gitnexus.yaml config which includes + # GitNexus tool definitions in the system prompt. + # + # At the MCP level, we need to make gitnexus available as a tool + # server. mini-swe-agent supports this via environment setup scripts + # that run inside the Docker container before each instance. + # + # The setup script installs gitnexus in the container and runs + # `gitnexus analyze --embeddings` on the repo. + + run_arm "gitnexus" "$SCRIPT_DIR/configs/gitnexus.yaml" +fi + +# ══════════════════════════════════════════════════════════════ +# SCORING +# ══════════════════════════════════════════════════════════════ +if [[ "$SKIP_SCORE" == false ]]; then + echo "" + echo "═══════════════════════════════════════════════════════════" + echo " Scoring patches with SWE-bench evaluation harness" + echo "═══════════════════════════════════════════════════════════" + echo "" + + # Extract patches from trajectories and score them + python3 "$SCRIPT_DIR/scoring/score-pairs.py" \ + --baseline "$RESULTS_DIR/baseline/trajectories" \ + --gitnexus "$RESULTS_DIR/gitnexus/trajectories" \ + --output "$RESULTS_DIR" \ + --model "$MODEL" \ + $IDS_ARG +fi + +echo "" +echo "Done! Results in: $RESULTS_DIR" +echo "Report: $RESULTS_DIR/report.md" diff --git a/eval/swebench/scoring/score-pairs.py b/eval/swebench/scoring/score-pairs.py new file mode 100644 index 0000000..c1aed39 --- /dev/null +++ b/eval/swebench/scoring/score-pairs.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +""" +Score SWE-bench Verified results: paired comparison of baseline vs GitNexus. + +Reads trajectory JSON files from both arms, extracts patches and token usage, +runs SWE-bench evaluation, and produces a paired comparison report. + +Usage: + python score-pairs.py --baseline results/baseline/trajectories \ + --gitnexus results/gitnexus/trajectories \ + --output results + +Based on d3thshot7777's methodology: +- Paired comparison (same instances in both arms) +- Exclude infra/container failures from both arms (fair pairs) +- Report: solve rate, tokens, API calls, per-instance deltas +""" + +import argparse +import json +import os +import sys +from collections import defaultdict +from datetime import datetime +from pathlib import Path +from typing import Any + + +def parse_args(): + p = argparse.ArgumentParser(description="Score SWE-bench GitNexus benchmark pairs") + p.add_argument( + "--baseline", required=True, help="Path to baseline trajectories dir" + ) + p.add_argument( + "--gitnexus", required=True, help="Path to gitnexus trajectories dir" + ) + p.add_argument( + "--output", required=True, help="Output directory for report and scores" + ) + p.add_argument("--model", default="", help="Model name for report header") + p.add_argument( + "--instance-ids", default="", help="Comma-separated instance IDs to include" + ) + p.add_argument( + "--exclude-ids", + default="", + help="Comma-separated instance IDs to exclude (infra failures)", + ) + p.add_argument( + "--skip-eval", + action="store_true", + help="Skip SWE-bench Docker eval (just analyze trajectories)", + ) + return p.parse_args() + + +def load_trajectories(traj_dir: Path) -> dict[str, dict]: + """Load trajectory JSON files from a directory. Key = instance_id.""" + results = {} + if not traj_dir.exists(): + print(f" WARNING: trajectory dir not found: {traj_dir}") + return results + for f in sorted(traj_dir.rglob("*.json")): + try: + data = json.loads(f.read_text()) + info = data.get("info", {}) + instance_id = info.get("instance_id", "") + if not instance_id: + # Try to extract from filename + instance_id = f.stem + model_stats = info.get("model_stats", {}) + results[instance_id] = { + "file": str(f), + "instance_id": instance_id, + "exit_status": info.get("exit_status", ""), + "submission": info.get("submission", ""), + "cost": model_stats.get("instance_cost", 0.0), + "api_calls": model_stats.get("api_calls", 0), + "tokens_in": 0, + "tokens_out": 0, + "messages": data.get("messages", []), + } + # Try to compute tokens from messages + total_in = 0 + total_out = 0 + for msg in data.get("messages", []): + extra = msg.get("extra", {}) + if isinstance(extra, dict): + resp = extra.get("response", {}) + if isinstance(resp, dict): + usage = resp.get("usage", {}) + total_in += usage.get("prompt_tokens", 0) + total_out += usage.get("completion_tokens", 0) + results[instance_id]["tokens_in"] = total_in + results[instance_id]["tokens_out"] = total_out + except Exception as e: + print(f" WARNING: failed to load {f}: {e}") + return results + + +def extract_patch(trajectory: dict) -> str: + """Extract the final patch from a trajectory.""" + submission = trajectory.get("submission", "") + if submission: + return submission + # Look for git diff in last few messages + for msg in reversed(trajectory.get("messages", [])): + content = msg.get("content", "") + if isinstance(content, str) and "diff --git" in content: + # Extract diff block + start = content.find("diff --git") + return content[start:] + return "" + + +def classify_exit(status: str) -> str: + """Classify exit status into categories.""" + if not status: + return "empty" + if status in ("Submitted", "resolved"): + return "submitted" + if status in ("LimitsExceeded", "TimeExceeded"): + return "limit" + if "FormatError" in status: + return "format_error" + return "other" + + +def count_gitnexus_tool_calls(messages: list) -> dict[str, int]: + """Count GitNexus tool calls in a trajectory.""" + counts = { + "query": 0, + "context": 0, + "impact": 0, + "cypher": 0, + "pdg_query": 0, + "explain": 0, + "detect_changes": 0, + } + for msg in messages: + content = msg.get("content", "") + if not isinstance(content, str): + continue + # Look for tool call references in message content or extra + extra = msg.get("extra", {}) + if isinstance(extra, dict): + actions = extra.get("actions", []) + for action in actions: + if isinstance(action, dict): + cmd = action.get("command", "") + for tool in counts: + if f"gitnexus {tool}" in cmd or f"gitnexus-{tool}" in cmd: + counts[tool] += 1 + return counts + + +def generate_report( + baseline: dict[str, dict], + gitnexus: dict[str, dict], + model: str, + exclude_ids: set[str], +) -> str: + """Generate the paired comparison report in markdown.""" + + # Find paired instances (both arms completed) + all_ids = sorted(set(baseline.keys()) | set(gitnexus.keys())) + fair_ids = [i for i in all_ids if i not in exclude_ids] + paired_ids = [i for i in fair_ids if i in baseline and i in gitnexus] + + # Stats + b_solved = sum(1 for i in paired_ids if baseline[i]["exit_status"] == "Submitted") + g_solved = sum(1 for i in paired_ids if gitnexus[i]["exit_status"] == "Submitted") + b_tokens = sum( + baseline[i]["tokens_in"] + baseline[i]["tokens_out"] for i in paired_ids + ) + g_tokens = sum( + gitnexus[i]["tokens_in"] + gitnexus[i]["tokens_out"] for i in paired_ids + ) + b_calls = sum(baseline[i]["api_calls"] for i in paired_ids) + g_calls = sum(gitnexus[i]["api_calls"] for i in paired_ids) + + b_rate = b_solved / len(paired_ids) * 100 if paired_ids else 0 + g_rate = g_solved / len(paired_ids) * 100 if paired_ids else 0 + token_delta = (1 - g_tokens / b_tokens) * 100 if b_tokens else 0 + call_delta = (1 - g_calls / b_calls) * 100 if b_calls else 0 + + # Per-instance deltas + improved = [] + regressed = [] + tied = [] + + for iid in paired_ids: + b_sub = baseline[iid]["exit_status"] == "Submitted" + g_sub = gitnexus[iid]["exit_status"] == "Submitted" + if g_sub and not b_sub: + improved.append(iid) + elif b_sub and not g_sub: + regressed.append(iid) + else: + tied.append(iid) + + # Chunk breakdown (50-instance chunks like d3thshot's dashboard) + chunks = [] + chunk_size = 50 + for start in range(0, len(paired_ids), chunk_size): + chunk_ids = paired_ids[start : start + chunk_size] + cb = sum(1 for i in chunk_ids if baseline[i]["exit_status"] == "Submitted") + cg = sum(1 for i in chunk_ids if gitnexus[i]["exit_status"] == "Submitted") + ct_b = sum( + baseline[i]["tokens_in"] + baseline[i]["tokens_out"] for i in chunk_ids + ) + ct_g = sum( + gitnexus[i]["tokens_in"] + gitnexus[i]["tokens_out"] for i in chunk_ids + ) + cc_b = sum(baseline[i]["api_calls"] for i in chunk_ids) + cc_g = sum(gitnexus[i]["api_calls"] for i in chunk_ids) + token_d = (1 - ct_g / ct_b) * 100 if ct_b else 0 + call_d = (1 - cc_g / cc_b) * 100 if cc_b else 0 + chunks.append( + { + "range": f"{start}:{start + len(chunk_ids)}", + "n": len(chunk_ids), + "b_solved": cb, + "g_solved": cg, + "delta": cg - cb, + "token_delta": f"{token_d:.1f}% fewer" + if token_d > 0 + else f"{-token_d:.1f}% more", + "call_delta": f"{call_d:.1f}% fewer" + if call_d > 0 + else f"{-call_d:.1f}% more", + } + ) + + # Build report + now = datetime.utcnow().isoformat()[:19] + lines = [ + f"# SWE-bench Verified — GitNexus Benchmark Report", + f"", + f"**Model:** {model or '(unknown)'} ", + f"**Date:** {now} ", + f"**Fair pairs:** {len(paired_ids)} of {len(all_ids)} total instances ", + f"**Excluded:** {len(exclude_ids)} infra/container failures ", + f"", + f"## Solve Rate", + f"", + f"| Condition | Solved | Rate |", + f"|-----------|--------|------|", + f"| Baseline (no GitNexus) | {b_solved}/{len(paired_ids)} | {b_rate:.1f}% |", + f"| GitNexus | {g_solved}/{len(paired_ids)} | {g_rate:.1f}% |", + f"| Delta | {g_solved - b_solved} | {g_rate - b_rate:+.1f}pp |", + f"", + f"## Total Tokens", + f"", + f"| Condition | Tokens |", + f"|-----------|--------|", + f"| Baseline | {b_tokens:,} |", + f"| GitNexus | {g_tokens:,} |", + f"| Delta | **{token_delta:+.1f}%** ({'fewer' if token_delta > 0 else 'more'}) |", + f"", + f"## API Calls", + f"", + f"| Condition | Calls |", + f"|-----------|-------|", + f"| Baseline | {b_calls:,} |", + f"| GitNexus | {g_calls:,} |", + f"| Delta | **{call_delta:+.1f}%** ({'fewer' if call_delta > 0 else 'more'}) |", + f"", + f"## Per-Instance Breakdown", + f"", + f"- **Improved** (GitNexus solved, baseline didn't): {len(improved)}", + f"- **Regressed** (baseline solved, GitNexus didn't): {len(regressed)}", + f"- **Tied** (both solved or both failed): {len(tied)}", + f"", + ] + + if improved: + lines.append("### Improved instances") + for iid in improved: + lines.append(f"- `{iid}`") + lines.append("") + + if regressed: + lines.append("### Regressed instances") + for iid in regressed: + lines.append(f"- `{iid}`") + lines.append("") + + if chunks: + lines.append("## Chunk Breakdown") + lines.append("") + lines.append( + "| Chunk | Pairs | Baseline | GitNexus | Solved Δ | Token Δ | Call Δ |" + ) + lines.append( + "|-------|-------|----------|----------|----------|---------|--------|" + ) + for c in chunks: + lines.append( + f"| {c['range']} | {c['n']} | {c['b_solved']}/{c['n']} | {c['g_solved']}/{c['n']} " + f"| {c['delta']:+d} | {c['token_delta']} | {c['call_delta']} |" + ) + lines.append("") + + lines.extend( + [ + "---", + "", + f"*Generated by `eval/swebench/scoring/score-pairs.py`*", + ] + ) + + return "\n".join(lines) + + +def main(): + args = parse_args() + + baseline_dir = Path(args.baseline) + gitnexus_dir = Path(args.gitnexus) + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + + print("Loading baseline trajectories...") + baseline = load_trajectories(baseline_dir) + print(f" Found {len(baseline)} instances") + + print("Loading GitNexus trajectories...") + gitnexus = load_trajectories(gitnexus_dir) + print(f" Found {len(gitnexus)} instances") + + # Parse exclude IDs + exclude_ids = set() + if args.exclude_ids: + exclude_ids = {x.strip() for x in args.exclude_ids.split(",") if x.strip()} + + # Auto-detect infra failures: instances where both arms hit limits or format errors + all_ids = sorted(set(baseline.keys()) | set(gitnexus.keys())) + for iid in all_ids: + b_status = baseline.get(iid, {}).get("exit_status", "") + g_status = gitnexus.get(iid, {}).get("exit_status", "") + # If both arms failed with non-submission and one is a limit error, + # likely an infra issue — exclude from fair pairs + if classify_exit(b_status) == "limit" and classify_exit(g_status) == "limit": + exclude_ids.add(iid) + + print(f"Excluded {len(exclude_ids)} instances (infra failures)") + + report = generate_report(baseline, gitnexus, args.model, exclude_ids) + + report_path = output_dir / "report.md" + report_path.write_text(report) + print(f"\nReport written to: {report_path}") + print(report) + + +if __name__ == "__main__": + main() diff --git a/lib/adapters/claude.mjs b/lib/adapters/claude.mjs new file mode 100644 index 0000000..df0d709 --- /dev/null +++ b/lib/adapters/claude.mjs @@ -0,0 +1,169 @@ +/** + * Claude Code adapter — wires the kit into Claude Code (claude.ai/code CLI/IDE). + * Same Adapter contract as ./cursor.mjs. + * + * - MCP → .mcp.json (project-scoped MCP servers Claude Code auto-loads) + * - Hooks → .claude/settings.json `hooks` (PreToolUse guards + SessionStart brief) + * - Skills → .claude/skills/ (symlinked store) + * - Always-on contract → CLAUDE.md (generated from the canonical contract) + */ +import fs from "node:fs"; +import path from "node:path"; +import { BUNDLE_ROOT, substituteRepoName } from "../kit-shared.mjs"; +import { AGENTS_MARKER_BEGIN, AGENTS_MARKER_END } from "../constants.mjs"; +import { readJsonSafe, writeJson } from "./json-util.mjs"; + +const MCP_ENTRY = { command: "npx", args: ["-y", "gitnexus@latest", "mcp"] }; + +/** PreToolUse/SessionStart hook commands → Claude project-relative hook scripts. */ +const HOOK_CMD = (script) => + `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${script}"`; + +/** The hook groups this adapter installs, keyed by Claude Code hook event. */ +const CLAUDE_HOOKS = { + PreToolUse: [ + ["Grep|Glob", "gitnexus-grep-guard.mjs"], + ["Read", "gitnexus-read-guard.mjs"], + ["Edit|Write|MultiEdit", "gitnexus-edit-guard.mjs"], + ["Bash", "gitnexus-bash-guard.mjs"], + ["mcp__gitnexus__.*", "gitnexus-mcp-guard.mjs"], + ], + SessionStart: [[null, "gitnexus-session.mjs"]], + PreCompact: [[null, "gitnexus-precompact.mjs"]], +}; + +/** A hook group is "ours" if any of its commands runs a gitnexus-* hook script. */ +function isOurHookGroup(group) { + return (group?.hooks ?? []).some((h) => + /\.claude\/hooks\/gitnexus-/.test(h?.command ?? ""), + ); +} + +function mergeClaudeSettings(absTarget) { + const settingsPath = path.join(absTarget, ".claude/settings.json"); + const cfg = readJsonSafe(settingsPath, {}); + cfg.hooks ??= {}; + for (const [event, groups] of Object.entries(CLAUDE_HOOKS)) { + const existing = (cfg.hooks[event] ?? []).filter((g) => !isOurHookGroup(g)); + const ours = groups.map(([matcher, script]) => ({ + ...(matcher ? { matcher } : {}), + hooks: [{ type: "command", command: HOOK_CMD(script) }], + })); + cfg.hooks[event] = [...existing, ...ours]; + } + writeJson(settingsPath, cfg); +} + +function removeClaudeSettings(absTarget) { + const settingsPath = path.join(absTarget, ".claude/settings.json"); + const cfg = readJsonSafe(settingsPath, null); + if (!cfg?.hooks) return; + for (const event of Object.keys(CLAUDE_HOOKS)) { + if (!Array.isArray(cfg.hooks[event])) continue; + cfg.hooks[event] = cfg.hooks[event].filter((g) => !isOurHookGroup(g)); + if (cfg.hooks[event].length === 0) delete cfg.hooks[event]; + } + if (Object.keys(cfg.hooks).length === 0) delete cfg.hooks; + writeJson(settingsPath, cfg); +} + +function mergeMcpJson(absTarget) { + const mcpPath = path.join(absTarget, ".mcp.json"); + const cfg = readJsonSafe(mcpPath, { mcpServers: {} }); + cfg.mcpServers ??= {}; + cfg.mcpServers.gitnexus = MCP_ENTRY; + writeJson(mcpPath, cfg); +} + +function removeMcpJson(absTarget) { + const mcpPath = path.join(absTarget, ".mcp.json"); + const cfg = readJsonSafe(mcpPath, null); + if (!cfg?.mcpServers?.gitnexus) return; + delete cfg.mcpServers.gitnexus; + if (Object.keys(cfg.mcpServers).length === 0) { + try { + fs.unlinkSync(mcpPath); + } catch { + /* ignore */ + } + } else { + writeJson(mcpPath, cfg); + } +} + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function mergeClaudeMd(absTarget, repoName) { + const fragmentPath = path.join(BUNDLE_ROOT, "templates/CLAUDE.gitnexus.md"); + const claudePath = path.join(absTarget, "CLAUDE.md"); + const fragment = substituteRepoName( + fs.readFileSync(fragmentPath, "utf8"), + repoName, + ); + const block = `${AGENTS_MARKER_BEGIN}\n${fragment.trim()}\n${AGENTS_MARKER_END}`; + const existing = fs.existsSync(claudePath) + ? fs.readFileSync(claudePath, "utf8") + : ""; + const re = new RegExp( + `${escapeRe(AGENTS_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MARKER_END)}\\n?`, + "m", + ); + const next = existing.match(re) + ? existing.replace(re, `${block}\n`) + : existing.trim() + ? `${existing.trimEnd()}\n\n${block}\n` + : `${block}\n`; + fs.writeFileSync(claudePath, next); +} + +function removeClaudeMdBlock(absTarget) { + const claudePath = path.join(absTarget, "CLAUDE.md"); + if (!fs.existsSync(claudePath)) return; + const re = new RegExp( + `\n?${escapeRe(AGENTS_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MARKER_END)}\\n?`, + "m", + ); + const next = fs.readFileSync(claudePath, "utf8").replace(re, "\n").trimEnd(); + if (next) fs.writeFileSync(claudePath, `${next}\n`); + else fs.unlinkSync(claudePath); +} + +/** @type {import('./cursor.mjs').Adapter} */ +export const claudeAdapter = { + id: "claude", + wants: (runtime) => /(^|,)(claude|all)(,|$)/.test(String(runtime)), + choice: { + key: "3", + value: "claude", + label: "Claude Code — hooks + MCP + skills + CLAUDE.md (hard enforcement)", + }, + skillLinkDir: ".claude/skills", + gitignoreLines: [".claude/skills/"], + backups: [], + + wire(absTarget, { repoName }) { + mergeMcpJson(absTarget); + mergeClaudeSettings(absTarget); + mergeClaudeMd(absTarget, repoName); + }, + + unwire(absTarget) { + removeMcpJson(absTarget); + removeClaudeSettings(absTarget); + removeClaudeMdBlock(absTarget); + }, + + nextSteps() { + return { + pre: ["Restart Claude Code / run `claude` in this repo (MCP + hooks load on start)"], + post: [ + "Confirm enforcement is live: `npm run gitnexus:agent-status`", + "Approve the gitnexus MCP server when Claude Code prompts on first run", + ], + }; + }, + + manifestFlags: () => ({ claudeManaged: true }), +}; diff --git a/lib/adapters/cursor.mjs b/lib/adapters/cursor.mjs new file mode 100644 index 0000000..407eb16 --- /dev/null +++ b/lib/adapters/cursor.mjs @@ -0,0 +1,108 @@ +/** + * Cursor adapter — IDE-specific wiring for the vendor-agnostic install core. + * + * The core (lib/kit.mjs) knows nothing about Cursor; it loops over the active + * adapters and calls this contract. To add a new IDE, add a sibling module with + * the same shape and register it in ./index.mjs — no core edits required. + * + * @typedef {Object} Adapter + * @property {string} id + * @property {(runtime: import('../constants.mjs').Runtime) => boolean} wants + * @property {{ key: string, value: string, label: string }} choice Interactive picker entry. + * @property {string|null} skillLinkDir Repo-relative dir to symlink the skill store into. + * @property {string[]} gitignoreLines IDE-specific .gitignore entries. + * @property {{ rel: string, bak: string }[]} backups Files to back up before bundle copy. + * @property {(absTarget: string, ctx: { repoName: string }) => void} wire + * @property {(absTarget: string, manifest: Record) => void} unwire + * @property {(ctx: { repoName: string }) => { pre: string[], post: string[] }} nextSteps + * @property {(manifest: Record) => Record} manifestFlags + */ +import fs from "node:fs"; +import path from "node:path"; +import { readJsonSafe, writeJson } from "./json-util.mjs"; + +const MCP_ENTRY = { command: "npx", args: ["-y", "gitnexus@latest", "mcp"] }; + +/** Per-session runtime files Cursor hooks scribble into .cursor/ — removed on uninstall. */ +// Cursor-only artifacts to unlink on uninstall. Shared session state lives under +// .gnkit/ and is removed wholesale by the core uninstall (rmRf .gnkit). +const CURSOR_RUNTIME_FILES = [".cursor/gitnexus-teaching-bundle.json"]; + +function unlinkQuiet(p) { + try { + fs.unlinkSync(p); + } catch { + /* absent */ + } +} + +function restoreBackup(absTarget, bakRel, destRel) { + const bak = path.join(absTarget, bakRel); + const dest = path.join(absTarget, destRel); + if (!fs.existsSync(bak)) return false; + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(bak, dest); + fs.unlinkSync(bak); + return true; +} + +/** @type {Adapter} */ +export const cursorAdapter = { + id: "cursor", + wants: (runtime) => runtime === "cursor" || runtime === "both", + choice: { + key: "1", + value: "cursor", + label: "Cursor — hooks + MCP + skills (hard enforcement)", + }, + skillLinkDir: ".cursor/skills", + // Shared .gnkit/ session state is ignored by the core base snippet; these are + // the Cursor-specific paths only. + gitignoreLines: [ + ".cursor/skills/", + ".cursor/gitnexus-teaching-bundle.json", + ".cursor/gn-kit-manifest.json", + ], + backups: [ + { rel: ".cursor/hooks.json", bak: ".cursor/hooks.json.gn-kit.bak" }, + { rel: ".cursor/mcp.json", bak: ".cursor/mcp.json.gn-kit.bak" }, + ], + + wire(absTarget) { + const mcpPath = path.join(absTarget, ".cursor/mcp.json"); + const cfg = readJsonSafe(mcpPath, { mcpServers: {} }); + cfg.mcpServers ??= {}; + cfg.mcpServers.gitnexus = MCP_ENTRY; + writeJson(mcpPath, cfg); + }, + + unwire(absTarget, manifest = {}) { + // hooks.json: restore pre-install backup if we made one, else remove ours. + if (!restoreBackup(absTarget, manifest.backups?.["hooks.json"], ".cursor/hooks.json")) { + unlinkQuiet(path.join(absTarget, ".cursor/hooks.json")); + } + // mcp.json: restore backup, else strip just the gitnexus server. + if (manifest.mcpManaged ?? true) { + const mcpBak = manifest.backups?.["mcp.json"]; + if (!restoreBackup(absTarget, mcpBak, ".cursor/mcp.json")) { + const mcpPath = path.join(absTarget, ".cursor/mcp.json"); + const cfg = readJsonSafe(mcpPath, null); + if (cfg?.mcpServers?.gitnexus) { + delete cfg.mcpServers.gitnexus; + if (Object.keys(cfg.mcpServers).length === 0) unlinkQuiet(mcpPath); + else writeJson(mcpPath, cfg); + } + } + } + for (const rel of CURSOR_RUNTIME_FILES) unlinkQuiet(path.join(absTarget, rel)); + }, + + nextSteps() { + return { + pre: ["Restart Cursor on this project (MCP + hooks load on restart)"], + post: ["Open a new Agent chat"], + }; + }, + + manifestFlags: () => ({ mcpManaged: true }), +}; diff --git a/lib/adapters/index.mjs b/lib/adapters/index.mjs new file mode 100644 index 0000000..a2d18be --- /dev/null +++ b/lib/adapters/index.mjs @@ -0,0 +1,30 @@ +/** + * Adapter registry — the single place that knows which IDE adapters exist. + * + * The install/uninstall core (lib/kit.mjs) is vendor-agnostic: it resolves the + * active adapters for a runtime and drives them through the Adapter contract + * (see ./cursor.mjs). Adding an IDE = add a module + one line here. + */ +import { runtimeIds } from "../kit-shared.mjs"; +import { cursorAdapter } from "./cursor.mjs"; +import { zedAdapter } from "./zed.mjs"; +import { claudeAdapter } from "./claude.mjs"; + +/** @type {import('./cursor.mjs').Adapter[]} */ +export const ADAPTERS = [cursorAdapter, zedAdapter, claudeAdapter]; + +/** @param {import('../constants.mjs').Runtime} runtime */ +export function activeAdapters(runtime) { + const ids = runtimeIds(runtime); + return ADAPTERS.filter((a) => ids.has(a.id)); +} + +/** + * Skill-store symlink targets for the active runtime (e.g. .cursor/skills, .agents/skills). + * @param {import('../constants.mjs').Runtime} runtime + */ +export function skillLinkDirs(runtime) { + return activeAdapters(runtime) + .map((a) => a.skillLinkDir) + .filter(Boolean); +} diff --git a/lib/adapters/json-util.mjs b/lib/adapters/json-util.mjs new file mode 100644 index 0000000..5a45cb5 --- /dev/null +++ b/lib/adapters/json-util.mjs @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Read + parse a JSON file, returning `fallback` if it is missing OR malformed. + * Never throws — a hand-edited broken config must not abort install/uninstall. + * @template T + * @param {string} filePath + * @param {T} fallback + * @returns {T} + */ +export function readJsonSafe(filePath, fallback) { + if (!fs.existsSync(filePath)) return fallback; + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return fallback; + } +} + +/** @param {string} filePath @param {unknown} obj */ +export function writeJson(filePath, obj) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(obj, null, 2) + "\n"); +} + +/** + * Deep-merge plain objects (arrays are replaced, not concatenated). + * @param {Record} base + * @param {Record} patch + * @returns {Record} + */ +export function deepMerge(base, patch) { + const out = { ...base }; + for (const [k, v] of Object.entries(patch)) { + if ( + v && + typeof v === "object" && + !Array.isArray(v) && + base[k] && + typeof base[k] === "object" && + !Array.isArray(base[k]) + ) { + out[k] = deepMerge(base[k], v); + } else { + out[k] = v; + } + } + return out; +} diff --git a/lib/adapters/zed.mjs b/lib/adapters/zed.mjs new file mode 100644 index 0000000..e621caf --- /dev/null +++ b/lib/adapters/zed.mjs @@ -0,0 +1,157 @@ +/** + * Zed adapter — Zed + Ollama wiring for the vendor-agnostic install core. + * Same Adapter contract as ./cursor.mjs. See that file for the typedef. + */ +import fs from "node:fs"; +import path from "node:path"; +import { BUNDLE_ROOT, substituteRepoName } from "../kit-shared.mjs"; +import { + AGENTS_MARKER_BEGIN, + AGENTS_MARKER_END, + ZED_PROFILE_KEY, + ZED_PROFILE_NAME, +} from "../constants.mjs"; +import { readJsonSafe, writeJson, deepMerge } from "./json-util.mjs"; + +/** Model names this adapter seeds into language_models.ollama — removed on uninstall. */ +const SEEDED_OLLAMA_MODELS = ["qwen2.5-coder:14b", "deepseek-r1:14b"]; + +// Portable MCP entry. MUST NOT bake a machine-specific absolute path (e.g. +// /Users//.nvm/.../bin/gitnexus) into the COMMITTED .zed/settings.json: +// Zed project settings win over user settings, so a hardcoded path breaks the MCP +// server for every teammate whose node/gitnexus lives elsewhere. Matches Cursor + Claude. +const MCP_ENTRY = { command: "npx", args: ["-y", "gitnexus@latest", "mcp"] }; + +function zedGitnexusFragment() { + return { + context_servers: { + gitnexus: { command: MCP_ENTRY.command, args: MCP_ENTRY.args, env: {} }, + }, + agent: { + profiles: { + [ZED_PROFILE_KEY]: { + name: ZED_PROFILE_NAME, + tools: { grep: false, fetch: false }, + enable_all_context_servers: false, + context_servers: { gitnexus: { tools: { "*": true } } }, + }, + }, + }, + language_models: { + ollama: { + available_models: SEEDED_OLLAMA_MODELS.map((name) => ({ + name, + display_name: + name === "qwen2.5-coder:14b" + ? "Qwen 2.5 Coder 14B (tools)" + : "DeepSeek R1 14B (tools)", + supports_tools: true, + })), + }, + }, + }; +} + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function mergeZedSettings(absTarget) { + const settingsPath = path.join(absTarget, ".zed/settings.json"); + const cfg = readJsonSafe(settingsPath, {}); + const merged = deepMerge(cfg, zedGitnexusFragment()); + // Drop legacy profile key (was misleadingly named "GitNexus" only). + if (merged.agent?.profiles?.gitnexus) delete merged.agent.profiles.gitnexus; + writeJson(settingsPath, merged); +} + +function mergeAgentsMd(absTarget, repoName) { + const fragmentPath = path.join(BUNDLE_ROOT, "templates/AGENTS.gitnexus.md"); + const agentsPath = path.join(absTarget, "AGENTS.md"); + const fragment = substituteRepoName( + fs.readFileSync(fragmentPath, "utf8"), + repoName, + ); + const block = `${AGENTS_MARKER_BEGIN}\n${fragment.trim()}\n${AGENTS_MARKER_END}`; + const existing = fs.existsSync(agentsPath) + ? fs.readFileSync(agentsPath, "utf8") + : ""; + const re = new RegExp( + `${escapeRe(AGENTS_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MARKER_END)}\\n?`, + "m", + ); + const next = existing.match(re) + ? existing.replace(re, `${block}\n`) + : existing.trim() + ? `${existing.trimEnd()}\n\n${block}\n` + : `${block}\n`; + fs.writeFileSync(agentsPath, next); +} + +function removeAgentsMdBlock(absTarget) { + const agentsPath = path.join(absTarget, "AGENTS.md"); + if (!fs.existsSync(agentsPath)) return; + const re = new RegExp( + `\n?${escapeRe(AGENTS_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MARKER_END)}\\n?`, + "m", + ); + const next = fs.readFileSync(agentsPath, "utf8").replace(re, "\n").trimEnd(); + if (next) fs.writeFileSync(agentsPath, `${next}\n`); + else fs.unlinkSync(agentsPath); +} + +export function removeZedSettings(absTarget) { + const settingsPath = path.join(absTarget, ".zed/settings.json"); + const cfg = readJsonSafe(settingsPath, null); + if (!cfg) return; + if (cfg.context_servers?.gitnexus) delete cfg.context_servers.gitnexus; + if (cfg.agent?.profiles?.gitnexus) delete cfg.agent.profiles.gitnexus; + if (cfg.agent?.profiles?.[ZED_PROFILE_KEY]) + delete cfg.agent.profiles[ZED_PROFILE_KEY]; + // Remove only the models we seeded; leave the user's own Ollama models intact. + const models = cfg.language_models?.ollama?.available_models; + if (Array.isArray(models)) { + cfg.language_models.ollama.available_models = models.filter( + (m) => !SEEDED_OLLAMA_MODELS.includes(m?.name), + ); + if (cfg.language_models.ollama.available_models.length === 0) + delete cfg.language_models.ollama; + } + writeJson(settingsPath, cfg); +} + +/** @type {import('./cursor.mjs').Adapter} */ +export const zedAdapter = { + id: "zed", + wants: (runtime) => runtime === "zed" || runtime === "both", + choice: { + key: "2", + value: "zed", + label: "Zed — MCP + skills + agent profile (Ollama/local friendly)", + }, + skillLinkDir: ".agents/skills", + gitignoreLines: [".agents/skills/"], + backups: [], + + wire(absTarget, { repoName }) { + mergeZedSettings(absTarget); + mergeAgentsMd(absTarget, repoName); + }, + + unwire(absTarget) { + removeZedSettings(absTarget); + removeAgentsMdBlock(absTarget); + }, + + nextSteps() { + return { + pre: ["Restart Zed / reopen project (trust worktree for .agents/skills/)"], + post: [ + `Agent panel → select profile **${ZED_PROFILE_NAME}**`, + "For Ollama: pick a model with supports_tools in .zed/settings.json", + ], + }; + }, + + manifestFlags: () => ({ zedManaged: true }), +}; diff --git a/lib/constants.mjs b/lib/constants.mjs index e727517..d12ea6c 100644 --- a/lib/constants.mjs +++ b/lib/constants.mjs @@ -1,19 +1,18 @@ -/** @typedef {'cursor' | 'zed' | 'both'} Runtime */ +/** @typedef {'cursor' | 'zed' | 'claude' | 'both' | 'all' | string} Runtime */ export const KIT_NAME = 'gitnexus-agent-kit'; -export const KIT_NAME_LEGACY = 'cursor-gitnexus-kit'; /** Primary manifest (IDE-neutral). */ export const MANIFEST_PATH = '.gitnexus/agent-kit-manifest.json'; /** Legacy Cursor-only manifest — read for migration, removed on update. */ export const MANIFEST_PATH_LEGACY = '.cursor/gn-kit-manifest.json'; -export const SKILLS_STORE = '.gitnexus/agent-kit/skills'; +export const SKILLS_STORE = '.gnkit/skills'; export const AGENTS_MARKER_BEGIN = ''; export const AGENTS_MARKER_END = ''; -/** @type {Runtime[]} */ -export const VALID_RUNTIMES = ['cursor', 'zed', 'both']; +/** Adapter ids + aliases. A runtime may also be a comma-list (e.g. "cursor,claude"). */ +export const VALID_RUNTIMES = ['cursor', 'zed', 'claude', 'both', 'all']; /** Zed agent profile — settings key + display name shown in Agent panel. */ export const ZED_PROFILE_KEY = 'zed-gitnexus'; @@ -22,21 +21,25 @@ export const ZED_PROFILE_NAME = 'Zed + GitNexus'; export const GITIGNORE_MARKER = '# GitNexus + gitnexus-agent-kit generated local state'; export const GITIGNORE_MARKER_LEGACY = '# GitNexus + cursor-gitnexus-kit generated local state'; -/** @param {string} v */ +/** @param {string} v Comma-list of adapter ids and/or aliases (cursor,zed,claude,both,all). */ export function parseRuntime(v) { const r = String(v || 'both').toLowerCase(); - if (!VALID_RUNTIMES.includes(/** @type {Runtime} */ (r))) { - throw new Error(`Invalid runtime "${v}". Use: cursor, zed, or both`); + const tokens = r.split(',').map((t) => t.trim()).filter(Boolean); + const bad = tokens.filter((t) => !VALID_RUNTIMES.includes(/** @type {Runtime} */ (t))); + if (!tokens.length || bad.length) { + throw new Error( + `Invalid runtime "${v}". Use any of: ${VALID_RUNTIMES.join(', ')} (comma-separated allowed).`, + ); } - return /** @type {Runtime} */ (r); + return /** @type {Runtime} */ (tokens.join(',')); } /** @param {Runtime} runtime */ export function wantsCursor(runtime) { - return runtime === 'cursor' || runtime === 'both'; + return /(^|,)(cursor|both|all)(,|$)/.test(String(runtime)); } /** @param {Runtime} runtime */ export function wantsZed(runtime) { - return runtime === 'zed' || runtime === 'both'; + return /(^|,)(zed|both|all)(,|$)/.test(String(runtime)); } diff --git a/lib/kit-shared.mjs b/lib/kit-shared.mjs index 585e4d9..99a8ee5 100644 --- a/lib/kit-shared.mjs +++ b/lib/kit-shared.mjs @@ -42,6 +42,30 @@ export function substituteRepoName(content, repoName) { /** Paths never copied verbatim — handled by dedicated installers. */ const BUNDLE_SKIP_PREFIXES = ["skills/", ".claude/skills/"]; +/** + * Runtime aliases. A runtime string is a comma-list of adapter ids and/or these + * aliases; `runtimeIds` expands it to a Set of concrete adapter ids. Keeping this + * in the (dependency-free) shared module lets both the copy filter and the adapter + * registry agree on membership without an import cycle. + */ +const RUNTIME_ALIASES = { + both: ["cursor", "zed"], + all: ["cursor", "zed", "claude"], +}; + +/** @param {string} runtime @returns {Set} */ +export function runtimeIds(runtime) { + const ids = new Set(); + for (const tok of String(runtime || "") + .toLowerCase() + .split(",") + .map((t) => t.trim()) + .filter(Boolean)) { + for (const id of RUNTIME_ALIASES[tok] ?? [tok]) ids.add(id); + } + return ids; +} + /** @param {string} rel */ export function isBundleSkipped(rel) { return BUNDLE_SKIP_PREFIXES.some((p) => rel.startsWith(p)); @@ -54,12 +78,12 @@ export function isBundleSkipped(rel) { export function shouldCopyBundleFile(rel, runtime) { if (isBundleSkipped(rel)) return false; if (rel.startsWith("templates/")) return false; - // Zed-only installs still need shared helper modules because scripts/gitnexus-agent.mjs - // imports health/brief/verification utilities from .cursor/hooks/lib. - if (rel.startsWith(".cursor/hooks/lib/")) return true; - if (rel === ".cursor/gitnexus-hooks.json") return true; - if (rel.startsWith(".cursor/")) { - return runtime === "cursor" || runtime === "both"; - } + // Shared helper modules + policy config ship for every runtime — zed/claude + // CLIs and hook glue import health/brief/classify utilities from .gnkit/lib. + if (rel.startsWith(".gnkit/lib/")) return true; + if (rel === ".gnkit/gitnexus-hooks.json") return true; + const ids = runtimeIds(runtime); + if (rel.startsWith(".cursor/")) return ids.has("cursor"); + if (rel.startsWith(".claude/")) return ids.has("claude"); return true; } diff --git a/lib/kit.mjs b/lib/kit.mjs index c148efd..1983215 100644 --- a/lib/kit.mjs +++ b/lib/kit.mjs @@ -1,7 +1,10 @@ #!/usr/bin/env node /** - * gitnexus-agent-kit — install / update / uninstall core - * Supports Cursor, Zed (+ Ollama), or both via --runtime. + * gitnexus-agent-kit — install / update / uninstall core (vendor-agnostic). + * + * This module knows nothing about any specific IDE. Per-IDE wiring lives in + * lib/adapters/* and is driven through the Adapter contract; this core just + * resolves the active adapters for a runtime and loops over them. */ import fs from "node:fs"; import path from "node:path"; @@ -22,23 +25,15 @@ import { MANIFEST_PATH_LEGACY, GITIGNORE_MARKER, parseRuntime, - wantsCursor, - wantsZed, - ZED_PROFILE_NAME, - ZED_PROFILE_KEY, } from "./constants.mjs"; +import { activeAdapters, skillLinkDirs } from "./adapters/index.mjs"; +import { readJsonSafe } from "./adapters/json-util.mjs"; import { migrateLegacyInstall } from "./migrate.mjs"; import { materializeSkillsStore, linkSkillsForRuntime, unlinkSkillLinks, } from "./skills.mjs"; -import { - mergeZedSettings, - mergeAgentsMd, - removeAgentsMdBlock, - removeZedSettings, -} from "./zed.mjs"; import { flatGitnexusScripts, allManagedScriptKeys, @@ -65,31 +60,25 @@ export const GITNEXUS_NPM_SCRIPTS = flatGitnexusScripts(); export { GITIGNORE_MARKER }; -const GITIGNORE_SNIPPET = ` -${GITIGNORE_MARKER} (safe to remove via gn-agent-kit uninstall) -.gitnexus/ -.tmp-agent/ -.gitnexus/agent-kit/ -.cursor/skills/ -.agents/skills/ -.cursor/gitnexus-teaching-bundle.json -.cursor/gn-kit-manifest.json -.gitnexus/agent-kit-manifest.json -.cursor/.gitnexus-session-edits.flag -.cursor/.gitnexus-session-primed.flag -.cursor/.gitnexus-prompt-hint.json -.cursor/.gitnexus-refresh-pending.flag -.cursor/.gitnexus-refresh-failed.flag -.cursor/.gitnexus-mcp-used.flag -.cursor/.gitnexus-impact-used.flag -.cursor/.gitnexus-detect-used.flag -.cursor/.gitnexus-staleness-cache.json -.cursor/.gitnexus-scorecard.json -.cursor/.gitnexus-deny-cache.json -.cursor/.gitnexus-session-health.json -.cursor/.gitnexus-session-user-notified.flag -.cursor/gitnexus-api-profile.json -`; +/** Shared (vendor-neutral) ignore entries; adapters contribute IDE-specific lines. */ +const GITIGNORE_BASE = [ + // The graph index is machine-specific (absolute repoPath baked in) + large → + // regenerated per-machine via agent-refresh. Covers the manifest too. + ".gitnexus/", + ".tmp-agent/", + // .gnkit/ holds the TRACKED kit payload (hook lib, policy config, skill store — + // teammates get these via git). Only per-session runtime state + the derived + // API profile are ignored; the IDE skill symlink dirs are ignored + regenerated. + ".gnkit/.gitnexus-*", + ".gnkit/gitnexus-api-profile.json", +]; + +/** @param {import('./constants.mjs').Runtime} runtime */ +function buildGitignoreSnippet(runtime) { + const lines = [...GITIGNORE_BASE]; + for (const a of activeAdapters(runtime)) lines.push(...a.gitignoreLines); + return `\n${GITIGNORE_MARKER} (safe to remove via gn-agent-kit uninstall)\n${lines.join("\n")}\n`; +} /** @returns {string[]} */ export function listBundleFiles() { @@ -144,21 +133,6 @@ function backupIfExists(targetRoot, rel, backupRel) { return backupRel; } -/** @param {string} absTarget @param {import('./constants.mjs').Runtime} runtime */ -export function mergeMcpJson(absTarget, runtime) { - if (!wantsCursor(runtime)) return; - const mcpPath = path.join(absTarget, ".cursor/mcp.json"); - const entry = { command: "npx", args: ["-y", "gitnexus@latest", "mcp"] }; - let cfg = { mcpServers: {} }; - if (fs.existsSync(mcpPath)) { - cfg = JSON.parse(fs.readFileSync(mcpPath, "utf8")); - } - cfg.mcpServers ??= {}; - cfg.mcpServers.gitnexus = entry; - fs.mkdirSync(path.dirname(mcpPath), { recursive: true }); - fs.writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + "\n"); -} - /** @param {string} targetRoot @param {string} [repoName] */ export function mergePackageScripts(targetRoot, repoName) { const name = repoName ?? path.basename(targetRoot); @@ -168,13 +142,14 @@ export function mergePackageScripts(targetRoot, repoName) { }); } -/** @param {string} targetRoot */ -export function appendGitignore(targetRoot) { +/** @param {string} targetRoot @param {import('./constants.mjs').Runtime} runtime */ +export function appendGitignore(targetRoot, runtime = "both") { const gi = path.join(targetRoot, ".gitignore"); const existing = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : ""; if (existing.includes(GITIGNORE_MARKER)) return []; - fs.appendFileSync(gi, GITIGNORE_SNIPPET); - return GITIGNORE_SNIPPET.trim().split("\n").filter(Boolean); + const snippet = buildGitignoreSnippet(parseRuntime(runtime)); + fs.appendFileSync(gi, snippet); + return snippet.trim().split("\n").filter(Boolean); } /** @param {string} targetRoot */ @@ -204,13 +179,12 @@ export function removeGitignoreSnippet(targetRoot) { fs.writeFileSync(gi, out.join("\n").replace(/\n+$/, "\n")); } -/** @param {string} targetRoot */ -export function removePackageScripts(targetRoot) { +/** @param {string} targetRoot @param {string[]} [keys] Keys to remove (defaults to all managed). */ +export function removePackageScripts(targetRoot, keys) { const pkgPath = path.join(targetRoot, "package.json"); - if (!fs.existsSync(pkgPath)) return; - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")); - if (!pkg.scripts) return; - for (const key of allManagedScriptKeys()) { + const pkg = readJsonSafe(pkgPath, null); + if (!pkg?.scripts) return; + for (const key of keys ?? allManagedScriptKeys()) { delete pkg.scripts[key]; } fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); @@ -221,8 +195,9 @@ export function readManifest(absTarget) { for (const rel of [MANIFEST_PATH, MANIFEST_PATH_LEGACY]) { const p = path.join(absTarget, rel); if (fs.existsSync(p)) { - const m = JSON.parse(fs.readFileSync(p, "utf8")); - if (!m.runtime) m.runtime = "cursor"; + const m = readJsonSafe(p, null); + if (!m) continue; + if (!m.runtime) m.runtime = "both"; return { path: rel, data: m }; } } @@ -259,6 +234,7 @@ export function installKit(targetRoot, opts = {}) { } const runtime = migration.runtime; + const adapters = activeAdapters(runtime); const repoName = opts.repoName ?? migration.legacyManifest?.repoName ?? @@ -270,19 +246,11 @@ export function installKit(targetRoot, opts = {}) { step(3, 7, `Copy bundle (runtime: ${runtime})`); const backups = {}; - if (wantsCursor(runtime)) { - const b1 = backupIfExists( - absTarget, - ".cursor/hooks.json", - ".cursor/hooks.json.gn-kit.bak", - ); - if (b1) backups["hooks.json"] = b1; - const b2 = backupIfExists( - absTarget, - ".cursor/mcp.json", - ".cursor/mcp.json.gn-kit.bak", - ); - if (b2) backups["mcp.json"] = b2; + for (const adapter of adapters) { + for (const b of adapter.backups) { + const made = backupIfExists(absTarget, b.rel, b.bak); + if (made) backups[path.basename(b.rel)] = made; + } } const files = []; @@ -301,24 +269,24 @@ export function installKit(targetRoot, opts = {}) { const skillNames = materializeSkillsStore(absTarget, repoName); linkSkillsForRuntime(absTarget, runtime); ok( - `${skillNames.length} skills → .gitnexus/agent-kit/skills/ (+ IDE symlinks)`, + `${skillNames.length} skills → .gnkit/skills/ (+ IDE symlinks)`, ); step(5, 7, "Wire MCP, npm gates, IDE config"); - mergeMcpJson(absTarget, runtime); - if (wantsZed(runtime)) { - mergeZedSettings(absTarget); - mergeAgentsMd(absTarget, repoName); - ok("Zed: .zed/settings.json + AGENTS.md"); + let manifestFlags = {}; + for (const adapter of adapters) { + adapter.wire(absTarget, { repoName }); + manifestFlags = { ...manifestFlags, ...adapter.manifestFlags() }; + ok(`${adapter.id}: wired`); } const scriptStats = mergePackageScripts(absTarget, repoName); - appendGitignore(absTarget); + appendGitignore(absTarget, runtime); ok( `package.json: ${scriptStats.added} added, ${scriptStats.updated} updated (${scriptStats.total} gitnexus entries)`, ); - if (wantsCursor(runtime)) ok("Cursor: MCP + hooks bundle"); step(6, 7, "Write manifest & chmod hooks"); + const npmScripts = allManagedScriptKeys(); const manifest = { kit: KIT_NAME, kitVersion: kitPkg.version, @@ -327,11 +295,10 @@ export function installKit(targetRoot, opts = {}) { runtime, files, skills: skillNames, - npmScripts: allManagedScriptKeys(), + npmScripts, gitignoreMarker: GITIGNORE_MARKER, backups, - mcpManaged: wantsCursor(runtime), - zedManaged: wantsZed(runtime), + ...manifestFlags, }; try { @@ -348,10 +315,8 @@ export function installKit(targetRoot, opts = {}) { if (opts.runSetup !== false) { step(7, 7, "Run gitnexus-setup.sh (index + sync)"); - const setupFlags = ["--skip-global-mcp"]; + const setupFlags = ["--skip-global-mcp", "--runtime", runtime]; if (opts.quick) setupFlags.push("--quick"); - if (wantsZed(runtime) && !wantsCursor(runtime)) - setupFlags.push("--runtime", "zed"); const r = spawnSync("bash", ["scripts/gitnexus-setup.sh", ...setupFlags], { cwd: absTarget, stdio: "inherit", @@ -370,7 +335,7 @@ export function installKit(targetRoot, opts = {}) { } if (opts.runSetup !== false && !opts.skipVerify) { - runVerify(absTarget, runtime); + runVerify(absTarget); } printInstallComplete(absTarget, repoName, mode, runtime, { @@ -380,12 +345,13 @@ export function installKit(targetRoot, opts = {}) { return manifest; } -/** @param {string} absTarget @param {import('./constants.mjs').Runtime} runtime */ -function runVerify(absTarget, runtime) { +/** @param {string} absTarget */ +function runVerify(absTarget) { console.log(""); const verifyScript = path.join(absTarget, "scripts/gitnexus-verify.mjs"); - const fallback = path.join(absTarget, ".cursor/hooks/lib/verify-kit.mjs"); + const fallback = path.join(absTarget, ".gnkit/lib/verify-kit.mjs"); const script = fs.existsSync(verifyScript) ? verifyScript : fallback; + if (!fs.existsSync(script)) return; const r = spawnSync(process.execPath, [script, absTarget], { cwd: absTarget, stdio: "inherit", @@ -418,37 +384,24 @@ function printInstallComplete( { label: "Repository", value: repoName, status: "ok" }, { label: "Runtime", value: runtime, status: "ok" }, { label: "Path", value: absTarget, status: "info" }, - { - label: "Index", - value: indexValue, - status: indexStatus, - }, + { label: "Index", value: indexValue, status: indexStatus }, ], }); - const steps = [ + const pre = []; + const post = []; + for (const adapter of activeAdapters(runtime)) { + const ns = adapter.nextSteps({ repoName }); + pre.push(...(ns.pre ?? [])); + post.push(...(ns.post ?? [])); + } + nextSteps([ + ...pre, "npm run gitnexus:verify — full kit check", "npm run gitnexus:health — human-friendly status", - ]; - if (wantsCursor(runtime)) { - steps.unshift( - "Restart Cursor on this project (MCP + hooks load on restart)", - ); - steps.push("Open a new Agent chat"); - } - if (wantsZed(runtime)) { - steps.unshift( - "Restart Zed / reopen project (trust worktree for .agents/skills/)", - ); - steps.push(`Agent panel → select profile **${ZED_PROFILE_NAME}**`); - steps.push( - "For Ollama: pick a model with supports_tools in .zed/settings.json", - ); - } - steps.push( + ...post, "npm run gitnexus.__gate.1.session — agent gate docs in package.json", - ); - nextSteps(steps); + ]); } /** @param {string} targetRoot */ @@ -560,7 +513,7 @@ export function uninstallKit(targetRoot, opts = {}) { throw new Error(`Not installed (missing ${MANIFEST_PATH})`); } const manifest = prev.data; - const runtime = parseRuntime(manifest.runtime ?? "cursor"); + const runtime = parseRuntime(manifest.runtime ?? "both"); for (const rel of manifest.files ?? []) { const abs = path.join(absTarget, rel); @@ -581,38 +534,21 @@ export function uninstallKit(targetRoot, opts = {}) { } catch { /* ignore */ } + // Shared neutral kit dir (hook lib + policy config + session state) — kit-owned. + rmRf(path.join(absTarget, ".gnkit")); - removePackageScripts(absTarget); + // Remove exactly the npm scripts we installed (manifest-recorded), falling + // back to the live managed set for pre-manifest installs. + removePackageScripts(absTarget, manifest.npmScripts); removeGitignoreSnippet(absTarget); - if (wantsCursor(runtime)) { - if (manifest.backups?.["hooks.json"]) { - restoreBackup( - absTarget, - manifest.backups["hooks.json"], - ".cursor/hooks.json", - ); - } else { - try { - fs.unlinkSync(path.join(absTarget, ".cursor/hooks.json")); - } catch { - /* ignore */ - } - } - if (manifest.mcpManaged) { - removeGitnexusMcp(absTarget, manifest.backups?.["mcp.json"]); - } - } - - if (wantsZed(runtime)) { - removeZedSettings(absTarget); - removeAgentsMdBlock(absTarget); + for (const adapter of activeAdapters(runtime)) { + adapter.unwire(absTarget, manifest); } for (const p of [ MANIFEST_PATH, MANIFEST_PATH_LEGACY, - ".cursor/gitnexus-teaching-bundle.json", ".cursor/hooks.json.gn-kit.bak", ".cursor/mcp.json.gn-kit.bak", ]) { @@ -633,39 +569,6 @@ export function uninstallKit(targetRoot, opts = {}) { pruneEmptyDirs(path.join(absTarget, ".zed"), absTarget); } -function restoreBackup(targetRoot, backupRel, destRel) { - const bak = path.join(targetRoot, backupRel); - const dest = path.join(targetRoot, destRel); - if (fs.existsSync(bak)) { - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.copyFileSync(bak, dest); - fs.unlinkSync(bak); - } -} - -/** @param {string} targetRoot */ -function removeGitnexusMcp(targetRoot, mcpBackupRel) { - const mcpPath = path.join(targetRoot, ".cursor/mcp.json"); - if (mcpBackupRel) { - restoreBackup(targetRoot, mcpBackupRel, ".cursor/mcp.json"); - return; - } - if (!fs.existsSync(mcpPath)) return; - const cfg = JSON.parse(fs.readFileSync(mcpPath, "utf8")); - if (cfg.mcpServers?.gitnexus) { - delete cfg.mcpServers.gitnexus; - if (Object.keys(cfg.mcpServers).length === 0) { - try { - fs.unlinkSync(mcpPath); - } catch { - /* ignore */ - } - } else { - fs.writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + "\n"); - } - } -} - function pruneEmptyDirs(dir, stopAt) { let cur = dir; while (cur.startsWith(stopAt) && cur !== stopAt) { @@ -703,13 +606,14 @@ export function cliMain(argv) { if (!cmd || cmd === "--help" || cmd === "-h") { console.log(`Usage: - node lib/kit.mjs install [--runtime cursor|zed|both] [--repo-name NAME] [--quick] [--no-setup] [--skip-verify] + node lib/kit.mjs install [--runtime cursor|zed|claude|both|all] [--repo-name NAME] [--quick] [--no-setup] [--skip-verify] node lib/kit.mjs install --interactive - node lib/kit.mjs update [--runtime cursor|zed|both] [--full] [--no-setup] [--skip-verify] - node lib/kit.mjs update-all [--runtime cursor|zed|both] [--no-setup] [--skip-verify] + node lib/kit.mjs update [--runtime ...] [--full] [--no-setup] [--skip-verify] + node lib/kit.mjs update-all [--runtime ...] [--no-setup] [--skip-verify] node lib/kit.mjs uninstall [--remove-index] - Runtime: cursor (hooks), zed (MCP + skills + profile), both (default) + Runtime: cursor (hooks) · zed (MCP + skills + profile) · claude (Claude Code hooks + MCP + skills + CLAUDE.md) + both = cursor+zed (default) · all = cursor+zed+claude · or a comma list e.g. "cursor,claude" update defaults to --quick (bundle + skills, skip index). Pass --full to rebuild .gitnexus/ update-all scans for .gitnexus/agent-kit-manifest.json under the search root.`); process.exit(cmd ? 0 : 2); diff --git a/lib/kit.test.mjs b/lib/kit.test.mjs index 495d56f..41b775f 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -24,6 +24,29 @@ import { } from "./constants.mjs"; import { migrateLegacyInstall } from "./migrate.mjs"; +/** + * Copy hook files into a tmp repo, routing `lib/*` to the neutral .gnkit/lib and + * `.sh` entry hooks to .cursor/hooks (matching the installed layout). + */ +function copyHookFiles(tmp, entries) { + fs.mkdirSync(path.join(tmp, ".gnkit/lib"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".cursor/hooks"), { recursive: true }); + for (const rel of entries) { + if (rel.startsWith("lib/")) { + const name = rel.slice(4); + fs.copyFileSync( + path.join(BUNDLE_ROOT, ".gnkit/lib", name), + path.join(tmp, ".gnkit/lib", name), + ); + } else { + fs.copyFileSync( + path.join(BUNDLE_ROOT, ".cursor/hooks", rel), + path.join(tmp, ".cursor/hooks", rel), + ); + } + } +} + /** Create a tmp git repo with hook files copied and a fresh|stale .gitnexus/meta.json. */ function setupKitRepo({ fresh = true } = {}) { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-kit-")); @@ -46,8 +69,7 @@ function setupKitRepo({ fresh = true } = {}) { }), ); - fs.mkdirSync(path.join(tmp, ".cursor/hooks/lib"), { recursive: true }); - const hooks = [ + copyHookFiles(tmp, [ "gitnexus-edit-guard.sh", "gitnexus-commit-guard.sh", "lib/first-nudge.mjs", @@ -58,13 +80,9 @@ function setupKitRepo({ fresh = true } = {}) { "lib/rename-helpers.mjs", "lib/stale-policy.mjs", "lib/session-primer.mjs", - ]; - for (const rel of hooks) { - fs.copyFileSync( - path.join(BUNDLE_ROOT, ".cursor/hooks", rel), - path.join(tmp, ".cursor/hooks", rel), - ); - } + "lib/classify.mjs", + "lib/cursor-emit.mjs", + ]); fs.chmodSync(path.join(tmp, ".cursor/hooks/gitnexus-edit-guard.sh"), 0o755); fs.chmodSync(path.join(tmp, ".cursor/hooks/gitnexus-commit-guard.sh"), 0o755); return tmp; @@ -91,11 +109,11 @@ describe("gitnexus-agent-kit", () => { it("runtime filter skips cursor paths for zed-only", () => { assert.equal(shouldCopyBundleFile(".cursor/hooks.json", "zed"), false); assert.equal( - shouldCopyBundleFile(".cursor/hooks/lib/agent-health.mjs", "zed"), + shouldCopyBundleFile(".gnkit/lib/agent-health.mjs", "zed"), true, ); assert.equal( - shouldCopyBundleFile(".cursor/gitnexus-hooks.json", "zed"), + shouldCopyBundleFile(".gnkit/gitnexus-hooks.json", "zed"), true, ); assert.equal( @@ -201,7 +219,7 @@ describe("gitnexus-agent-kit", () => { }); assert.ok(fs.existsSync(path.join(tmp, ".zed/settings.json"))); assert.ok( - fs.existsSync(path.join(tmp, ".cursor/hooks/lib/agent-health.mjs")), + fs.existsSync(path.join(tmp, ".gnkit/lib/agent-health.mjs")), "zed-only installs shared health helpers used by scripts/gitnexus-agent.mjs", ); assert.ok( @@ -212,6 +230,13 @@ describe("gitnexus-agent-kit", () => { fs.readFileSync(path.join(tmp, ".zed/settings.json"), "utf8"), ); assert.ok(zed.context_servers?.gitnexus); + // Portable command — must NOT hardcode a machine-specific absolute path into + // the committed .zed/settings.json (breaks other teammates). + assert.equal(zed.context_servers.gitnexus.command, "npx"); + assert.ok( + !/(^|["/])(Users|home)\//.test(JSON.stringify(zed.context_servers.gitnexus)), + "no hardcoded absolute path in zed context_servers", + ); assert.ok(zed.agent?.profiles?.[ZED_PROFILE_KEY]); assert.equal(zed.agent.profiles[ZED_PROFILE_KEY].name, "Zed + GitNexus"); assert.ok( @@ -228,7 +253,7 @@ describe("gitnexus-agent-kit", () => { fs.existsSync( path.join( tmp, - ".gitnexus/agent-kit/skills/gitnexus-workspace/SKILL.md", + ".gnkit/skills/gitnexus-workspace/SKILL.md", ), ), ); @@ -237,6 +262,122 @@ describe("gitnexus-agent-kit", () => { fs.rmSync(tmp, { recursive: true, force: true }); }); + it("installKit claude runtime wires MCP, hooks, CLAUDE.md, skills", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-claude-")); + execSync("git init -q", { cwd: tmp }); + execSync("git config user.email t@t.com && git config user.name t", { + cwd: tmp, + shell: true, + }); + fs.writeFileSync(path.join(tmp, "f.txt"), "x"); + execSync("git add f.txt && git commit -q -m init", { cwd: tmp, shell: true }); + installKit(tmp, { + runtime: "claude", + quick: true, + runSetup: false, + skipVerify: true, + }); + // MCP via project .mcp.json + const mcp = JSON.parse(fs.readFileSync(path.join(tmp, ".mcp.json"), "utf8")); + assert.ok(mcp.mcpServers?.gitnexus, ".mcp.json has gitnexus server"); + // Hooks in .claude/settings.json + const settings = JSON.parse( + fs.readFileSync(path.join(tmp, ".claude/settings.json"), "utf8"), + ); + const pre = settings.hooks?.PreToolUse ?? []; + assert.ok( + pre.some((g) => /gitnexus-grep-guard/.test(g.hooks?.[0]?.command ?? "")), + "PreToolUse has the grep guard", + ); + assert.ok( + pre.some((g) => g.matcher === "Bash"), + "PreToolUse gates Bash (commit gate)", + ); + assert.ok(settings.hooks?.SessionStart?.length, "SessionStart hook wired"); + // Always-on contract in CLAUDE.md + assert.ok( + fs.readFileSync(path.join(tmp, "CLAUDE.md"), "utf8").includes("GitNexus"), + ); + // Skills symlinked into .claude/skills; shared hook lib shipped; no Cursor hooks. + assert.ok( + fs.existsSync(path.join(tmp, ".claude/skills/gitnexus-workspace/SKILL.md")), + ); + assert.ok( + fs.existsSync(path.join(tmp, ".claude/hooks/gitnexus-grep-guard.mjs")), + ); + assert.ok( + fs.existsSync(path.join(tmp, ".gnkit/lib/classify.mjs")), + "shared classify core ships for claude", + ); + assert.ok( + !fs.existsSync(path.join(tmp, ".cursor/hooks.json")), + "claude-only install must not enable Cursor hooks", + ); + const m = readManifest(tmp); + assert.equal(m?.data.runtime, "claude"); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("claude grep guard denies a symbol search via Claude's hook protocol", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-claude-hook-")); + execSync("git init -q", { cwd: tmp }); + execSync("git config user.email t@t.com && git config user.name t", { + cwd: tmp, + shell: true, + }); + fs.writeFileSync(path.join(tmp, "f.txt"), "x"); + execSync("git add f.txt && git commit -q -m init", { cwd: tmp, shell: true }); + const head = execSync("git rev-parse HEAD", { + cwd: tmp, + encoding: "utf8", + }).trim(); + fs.mkdirSync(path.join(tmp, ".gitnexus"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, ".gitnexus/meta.json"), + JSON.stringify({ lastCommit: head, stats: { nodes: 50, embeddings: 50 } }), + ); + fs.mkdirSync(path.join(tmp, ".gnkit/lib"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".claude/hooks"), { recursive: true }); + for (const f of [ + "classify.mjs", + "claude-emit.mjs", + "hook-helpers.mjs", + "cypher-helpers.mjs", + "rename-helpers.mjs", + "stale-policy.mjs", + "session-primer.mjs", + "load-staleness.mjs", + "check-staleness.mjs", + ]) { + fs.copyFileSync( + path.join(BUNDLE_ROOT, ".gnkit/lib", f), + path.join(tmp, ".gnkit/lib", f), + ); + } + fs.copyFileSync( + path.join(BUNDLE_ROOT, ".claude/hooks/gitnexus-grep-guard.mjs"), + path.join(tmp, ".claude/hooks/gitnexus-grep-guard.mjs"), + ); + const r = spawnSync( + process.execPath, + [path.join(tmp, ".claude/hooks/gitnexus-grep-guard.mjs")], + { + cwd: tmp, + input: JSON.stringify({ + tool_name: "Grep", + tool_input: { pattern: "UserService" }, + }), + encoding: "utf8", + env: { ...process.env, CLAUDE_PROJECT_DIR: tmp }, + }, + ); + const out = JSON.parse(r.stdout.trim()); + assert.equal(out.hookSpecificOutput.hookEventName, "PreToolUse"); + assert.equal(out.hookSpecificOutput.permissionDecision, "deny"); + assert.ok(out.hookSpecificOutput.permissionDecisionReason.includes("gitnexus_context")); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("updateKit can upgrade zed-only install to both runtimes", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-upboth-")); execSync("git init -q", { cwd: tmp }); @@ -301,7 +442,7 @@ describe("gitnexus-agent-kit", () => { `expected enforcement rule in bundle, got: ${files.filter((f) => f.includes("enforcement")).join(", ")}`, ); assert.ok(files.includes(".cursor/hooks.json")); - assert.ok(files.includes(".cursor/hooks/lib/load-staleness.mjs")); + assert.ok(files.includes(".gnkit/lib/load-staleness.mjs")); assert.ok(fs.existsSync(BUNDLE_ROOT)); }); @@ -348,21 +489,21 @@ describe("gitnexus-agent-kit", () => { it("bundle includes agent reasoning shortcuts", () => { const files = listBundleFiles(); - assert.ok(files.includes(".cursor/hooks/lib/hook-helpers.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/cypher-helpers.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/rename-helpers.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/detect-api-router.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/graph-smoke.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/agent-brief.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/agent-health.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/persistence-health.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/session-health-audit.mjs")); + assert.ok(files.includes(".gnkit/lib/hook-helpers.mjs")); + assert.ok(files.includes(".gnkit/lib/cypher-helpers.mjs")); + assert.ok(files.includes(".gnkit/lib/rename-helpers.mjs")); + assert.ok(files.includes(".gnkit/lib/detect-api-router.mjs")); + assert.ok(files.includes(".gnkit/lib/graph-smoke.mjs")); + assert.ok(files.includes(".gnkit/lib/agent-brief.mjs")); + assert.ok(files.includes(".gnkit/lib/agent-health.mjs")); + assert.ok(files.includes(".gnkit/lib/persistence-health.mjs")); + assert.ok(files.includes(".gnkit/lib/session-health-audit.mjs")); assert.ok(files.includes(".cursor/hooks/gitnexus-session-health.sh")); assert.ok(files.includes(".cursor/hooks/gitnexus-session-health-user.sh")); - assert.ok(files.includes(".cursor/gitnexus-hooks.json")); + assert.ok(files.includes(".gnkit/gitnexus-hooks.json")); assert.ok(files.includes("skills/gitnexus-security-review/SKILL.md")); const brief = fs.readFileSync( - path.join(BUNDLE_ROOT, ".cursor/hooks/lib/agent-brief.mjs"), + path.join(BUNDLE_ROOT, ".gnkit/lib/agent-brief.mjs"), "utf8", ); assert.ok(brief.includes("Skill routing:")); @@ -371,7 +512,7 @@ describe("gitnexus-agent-kit", () => { it("hook-helpers builds copy-paste MCP calls", async () => { const helpers = await import( - new URL("../bundle/.cursor/hooks/lib/hook-helpers.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/hook-helpers.mjs", import.meta.url) .href ); const call = helpers.mcpContext("fooBar", "my-repo"); @@ -432,7 +573,7 @@ describe("gitnexus-agent-kit", () => { it("cypher-helpers builds field access and call chain queries", async () => { const cypher = await import( - new URL("../bundle/.cursor/hooks/lib/cypher-helpers.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/cypher-helpers.mjs", import.meta.url) .href ); assert.ok(cypher.isLikelyFieldName("address")); @@ -465,11 +606,11 @@ describe("gitnexus-agent-kit", () => { it("rename-helpers and data-flow detection", async () => { const rename = await import( - new URL("../bundle/.cursor/hooks/lib/rename-helpers.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/rename-helpers.mjs", import.meta.url) .href ); const cypher = await import( - new URL("../bundle/.cursor/hooks/lib/cypher-helpers.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/cypher-helpers.mjs", import.meta.url) .href ); const parsed = rename.parseRenameFromPrompt( @@ -488,7 +629,7 @@ describe("gitnexus-agent-kit", () => { const { detectApiRouterProfile, writeApiRouterProfile, API_PROFILE_FILE } = await import( new URL( - "../bundle/.cursor/hooks/lib/detect-api-router.mjs", + "../bundle/.gnkit/lib/detect-api-router.mjs", import.meta.url, ).href ); @@ -529,11 +670,11 @@ describe("gitnexus-agent-kit", () => { it("stale policy requires refresh before classical fallback", async () => { const { evaluateStalePolicy } = await import( - new URL("../bundle/.cursor/hooks/lib/stale-policy.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/stale-policy.mjs", import.meta.url) .href ); const session = await import( - new URL("../bundle/.cursor/hooks/lib/session-primer.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url) .href ); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-stale-policy-")); @@ -575,9 +716,7 @@ describe("gitnexus-agent-kit", () => { stats: { nodes: 10, embeddings: 10 }, }), ); - fs.mkdirSync(path.join(tmp, ".cursor/hooks"), { recursive: true }); - fs.mkdirSync(path.join(tmp, ".cursor/hooks/lib"), { recursive: true }); - for (const f of [ + copyHookFiles(tmp, [ "gitnexus-shell-staleness-guard.sh", "lib/hook-helpers.mjs", "lib/stale-policy.mjs", @@ -586,16 +725,9 @@ describe("gitnexus-agent-kit", () => { "lib/check-staleness.mjs", "lib/cypher-helpers.mjs", "lib/rename-helpers.mjs", - "lib/graph-session.mjs", - ]) { - const src = f.startsWith("lib/") - ? path.join(BUNDLE_ROOT, ".cursor/hooks", f) - : path.join(BUNDLE_ROOT, ".cursor/hooks", f); - fs.copyFileSync( - src, - path.join(tmp, ".cursor/hooks", f.replace(/^lib\//, "lib/")), - ); - } + "lib/classify.mjs", + "lib/cursor-emit.mjs", + ]); fs.chmodSync( path.join(tmp, ".cursor/hooks/gitnexus-shell-staleness-guard.sh"), 0o755, @@ -643,7 +775,7 @@ describe("gitnexus-agent-kit", () => { ); const check = path.join( BUNDLE_ROOT, - ".cursor/hooks/lib/check-staleness.mjs", + ".gnkit/lib/check-staleness.mjs", ); const r = spawnSync(process.execPath, [check, tmp], { encoding: "utf8" }); const out = JSON.parse(r.stdout.trim()); @@ -675,7 +807,7 @@ describe("gitnexus-agent-kit", () => { ); const check = path.join( BUNDLE_ROOT, - ".cursor/hooks/lib/check-staleness.mjs", + ".gnkit/lib/check-staleness.mjs", ); const r = spawnSync(process.execPath, [check, tmp], { encoding: "utf8" }); const out = JSON.parse(r.stdout.trim()); @@ -723,18 +855,18 @@ describe("gitnexus-agent-kit", () => { assert.ok(files.includes("scripts/gitnexus-teaching/script-gates.mjs")); assert.ok(files.includes("scripts/gitnexus-gate-hint.mjs")); assert.ok(files.includes("scripts/lib/setup-ui.mjs")); - assert.ok(files.includes(".cursor/hooks/lib/verify-kit.mjs")); + assert.ok(files.includes(".gnkit/lib/verify-kit.mjs")); const preCommit = fs.readFileSync( path.join(BUNDLE_ROOT, ".githooks/pre-commit"), "utf8", ); - assert.ok(preCommit.includes("npm run gitnexus:pdg")); + assert.ok(preCommit.includes("npm run gitnexus:full-pdg")); assert.ok(!preCommit.includes("npm run gitnexus:refresh")); }); it("verify-kit reports missing files on empty repo", async () => { const { verifyKitInstall } = await import( - new URL("../bundle/.cursor/hooks/lib/verify-kit.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/verify-kit.mjs", import.meta.url) .href ); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-verify-")); @@ -767,7 +899,7 @@ describe("gitnexus-agent-kit", () => { stats: { nodes: 10, embeddings: 10, processes: 2, communities: 1 }, }), ); - fs.mkdirSync(path.join(tmp, ".cursor/hooks/lib"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".gnkit/lib"), { recursive: true }); for (const f of [ "check-staleness.mjs", "cypher-helpers.mjs", @@ -778,8 +910,8 @@ describe("gitnexus-agent-kit", () => { "persistence-health.mjs", ]) { fs.copyFileSync( - path.join(BUNDLE_ROOT, ".cursor/hooks/lib", f), - path.join(tmp, ".cursor/hooks/lib", f), + path.join(BUNDLE_ROOT, ".gnkit/lib", f), + path.join(tmp, ".gnkit/lib", f), ); } fs.mkdirSync(path.join(tmp, ".cursor"), { recursive: true }); @@ -791,7 +923,7 @@ describe("gitnexus-agent-kit", () => { path.join(tmp, ".cursor/mcp.json"), JSON.stringify({ mcpServers: { gitnexus: {} } }), ); - const health = path.join(tmp, ".cursor/hooks/lib/agent-health.mjs"); + const health = path.join(tmp, ".gnkit/lib/agent-health.mjs"); const r = spawnSync(process.execPath, [health, tmp], { encoding: "utf8" }); assert.ok(r.stdout.includes("GitNexus Cursor Kit")); assert.ok(r.stdout.includes("Cypher")); @@ -835,7 +967,7 @@ describe("gitnexus-agent-kit", () => { it("hook config enforces polyglot source extensions", async () => { const helpers = await import( - new URL("../bundle/.cursor/hooks/lib/hook-helpers.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/hook-helpers.mjs", import.meta.url) .href ); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-poly-")); @@ -863,9 +995,9 @@ describe("gitnexus-agent-kit", () => { "CUDA headers should count as source", ); // Custom override narrows the set. - fs.mkdirSync(path.join(tmp, ".cursor"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".gnkit"), { recursive: true }); fs.writeFileSync( - path.join(tmp, ".cursor/gitnexus-hooks.json"), + path.join(tmp, ".gnkit/gitnexus-hooks.json"), JSON.stringify({ sourceExts: ["js"] }), ); const narrowed = helpers.loadHookConfig(tmp); @@ -879,7 +1011,7 @@ describe("gitnexus-agent-kit", () => { it("cypher field access matches Methods (untyped source node)", async () => { const cypher = await import( - new URL("../bundle/.cursor/hooks/lib/cypher-helpers.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/cypher-helpers.mjs", import.meta.url) .href ); const q = cypher.cypherFieldAccess("balance", "r"); @@ -893,12 +1025,12 @@ describe("gitnexus-agent-kit", () => { it("staleness load caches result within TTL", async () => { const tmp = setupKitRepo({ fresh: true }); - const load = path.join(tmp, ".cursor/hooks/lib/load-staleness.mjs"); + const load = path.join(tmp, ".gnkit/lib/load-staleness.mjs"); const first = spawnSync(process.execPath, [load, tmp], { encoding: "utf8", }); assert.equal(JSON.parse(first.stdout.trim()).fresh, true); - const cacheFile = path.join(tmp, ".cursor/.gitnexus-staleness-cache.json"); + const cacheFile = path.join(tmp, ".gnkit/.gitnexus-staleness-cache.json"); assert.ok(fs.existsSync(cacheFile), "cache file written after first load"); const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8")); assert.equal(cached.data.fresh, true); @@ -909,7 +1041,7 @@ describe("gitnexus-agent-kit", () => { it("edit-guard enforces impact-before-edit when fresh", async () => { const tmp = setupKitRepo({ fresh: true }); const session = await import( - new URL("../bundle/.cursor/hooks/lib/session-primer.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url) .href ); @@ -933,7 +1065,7 @@ describe("gitnexus-agent-kit", () => { it("commit-guard requires detect_changes before commit when fresh", async () => { const tmp = setupKitRepo({ fresh: true }); const session = await import( - new URL("../bundle/.cursor/hooks/lib/session-primer.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url) .href ); @@ -969,9 +1101,212 @@ describe("gitnexus-agent-kit", () => { fs.rmSync(tmp, { recursive: true, force: true }); }); + it("compaction middleware: source-aware clear + durable memory helpers", async () => { + const session = await import( + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url).href + ); + // Genuine new session clears; compaction/resume preserves (same task continues). + assert.equal(session.shouldClearOnSource("startup"), true); + assert.equal(session.shouldClearOnSource("clear"), true); + assert.equal(session.shouldClearOnSource("compact"), false); + assert.equal(session.shouldClearOnSource("resume"), false); + assert.equal(session.shouldClearOnSource(undefined), true); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-mem-")); + const origHome = process.env.HOME; + process.env.HOME = tmp; // keep the test off the real ~/.claude + try { + const mem = session.memoryPath(tmp); + assert.ok( + mem.includes("/.claude/projects/") && mem.endsWith("memory/MEMORY.md"), + "memory is Claude Code's native per-project file", + ); + assert.ok(mem.startsWith(tmp), "HOME override contains the write"); + session.appendMemoryCheckpoint(tmp, "note-1"); + const c1 = fs.readFileSync(mem, "utf8"); + assert.ok(c1.includes("Project working memory") && c1.includes("note-1")); + session.appendMemoryCheckpoint(tmp, "note-2"); + const c2 = fs.readFileSync(mem, "utf8"); + assert.ok(c2.includes("note-1") && c2.includes("note-2"), "appends, never overwrites"); + } finally { + process.env.HOME = origHome; + } + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("claude SessionStart preserves gates on compact, clears on startup", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-compact-")); + execSync("git init -q", { cwd: tmp }); + execSync("git config user.email t@t.com && git config user.name t", { + cwd: tmp, + shell: true, + }); + fs.writeFileSync(path.join(tmp, "f.txt"), "x"); + execSync("git add f.txt && git commit -q -m init", { cwd: tmp, shell: true }); + const head = execSync("git rev-parse HEAD", { cwd: tmp, encoding: "utf8" }).trim(); + fs.mkdirSync(path.join(tmp, ".gitnexus"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, ".gitnexus/meta.json"), + JSON.stringify({ lastCommit: head, stats: { nodes: 50, embeddings: 50 } }), + ); + fs.mkdirSync(path.join(tmp, ".gnkit/lib"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".claude/hooks"), { recursive: true }); + for (const f of [ + "claude-emit.mjs", + "session-primer.mjs", + "hook-helpers.mjs", + "cypher-helpers.mjs", + "rename-helpers.mjs", + "stale-policy.mjs", + "load-staleness.mjs", + "check-staleness.mjs", + ]) { + fs.copyFileSync( + path.join(BUNDLE_ROOT, ".gnkit/lib", f), + path.join(tmp, ".gnkit/lib", f), + ); + } + fs.copyFileSync( + path.join(BUNDLE_ROOT, ".claude/hooks/gitnexus-session.mjs"), + path.join(tmp, ".claude/hooks/gitnexus-session.mjs"), + ); + const session = await import( + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url).href + ); + const runSession = (source) => + spawnSync(process.execPath, [path.join(tmp, ".claude/hooks/gitnexus-session.mjs")], { + cwd: tmp, + input: JSON.stringify({ source }), + encoding: "utf8", + env: { ...process.env, CLAUDE_PROJECT_DIR: tmp, HOME: tmp }, + }); + + // Mark a satisfied gate, then COMPACT → gate must survive + recovery brief. + session.setMcpToolUsed(tmp, "gitnexus_impact"); + assert.ok(session.isImpactUsed(tmp)); + const compact = runSession("compact"); + assert.ok(session.isImpactUsed(tmp), "compaction must NOT clear satisfied gates"); + const cout = JSON.parse(compact.stdout.trim()); + assert.match(cout.hookSpecificOutput.additionalContext, /COMPACTED|preserved/i); + assert.match(cout.hookSpecificOutput.additionalContext, /MEMORY\.md/); + // Regression guard: the recovery brief must RE-STATE graph-first discipline, not only + // memory recovery. Post-compaction is exactly where agents drift back to grep; dropping + // the playbook here made the agent "respect GN less" mid-session. + const rc = cout.hookSpecificOutput.additionalContext; + assert.match(rc, /graph-first still applies/i); + assert.match(rc, /gitnexus_query/); + assert.match(rc, /detect_changes before/i); + assert.doesNotMatch(rc, /do NOT re-run the ✓ ones/i); // the discouraging wording is gone + + // A genuine startup DOES clear (new session). + runSession("startup"); + assert.ok(!session.isImpactUsed(tmp), "startup clears gates (new session)"); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("claude PreCompact hook emits no stdout (side-effect only) + checkpoints memory", async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-precompact-")); + execSync("git init -q", { cwd: tmp }); + execSync("git config user.email t@t.com && git config user.name t", { + cwd: tmp, + shell: true, + }); + fs.writeFileSync(path.join(tmp, "f.txt"), "x"); + execSync("git add f.txt && git commit -q -m init", { cwd: tmp, shell: true }); + const head = execSync("git rev-parse HEAD", { cwd: tmp, encoding: "utf8" }).trim(); + fs.mkdirSync(path.join(tmp, ".gitnexus"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, ".gitnexus/meta.json"), + JSON.stringify({ lastCommit: head, stats: { nodes: 50, embeddings: 50 } }), + ); + fs.mkdirSync(path.join(tmp, ".gnkit/lib"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".claude/hooks"), { recursive: true }); + for (const f of [ + "claude-emit.mjs", + "session-primer.mjs", + "hook-helpers.mjs", + "cypher-helpers.mjs", + "rename-helpers.mjs", + "stale-policy.mjs", + "load-staleness.mjs", + "check-staleness.mjs", + ]) { + fs.copyFileSync( + path.join(BUNDLE_ROOT, ".gnkit/lib", f), + path.join(tmp, ".gnkit/lib", f), + ); + } + fs.copyFileSync( + path.join(BUNDLE_ROOT, ".claude/hooks/gitnexus-precompact.mjs"), + path.join(tmp, ".claude/hooks/gitnexus-precompact.mjs"), + ); + const r = spawnSync( + process.execPath, + [path.join(tmp, ".claude/hooks/gitnexus-precompact.mjs")], + { + cwd: tmp, + input: JSON.stringify({ trigger: "auto" }), + encoding: "utf8", + env: { ...process.env, CLAUDE_PROJECT_DIR: tmp, HOME: tmp }, + }, + ); + assert.equal(r.status, 0, "hook exits cleanly"); + assert.equal( + r.stdout.trim(), + "", + "PreCompact emits NO stdout — Claude Code rejects additionalContext on PreCompact", + ); + const session = await import( + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url).href + ); + const origHome = process.env.HOME; + process.env.HOME = tmp; + const mem = session.memoryPath(tmp); + process.env.HOME = origHome; + assert.ok(fs.existsSync(mem), "checkpoint written to Claude project memory"); + assert.ok(fs.readFileSync(mem, "utf8").includes("checkpoint")); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("telemetry archives each session's scorecard and aggregates", async () => { + const session = await import( + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url).href + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-telemetry-")); + + // Session 1: two grep redirects + a graph call, then session start clears it. + session.bumpScore(tmp, "grepRedirects"); + session.bumpScore(tmp, "grepRedirects"); + session.bumpScore(tmp, "graphCalls"); + session.clearSessionState(tmp); // flushes then wipes the scorecard + assert.ok( + !fs.existsSync(path.join(tmp, ".gnkit/.gitnexus-scorecard.json")), + "scorecard cleared after session", + ); + + // Session 2: one impact gate. + session.bumpScore(tmp, "impactGate"); + session.clearSessionState(tmp); + + const records = session.readTelemetry(tmp); + assert.equal(records.length, 2, "one telemetry record per session"); + assert.equal(records[0].counts.grepRedirects, 2); + + const s = session.summarizeTelemetry(records); + assert.equal(s.sessions, 2); + assert.equal(s.totals.grepRedirects, 2); + assert.equal(s.totals.impactGate, 1); + assert.equal(s.avgPerSession.graphCalls, 0.5); + + // An empty session records nothing. + session.clearSessionState(tmp); + assert.equal(session.readTelemetry(tmp).length, 2, "empty session not logged"); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("session scorecard counts enforcement events", async () => { const session = await import( - new URL("../bundle/.cursor/hooks/lib/session-primer.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url) .href ); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-score-")); @@ -1003,7 +1338,7 @@ describe("gitnexus-agent-kit", () => { it("persistence-health classifies database failures", async () => { const { classifyPersistenceOutput, inspectPersistence } = await import( new URL( - "../bundle/.cursor/hooks/lib/persistence-health.mjs", + "../bundle/.gnkit/lib/persistence-health.mjs", import.meta.url, ).href ); @@ -1018,7 +1353,7 @@ describe("gitnexus-agent-kit", () => { it("cypher-cli parses tables, counts, and JSON", async () => { const { parseRows, parseCount, firstColumn } = await import( - new URL("../bundle/.cursor/hooks/lib/cypher-cli.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/cypher-cli.mjs", import.meta.url) .href ); const rows = parseRows( @@ -1036,7 +1371,7 @@ describe("gitnexus-agent-kit", () => { it("commit-message drafts a template offline (no staged code)", async () => { const { draftCommitMessage } = await import( - new URL("../bundle/.cursor/hooks/lib/commit-message.mjs", import.meta.url) + new URL("../bundle/.gnkit/lib/commit-message.mjs", import.meta.url) .href ); const tmp = setupKitRepo({ fresh: true }); @@ -1049,7 +1384,7 @@ describe("gitnexus-agent-kit", () => { it("generate-arch-doc writes stats doc from meta.json", async () => { const { generateArchDoc, ARCH_DOC_PATH } = await import( new URL( - "../bundle/.cursor/hooks/lib/generate-arch-doc.mjs", + "../bundle/.gnkit/lib/generate-arch-doc.mjs", import.meta.url, ).href ); @@ -1066,7 +1401,7 @@ describe("gitnexus-agent-kit", () => { it("arch-doc reports reason when no index present", async () => { const { generateArchDoc } = await import( new URL( - "../bundle/.cursor/hooks/lib/generate-arch-doc.mjs", + "../bundle/.gnkit/lib/generate-arch-doc.mjs", import.meta.url, ).href ); @@ -1106,10 +1441,216 @@ describe("gitnexus-agent-kit", () => { ); }); + it("contract files are generated from the single canonical source", async () => { + const gen = await import( + new URL("../scripts/gen-contract.mjs", import.meta.url).href + ); + const rendered = gen.renderAll(); + for (const [file, expected] of Object.entries(rendered)) { + const onDisk = fs.readFileSync(file, "utf8"); + assert.equal( + onDisk, + expected, + `${path.basename(file)} is stale — run \`npm run gen:contract\` after editing scripts/contract/enforcement-contract.md`, + ); + } + // The canonical contract teaches the v1.6.8 tools (no skill/rule drift). + const body = fs.readFileSync(gen.CONTRACT_SRC, "utf8"); + for (const tool of ["pdg_query", "trace", "explain"]) { + assert.ok(body.includes(tool), `contract must teach ${tool}`); + } + }); + + it("classify grep gate closes the quote and one-MCP-unlock loopholes", async () => { + const { classifyGrep } = await import( + new URL("../bundle/.gnkit/lib/classify.mjs", import.meta.url).href + ); + const helpers = await import( + new URL("../bundle/.gnkit/lib/hook-helpers.mjs", import.meta.url) + .href + ); + const config = helpers.loadHookConfig("/no/such/root"); + const base = { + phase: "fresh", + graphUsed: false, + config, + repo: "r", + root: "/tmp/x", + staleMustRefreshMsg: "STALE", + staleFallbackMsg: "FALLBACK", + }; + const grep = (pattern, extra = {}, over = {}) => + classifyGrep( + { tool: "Grep", toolInput: { pattern, ...extra } }, + { ...base, ...over }, + ); + + // PascalCase symbol → deny → context. + let v = grep("UserService"); + assert.equal(v.decision, "deny"); + assert.ok(v.agentMessage.includes("gitnexus_context")); + + // QUOTE BYPASS CLOSED: quoting a symbol no longer reads as a literal — + // it is still routed to the graph (context for PascalCase) and denied. + v = grep('"UserService"'); + assert.equal(v.decision, "deny"); + assert.ok(v.agentMessage.includes("gitnexus_context")); + + // A quoted lowercase identifier is also still denied (routed to cypher). + v = grep('"validateUser"'); + assert.equal(v.decision, "deny"); + assert.ok(/ACCESSES|gitnexus_cypher|gitnexus_context/.test(v.agentMessage)); + + // ONE-MCP-UNLOCK CLOSED: scoped source grep stays denied even after graph use. + v = grep("calculateExposure", { path: "src/risk.js" }, { graphUsed: true }); + assert.equal(v.decision, "deny"); + + // Field-shaped term → routed to the graph (cypher/context), still denied. + v = grep("balance"); + assert.equal(v.decision, "deny"); + assert.ok(/ACCESSES|gitnexus_cypher|gitnexus_context/.test(v.agentMessage)); + + // Genuine literal phrase → allowed (real grep use). + assert.equal(grep("user not found").decision, "allow"); + + // Searching inside a non-source config/doc file → allowed even if id-shaped. + assert.equal(grep("version", { path: "package.json" }).decision, "allow"); + assert.equal(grep("retries", { path: "docs/config.md" }).decision, "allow"); + + // SemanticSearch always routes to hybrid query. + v = classifyGrep( + { tool: "SemanticSearch", toolInput: { query: "auth flow" } }, + base, + ); + assert.equal(v.decision, "deny"); + assert.ok(v.agentMessage.includes("search_query")); + + // Stale phases: symbol denied under must_refresh, config literal allowed, + // classical_fallback lets everything through. + assert.equal(grep("getUserById", {}, { phase: "must_refresh" }).decision, "deny"); + assert.equal( + grep("version", { path: "package.json" }, { phase: "must_refresh" }) + .decision, + "allow", + ); + assert.equal( + grep("getUserById", {}, { phase: "classical_fallback" }).decision, + "allow", + ); + }); + + it("classify closes the shell-grep escape hatch + alternation loophole", async () => { + const { classifyShell, classifyGrep } = await import( + new URL("../bundle/.gnkit/lib/classify.mjs", import.meta.url).href + ); + const helpers = await import( + new URL("../bundle/.gnkit/lib/hook-helpers.mjs", import.meta.url).href + ); + const config = helpers.loadHookConfig("/no/such/root"); + const base = { + phase: "fresh", + graphUsed: false, + config, + repo: "r", + root: "/tmp/x", + staleMustRefreshMsg: "STALE", + staleFallbackMsg: "FALLBACK", + }; + const shell = (command, over = {}) => + classifyShell({ command }, { ...base, ...over }); + const grep = (pattern, extra = {}) => + classifyGrep({ tool: "Grep", toolInput: { pattern, ...extra } }, base); + + // ALTERNATION LOOPHOLE CLOSED: `a\|b` of symbols is a symbol search, not a literal. + let v = grep("signedRequest\\|createBinanceFuturesTransport", { path: "src/a.js" }); + assert.equal(v.decision, "deny"); + assert.ok(v.agentMessage.includes("gitnexus_context")); + // decl / assignment branches are caught too + assert.equal(grep("isScaleIn =|const oppStop", { path: "src/a.js" }).decision, "deny"); + // but a real literal alternation over a non-source file is still fine + assert.equal(grep("error|warning", { path: "logs/app.log" }).decision, "allow"); + + // SHELL ESCAPE HATCH CLOSED: bash grep/rg/git-grep over source is gated like the tool. + assert.equal(shell("grep -n 'computeDryRunPnl' src/future/server/sync.js | head").decision, "deny"); + assert.equal(shell("grep -n 'signedRequest\\|createBinanceFuturesTransport' src/a.js").decision, "deny"); + assert.equal(shell("rg computeDryRunPnl src/").decision, "deny"); + assert.equal(shell("rg computeDryRunPnl").decision, "deny"); + assert.equal(shell("git grep signedRequest").decision, "deny"); + assert.equal(shell("grep -rn createBinanceFuturesTransport src").decision, "deny"); + + // LEGIT SHELL STAYS ALLOWED: piped filters, non-source, maintenance, read-only git. + assert.equal(shell("ps aux | grep node").decision, "allow"); + assert.equal(shell("npm ls | grep gitnexus").decision, "allow"); + assert.equal(shell("cat package.json | grep name").decision, "allow"); + assert.equal(shell("grep -n TODO README.md").decision, "allow"); + assert.equal(shell("npm run gitnexus:agent-refresh").decision, "allow"); + assert.equal(shell("git status").decision, "allow"); + + // Shell redirect message names the tool and points at the graph. + v = shell("rg createBinanceFuturesTransport src/"); + assert.ok(/bypasses the graph/.test(v.agentMessage)); + assert.ok(/gitnexus_(context|cypher)/.test(v.agentMessage)); + + // Phase behaviour: stale denies (refresh first); classical_fallback lets it through. + assert.equal(shell("grep foo src/a.js", { phase: "must_refresh" }).decision, "deny"); + assert.equal(shell("grep bar src/a.js", { phase: "classical_fallback" }).decision, "allow"); + }); + + it("classical-fallback escape hatch grants classical tools on a FRESH index", async () => { + const sp = await import( + new URL("../bundle/.gnkit/lib/session-primer.mjs", import.meta.url).href + ); + const { evaluateStalePolicy } = await import( + new URL("../bundle/.gnkit/lib/stale-policy.mjs", import.meta.url).href + ); + const { classifyShell } = await import( + new URL("../bundle/.gnkit/lib/classify.mjs", import.meta.url).href + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gn-fallback-")); + + // Baseline: a FRESH index means enforcement on (no classical). + assert.equal(evaluateStalePolicy({ fresh: true }, tmp).phase, "fresh"); + + // Grant → classical_fallback EVEN ON A FRESH INDEX, reason surfaced. + sp.grantClassicalFallback(tmp, "GN returned wrong callers"); + const g = sp.fallbackGrant(tmp); + assert.ok(g && g.reason === "GN returned wrong callers" && g.remainingMs > 0); + const p = evaluateStalePolicy({ fresh: true }, tmp); + assert.equal(p.phase, "classical_fallback"); + assert.equal(p.allowClassical, true); + assert.ok(p.override); + // a normally-redirected shell symbol grep is now allowed under the grant + const cfg = { mode: "warn", sourceExtRe: /\.(m?js|tsx?)$/i, sourcePathRes: [], broadGlobRes: [] }; + assert.equal( + classifyShell( + { command: "grep -n OrderService src/a.js" }, + { phase: p.phase, config: cfg, repo: "r", root: tmp, staleFallbackMsg: "FB" }, + ).decision, + "allow", + ); + + // Revoke → enforcement re-armed. + sp.revokeClassicalFallback(tmp); + assert.equal(sp.fallbackGrant(tmp), null); + assert.equal(evaluateStalePolicy({ fresh: true }, tmp).phase, "fresh"); + + // Expired grant self-cleans and does not grant. + sp.grantClassicalFallback(tmp, "stale", -1); + assert.equal(sp.fallbackGrant(tmp), null); + + // A new session (clearSessionState) drops any active grant → re-armed. + sp.grantClassicalFallback(tmp, "still wrong"); + assert.ok(sp.fallbackGrant(tmp)); + sp.clearSessionState(tmp); + assert.equal(sp.fallbackGrant(tmp), null); + + fs.rmSync(tmp, { recursive: true, force: true }); + }); + it("session-health-audit builds agent context and user message", async () => { const auditMod = await import( new URL( - "../bundle/.cursor/hooks/lib/session-health-audit.mjs", + "../bundle/.gnkit/lib/session-health-audit.mjs", import.meta.url, ).href ); diff --git a/lib/migrate.mjs b/lib/migrate.mjs index f8a4acd..547edb2 100644 --- a/lib/migrate.mjs +++ b/lib/migrate.mjs @@ -17,6 +17,7 @@ import { wantsZed, } from "./constants.mjs"; import { listSkillNames } from "./skills.mjs"; +import { removeZedSettings } from "./adapters/zed.mjs"; /** @typedef {{ actions: string[], legacyManifest: object|null, runtime: import('./constants.mjs').Runtime }} MigrateResult */ /** @@ -33,6 +34,8 @@ export function migrateLegacyInstall(absTarget, runtime) { migrateGitignore(absTarget, actions); cleanupLegacySkills(absTarget, rt, actions); cleanupLegacyClaudeSkills(absTarget, actions); + cleanupLegacyHookLib(absTarget, actions); + if (!wantsZed(rt)) cleanupOrphanedZed(absTarget, actions); migrateZedProfileKey(absTarget, actions); cleanupLegacyManifestFile(absTarget, actions); @@ -156,6 +159,84 @@ function cleanupLegacyClaudeSkills(absTarget, actions) { } } +/** + * Pre-relocation installs kept the shared hook lib, policy config, and per-session + * state under .cursor/. Those now live in the neutral .gnkit/ namespace, so remove + * the obsolete copies (they would otherwise sit orphaned next to the new ones). + * @param {string} absTarget @param {string[]} actions + */ +function cleanupLegacyHookLib(absTarget, actions) { + const legacyLib = path.join(absTarget, ".cursor/hooks/lib"); + if (fs.existsSync(legacyLib)) { + try { + fs.rmSync(legacyLib, { recursive: true, force: true }); + actions.push("hooks: removed legacy .cursor/hooks/lib (moved to .gnkit/lib)"); + } catch { + /* ignore */ + } + } + // Old skill-store location (moved to the tracked .gnkit/skills so teammates get + // skills via git). Held only the store; the manifest is .gitnexus/agent-kit-manifest.json. + const legacyStore = path.join(absTarget, ".gitnexus/agent-kit"); + if (fs.existsSync(legacyStore)) { + try { + fs.rmSync(legacyStore, { recursive: true, force: true }); + actions.push("skills: removed legacy .gitnexus/agent-kit store (moved to .gnkit/skills)"); + } catch { + /* ignore */ + } + } + for (const rel of [ + ".cursor/gitnexus-hooks.json", + ".cursor/gitnexus-api-profile.json", + ]) { + const abs = path.join(absTarget, rel); + if (!fs.existsSync(abs)) continue; + try { + fs.unlinkSync(abs); + actions.push(`hooks: removed legacy ${rel} (moved to .gnkit)`); + } catch { + /* ignore */ + } + } + // Per-session state flags/caches relocated from .cursor/ to .gnkit/. + const cursorDir = path.join(absTarget, ".cursor"); + if (fs.existsSync(cursorDir)) { + let removed = 0; + for (const f of fs.readdirSync(cursorDir)) { + if (/^\.gitnexus-.*\.(flag|json)$/.test(f)) { + try { + fs.unlinkSync(path.join(cursorDir, f)); + removed++; + } catch { + /* ignore */ + } + } + } + if (removed) actions.push(`hooks: cleared ${removed} legacy .cursor session-state file(s)`); + } +} + +/** + * When Zed is NOT in the active runtime, strip any orphaned gitnexus config left + * in a committed .zed/settings.json by a prior Zed install — otherwise Zed keeps + * spawning a stale (possibly absolute-path, teammate-breaking) MCP server. + * @param {string} absTarget @param {string[]} actions + */ +function cleanupOrphanedZed(absTarget, actions) { + const p = path.join(absTarget, ".zed/settings.json"); + if (!fs.existsSync(p)) return; + try { + const cfg = JSON.parse(fs.readFileSync(p, "utf8")); + if (cfg.context_servers?.gitnexus || cfg.agent?.profiles?.[ZED_PROFILE_KEY]) { + removeZedSettings(absTarget); + actions.push("zed: removed orphaned gitnexus config (Zed not in runtime)"); + } + } catch { + /* ignore malformed settings */ + } +} + /** @param {string} absTarget @param {string[]} actions */ function migrateZedProfileKey(absTarget, actions) { const settingsPath = path.join(absTarget, ".zed/settings.json"); diff --git a/lib/prompt.mjs b/lib/prompt.mjs index d71430a..f7c8c77 100644 --- a/lib/prompt.mjs +++ b/lib/prompt.mjs @@ -1,7 +1,8 @@ import readline from 'node:readline/promises'; import { stdin as input, stdout as output } from 'node:process'; import { c } from '../bundle/scripts/lib/setup-ui.mjs'; -import { VALID_RUNTIMES, parseRuntime } from './constants.mjs'; +import { parseRuntime } from './constants.mjs'; +import { ADAPTERS } from './adapters/index.mjs'; /** * @param {{ message: string, choices: { key: string, label: string }[] }} opts @@ -32,15 +33,19 @@ export async function pickChoice({ message, choices }) { /** @returns {Promise} */ export async function pickRuntimeInteractive() { + // Choices are derived from the adapter registry, plus a synthesized "both". + const adapterChoices = ADAPTERS.map((a) => ({ key: a.choice.key, label: a.choice.label })); + const allKey = String(ADAPTERS.length + 1); + const choices = [ + ...adapterChoices, + { key: allKey, label: 'All — every adapter (Cursor + Zed + Claude Code) in the same repo' }, + ]; + const map = Object.fromEntries(ADAPTERS.map((a) => [a.choice.key, a.choice.value])); + map[allKey] = 'all'; const key = await pickChoice({ message: 'Which agent environment do you use?', - choices: [ - { key: '1', label: 'Cursor — hooks + MCP + skills (hard enforcement)' }, - { key: '2', label: 'Zed — MCP + skills + agent profile (Ollama/local friendly)' }, - { key: '3', label: 'Both — Cursor hooks + Zed profile in the same repo' }, - ], + choices, }); - const map = { '1': 'cursor', '2': 'zed', '3': 'both' }; return parseRuntime(map[key]); } diff --git a/lib/skills.mjs b/lib/skills.mjs index d3fd7fc..231380d 100644 --- a/lib/skills.mjs +++ b/lib/skills.mjs @@ -1,9 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; -import { BUNDLE_ROOT, substituteRepoName } from './kit-shared.mjs'; +import { BUNDLE_ROOT, substituteRepoName, isTextCandidate } from './kit-shared.mjs'; import { SKILLS_STORE } from './constants.mjs'; -import { isTextCandidate } from './kit-shared.mjs'; -import { wantsCursor, wantsZed } from './constants.mjs'; +import { skillLinkDirs } from './adapters/index.mjs'; /** @param {string} dir */ export function listSkillNames(dir) { @@ -81,16 +80,8 @@ export function linkSkillsForRuntime(absTarget, runtime) { const names = listSkillNames(store); if (!names.length) return []; - if (wantsCursor(runtime)) { - const root = path.join(absTarget, '.cursor/skills'); - fs.mkdirSync(root, { recursive: true }); - for (const name of names) { - replaceWithSymlink(path.join(root, name), path.join(store, name)); - } - } - - if (wantsZed(runtime)) { - const root = path.join(absTarget, '.agents/skills'); + for (const dir of skillLinkDirs(runtime)) { + const root = path.join(absTarget, dir); fs.mkdirSync(root, { recursive: true }); for (const name of names) { replaceWithSymlink(path.join(root, name), path.join(store, name)); @@ -104,17 +95,10 @@ export function linkSkillsForRuntime(absTarget, runtime) { export function unlinkSkillLinks(absTarget, runtime) { const store = path.join(absTarget, SKILLS_STORE); const names = listSkillNames(store); - for (const name of names) { - if (wantsCursor(runtime)) { - try { - fs.rmSync(path.join(absTarget, '.cursor/skills', name), { recursive: true, force: true }); - } catch { - /* ignore */ - } - } - if (wantsZed(runtime)) { + for (const dir of skillLinkDirs(runtime)) { + for (const name of names) { try { - fs.rmSync(path.join(absTarget, '.agents/skills', name), { recursive: true, force: true }); + fs.rmSync(path.join(absTarget, dir, name), { recursive: true, force: true }); } catch { /* ignore */ } diff --git a/lib/verify-zed.mjs b/lib/verify-zed.mjs deleted file mode 100644 index 51d7fab..0000000 --- a/lib/verify-zed.mjs +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env node -/** - * Zed / zed-only verification for gitnexus-agent-kit. - * Usage: node lib/verify-zed.mjs [--runtime zed|both] - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { MANIFEST_PATH, ZED_PROFILE_KEY, ZED_PROFILE_NAME } from './constants.mjs'; -import { listSkillNames } from './skills.mjs'; -import { SKILLS_STORE } from './constants.mjs'; - -const root = process.argv[2] ?? process.cwd(); -const runtimeArg = process.argv.includes('--runtime') - ? process.argv[process.argv.indexOf('--runtime') + 1] - : 'zed'; - -function check(p, label) { - const ok = fs.existsSync(path.join(root, p)); - return { ok, label, detail: ok ? 'present' : 'missing' }; -} - -function isSymlinkTo(p, expectedTarget) { - try { - const st = fs.lstatSync(path.join(root, p)); - if (!st.isSymbolicLink()) return false; - const target = fs.readlinkSync(path.join(root, p)); - return path.resolve(path.dirname(path.join(root, p)), target) === path.resolve(root, expectedTarget); - } catch { - return false; - } -} - -export function verifyZedInstall(repoRoot) { - const checks = []; - checks.push(check(MANIFEST_PATH, 'agent-kit manifest')); - checks.push(check('.zed/settings.json', 'Zed project settings')); - checks.push(check('AGENTS.md', 'AGENTS.md instructions')); - - const store = path.join(repoRoot, SKILLS_STORE); - const names = listSkillNames(store); - checks.push({ - ok: names.length >= 10, - label: 'Canonical skills store', - detail: names.length ? `${names.length} skills in ${SKILLS_STORE}` : 'empty', - }); - - const linked = isSymlinkTo('.agents/skills/gitnexus-workspace', `${SKILLS_STORE}/gitnexus-workspace`); - checks.push({ - ok: linked, - label: 'Zed skills symlinks', - detail: linked ? 'gitnexus-workspace → store' : 'run kit install --runtime zed', - }); - - let zedCfg = {}; - try { - zedCfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.zed/settings.json'), 'utf8')); - } catch { - /* noop */ - } - checks.push({ - ok: Boolean(zedCfg.context_servers?.gitnexus), - label: 'GitNexus MCP (Zed)', - detail: zedCfg.context_servers?.gitnexus ? 'context_servers.gitnexus' : 'missing', - }); - checks.push({ - ok: Boolean(zedCfg.agent?.profiles?.[ZED_PROFILE_KEY]), - label: 'Zed + GitNexus agent profile', - detail: zedCfg.agent?.profiles?.[ZED_PROFILE_KEY] - ? `"${ZED_PROFILE_NAME}" — grep off, gitnexus MCP on` - : 'missing', - }); - - const failed = checks.filter((c) => !c.ok); - return { - root: repoRoot, - runtime: runtimeArg, - healthy: failed.length === 0, - passed: checks.length - failed.length, - failed: failed.length, - total: checks.length, - checks, - }; -} - -async function main() { - const ui = await import( - new URL('../bundle/scripts/lib/setup-ui.mjs', import.meta.url).href - ); - const report = verifyZedInstall(root); - ui.banner('GitNexus Kit — Zed verification', path.basename(root)); - ui.summaryTable({ - title: `Checks: ${report.passed}/${report.total} passed`, - rows: report.checks.map((c) => ({ - label: c.label, - value: c.detail, - status: c.ok ? 'ok' : 'fail', - })), - }); - if (report.healthy) { - ui.ok(`Zed wiring verified — select profile "${ZED_PROFILE_NAME}" in Agent panel`); - } else { - ui.fail('Zed kit incomplete'); - } - process.exit(report.healthy ? 0 : 1); -} - -const isMain = - process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]); -if (isMain) main(); diff --git a/lib/zed.mjs b/lib/zed.mjs deleted file mode 100644 index d25b419..0000000 --- a/lib/zed.mjs +++ /dev/null @@ -1,150 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { BUNDLE_ROOT, substituteRepoName } from './kit-shared.mjs'; -import { AGENTS_MARKER_BEGIN, AGENTS_MARKER_END, ZED_PROFILE_KEY, ZED_PROFILE_NAME } from './constants.mjs'; - -function resolveGitnexusCommand() { - const r = spawnSync('which', ['gitnexus'], { encoding: 'utf8' }); - if (r.status === 0 && (r.stdout || '').trim()) { - return { command: (r.stdout || '').trim(), args: ['mcp'] }; - } - return { command: 'npx', args: ['-y', 'gitnexus@latest', 'mcp'] }; -} - -/** @returns {Record} */ -function zedGitnexusFragment() { - const mcp = resolveGitnexusCommand(); - return { - context_servers: { - gitnexus: { - command: mcp.command, - args: mcp.args, - env: {}, - }, - }, - agent: { - profiles: { - [ZED_PROFILE_KEY]: { - name: ZED_PROFILE_NAME, - tools: { - grep: false, - fetch: false, - }, - enable_all_context_servers: false, - context_servers: { - gitnexus: { - tools: { '*': true }, - }, - }, - }, - }, - }, - language_models: { - ollama: { - available_models: [ - { - name: 'qwen2.5-coder:14b', - display_name: 'Qwen 2.5 Coder 14B (tools)', - supports_tools: true, - }, - { - name: 'deepseek-r1:14b', - display_name: 'DeepSeek R1 14B (tools)', - supports_tools: true, - }, - ], - }, - }, - }; -} - -/** Deep-merge plain objects (arrays replaced). */ -function deepMerge(base, patch) { - const out = { ...base }; - for (const [k, v] of Object.entries(patch)) { - if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object') { - out[k] = deepMerge(/** @type {Record} */ (base[k]), /** @type {Record} */ (v)); - } else { - out[k] = v; - } - } - return out; -} - -/** - * Merge GitNexus MCP + agent profile into `.zed/settings.json`. - * @param {string} absTarget - */ -export function mergeZedSettings(absTarget) { - const settingsPath = path.join(absTarget, '.zed/settings.json'); - let cfg = {}; - if (fs.existsSync(settingsPath)) { - cfg = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - } - const merged = deepMerge(cfg, zedGitnexusFragment()); - // Drop legacy profile key (was misleadingly named "GitNexus" only) - if (merged.agent?.profiles?.gitnexus) { - delete merged.agent.profiles.gitnexus; - } - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n'); -} - -/** - * Merge always-on enforcement block into project `AGENTS.md`. - * @param {string} absTarget - * @param {string} repoName - */ -export function mergeAgentsMd(absTarget, repoName) { - const fragmentPath = path.join(BUNDLE_ROOT, 'templates/AGENTS.gitnexus.md'); - const agentsPath = path.join(absTarget, 'AGENTS.md'); - const fragment = substituteRepoName(fs.readFileSync(fragmentPath, 'utf8'), repoName); - const block = `${AGENTS_MARKER_BEGIN}\n${fragment.trim()}\n${AGENTS_MARKER_END}`; - - let existing = ''; - if (fs.existsSync(agentsPath)) { - existing = fs.readFileSync(agentsPath, 'utf8'); - } - - const re = new RegExp( - `${escapeRe(AGENTS_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MARKER_END)}\\n?`, - 'm' - ); - const next = existing.match(re) - ? existing.replace(re, `${block}\n`) - : existing.trim() - ? `${existing.trimEnd()}\n\n${block}\n` - : `${block}\n`; - - fs.writeFileSync(agentsPath, next); -} - -/** @param {string} s */ -function escapeRe(s) { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** Remove kit block from AGENTS.md if present. */ -export function removeAgentsMdBlock(absTarget) { - const agentsPath = path.join(absTarget, 'AGENTS.md'); - if (!fs.existsSync(agentsPath)) return; - const re = new RegExp( - `\n?${escapeRe(AGENTS_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(AGENTS_MARKER_END)}\\n?`, - 'm' - ); - const next = fs.readFileSync(agentsPath, 'utf8').replace(re, '\n').trimEnd(); - if (next) fs.writeFileSync(agentsPath, `${next}\n`); - else fs.unlinkSync(agentsPath); -} - -/** Strip gitnexus keys from .zed/settings.json (best-effort). */ -export function removeZedSettings(absTarget) { - const settingsPath = path.join(absTarget, '.zed/settings.json'); - if (!fs.existsSync(settingsPath)) return; - const cfg = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - if (cfg.context_servers?.gitnexus) delete cfg.context_servers.gitnexus; - if (cfg.agent?.profiles?.gitnexus) delete cfg.agent.profiles.gitnexus; - if (cfg.agent?.profiles?.[ZED_PROFILE_KEY]) delete cfg.agent.profiles[ZED_PROFILE_KEY]; - fs.writeFileSync(settingsPath, JSON.stringify(cfg, null, 2) + '\n'); -} diff --git a/package.json b/package.json index 4651786..cbd31f5 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,12 @@ "node": ">=22.9.0" }, "scripts": { - "install": "./bin/install.sh", + "kit:install": "./bin/install.sh", "install:target": "./bin/install.sh", "update:target": "./bin/update.sh", "uninstall:target": "./bin/uninstall.sh", "eval": "node eval/run-eval.mjs", + "gen:contract": "node scripts/gen-contract.mjs", "test": "node --test lib/kit.test.mjs" }, "keywords": [ diff --git a/scripts/contract/agents-zed-footer.md b/scripts/contract/agents-zed-footer.md new file mode 100644 index 0000000..3437daa --- /dev/null +++ b/scripts/contract/agents-zed-footer.md @@ -0,0 +1,9 @@ +## Zed + local models (Ollama) + +- Select the **Zed + GitNexus** agent profile (grep disabled; gitnexus MCP enabled). +- Invoke `/gitnexus-enforcement` or `/gitnexus-workspace` when starting a hard task. +- Local models: keep MCP calls small (`query` limit 5, `impact` summaryOnly when exploring). + +## npm gates + +Run gated scripts from `package.json` when hooks remind you: `gitnexus.__gate.*` — they document the enforced playbook for this repo. diff --git a/scripts/contract/claude-footer.md b/scripts/contract/claude-footer.md new file mode 100644 index 0000000..de16886 --- /dev/null +++ b/scripts/contract/claude-footer.md @@ -0,0 +1,10 @@ +## Claude Code + +- The `gitnexus` MCP server is configured in `.mcp.json` — approve it on first run. +- Hooks in `.claude/settings.json` enforce the loop: symbol Grep → `gitnexus_context`, large source Read → `gitnexus_query`, edits gated on `gitnexus_impact`, `git commit` gated on `gitnexus_detect_changes`, and stale shell commands blocked until refresh. +- Skills live in `.claude/skills/` — invoke `/gitnexus-enforcement` or `/gitnexus-workspace` on hard tasks. +- Stale index or missing embeddings → run `npm run gitnexus:agent-refresh` (Bash, pre-approved); never ask the user to analyze. + +## npm gates + +Run gated scripts from `package.json` when hooks remind you: `gitnexus.__gate.*` — they document the enforced playbook for this repo. diff --git a/scripts/contract/enforcement-contract.md b/scripts/contract/enforcement-contract.md new file mode 100644 index 0000000..96a79d7 --- /dev/null +++ b/scripts/contract/enforcement-contract.md @@ -0,0 +1,157 @@ +## North star + +> **GitNexus is the default reasoning layer for every task — not a fallback when code is unfamiliar.** Prefer graph + embeddings when the index is fresh. Use `query` to orient (BM25 + vectors). Use `cypher` for precise structural graph questions. Refresh autonomously when stale or embeddings are missing. Classical tools only **after refresh fails** or GN is wrong — say why. + +**Model tiers:** the graph + gates improve **every** agent — budget/local models gain the most *relative* lift; flagship models waste fewer tokens and follow the same enforced loop. Local LLM / zero API cost: rebuild context freely; do not skip gates for speed. + +## Every task (not “unfamiliar code only”) + +Use the graph for **all** agent work — explore, debug, fix, refactor, review, rename, commit — not only architecture questions. + +| Task type | Graph role | +| --- | --- | +| Answer / explain / debug | `query` → `context` → `cypher` if structural → Read offset/limit | +| Field / property data flow | READ schema → `cypher` (`ACCESSES` read/write) | +| N-hop call chains, overrides, process steps | READ schema → `cypher` | +| Statement-level data/control flow, taint | `pdg_query` / `explain` / `trace` (see deep precision) | +| Edit runtime source (any size) | `impact` upstream before Write/StrReplace | +| Refactor / rename / shared code | `impact` + `rename` dry_run OR `context` on hub symbols | +| Review / “what did I change?” | `detect_changes`; `query` to orient | +| Session start | `agent-brief` or repo context; confirm kit health | + +**Anti-patterns:** reserving GitNexus for big exploratory prompts; grep/read from memory on “familiar” files; grepping field names instead of `cypher`; **StrReplace/find-and-replace for symbol renames** instead of `rename` dry_run; skipping `impact` on “small” edits; jumping to `context`/`impact`/`grep` without `query` first (skips embeddings). `SemanticSearch` is blocked — use `query`. + +## Graph + embeddings + cypher (layered) + +| Need | Tool | Why | +| --- | --- | --- | +| Orient — any fuzzy or grounding step | `query` | Hybrid BM25 + **embedding** vectors (RRF) | +| One symbol, callers, 360° | `context` | Structural graph (canned API) | +| **Precise structural graph questions** | **`cypher`** | Raw traversals the canned tools don't express | +| Pre-edit blast radius | `impact` | Graph traversal | +| Pre-commit / done | `detect_changes` | Diff → processes | + +### When to escalate to `cypher` (after `query` / `context`) + +READ `gitnexus://repo/__GITNEXUS_REPO__/schema` before ad-hoc Cypher. + +| Question | Cypher edge / pattern | +| --- | --- | +| Who reads/writes field/property X? | `ACCESSES` with `reason: read` / `write` | +| Custom N-hop call chain | `CALLS` variable-length path | +| Method override chain | `METHOD_OVERRIDES` | +| Ordered steps in a process | `STEP_IN_PROCESS` + `r.step` | +| All methods on a class | `HAS_METHOD` | +| Diamond / multi-inheritance | `EXTENDS` multi-path MATCH | + +**Order:** `query` (orient) → `context` (symbol) → **`cypher`** (structural precision) → `impact` (before edits). Do not start with `cypher` for fuzzy questions — that's what `query` + embeddings are for. + +Refresh always includes `--embeddings` (`gitnexus:refresh` / `agent-refresh`). Missing embeddings = stale (same as commit behind). + +## Deep precision — PDG, taint, trace + +When `cypher` isn't enough, escalate to statement-level tools (require a PDG index — `gitnexus:pdg`): + +| Need | Tool | +| --- | --- | +| Statement-level blast radius (control + data) | `impact` with `mode: "pdg"` | +| What predicate controls a line / why does it run? | `pdg_query` (`mode: "controls"`) | +| Where does a variable's value flow / reach? | `pdg_query` (`mode: "flows"`) | +| Source → sink path between two symbols | `trace` | +| Taint review — injection, path traversal, XSS | `explain` | + +## Full tool surface — reach for the right one + +Know every tool and *when* it wins (single-repo; cross-repo `group_*` is out of scope for this kit). Don't stop at `query`/`context` — the advanced tools answer in one call what takes many manual hops. + +| Tool | Reach for it when | +| --- | --- | +| `query` | Orient — "how does X work?", find the execution flow for a concept (BM25 + vectors). Always first for fuzzy work. | +| `context` | 360° on ONE symbol — callers, callees, categorized refs, the processes it's in. After `query`, or when the symbol is known. | +| `cypher` | Precise structural questions the canned tools don't express — field `ACCESSES`, N-hop `CALLS`, `METHOD_OVERRIDES`, `STEP_IN_PROCESS`. READ schema first. | +| `impact` | BEFORE editing a symbol — upstream blast radius + risk + affected processes. `mode: "pdg"` for statement-level (control+data) precision. | +| `trace` | "How does A reach B?" — shortest call/member path between two symbols in ONE call (replaces 3–8 manual `context` hops). | +| `pdg_query` | "What condition gates this line?" (`mode: "controls"`) / "where does this variable flow?" (`mode: "flows"`). Intra-function; needs PDG. | +| `explain` | Security review — taint source→sink (command/code/sql injection, path-traversal, XSS), intra- AND inter-procedural. Needs PDG. | +| `detect_changes` | BEFORE commit / "what did my edits affect?" — diff → affected symbols/processes/risk. `scope`: unstaged \| staged \| all \| compare. | +| `rename` | Coordinated multi-file symbol rename — `dry_run: true` first. Never find-and-replace identifiers. | +| `api_impact` | BEFORE changing an HTTP route handler (framework router) — consumers, response-shape mismatches, middleware chain, risk. | +| `route_map` | Map routes → consumers + handler + middleware; find orphaned routes. (Custom router → `context` on the dispatcher instead.) | +| `shape_check` | Detect API response-shape drift — keys a route returns vs keys consumers access (flags MISMATCH). | +| `tool_map` | Map MCP/RPC tool definitions → handler files + descriptions (tool-API work, impact of a tool-contract change). | +| `check` | Structural integrity — detect circular File `IMPORTS` cycles (health / CI gate). | +| `list_repos` | Only when multiple repos are indexed — discover/disambiguate before passing `repo:` to other tools. | + +Cheap resource reads (prefer before heavy tools): `READ gitnexus://repo/__GITNEXUS_REPO__/{context|schema|clusters|processes|process/}`. + +## MCP defaults (generous — local LLM) + +Run hook copy-paste calls verbatim; expand freely when needed: + +| Tool | Default | Notes | +| --- | --- | --- | +| `context` | `include_content: false` | Need body → Read offset/limit | +| `query` | `limit: 5`, `max_symbols: 12` | Phrase `search_query` as a natural-language **concept** ("where tokens are validated"), not a keyword — that feeds the embedding ranker; always pass `task_context` + `goal`. Known symbol name → use `context` instead. | +| `cypher` | READ schema first | Use `$params` for symbol/field names | +| `impact` | `summaryOnly: false`, `limit: 100` | Full blast radius before edits; `mode: "pdg"` for statement-level | +| `pdg_query` | `mode: "controls"` / `"flows"` | Statement-level control/data dependence | +| `trace` / `explain` | source → sink | Path between symbols; taint analysis | +| `rename` | `dry_run: true` first | Coordinated multi-file symbol rename | +| `detect_changes` | `scope: unstaged` | Pre-commit → `staged`; PR → `compare` | + +## Session (autonomous Shell) + +New chat: run session health ritual if injected — `npm run gitnexus:agent-status`, one-sentence confirm to user. + +`npm run gitnexus:agent-brief` or READ `gitnexus://repo/__GITNEXUS_REPO__/context`. Stale or missing embeddings → **`npm run gitnexus:agent-refresh` first** (`required_permissions: ["all"]`). Hooks **block** Grep/Read/MCP/shell until refresh succeeds; classical tools only if refresh **fails** (say why). Never ask user to analyze. + +## Stale loop (mandatory) + +``` +stale → agent-refresh (Shell, pre-approved) + → fresh → query / context / cypher / impact + → still stale after refresh → agent-refresh retry once if plausible + → refresh failed → classical fallback OK (one sentence why) +``` + +Session start runs auto-refresh when stale. Do **not** grep/read “while refreshing” — refresh is the next tool, not a background hint. + +## Gates (do not skip — every task) + +``` +1. brief OR context — session start +2. query — orient / ground (graph + embeddings) before reasoning or edits +3. context → process — drill into symbols +4. cypher — structural precision (field ACCESSES, N-hop CALLS, overrides, process steps) +5. impact upstream — before runtime source edits +6. rename dry_run — before coordinated symbol renames (not StrReplace across files) +7. detect_changes — before commit / done +``` + +HIGH/CRITICAL impact → warn before proceeding. + +## When fresh — hooks block (enforced, not advisory) + +Symbol grep → `context`. **Field/property grep → READ schema → `cypher` (`ACCESSES`).** SemanticSearch/broad Glob → `query`. Large source Read → `query` → `context` → Read offset/limit; **data-flow / model reads → `cypher` first.** Symbol **StrReplace rename** → `rename` dry_run. + +**Hard gates (deny until satisfied, once per session):** +- **Edit runtime source** → blocked until one `impact` (or `rename`) call this session. Run blast radius first; warn on HIGH/CRITICAL. +- **`git commit`** → blocked until one `detect_changes` call this session. Confirm affected processes match intent. + +Enforcement is **polyglot** — JS/TS, Python, Rust, Go, Java, and more count as source (configure `sourceExts` in `.gnkit/gitnexus-hooks.json`). + +## Deep review (intel layer) + +At a **milestone** — feature done / big-task checkpoint / shared-code refactor / pre-ship, or "audit / find real bugs / is this solid?" — **and** only when the work is *substantial* (multi-file or high `impact` blast-radius): run a **microscope-waves** pass → load the `gitnexus-microscope` skill. Multi-lens, opinionated (not just defects), adversarially verified, iterated in waves. Skip it for small localized changes. + +## Durable memory (survives compaction + sessions) + +Maintain your **Claude Code project memory** — `~/.claude/projects//memory/MEMORY.md` (Claude Code's native memory; **all agents share this one file** — Claude refers to its own, other agents mirror it). Record task, key decisions, findings, open items, important `file:line`. Update it at milestones and whenever you conclude something that must outlive the current transcript. Context compaction and new sessions drop the conversation; this file does not. On recovery (post-compaction/resume) READ it first and reconcile it with reality — **nothing important may be lost.** + +## Fallback + +**Stale index** → run `agent-refresh` first; classical Grep/Read stay denied until it succeeds. **If refresh fails** (or MCP down): classical Grep/Read OK — one-sentence why. + +**GitNexus fresh but wrong / suspicious / incomplete?** Don't silently fight the gate — take the escape hatch: `npm run gitnexus:fallback -- ""` opens ~15 min where classical Grep/Read/shell are allowed (auto-resumes; end early with `npm run gitnexus:fallback:off`). It is logged to telemetry and shown in `gitnexus:status` + the session brief — re-confirm findings with the graph once GN is reliable. Repeated grants signal a genuine GitNexus problem worth reporting. + +Optional: `GITNEXUS_MODE=guide` (nudge-only). Paths: `.gnkit/gitnexus-hooks.json`. Playbooks: `gitnexus-enforcement` skill. diff --git a/scripts/gen-contract.mjs b/scripts/gen-contract.mjs new file mode 100644 index 0000000..fd61c42 --- /dev/null +++ b/scripts/gen-contract.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Generate the IDE contract files from ONE canonical source. + * + * Single source of truth: scripts/contract/enforcement-contract.md (the neutral, + * vendor-agnostic enforcement contract). This script wraps it per IDE adapter: + * - Cursor → bundle/.cursor/rules/00-gitnexus-enforcement.mdc (always-on rule) + * - Zed → bundle/templates/AGENTS.gitnexus.md (always-on AGENTS.md block) + * + * Edit the contract, run `npm run gen:contract`, commit. A test + * (`contract files are generated from the single canonical source`) fails if the + * on-disk files drift from the rendered output, so they can never silently diverge. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(HERE, ".."); + +export const CONTRACT_SRC = path.join(HERE, "contract/enforcement-contract.md"); +export const ZED_FOOTER_SRC = path.join(HERE, "contract/agents-zed-footer.md"); +export const CLAUDE_FOOTER_SRC = path.join(HERE, "contract/claude-footer.md"); +export const CURSOR_RULE_OUT = path.join( + ROOT, + "bundle/.cursor/rules/00-gitnexus-enforcement.mdc", +); +export const AGENTS_OUT = path.join(ROOT, "bundle/templates/AGENTS.gitnexus.md"); +export const CLAUDE_OUT = path.join(ROOT, "bundle/templates/CLAUDE.gitnexus.md"); + +const CURSOR_FRONTMATTER = `--- +description: North-star contract — graph + embeddings + cypher on every task when fresh, autonomous refresh when stale, classical fallback when GN fails. +alwaysApply: true +--- +`; + +const GENERATED_NOTE = + ""; + +/** @param {string} body canonical contract markdown */ +export function renderCursorRule(body) { + return `${CURSOR_FRONTMATTER}\n${GENERATED_NOTE}\n\n# GitNexus enforcement\n\n${body.trim()}\n`; +} + +/** @param {string} body @param {string} footer @param {string} title */ +function renderWithFooter(body, footer, title) { + return `${GENERATED_NOTE}\n\n# ${title}\n\n${body.trim()}\n\n${footer.trim()}\n`; +} + +/** @param {string} body @param {string} zedFooter */ +export function renderAgents(body, zedFooter) { + return renderWithFooter(body, zedFooter, "GitNexus agent kit — always-on instructions"); +} + +/** @param {string} body @param {string} claudeFooter */ +export function renderClaude(body, claudeFooter) { + return renderWithFooter(body, claudeFooter, "GitNexus agent kit — always-on instructions"); +} + +export function renderAll() { + const body = fs.readFileSync(CONTRACT_SRC, "utf8"); + const zedFooter = fs.readFileSync(ZED_FOOTER_SRC, "utf8"); + const claudeFooter = fs.readFileSync(CLAUDE_FOOTER_SRC, "utf8"); + return { + [CURSOR_RULE_OUT]: renderCursorRule(body), + [AGENTS_OUT]: renderAgents(body, zedFooter), + [CLAUDE_OUT]: renderClaude(body, claudeFooter), + }; +} + +function main() { + const outputs = renderAll(); + for (const [file, content] of Object.entries(outputs)) { + fs.writeFileSync(file, content); + console.log(`wrote ${path.relative(ROOT, file)}`); + } +} + +const isMain = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) main(); diff --git a/scripts/refresh-bundle-from-source.sh b/scripts/refresh-bundle-from-source.sh index 3175151..dabfb09 100755 --- a/scripts/refresh-bundle-from-source.sh +++ b/scripts/refresh-bundle-from-source.sh @@ -19,15 +19,23 @@ info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } info "Refreshing bundle from $SRC" -rm -rf "$KIT_ROOT/bundle" -mkdir -p "$KIT_ROOT/bundle/.cursor/rules" "$KIT_ROOT/bundle/.claude/skills" +# NOTE: bundle/skills/ and bundle/docs/ are kit-owned sources of truth and are +# NOT derived from $SRC. Never `rm -rf bundle` wholesale — that would wipe the +# canonical skill store (incl. gitnexus-local) and the team docs. We only +# refresh the subtrees that genuinely come from the source repo. +rm -rf \ + "$KIT_ROOT/bundle/.cursor" \ + "$KIT_ROOT/bundle/.githooks" \ + "$KIT_ROOT/bundle/.vscode" \ + "$KIT_ROOT/bundle/scripts" \ + "$KIT_ROOT/bundle/.gitnexusignore" +mkdir -p "$KIT_ROOT/bundle/.cursor/rules" cp -a "$SRC/.cursor/rules/"* "$KIT_ROOT/bundle/.cursor/rules/" cp "$SRC/.cursor/hooks.json" "$KIT_ROOT/bundle/.cursor/" cp -a "$SRC/.cursor/hooks" "$KIT_ROOT/bundle/.cursor/" -cp -a "$SRC/.claude/skills/gitnexus" "$KIT_ROOT/bundle/.claude/skills/" -cp -a "$SRC/.claude/skills/gitnexus-workspace" "$KIT_ROOT/bundle/.claude/skills/" -cp -a "$SRC/.claude/skills/gitnexus-enforcement" "$KIT_ROOT/bundle/.claude/skills/" +# Skills are NOT copied from $SRC. The shipped store is bundle/skills/ — edit it +# directly. There is no bundle/.claude/skills/ tree anymore. mkdir -p "$KIT_ROOT/bundle/.githooks" "$KIT_ROOT/bundle/.vscode" "$KIT_ROOT/bundle/scripts/lib" "$KIT_ROOT/bundle/scripts/gitnexus-teaching" "$KIT_ROOT/bundle/docs" cp "$SRC/.githooks/pre-commit" "$KIT_ROOT/bundle/.githooks/" cp "$SRC/.vscode/settings.json" "$KIT_ROOT/bundle/.vscode/" @@ -37,28 +45,30 @@ for f in gitnexus-setup.sh sync-cursor-gitnexus-teaching.sh pack-gitnexus-teachi done cp "$SRC/scripts/lib/project-tmp.mjs" "$KIT_ROOT/bundle/scripts/lib/" cp "$SRC/scripts/gitnexus-teaching/"* "$KIT_ROOT/bundle/scripts/gitnexus-teaching/" -if [[ -f "$KIT_ROOT/bundle/docs/GITNEXUS-TEAM-BUNDLE.md" ]]; then - cp "$KIT_ROOT/bundle/docs/GITNEXUS-TEAM-BUNDLE.md" "$KIT_ROOT/docs/TEAM-BUNDLE.md" -elif [[ -f "$SRC/docs/GITNEXUS-TEAM-BUNDLE.md" ]]; then - cp "$SRC/docs/GITNEXUS-TEAM-BUNDLE.md" "$KIT_ROOT/bundle/docs/GITNEXUS-TEAM-BUNDLE.md" - cp "$SRC/docs/GITNEXUS-TEAM-BUNDLE.md" "$KIT_ROOT/docs/TEAM-BUNDLE.md" -fi +# Docs flow ONE WAY: docs/ (current, vendor-neutral) is the source of truth. +# The bundle ships team handouts under bundle/docs/GITNEXUS-*.md, which are +# REGENERATED from docs/ here — never copied back over docs/, and never sourced +# from $SRC (the source repo's docs are stale Cursor-only copies). +# docs/TEAM-BUNDLE.md -> bundle/docs/GITNEXUS-TEAM-BUNDLE.md +# docs/SKILLS.md -> bundle/docs/GITNEXUS-SKILLS.md +# bundle/docs/GITNEXUS-CURSOR-GUIDE.md is kit-owned (Cursor handout, no neutral +# twin) and is left as-is. +cp "$KIT_ROOT/docs/TEAM-BUNDLE.md" "$KIT_ROOT/bundle/docs/GITNEXUS-TEAM-BUNDLE.md" +cp "$KIT_ROOT/docs/SKILLS.md" "$KIT_ROOT/bundle/docs/GITNEXUS-SKILLS.md" # Strip region enforcement from refreshed bundle (kit no longer ships regions) for f in \ - bundle/.cursor/hooks/lib/region-edit-check.mjs \ - bundle/.cursor/hooks/lib/region-infer.mjs \ - bundle/.cursor/hooks/lib/region-picker-context.mjs \ - bundle/.cursor/hooks/lib/region-session.mjs \ - bundle/.cursor/hooks/lib/region-user-guide.mjs \ - bundle/.claude/skills/agent-region/SKILL.md \ + bundle/.gnkit/lib/region-edit-check.mjs \ + bundle/.gnkit/lib/region-infer.mjs \ + bundle/.gnkit/lib/region-picker-context.mjs \ + bundle/.gnkit/lib/region-session.mjs \ + bundle/.gnkit/lib/region-user-guide.mjs \ bundle/docs/AGENT-REGIONS-GUIDE.md \ bundle/docs/regions.overlay.stub.json \ bundle/docs/AGENT-PROFILES.stub.md \ bundle/scripts/gitnexus-teaching/generate-regions.mjs; do rm -rf "$KIT_ROOT/$f" 2>/dev/null || true done -rmdir "$KIT_ROOT/bundle/.claude/skills/agent-region" 2>/dev/null || true SOURCE_REPO_NAME="$(basename "$SRC")" find "$KIT_ROOT/bundle" -type f \( -name '*.mdc' -o -name '*.sh' -o -name '*.mjs' -o -name 'SKILL.md' -o -name '*.md' \) -print0 \ diff --git a/scripts/sync-bundle-skills.mjs b/scripts/sync-bundle-skills.mjs index 438d1dd..12a8041 100644 --- a/scripts/sync-bundle-skills.mjs +++ b/scripts/sync-bundle-skills.mjs @@ -1,43 +1,23 @@ #!/usr/bin/env node /** - * Maintainer script: flatten .claude/skills → bundle/skills (canonical store). - * Run from kit repo root after editing skills under .claude/skills/. + * RETIRED. Do not use. + * + * `bundle/skills/` is now the single source of truth for the shipped skill + * store (it is what `lib/skills.mjs` materializes into target repos via + * `BUNDLE_ROOT/skills`). Edit `bundle/skills//SKILL.md` directly. + * + * This script used to flatten a separate `bundle/.claude/skills/` tree into + * `bundle/skills/`, wiping the destination first (`rmSync`). That tree was + * stale and is gone — running the old flow would REGRESS the shipped store and + * delete `gitnexus-local`. The guard below exists so nobody resurrects it. */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const KIT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const SRC = path.join(KIT_ROOT, 'bundle/.claude/skills'); -const DEST = path.join(KIT_ROOT, 'bundle/skills'); - -function copyTree(src, dest) { - fs.mkdirSync(dest, { recursive: true }); - for (const ent of fs.readdirSync(src, { withFileTypes: true })) { - const s = path.join(src, ent.name); - const d = path.join(dest, ent.name); - if (ent.isDirectory()) copyTree(s, d); - else fs.copyFileSync(s, d); - } -} - -fs.rmSync(DEST, { recursive: true, force: true }); -fs.mkdirSync(DEST, { recursive: true }); - -for (const name of ['gitnexus-enforcement', 'gitnexus-workspace']) { - copyTree(path.join(SRC, name), path.join(DEST, name)); -} -const nested = path.join(SRC, 'gitnexus'); -if (fs.existsSync(nested)) { - for (const ent of fs.readdirSync(nested, { withFileTypes: true })) { - if (ent.isDirectory()) copyTree(path.join(nested, ent.name), path.join(DEST, ent.name)); - } -} -if (fs.existsSync(path.join(DEST, 'gitnexus-local'))) { - console.log('kept existing gitnexus-local'); -} else { - console.log('note: gitnexus-local lives only in bundle/skills/ — edit there directly'); -} - -const count = fs.readdirSync(DEST).filter((n) => fs.existsSync(path.join(DEST, n, 'SKILL.md'))).length; -console.log(`Synced ${count} skills → bundle/skills/`); +console.error( + [ + 'sync-bundle-skills.mjs is retired and intentionally does nothing.', + '', + 'The canonical skill store is bundle/skills/ — edit SKILL.md files there', + 'directly. There is no longer a bundle/.claude/skills/ source tree to sync', + 'from. See docs/SKILLS.md for the skill index.', + ].join('\n'), +); +process.exit(1);