@flyingrobots/graft 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,73 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.4.0] - 2026-04-05
9
+
10
+ ### Added
11
+
12
+ - **WARP Level 1 — structural memory substrate**: git-warp-backed
13
+ graph stores structural facts per commit. Directory tree, file,
14
+ symbol, and commit nodes with containment edges and provenance
15
+ links (touches, adds, changes, removes).
16
+ - **`graft_since`**: structural changes since a git ref — symbols
17
+ added, removed, and changed per file with summary lines. Instant.
18
+ - **`graft_map`**: structural map of a directory — all files and
19
+ their symbols in one call via tree-sitter.
20
+ - **`graft index` CLI**: manual WARP indexing trigger.
21
+ - **WARP indexer**: walks git history, parses files with tree-sitter,
22
+ emits WARP patches. Handles nested symbols, file deletion,
23
+ signature changes, unsupported language degradation.
24
+ - **Observer factory**: 8 canonical lens patterns for focused graph
25
+ projections (file symbols, all symbols, directory files, etc.).
26
+ - **11 WARP invariants**: observer-only-access, materialization-
27
+ deterministic, delta-only-storage, address-not-identity, and more.
28
+ - **`@git-stunts/git-warp` v16** + `@git-stunts/plumbing` deps.
29
+
30
+ ## [0.3.5] - 2026-04-05
31
+
32
+ ### Fixed
33
+
34
+ - **CI**: use `npx npm@latest` for OIDC trusted publishing — avoids
35
+ self-upgrade breakage on Node 22's bundled npm.
36
+
37
+ ## [0.3.4] - 2026-04-05
38
+
39
+ ### Fixed
40
+
41
+ - **CI**: upgrade npm CLI to >=11.5.1 for OIDC trusted publishing.
42
+
43
+ ## [0.3.3] - 2026-04-05
44
+
45
+ ### Fixed
46
+
47
+ - **CI**: use `npm publish` instead of `pnpm publish` for OIDC
48
+ provenance.
49
+
50
+ ## [0.3.2] - 2026-04-05
51
+
52
+ ### Added
53
+
54
+ - **`graft init`**: zero-friction project onboarding. Scaffolds
55
+ `.graftignore`, adds `.graft/` to `.gitignore`, generates
56
+ `CLAUDE.md` snippet instructing agents to prefer graft tools,
57
+ and prints Claude Code hook config. Idempotent.
58
+ - **CI**: release workflow attaches npm tarball + SHA256SUMS to
59
+ GitHub releases as downloadable assets.
60
+ - **CI**: npm publish via OIDC provenance (no secret needed).
61
+
62
+ ### Changed
63
+
64
+ - **CLI bootstrap**: `bin/graft.js` resolves tsx from the package's
65
+ own `node_modules`, so `graft init` works from any directory.
66
+ - **Docs**: regenerated README, GUIDE, BEARING, and VISION signposts.
67
+ - **package.json**: added `publishConfig`, `homepage`, `bugs`,
68
+ `packageManager`, upgraded keywords for MCP/agent discovery.
69
+
70
+ ### Fixed
71
+
72
+ - **CI**: removed pnpm version override that conflicted with
73
+ `packageManager` field.
74
+
8
75
  ## [0.3.1] - 2026-04-05
9
76
 
10
77
  ### Changed
package/README.md CHANGED
@@ -10,7 +10,7 @@ tools (outlines, diffs, symbol history) are useful to anyone.
10
10
 
11
11
  ## Why
12
12
 
