diff --git a/.claude/helpers/graft-hooks.cjs b/.claude/helpers/graft-hooks.cjs new file mode 100755 index 0000000..4a04dc1 --- /dev/null +++ b/.claude/helpers/graft-hooks.cjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +const path = require('path'); +const fs = require('fs'); +const { pathToFileURL } = require('url'); +const { execFileSync } = require('child_process'); +const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); +const BAKED = "/usr/lib/node_modules/@nanonets/graft/dist/claude"; + +// The dist/claude dir of @nanonets/graft resolved from a base whose node_modules is searched. +function fromPkg(base) { + try { + const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] }); + return path.join(path.dirname(pkg), 'dist', 'claude'); + } catch { return null; } +} + +// The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand. +function globalRoot() { + try { + const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }).trim(); + return root || null; + } catch { return null; /* npm unavailable */ } +} + +// The version of the package a dist/claude dir belongs to, or null if unreadable. +function versionOf(distClaude) { + try { + return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null; + } catch { return null; } +} + +// Numeric-dotted compare of the release part; an unreadable version loses to any known one. +function newer(a, b) { + if (!a) return false; + if (!b) return true; + const p = (v) => String(v).split('-')[0].split('.').map((n) => Number(n) || 0); + const pa = p(a), pb = p(b); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d > 0; + } + return false; +} + +// The highest-versioned dir in `dirs` that actually contains `name`, or null. +function best(dirs, name) { + let bestDir = null, bestVer = null; + for (const d of dirs) { + if (!d || !fs.existsSync(path.join(d, name))) continue; + const v = versionOf(d); + if (bestDir === null || newer(v, bestVer)) { bestDir = d; bestVer = v; } + } + return bestDir; +} + +function entry(name) { + // Cheap candidates first, and only shell out to npm when every one of them misses. + const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))]; + const hit = best(cheap, name); + if (hit) return path.join(hit, name); + const gr = globalRoot(); + const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude'); + if (global && fs.existsSync(path.join(global, name))) return path.join(global, name); + return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent +} + +import(pathToFileURL(entry("hooks.js")).href).then((m) => m.main(process.argv[2])).catch(() => { /* graft unavailable — no-op */ }); diff --git a/.claude/helpers/graft-statusline.cjs b/.claude/helpers/graft-statusline.cjs new file mode 100755 index 0000000..b596658 --- /dev/null +++ b/.claude/helpers/graft-statusline.cjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +const path = require('path'); +const fs = require('fs'); +const { pathToFileURL } = require('url'); +const { execFileSync } = require('child_process'); +const dir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); +const BAKED = "/usr/lib/node_modules/@nanonets/graft/dist/claude"; + +// The dist/claude dir of @nanonets/graft resolved from a base whose node_modules is searched. +function fromPkg(base) { + try { + const pkg = require.resolve('@nanonets/graft/package.json', { paths: [base] }); + return path.join(path.dirname(pkg), 'dist', 'claude'); + } catch { return null; } +} + +// The global node_modules dir per npm (handles Homebrew/Windows/volta). Queried on demand. +function globalRoot() { + try { + const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], shell: process.platform === 'win32' }).trim(); + return root || null; + } catch { return null; /* npm unavailable */ } +} + +// The version of the package a dist/claude dir belongs to, or null if unreadable. +function versionOf(distClaude) { + try { + return JSON.parse(fs.readFileSync(path.join(distClaude, '..', '..', 'package.json'), 'utf8')).version || null; + } catch { return null; } +} + +// Numeric-dotted compare of the release part; an unreadable version loses to any known one. +function newer(a, b) { + if (!a) return false; + if (!b) return true; + const p = (v) => String(v).split('-')[0].split('.').map((n) => Number(n) || 0); + const pa = p(a), pb = p(b); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d > 0; + } + return false; +} + +// The highest-versioned dir in `dirs` that actually contains `name`, or null. +function best(dirs, name) { + let bestDir = null, bestVer = null; + for (const d of dirs) { + if (!d || !fs.existsSync(path.join(d, name))) continue; + const v = versionOf(d); + if (bestDir === null || newer(v, bestVer)) { bestDir = d; bestVer = v; } + } + return bestDir; +} + +function entry(name) { + // Cheap candidates first, and only shell out to npm when every one of them misses. + const cheap = [BAKED, fromPkg(dir), fromPkg(path.join(path.dirname(process.execPath), '..', 'lib'))]; + const hit = best(cheap, name); + if (hit) return path.join(hit, name); + const gr = globalRoot(); + const global = gr && path.join(gr, '@nanonets', 'graft', 'dist', 'claude'); + if (global && fs.existsSync(path.join(global, name))) return path.join(global, name); + return path.join(dir, 'dist', 'claude', name); // last-ditch; import will no-op if absent +} + +import(pathToFileURL(entry("statusline.js")).href).then((m) => m.main()).catch(() => { /* graft unavailable — no-op */ }); diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..cfda485 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,78 @@ +{ + "statusLine": { + "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-statusline.cjs\"" + }, + "subagentStatusLine": { + "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-statusline.cjs\"" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" post-edit", + "timeout": 10000 + } + ] + }, + { + "matcher": "Bash|mcp__graft__|Read|Grep|Glob", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" tool-savings", + "timeout": 8000 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" prompt", + "timeout": 15000 + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" session-start", + "timeout": 8000 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/graft-hooks.cjs\" stop", + "timeout": 8000 + } + ] + } + ] + }, + "footerLinksRegexes": [ + "graft/[\\w./-]+\\.md" + ], + "permissions": { + "allow": [ + "Bash(graft:*)", + "Bash(npx graft:*)", + "Bash(graft-dev:*)", + "Bash(node dist/cli.js:*)" + ] + } +} diff --git a/.claude/skills/graft/SKILL.md b/.claude/skills/graft/SKILL.md new file mode 100644 index 0000000..4302ec8 --- /dev/null +++ b/.claude/skills/graft/SKILL.md @@ -0,0 +1,150 @@ +--- +name: graft +description: This repo is indexed by graft/. For ANY task here, whether + understanding how something works, finding where code lives, tracing what + calls a symbol or what a change breaks, or scoping an edit, get your context + from graft before grepping or reading source files. +--- + +# graft + +`graft/` holds a graph of this repo: small markdown nodes that each explain one +part in prose and name the exact `file:line` spans they cover, plus a wiring +graph of who-calls-what. Querying a node costs a few hundred tokens; rebuilding +that understanding by reading source costs thousands, and misses the edges. + +Every command below is `$0`, needs no API key, and returns in under a second. +There are six of them. **Pick the one that fits the task, run it, act on the +answer; don't chain tools hoping for more. Most tasks need one call.** + +## The tools + +### 1 · `graft ask "" --source`: locate + understand (the default) +Ranked retrieval over the graph, routed automatically between prose nodes and +the wiring graph, returning the top hits with exact `file:line`. +- `--source` inlines the code at each hit, the ≤8-line **crux** of each + definition, so the result IS the code you need, no follow-up file read. Add + `--full` only when the crux is too small to act on. +- `--in ` narrows to a subtree before ranking; `-n N` caps results (default 8). +- **Use it when** the question is conceptual or locational: "how does auth + work", "where is rate-limiting handled", "what assembles the request pipeline". +- One ask usually answers. A genuinely multi-part question needs one ask per + distinct sub-aspect, never the same question reworded. Few or weak hits mean + switch tool (grep / skeleton / callers), don't re-ask. + +### 2 · `graft grep ""`: exhaustive find +Regex (or `--fixed` for a literal) over every indexed file, hits **grouped by +enclosing symbol** and ranked by coupling; it also reports files it couldn't read. +- **Use it when** you need every occurrence: all call sites, all uses of a + constant, all providers. `ask` is ranked top-N and *will* miss instances; + grep won't. One grep replaces a spray of asks. +- Search a **short symbol name or literal**, not a full guessed signature: an + over-specific regex (`func (s *Server) GenerateHandler`) returns nothing even + when the code is indexed. If a grep misses, **loosen it** (drop the receiver + and signature, keep the bare name) and retry `graft grep` — do NOT switch to + raw `grep -rn`, which is slower and unranked. +- `-i` case-insensitive; `--in ` scopes to a subtree. Raw `grep -rn` is + only for files graft genuinely doesn't index (docs, configs, brand-new files). + +### 3 · `graft skeleton `: a file's API at a glance +Signatures-only view of one file (every function / method / type with its span) +in ~200 tokens, ~10x cheaper than reading the file. +- **Use it when** you need "what's in this file / what can I call here" before + editing or wiring into it. One skeleton is the whole answer for a file; don't + re-skeleton the same file, and don't skeleton every file `map` already named. + +### 4 · `graft callers `: the exact edges +Precomputed call/reference edges, not a text search. Symbol can be bare +(`Foo`), qualified (`Class.method`), or package-qualified (`pkg.Fn`). +- default `--direction in`: **who calls/references** this; run before you + rename, delete, or change its signature. +- `--direction out`: **what this symbol itself calls/depends on** (the old `callees`). +- `--depth N`: walk transitively N hops for the **full blast radius** (the old + `impact`); `--depth 2` is the usual "what breaks if I touch this". +- `--depth all`: the **entire connected closure** — every source reachable + through the edges. Reach for this before a **refactor, rename, or any + multi-file change**: it surfaces the sibling and downstream files (platform + variants, a module you must split out) that a single-file edit would miss. + +### 5 · `graft map`: orientation for an unfamiliar repo or area +A token-budgeted tour: directory clusters, per-directory hubs, and global +hotspots, straight from the wiring graph. +- **Use it when** you land in a repo cold or are asked for "the architecture". + `map` alone is the answer: read the hub cards it names; do NOT then skeleton + or ask your way through every subsystem it lists. `--max-dirs N` widens it. + +### 6 · Lifecycle: `graft build` / `graft check` +Every tool above refreshes the graph itself before answering, so what those tools +return always describes the code as it is right now — including edits you just made +and have not committed. You do **not** need to run `build` after editing. + +One caveat, if you `grep` the markdown under `graft/` directly: those cards are a +projection, rebuilt at the end of the turn rather than on each query, so after an edit +they can lag. The tools above never do — prefer them, and treat a card's spans as +stale if you have edited that file this turn. + +`build` is for the LLM layer (`--deep` adds a concept map; skip unless asked); +`check` fails when `graft/` is stale, for CI. + +## Scenarios: the shortest path through a coding task + +| When you're… | Reach for | Calls | +|---|---|---| +| Onboarding / "explain this codebase" | `graft map`, then read the named hub cards | 1 | +| Understanding a flow ("how does X work") | `graft ask "" --source` | 1 | +| Finding where a change belongs | `graft ask "where is " --source` | 1 | +| Editing a symbol you can already name | `graft grep ""`, edit at the `file:line` (skip `ask` — you know where it is) | 1 | +| Renaming / deleting / changing a signature | `graft callers --depth 2` first | 1 | +| Refactor / multi-file change (before editing) | `graft callers --depth all` — map every connected file, don't stop at the first | 1 | +| "What does this depend on?" | `graft callers --direction out` | 1 | +| Finding every occurrence of a pattern | `graft grep ""` | 1 | +| "What's the API of this file?" | `graft skeleton ` | 1 | +| Debugging a failure in area X | `graft ask "" --source`, then `callers` on the suspect | 1–2 | +| Judging a diff's risk before merge | `graft callers --depth 2` | 1 / symbol | +| Working inside one repo of a monorepo | add `--in /` to ask / grep / callers | n/a | + +In a multi-repo workspace, graft ranks fairly so the biggest repo can't drown +the rest, and every hit carries a `[scope/]` label naming its sub-project; when +you already know where you're working, narrow with `graft ask "" --in /`. + +## Spend the fewest calls +- A node's `covers:` list already gives exact `file:line` for every symbol, so + cite straight from it. The spans are generated from source and authoritative; + don't re-open or re-grep files to "double-check". +- When the task already names the file or symbol to change, go straight there: + `graft grep ""` for the exact `file:line`, then edit. Reserve + `graft ask` for when you don't yet know where the code lives — an `ask` + round-trip is wasted on a target you can already name. +- Trust the answer and act. Reach for a second tool only when the first genuinely + fell short: weak hits, a truncated span, or a need to be exhaustive. +- If graft names a path that isn't on disk, its index is ahead of your checkout + (a branch switch or unpulled move). Don't read the missing file — `graft grep` + the symbol to find where it lives now, or run `graft build` to refresh. + +## Report what graft saved, every turn +Each retrieval tool **opens** its output with a `[graft] tokens saved ≈ N` line: +the estimated tokens that call saved versus reading the files it covers whole. +Whenever you used any graft tool in a turn, close your reply with a one-line +tally summing those numbers across every graft call you made, e.g. +`🌱 graft saved ~12,400 tokens this turn (3 calls)`. A call with no such line +(tiny files, where the pointers cost as much as the source) saved nothing, so +skip it. This is the per-turn figure; the statusline carries the running +session total. + +**Never pipe a graft command through `head`, `tail`, or `sed -n`.** Every tool +is already capped and states what it dropped; clipping it costs you hits you +asked for, and it silently drops the savings line the statusline's running +total is parsed from. + +## When graft isn't enough +- Span truncated ("+N more lines"): open the file at that exact range. +- A node lacks a detail: ask a more specific question; only then read source at + the exact `file:line`, never a whole file to rebuild understanding graft gives. +- You may also grep / ls / cat inside `graft/` directly (plain markdown; + `graft/INDEX.md` indexes the nodes), but the tools above are faster and + exhaustive where it matters, so reach for them first. + +When the graft MCP server is connected, these are exposed as tools too: +`graft_find_code`, `graft_find_all`, `graft_file_api`, `graft_trace_calls` (with +`direction` / `depth`), `graft_repo_map`, `graft_check_freshness`. Use whichever surface is +available; the guidance is identical. diff --git a/.gitignore b/.gitignore index 2718d79..ef70b62 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ compile_commands.json *.user .DS_Store .cache/ + +# graft's local graph cache — regenerable, not committed (run `graft build`). +/graft/ diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..615e581 --- /dev/null +++ b/.ignore @@ -0,0 +1,5 @@ +# graft's cards are gitignored but should stay greppable: ripgrep reads +# .ignore before .gitignore, so this re-admits the tree to search only. +!graft/ +graft/.cache/ +graft/.graph/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..fcedf6a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "graft": { + "command": "graft", + "args": [ + "mcp" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d8af4a5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ + +## Graft — repo context graph + +This repo is indexed in `graft/`: small linked markdown nodes that explain each +system and carry exact file:line spans, kept in sync with the code through git. + +For ANY task here — understanding how something works, finding where code lives, +or scoping a change — get context from the graph before grepping or opening +source files. Re-ask freely (it's cheap) and reuse literal identifiers you +already have (symbol, error string, file name) as the query. New to this repo? +Run `graft map` first — a token-budgeted orientation (dir clusters, hubs, +hotspots), no LLM, no key. + +- Run `graft ask "" --source` → ranked nodes with the relevant + code spans inlined (each hit's ≤8-line crux by default; `--full` for whole + definitions when the crux isn't enough). Match the tool to the task shape: + for understanding or editing, the top node IS the answer — cite its + `covers:` file:line spans and edit straight from `--source`. For + exhaustive tasks ("every occurrence / every caller of this pattern"), ranked + results are top-N, not complete — run `graft grep ""` instead + (exhaustive over indexed files, grouped by enclosing symbol), falling back + to raw `grep -rn` only for unindexed files. +- `graft skeleton ` → every definition's signature + span, ~10× cheaper + than reading the file; use it to skim an API surface. +- `graft callers ` gives precomputed, exact edges — who calls this. + Add `--direction out` for what it calls, or `--depth N` to walk + transitively for the full blast radius. For structural questions, skip + ranking and use this directly. +- Or browse: `graft/INDEX.md` lists every node; follow the links. +- Monorepos and folders of multiple repos rank fairly across sub-projects — + hits carry `[scope/]` labels naming which one they're from. Narrow with + `graft ask "" --in /` once you know where you're working. + +If a returned span is truncated ("+N more lines"), open the file at that exact +range before finalizing. Only open source files when a node genuinely lacks a +needed detail, and then at the exact file:line the node points to — never +re-read whole files. + +After big code changes, refresh the graph with `graft build` (deterministic, +no API key, $0). + diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..32d36b7 --- /dev/null +++ b/opencode.json @@ -0,0 +1,12 @@ +{ + "mcp": { + "graft": { + "type": "local", + "command": [ + "graft", + "mcp" + ], + "enabled": true + } + } +}