13
- Empirical analysis of 1,091 real coding sessions (Blacklight) found
13
+ Empirical analysis of 1,091 real coding sessions ([Blacklight](https://github.com/flyingrobots/blacklight)) found
14
14
  that **Read accounts for 96.2 GB of context burden** — 6.6x all
15
15
  other tools combined. 58% of reads are full-file. The fattest 2.4%
16
16
  of reads produce 24% of raw bytes. Dynamic read caps + session
@@ -36,6 +36,16 @@ docker run -i --rm -v "$PWD:/workspace" flyingrobots/graft
36
36
 
37
37
  ## Quick start
38
38
 
39
+ ```bash
40
+ npx @flyingrobots/graft init
41
+ ```
42
+
43
+ Scaffolds `.graftignore`, adds `.graft/` to `.gitignore`, generates
44
+ a `CLAUDE.md` snippet telling agents to prefer graft tools, and
45
+ prints Claude Code hook config.
46
+
47
+ Then add graft to your MCP config:
48
+
39
49
  ```json
40
50
  {
41
51
  "mcpServers": {
@@ -96,6 +106,47 @@ is structured JSON.
96
106
  | `doctor` | Runtime health check |
97
107
  | `stats` | Decision metrics summary |
98
108
 
109
+ ## Claude Code hooks
110
+
111
+ Two hooks work alongside the MCP server to govern native `Read`
112
+ calls — a safety net for when agents bypass graft's tools:
113
+
114
+ ```json
115
+ {
116
+ "hooks": {
117
+ "PreToolUse": [
118
+ {
119
+ "matcher": "Read",
120
+ "hooks": [
121
+ {
122
+ "type": "command",
123
+ "command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/pretooluse-read.ts"
124
+ }
125
+ ]
126
+ }
127
+ ],
128
+ "PostToolUse": [
129
+ {
130
+ "matcher": "Read",
131
+ "hooks": [
132
+ {
133
+ "type": "command",
134
+ "command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/posttooluse-read.ts"
135
+ }
136
+ ]
137
+ }
138
+ ]
139
+ }
140
+ }
141
+ ```
142
+
143
+ Add to `.claude/settings.json` in your project root.
144
+ **PreToolUse** blocks banned files before the read.
145
+ **PostToolUse** shows the agent what `safe_read` would have saved.
146
+
147
+ See the **[Setup Guide](docs/GUIDE.md)** for full details on hooks,
148
+ per-editor MCP config, `.graftignore`, and troubleshooting.
149
+
99
150
  ## Reason codes
100
151
 
101
152
  Every refusal or policy decision includes a machine-readable reason
package/bin/graft.js CHANGED
@@ -1,11 +1,42 @@
1
- #!/usr/bin/env -S node --import tsx
1
+ #!/usr/bin/env node
2
2
 
3
- // Graft MCP server stdio transport
4
- // Usage: npx @flyingrobots/graft
3
+ // Graft context governor for coding agents
4
+ // Bootstrap: re-exec with tsx loader resolved from the package's own deps.
5
5
 
6
- import { createGraftServer } from "../src/mcp/server.js";
7
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+ import { execFileSync } from "node:child_process";
9
+ import { createRequire } from "node:module";
8
10
 
9
- const graft = createGraftServer();
10
- const transport = new StdioServerTransport();
11
- await graft.getMcpServer().connect(transport);
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const require = createRequire(import.meta.url);
13
+
14
+ // If already running under tsx, proceed directly
15
+ if (process.env.__GRAFT_TSX_LOADED === "1") {
16
+ const command = process.argv[2];
17
+ if (command === "init") {
18
+ const { runInit } = await import("../src/cli/init.js");
19
+ runInit();
20
+ } else if (command === "index") {
21
+ const { runIndex } = await import("../src/cli/index-cmd.js");
22
+ await runIndex();
23
+ } else {
24
+ const { createGraftServer } = await import("../src/mcp/server.js");
25
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
26
+ const graft = createGraftServer();
27
+ const transport = new StdioServerTransport();
28
+ await graft.getMcpServer().connect(transport);
29
+ }
30
+ } else {
31
+ // Re-exec with tsx loader from our own node_modules
32
+ const tsxPath = require.resolve("tsx/esm");
33
+ const script = join(__dirname, "graft.js");
34
+ try {
35
+ execFileSync(process.execPath, ["--import", tsxPath, script, ...process.argv.slice(2)], {
36
+ stdio: "inherit",
37
+ env: { ...process.env, __GRAFT_TSX_LOADED: "1" },
38
+ });
39
+ } catch (err) {
40
+ process.exit(err?.status ?? 1);
41
+ }
42
+ }
package/docs/GUIDE.md CHANGED
@@ -12,6 +12,20 @@ Or run without installing:
12
12
  npx @flyingrobots/graft
13
13
  ```
14
14
 
15
+ ## Quick setup
16
+
17
+ ```bash
18
+ npx @flyingrobots/graft init
19
+ ```
20
+
21
+ Scaffolds your project for graft in one command:
22
+ - Creates `.graftignore` (template with examples)
23
+ - Adds `.graft/` to `.gitignore`
24
+ - Generates a `CLAUDE.md` snippet instructing agents to prefer graft tools
25
+ - Prints Claude Code hook config for manual setup
26
+
27
+ Idempotent — safe to run again without duplicating entries.
28
+
15
29
  ## MCP Configuration
16
30
 
17
31
  Graft runs as an MCP server over stdio. Add it to your editor or
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@flyingrobots/graft",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Context governor for coding agents — MCP server with policy-enforced reads, structural outlines, and session tracking",
5
5
  "type": "module",
6
+ "packageManager": "pnpm@10.30.0",
6
7
  "bin": {
7
8
  "graft": "./bin/graft.js",
8
9
  "git-graft": "./bin/graft.js"
@@ -16,6 +17,14 @@
16
17
  "README.md",
17
18
  "CHANGELOG.md"
18
19
  ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest",
24
+ "lint": "eslint .",
25
+ "typecheck": "tsc --noEmit",
26
+ "pack:check": "pnpm pack --dry-run"
27
+ },
19
28
  "engines": {
20
29
  "node": ">=20.11.0"
21
30
  },
@@ -48,6 +57,8 @@
48
57
  "url": "git+https://github.com/flyingrobots/graft.git"
49
58
  },
50
59
  "dependencies": {
60
+ "@git-stunts/git-warp": "^16.0.0",
61
+ "@git-stunts/plumbing": "^2.8.0",
51
62
  "@modelcontextprotocol/sdk": "^1.29.0",
52
63
  "picomatch": "^4.0.4",
53
64
  "tree-sitter-wasms": "0.1.13",
@@ -65,12 +76,7 @@
65
76
  "typescript-eslint": "^8.58.0",
66
77
  "vitest": "^4.1.2"
67
78
  },
68
- "scripts": {
69
- "build": "tsc -p tsconfig.build.json",
70
- "test": "vitest run",
71
- "test:watch": "vitest",
72
- "lint": "eslint .",
73
- "typecheck": "tsc --noEmit",
74
- "pack:check": "pnpm pack --dry-run"
79
+ "pnpm": {
80
+ "onlyBuiltDependencies": []
75
81
  }
76
- }
82
+ }
@@ -0,0 +1,22 @@
1
+ import { openWarp } from "../warp/open.js";
2
+ import { indexCommits } from "../warp/indexer.js";
3
+
4
+ export async function runIndex(): Promise<void> {
5
+ const cwd = process.cwd();
6
+ const from: string | undefined = process.argv[3];
7
+
8
+ console.log(`\nIndexing structural history in ${cwd}\n`);
9
+
10
+ try {
11
+ const warp = await openWarp({ cwd });
12
+ const result = await indexCommits(warp, { cwd, ...(from !== undefined ? { from } : {}) });
13
+
14
+ console.log(` commits indexed: ${String(result.commitsIndexed)}`);
15
+ console.log(` patches written: ${String(result.patchesWritten)}`);
16
+ console.log("\nDone.\n");
17
+ } catch (err: unknown) {
18
+ const msg = err instanceof Error ? err.message : String(err);
19
+ console.error(`Error: ${msg}`);
20
+ process.exitCode = 1;
21
+ }
22
+ }
@@ -0,0 +1,112 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ const GRAFTIGNORE_TEMPLATE = `# Graft ignore patterns — files matching these are refused by safe_read.
5
+ # Syntax: same as .gitignore (glob matching via picomatch).
6
+
7
+ # Examples:
8
+ # *.generated.ts
9
+ # vendor/**
10
+ # data/**/*.json
11
+ `;
12
+
13
+ const AGENT_SNIPPET = `## File reads
14
+
15
+ This project uses [graft](https://github.com/flyingrobots/graft) as
16
+ a context governor. Prefer graft's MCP tools over native file reads:
17
+
18
+ - Use \`safe_read\` instead of \`Read\` for file contents
19
+ - Use \`file_outline\` to see structure before reading
20
+ - Use \`read_range\` with jump table entries for targeted reads
21
+ - Use \`graft_diff\` instead of \`git diff\` for structural changes
22
+ - Use \`explain\` if you get an unfamiliar reason code
23
+ - Call \`set_budget\` at session start if context is tight
24
+
25
+ These tools enforce read policy, cache observations, and track
26
+ session metrics. Native reads bypass all of that.
27
+ `;
28
+
29
+ const GITIGNORE_ENTRY = "\n# Graft runtime data\n.graft/\n";
30
+
31
+ const HOOKS_CONFIG = `
32
+ Add to .claude/settings.json for Claude Code hook integration:
33
+
34
+ {
35
+ "hooks": {
36
+ "PreToolUse": [
37
+ {
38
+ "matcher": "Read",
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/pretooluse-read.ts"
43
+ }
44
+ ]
45
+ }
46
+ ],
47
+ "PostToolUse": [
48
+ {
49
+ "matcher": "Read",
50
+ "hooks": [
51
+ {
52
+ "type": "command",
53
+ "command": "node --import tsx node_modules/@flyingrobots/graft/src/hooks/posttooluse-read.ts"
54
+ }
55
+ ]
56
+ }
57
+ ]
58
+ }
59
+ }
60
+ `;
61
+
62
+ function writeIfMissing(filePath: string, content: string, label: string): void {
63
+ if (fs.existsSync(filePath)) {
64
+ console.log(` exists ${label}`);
65
+ } else {
66
+ fs.writeFileSync(filePath, content);
67
+ console.log(` create ${label}`);
68
+ }
69
+ }
70
+
71
+ function appendIfMissing(filePath: string, marker: string, content: string, label: string): void {
72
+ if (fs.existsSync(filePath)) {
73
+ const existing = fs.readFileSync(filePath, "utf-8");
74
+ if (existing.includes(marker)) {
75
+ console.log(` exists ${label} (already has graft entry)`);
76
+ return;
77
+ }
78
+ fs.appendFileSync(filePath, content);
79
+ console.log(` append ${label}`);
80
+ } else {
81
+ fs.writeFileSync(filePath, content.trimStart());
82
+ console.log(` create ${label}`);
83
+ }
84
+ }
85
+
86
+ export function runInit(): void {
87
+ const cwd = process.cwd();
88
+ console.log(`\nInitializing graft in ${cwd}\n`);
89
+
90
+ // 1. .graftignore
91
+ writeIfMissing(path.join(cwd, ".graftignore"), GRAFTIGNORE_TEMPLATE, ".graftignore");
92
+
93
+ // 2. .gitignore — append .graft/
94
+ appendIfMissing(path.join(cwd, ".gitignore"), ".graft/", GITIGNORE_ENTRY, ".gitignore");
95
+
96
+ // 3. CLAUDE.md — append agent instructions snippet
97
+ appendIfMissing(path.join(cwd, "CLAUDE.md"), "safe_read", "\n" + AGENT_SNIPPET, "CLAUDE.md");
98
+
99
+ // 4. Print hooks config for manual setup
100
+ console.log(HOOKS_CONFIG);
101
+
102
+ console.log("Done. Add graft to your MCP config:\n");
103
+ console.log(` {
104
+ "mcpServers": {
105
+ "graft": {
106
+ "command": "npx",
107
+ "args": ["-y", "@flyingrobots/graft"]
108
+ }
109
+ }
110
+ }
111
+ `);
112
+ }
@@ -9,6 +9,7 @@ import type { SessionTracker } from "../session/tracker.js";
9
9
  import type { McpToolResult } from "./receipt.js";
10
10
  import type { FileSystem } from "../ports/filesystem.js";
11
11
  import type { JsonCodec } from "../ports/codec.js";
12
+ import type WarpApp from "@git-stunts/git-warp";
12
13
 
13
14
  import type { z } from "zod";
14
15
 
@@ -32,6 +33,7 @@ export interface ToolContext {
32
33
  readonly codec: JsonCodec;
33
34
  respond(tool: string, data: Record<string, unknown>): McpToolResult;
34
35
  resolvePath(relative: string): string;
36
+ getWarp(): Promise<WarpApp>;
35
37
  }
36
38
 
37
39
  /**
package/src/mcp/server.ts CHANGED
@@ -13,6 +13,8 @@ import { nodeFs } from "../adapters/node-fs.js";
13
13
  import { CanonicalJsonCodec } from "../adapters/canonical-json.js";
14
14
  import { evaluatePolicy } from "../policy/evaluate.js";
15
15
  import { RefusedResult } from "../policy/types.js";
16
+ import type WarpApp from "@git-stunts/git-warp";
17
+ import { openWarp } from "../warp/open.js";
16
18
 
17
19
  // Tool definitions — each file exports a ToolDefinition object
18
20
  import { safeReadTool } from "./tools/safe-read.js";
@@ -26,6 +28,8 @@ import { doctorTool } from "./tools/doctor.js";
26
28
  import { statsTool } from "./tools/stats.js";
27
29
  import { explainTool } from "./tools/explain.js";
28
30
  import { setBudgetTool } from "./tools/budget.js";
31
+ import { sinceTool } from "./tools/since.js";
32
+ import { mapTool } from "./tools/map.js";
29
33
 
30
34
  export type { McpToolResult, ToolHandler, ToolContext };
31
35
 
@@ -43,6 +47,8 @@ const TOOL_REGISTRY: readonly ToolDefinition[] = [
43
47
  statsTool,
44
48
  explainTool,
45
49
  setBudgetTool,
50
+ sinceTool,
51
+ mapTool,
46
52
  ];
47
53
 
48
54
  export interface GraftServer {
@@ -78,7 +84,19 @@ export function createGraftServer(): GraftServer {
78
84
  return result;
79
85
  }
80
86
 
81
- const ctx: ToolContext = { projectRoot, graftDir, session, cache, metrics, respond, resolvePath: createPathResolver(projectRoot), fs: nodeFs, codec };
87
+ // Lazy WARP initialization only loaded when a WARP-backed tool needs it.
88
+ // Single pending promise prevents duplicate instances from concurrent calls.
89
+ // On rejection, clear cache so subsequent calls can retry.
90
+ let warpPromise: Promise<WarpApp> | null = null;
91
+ function getWarp(): Promise<WarpApp> {
92
+ warpPromise ??= openWarp({ cwd: projectRoot }).catch((err: unknown) => {
93
+ warpPromise = null;
94
+ throw err;
95
+ });
96
+ return warpPromise;
97
+ }
98
+
99
+ const ctx: ToolContext = { projectRoot, graftDir, session, cache, metrics, respond, resolvePath: createPathResolver(projectRoot), fs: nodeFs, codec, getWarp };
82
100
 
83
101
  function wrapWithPolicyCheck(toolName: string, inner: ToolHandler): ToolHandler {
84
102
  return (args: Record<string, unknown>) => {
@@ -0,0 +1,82 @@
1
+ import * as path from "node:path";
2
+ import { z } from "zod";
3
+ import { extractOutline } from "../../parser/outline.js";
4
+ import { detectLang } from "../../parser/lang.js";
5
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ interface FileEntry {
9
+ path: string;
10
+ lang: string;
11
+ symbols: { name: string; kind: string; signature?: string | undefined; exported: boolean; startLine?: number | undefined; endLine?: number | undefined }[];
12
+ }
13
+
14
+ /**
15
+ * List files in a directory (git ls-files for tracked files).
16
+ */
17
+ function listFiles(dirPath: string, cwd: string): string[] {
18
+ try {
19
+ const args = dirPath.length > 0
20
+ ? ["ls-files", "--", dirPath]
21
+ : ["ls-files"];
22
+ return execFileSync("git", args, { cwd, encoding: "utf-8" })
23
+ .trim().split("\n").filter((l) => l.length > 0);
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ export const mapTool: ToolDefinition = {
30
+ name: "graft_map",
31
+ description:
32
+ "Structural map of a directory — all files and their symbols " +
33
+ "(function signatures, class shapes, exports) in one call. " +
34
+ "Uses tree-sitter to parse the working tree directly.",
35
+ schema: {
36
+ path: z.string().optional(),
37
+ },
38
+ createHandler(ctx: ToolContext): ToolHandler {
39
+ return (args) => {
40
+ const dirPath = (args["path"] as string | undefined) ?? "";
41
+
42
+ const filePaths = listFiles(dirPath, ctx.projectRoot);
43
+ const files: FileEntry[] = [];
44
+
45
+ for (const filePath of filePaths) {
46
+ const lang = detectLang(filePath);
47
+ if (lang === null) continue;
48
+
49
+ let content: string;
50
+ try {
51
+ content = ctx.fs.readFileSync(path.join(ctx.projectRoot, filePath), "utf-8");
52
+ } catch {
53
+ continue;
54
+ }
55
+
56
+ const result = extractOutline(content, lang);
57
+ const symbols: FileEntry["symbols"] = result.entries.map((entry) => {
58
+ const jump = result.jumpTable?.find((j) => j.symbol === entry.name);
59
+ return {
60
+ name: entry.name,
61
+ kind: entry.kind,
62
+ signature: entry.signature,
63
+ exported: entry.exported,
64
+ startLine: jump?.start,
65
+ endLine: jump?.end,
66
+ };
67
+ });
68
+
69
+ files.push({ path: filePath, lang, symbols });
70
+ }
71
+
72
+ files.sort((a, b) => a.path.localeCompare(b.path));
73
+ const totalSymbols = files.reduce((n, f) => n + f.symbols.length, 0);
74
+
75
+ return ctx.respond("graft_map", {
76
+ directory: dirPath.length > 0 ? dirPath : ".",
77
+ files,
78
+ summary: `${String(files.length)} files, ${String(totalSymbols)} symbols`,
79
+ });
80
+ };
81
+ },
82
+ };
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { graftDiff } from "../../operations/graft-diff.js";
3
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
4
+
5
+ export const sinceTool: ToolDefinition = {
6
+ name: "graft_since",
7
+ description:
8
+ "Structural changes since a git ref. Shows symbols added, removed, " +
9
+ "and changed per file — not line hunks. Includes per-file summary " +
10
+ "lines for quick triage. Defaults to HEAD as the comparison target.",
11
+ schema: {
12
+ base: z.string(),
13
+ head: z.string().optional(),
14
+ },
15
+ createHandler(ctx: ToolContext): ToolHandler {
16
+ return (args) => {
17
+ const base = args["base"] as string;
18
+ const head = (args["head"] as string | undefined) ?? "HEAD";
19
+
20
+ const result = graftDiff({
21
+ cwd: ctx.projectRoot,
22
+ fs: ctx.fs,
23
+ base,
24
+ head,
25
+ });
26
+
27
+ // Aggregate symbol-level changes across all files
28
+ let totalAdded = 0;
29
+ let totalRemoved = 0;
30
+ let totalChanged = 0;
31
+
32
+ for (const file of result.files) {
33
+ totalAdded += file.diff.added.length;
34
+ totalRemoved += file.diff.removed.length;
35
+ totalChanged += file.diff.changed.length;
36
+ }
37
+
38
+ return ctx.respond("graft_since", {
39
+ ...result,
40
+ summary: `+${String(totalAdded)} added, -${String(totalRemoved)} removed, ~${String(totalChanged)} changed across ${String(result.files.length)} files`,
41
+ });
42
+ };
43
+ },
44
+ };
@@ -0,0 +1,398 @@
1
+ /**
2
+ * WARP Indexer — walks git history and writes structural delta
3
+ * patches into the WARP graph.
4
+ *
5
+ * Observer Law: this module WRITES facts. It never reads them back.
6
+ * Reading is done exclusively through observers (see observers.ts).
7
+ */
8
+
9
+ import type WarpApp from "@git-stunts/git-warp";
10
+ import { execFileSync } from "node:child_process";
11
+ import { extractOutline } from "../parser/outline.js";
12
+ import { diffOutlines } from "../parser/diff.js";
13
+ import { detectLang } from "../parser/lang.js";
14
+ import { getFileAtRef } from "../git/diff.js";
15
+ import type { OutlineEntry, JumpEntry } from "../parser/types.js";
16
+ import type { DiffEntry } from "../parser/diff.js";
17
+
18
+ export interface IndexOptions {
19
+ readonly cwd: string;
20
+ readonly from?: string;
21
+ readonly to?: string;
22
+ }
23
+
24
+ export interface IndexResult {
25
+ readonly commitsIndexed: number;
26
+ readonly patchesWritten: number;
27
+ readonly commitTicks: ReadonlyMap<string, number>;
28
+ }
29
+
30
+ // Patch builder shape — matches PatchBuilderV2's fluent API.
31
+ interface PatchOps {
32
+ addNode(id: string): PatchOps;
33
+ removeNode(id: string): PatchOps;
34
+ setProperty(id: string, key: string, value: unknown): PatchOps;
35
+ addEdge(from: string, to: string, label: string): PatchOps;
36
+ removeEdge(from: string, to: string, label: string): PatchOps;
37
+ }
38
+
39
+ function listCommits(cwd: string, from?: string, to?: string): string[] {
40
+ const range = from !== undefined ? `${from}..${to ?? "HEAD"}` : to ?? "HEAD";
41
+ const args = ["log", "--reverse", "--format=%H", range];
42
+ try {
43
+ return execFileSync("git", args, { cwd, encoding: "utf-8" })
44
+ .trim().split("\n").filter((l) => l.length > 0);
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ function getCommitChanges(sha: string, cwd: string): { status: string; path: string }[] {
51
+ // --root handles the initial commit (no parent to diff against)
52
+ const args = ["diff-tree", "--root", "--no-commit-id", "-r", "--name-status", sha];
53
+ try {
54
+ return execFileSync("git", args, { cwd, encoding: "utf-8" })
55
+ .trim().split("\n").filter((l) => l.length > 0).map((line) => {
56
+ const parts = line.split("\t");
57
+ return { status: parts[0] ?? "", path: parts[1] ?? "" };
58
+ });
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ function getCommitMeta(sha: string, cwd: string): { message: string; author: string; email: string; timestamp: string } {
65
+ try {
66
+ const output = execFileSync("git", ["log", "-1", "--format=%s%n%aN%n%aE%n%aI", sha], { cwd, encoding: "utf-8" });
67
+ const lines = output.trim().split("\n");
68
+ return { message: lines[0] ?? "", author: lines[1] ?? "", email: lines[2] ?? "", timestamp: lines[3] ?? "" };
69
+ } catch {
70
+ return { message: "", author: "", email: "", timestamp: "" };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Check if a commit has a parent (is not the root commit).
76
+ */
77
+ function hasParent(sha: string, cwd: string): boolean {
78
+ try {
79
+ execFileSync("git", ["rev-parse", "--verify", `${sha}~1`], { cwd, encoding: "utf-8" });
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function dirNodeId(dirPath: string): string {
87
+ return `dir:${dirPath}`;
88
+ }
89
+
90
+ function fileNodeId(filePath: string): string {
91
+ return `file:${filePath}`;
92
+ }
93
+
94
+ function symNodeId(filePath: string, name: string): string {
95
+ return `sym:${filePath}:${name}`;
96
+ }
97
+
98
+ /**
99
+ * Build a lookup from symbol name → line range from the jump table.
100
+ */
101
+ function buildJumpLookup(jumpTable: readonly JumpEntry[]): Map<string, { start: number; end: number }> {
102
+ const lookup = new Map<string, { start: number; end: number }>();
103
+ for (const entry of jumpTable) {
104
+ lookup.set(entry.symbol, { start: entry.start, end: entry.end });
105
+ }
106
+ return lookup;
107
+ }
108
+
109
+ /**
110
+ * Emit directory nodes + edges for all path components of a file.
111
+ */
112
+ function emitDirectoryChain(patch: PatchOps, filePath: string): void {
113
+ const parts = filePath.split("/");
114
+ if (parts.length <= 1) return;
115
+
116
+ let current = "";
117
+ for (let i = 0; i < parts.length - 1; i++) {
118
+ const parent = current;
119
+ const part = parts[i] ?? "";
120
+ current = current.length > 0 ? `${current}/${part}` : part;
121
+ const dirId = dirNodeId(current);
122
+ patch.addNode(dirId);
123
+ patch.setProperty(dirId, "path", current);
124
+
125
+ if (parent.length > 0) {
126
+ patch.addEdge(dirNodeId(parent), dirId, "contains");
127
+ }
128
+ }
129
+
130
+ patch.addEdge(dirNodeId(current), fileNodeId(filePath), "contains");
131
+ }
132
+
133
+ /**
134
+ * Emit symbol nodes + edges for all entries in a file outline.
135
+ */
136
+ function emitSymbols(
137
+ patch: PatchOps,
138
+ filePath: string,
139
+ entries: readonly OutlineEntry[],
140
+ jumpLookup: Map<string, { start: number; end: number }>,
141
+ parentSymId?: string,
142
+ ): void {
143
+ for (const entry of entries) {
144
+ const symId = symNodeId(filePath, entry.name);
145
+ patch.addNode(symId);
146
+ patch.setProperty(symId, "name", entry.name);
147
+ patch.setProperty(symId, "kind", entry.kind);
148
+ patch.setProperty(symId, "exported", entry.exported);
149
+ if (entry.signature !== undefined) {
150
+ patch.setProperty(symId, "signature", entry.signature);
151
+ }
152
+ const jump = jumpLookup.get(entry.name);
153
+ if (jump !== undefined) {
154
+ patch.setProperty(symId, "startLine", jump.start);
155
+ patch.setProperty(symId, "endLine", jump.end);
156
+ }
157
+ patch.addEdge(fileNodeId(filePath), symId, "contains");
158
+ if (parentSymId !== undefined) {
159
+ patch.addEdge(parentSymId, symId, "child_of");
160
+ }
161
+ if (entry.children !== undefined && entry.children.length > 0) {
162
+ emitSymbols(patch, filePath, entry.children, jumpLookup, symId);
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Tombstone (remove) symbol nodes recursively including children.
169
+ */
170
+ function removeSymbols(
171
+ patch: PatchOps,
172
+ filePath: string,
173
+ entries: readonly OutlineEntry[],
174
+ ): void {
175
+ for (const entry of entries) {
176
+ // Recurse into children FIRST (bottom-up removal)
177
+ if (entry.children !== undefined && entry.children.length > 0) {
178
+ removeSymbols(patch, filePath, entry.children);
179
+ }
180
+ const symId = symNodeId(filePath, entry.name);
181
+ patch.removeEdge(fileNodeId(filePath), symId, "contains");
182
+ patch.removeNode(symId);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Remove symbols from DiffEntry (removed symbols in a diff).
188
+ * Also recursively handles childDiff if present.
189
+ */
190
+ function removeDiffSymbols(
191
+ patch: PatchOps,
192
+ filePath: string,
193
+ fileId: string,
194
+ entries: readonly DiffEntry[],
195
+ ): void {
196
+ for (const entry of entries) {
197
+ // Recurse into childDiff if present (remove grandchildren first)
198
+ if (entry.childDiff !== undefined) {
199
+ removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.removed]);
200
+ removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.added]);
201
+ removeDiffSymbols(patch, filePath, fileId, [...entry.childDiff.changed]);
202
+ }
203
+ const symId = symNodeId(filePath, entry.name);
204
+ patch.removeEdge(fileId, symId, "contains");
205
+ patch.removeNode(symId);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Apply child diffs for changed symbols (methods added/removed/changed
211
+ * inside a class that kept its name).
212
+ */
213
+ function applyChildDiffs(
214
+ patch: PatchOps,
215
+ filePath: string,
216
+ fileId: string,
217
+ commitId: string,
218
+ changed: readonly DiffEntry[],
219
+ jumpLookup: Map<string, { start: number; end: number }>,
220
+ ): void {
221
+ for (const entry of changed) {
222
+ if (entry.childDiff === undefined) continue;
223
+ const parentSymId = symNodeId(filePath, entry.name);
224
+
225
+ for (const added of entry.childDiff.added) {
226
+ const symId = symNodeId(filePath, added.name);
227
+ patch.addNode(symId);
228
+ patch.setProperty(symId, "name", added.name);
229
+ patch.setProperty(symId, "kind", added.kind);
230
+ patch.setProperty(symId, "exported", false);
231
+ if (added.signature !== undefined) {
232
+ patch.setProperty(symId, "signature", added.signature);
233
+ }
234
+ const jump = jumpLookup.get(added.name);
235
+ if (jump !== undefined) {
236
+ patch.setProperty(symId, "startLine", jump.start);
237
+ patch.setProperty(symId, "endLine", jump.end);
238
+ }
239
+ patch.addEdge(fileId, symId, "contains");
240
+ patch.addEdge(parentSymId, symId, "child_of");
241
+ patch.addEdge(commitId, symId, "adds");
242
+ }
243
+
244
+ for (const removed of entry.childDiff.removed) {
245
+ const symId = symNodeId(filePath, removed.name);
246
+ patch.addEdge(commitId, symId, "removes");
247
+ patch.removeEdge(fileId, symId, "contains");
248
+ patch.removeNode(symId);
249
+ }
250
+
251
+ // Recurse into changed children that have their own childDiffs
252
+ applyChildDiffs(patch, filePath, fileId, commitId, [...entry.childDiff.changed], jumpLookup);
253
+
254
+ for (const child of entry.childDiff.changed) {
255
+ const symId = symNodeId(filePath, child.name);
256
+ patch.setProperty(symId, "kind", child.kind);
257
+ if (child.signature !== undefined) {
258
+ patch.setProperty(symId, "signature", child.signature);
259
+ }
260
+ patch.addEdge(commitId, symId, "changes");
261
+ }
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Index a range of commits into the WARP graph.
267
+ */
268
+ export async function indexCommits(
269
+ warp: WarpApp,
270
+ options: IndexOptions,
271
+ ): Promise<IndexResult> {
272
+ const { cwd } = options;
273
+ const commits = listCommits(cwd, options.from, options.to);
274
+
275
+ let patchesWritten = 0;
276
+ const commitTicks = new Map<string, number>();
277
+
278
+ for (const sha of commits) {
279
+ const changes = getCommitChanges(sha, cwd);
280
+
281
+ // Only materialize when removals are possible (D or M status).
282
+ // Materialization is expensive — O(n) replay of all prior patches.
283
+ // Add-only commits (A status) and no-change commits don't need it.
284
+ const hasRemovals = changes.some((c) => c.status === "D" || c.status === "M");
285
+ if (hasRemovals) {
286
+ await warp.core().materialize();
287
+ }
288
+
289
+ const meta = getCommitMeta(sha, cwd);
290
+ const parentExists = hasParent(sha, cwd);
291
+ const parentRef = `${sha}~1`;
292
+
293
+ await warp.patch((p) => {
294
+ const patch = p as unknown as PatchOps;
295
+
296
+ const commitId = `commit:${sha}`;
297
+ patch.addNode(commitId);
298
+ patch.setProperty(commitId, "sha", sha);
299
+ patch.setProperty(commitId, "message", meta.message);
300
+ patch.setProperty(commitId, "author", meta.author);
301
+ patch.setProperty(commitId, "email", meta.email);
302
+ patch.setProperty(commitId, "timestamp", meta.timestamp);
303
+
304
+ for (const change of changes) {
305
+ const filePath = change.path;
306
+ const fileId = fileNodeId(filePath);
307
+ const lang = detectLang(filePath);
308
+
309
+ if (change.status === "D") {
310
+ if (lang !== null && parentExists) {
311
+ const oldContent = getFileAtRef(parentRef, filePath, cwd);
312
+ if (oldContent !== null) {
313
+ const oldOutline = extractOutline(oldContent, lang).entries;
314
+ removeSymbols(patch, filePath, oldOutline);
315
+ }
316
+ }
317
+ patch.removeNode(fileId);
318
+ continue;
319
+ }
320
+
321
+ // Added or modified — ensure file + directory nodes exist
322
+ patch.addNode(fileId);
323
+ patch.setProperty(fileId, "path", filePath);
324
+ patch.setProperty(fileId, "lang", lang ?? "unknown");
325
+ patch.addEdge(commitId, fileId, "touches");
326
+ emitDirectoryChain(patch, filePath);
327
+
328
+ if (lang === null) continue;
329
+
330
+ const newContent = getFileAtRef(sha, filePath, cwd);
331
+ if (newContent === null) continue;
332
+ const newResult = extractOutline(newContent, lang);
333
+ const newOutline = newResult.entries;
334
+ const jumpLookup = buildJumpLookup(newResult.jumpTable ?? []);
335
+
336
+ if (change.status === "A" || !parentExists) {
337
+ // New file or root commit — emit all symbols
338
+ emitSymbols(patch, filePath, newOutline, jumpLookup);
339
+ } else {
340
+ // Modified file — structural diff
341
+ const oldContent = getFileAtRef(parentRef, filePath, cwd);
342
+ if (oldContent === null) {
343
+ emitSymbols(patch, filePath, newOutline, jumpLookup);
344
+ continue;
345
+ }
346
+
347
+ const oldOutline = extractOutline(oldContent, lang).entries;
348
+ const diff = diffOutlines(oldOutline, newOutline);
349
+
350
+ // Remove deleted symbols
351
+ for (const removed of diff.removed) {
352
+ const symId = symNodeId(filePath, removed.name);
353
+ patch.addEdge(commitId, symId, "removes");
354
+ removeDiffSymbols(patch, filePath, fileId, [removed]);
355
+ }
356
+
357
+ // Add new symbols (preserve actual exported status)
358
+ for (const added of diff.added) {
359
+ const symId = symNodeId(filePath, added.name);
360
+ patch.addNode(symId);
361
+ patch.setProperty(symId, "name", added.name);
362
+ patch.setProperty(symId, "kind", added.kind);
363
+ // DiffEntry doesn't carry exported — default false for safety.
364
+ // Full exported status comes from emitSymbols on initial add.
365
+ patch.setProperty(symId, "exported", false);
366
+ if (added.signature !== undefined) {
367
+ patch.setProperty(symId, "signature", added.signature);
368
+ }
369
+ const jump = jumpLookup.get(added.name);
370
+ if (jump !== undefined) {
371
+ patch.setProperty(symId, "startLine", jump.start);
372
+ patch.setProperty(symId, "endLine", jump.end);
373
+ }
374
+ patch.addEdge(fileId, symId, "contains");
375
+ patch.addEdge(commitId, symId, "adds");
376
+ }
377
+
378
+ // Update changed symbols
379
+ for (const changed of diff.changed) {
380
+ const symId = symNodeId(filePath, changed.name);
381
+ patch.setProperty(symId, "kind", changed.kind);
382
+ if (changed.signature !== undefined) {
383
+ patch.setProperty(symId, "signature", changed.signature);
384
+ }
385
+ patch.addEdge(commitId, symId, "changes");
386
+ }
387
+
388
+ // Apply nested child diffs (methods in classes)
389
+ applyChildDiffs(patch, filePath, fileId, commitId, [...diff.changed], jumpLookup);
390
+ }
391
+ }
392
+ });
393
+ patchesWritten++;
394
+ commitTicks.set(sha, patchesWritten);
395
+ }
396
+
397
+ return { commitsIndexed: commits.length, patchesWritten, commitTicks };
398
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * WARP Observer Factory — canonical observer lenses for graft queries.
3
+ *
4
+ * Observer Law: this module READS through observers. It never
5
+ * walks the graph directly, maintains shadow state, or implements
6
+ * traversal algorithms.
7
+ *
8
+ * Each function returns an observer with a focused lens. The lens
9
+ * determines the aperture — what the observer can see.
10
+ */
11
+
12
+ import type WarpApp from "@git-stunts/git-warp";
13
+ import type { Observer } from "@git-stunts/git-warp";
14
+
15
+ /** Lens config for creating focused observers. */
16
+ export interface Lens {
17
+ match: string;
18
+ expose?: string[];
19
+ redact?: string[];
20
+ }
21
+
22
+ /**
23
+ * Observe all symbols in a specific file.
24
+ * Aperture: sym:<path>:*
25
+ */
26
+ export function fileSymbolsLens(filePath: string): Lens {
27
+ return {
28
+ match: `sym:${filePath}:*`,
29
+ expose: ["name", "kind", "signature", "exported", "startLine", "endLine"],
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Observe all symbols in the project.
35
+ * Aperture: sym:*
36
+ */
37
+ export function allSymbolsLens(): Lens {
38
+ return {
39
+ match: "sym:*",
40
+ expose: ["name", "kind", "signature", "exported"],
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Observe all files in the project.
46
+ * Aperture: file:*
47
+ */
48
+ export function allFilesLens(): Lens {
49
+ return {
50
+ match: "file:*",
51
+ expose: ["path", "lang"],
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Observe a single symbol by name across all files.
57
+ * Aperture: sym:*:<name>
58
+ */
59
+ export function symbolByNameLens(symbolName: string): Lens {
60
+ return {
61
+ match: `sym:*:${symbolName}`,
62
+ expose: ["name", "kind", "signature", "exported", "startLine", "endLine"],
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Observe a directory subtree.
68
+ * Aperture: dir:<path>*
69
+ */
70
+ export function directoryLens(dirPath: string): Lens {
71
+ return {
72
+ match: `dir:${dirPath}*`,
73
+ expose: ["path"],
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Observe all files under a directory.
79
+ * Aperture: file:<path>/*
80
+ */
81
+ export function directoryFilesLens(dirPath: string): Lens {
82
+ return {
83
+ match: `file:${dirPath}/*`,
84
+ expose: ["path", "lang"],
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Observe commit metadata.
90
+ * Aperture: commit:*
91
+ */
92
+ export function commitsLens(): Lens {
93
+ return {
94
+ match: "commit:*",
95
+ expose: ["sha", "message", "timestamp", "author", "email"],
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Create an observer on the current frontier with a given lens.
101
+ * Observers are static snapshots — create a new one after writes.
102
+ */
103
+ export function observe(warp: WarpApp, lens: Lens): Promise<Observer> {
104
+ return warp.observer(lens);
105
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * WARP graph initialization — opens the graft-ast graph backed by
3
+ * the repo's own .git directory.
4
+ *
5
+ * Single entry point. Returns a WarpApp instance ready for patch
6
+ * writes and observer reads.
7
+ */
8
+
9
+ import WarpApp, { GitGraphAdapter } from "@git-stunts/git-warp";
10
+ import GitPlumbing from "@git-stunts/plumbing";
11
+
12
+ const GRAPH_NAME = "graft-ast";
13
+ const WRITER_ID = "graft";
14
+
15
+ export interface OpenWarpOptions {
16
+ readonly cwd: string;
17
+ }
18
+
19
+ export async function openWarp(options: OpenWarpOptions): Promise<WarpApp> {
20
+ // createDefault() wires the ShellRunnerFactory (required port)
21
+ const plumbing = GitPlumbing.createDefault({ cwd: options.cwd });
22
+ const persistence = new GitGraphAdapter({ plumbing });
23
+
24
+ return WarpApp.open({
25
+ persistence,
26
+ graphName: GRAPH_NAME,
27
+ writerId: WRITER_ID,
28
+ onDeleteWithData: "cascade",
29
+ });
30
+ }
@@ -0,0 +1,11 @@
1
+ declare module "@git-stunts/plumbing" {
2
+ import type { GitPlumbing as GitPlumbingInterface } from "@git-stunts/git-warp";
3
+
4
+ export default class GitPlumbing implements GitPlumbingInterface {
5
+ readonly emptyTree: string;
6
+ constructor(options: { runner: unknown; cwd?: string });
7
+ static createDefault(options?: { cwd?: string; env?: string }): GitPlumbing;
8
+ execute(options: { args: string[]; input?: string | Uint8Array }): Promise<string>;
9
+ executeStream(options: { args: string[] }): Promise<AsyncIterable<Uint8Array> & { collect(opts?: { asString?: boolean }): Promise<Uint8Array | string> }>;
10
+ }
11
+ }