@flyingrobots/graft 0.4.0 → 0.5.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.
Files changed (111) hide show
  1. package/ARCHITECTURE.md +386 -0
  2. package/CHANGELOG.md +47 -0
  3. package/CODE_OF_CONDUCT.md +65 -0
  4. package/README.md +153 -17
  5. package/bin/graft.js +4 -14
  6. package/docs/ADVANCED_GUIDE.md +49 -0
  7. package/docs/CLI.md +43 -0
  8. package/docs/GUIDE.md +321 -32
  9. package/docs/MCP.md +44 -0
  10. package/package.json +15 -4
  11. package/src/adapters/node-fs.ts +4 -0
  12. package/src/adapters/node-git.ts +47 -0
  13. package/src/adapters/node-process-runner.ts +27 -0
  14. package/src/cli/index-cmd.ts +75 -11
  15. package/src/cli/init.ts +808 -57
  16. package/src/cli/main.ts +437 -0
  17. package/src/contracts/capabilities.ts +341 -0
  18. package/src/contracts/causal-ontology.ts +622 -0
  19. package/src/contracts/causal-surface-next-action.ts +18 -0
  20. package/src/contracts/output-schemas.ts +1169 -0
  21. package/src/git/diff.ts +25 -21
  22. package/src/git/target-git-hook-bootstrap.ts +56 -0
  23. package/src/hooks/posttooluse-read.ts +21 -74
  24. package/src/hooks/pretooluse-read.ts +20 -56
  25. package/src/hooks/read-governor.ts +95 -0
  26. package/src/hooks/read-messages.ts +53 -0
  27. package/src/mcp/burden.ts +123 -0
  28. package/src/mcp/cache.ts +51 -0
  29. package/src/mcp/cached-file.ts +10 -8
  30. package/src/mcp/context.ts +65 -2
  31. package/src/mcp/daemon-control-plane.ts +554 -0
  32. package/src/mcp/daemon-job-scheduler.ts +279 -0
  33. package/src/mcp/daemon-repos.ts +216 -0
  34. package/src/mcp/daemon-server.ts +396 -0
  35. package/src/mcp/daemon-worker-pool.ts +310 -0
  36. package/src/mcp/daemon-worker-process.ts +52 -0
  37. package/src/mcp/metrics.ts +108 -1
  38. package/src/mcp/monitor-tick-job.ts +99 -0
  39. package/src/mcp/persisted-local-history.ts +1246 -0
  40. package/src/mcp/persistent-monitor-runtime.ts +549 -0
  41. package/src/mcp/policy.ts +84 -0
  42. package/src/mcp/receipt.ts +82 -12
  43. package/src/mcp/repo-concurrency.ts +318 -0
  44. package/src/mcp/repo-state.ts +777 -0
  45. package/src/mcp/repo-tool-job.ts +302 -0
  46. package/src/mcp/run-capture-config.ts +33 -0
  47. package/src/mcp/runtime-causal-context.ts +72 -0
  48. package/src/mcp/runtime-observability.ts +219 -0
  49. package/src/mcp/runtime-staged-target.ts +161 -0
  50. package/src/mcp/runtime-workspace-overlay.ts +255 -0
  51. package/src/mcp/semantic-transition-guidance.ts +60 -0
  52. package/src/mcp/semantic-transition-summary.ts +130 -0
  53. package/src/mcp/server.ts +696 -55
  54. package/src/mcp/stdio-server.ts +12 -0
  55. package/src/mcp/stdio.ts +2 -5
  56. package/src/mcp/tools/activity-view.ts +325 -0
  57. package/src/mcp/tools/causal-attach.ts +67 -0
  58. package/src/mcp/tools/causal-status.ts +58 -0
  59. package/src/mcp/tools/changed-since.ts +13 -11
  60. package/src/mcp/tools/code-find.ts +164 -0
  61. package/src/mcp/tools/code-refs.ts +466 -0
  62. package/src/mcp/tools/code-show.ts +252 -0
  63. package/src/mcp/tools/daemon-monitors.ts +14 -0
  64. package/src/mcp/tools/daemon-repos.ts +22 -0
  65. package/src/mcp/tools/daemon-sessions.ts +14 -0
  66. package/src/mcp/tools/daemon-status.ts +12 -0
  67. package/src/mcp/tools/doctor.ts +45 -2
  68. package/src/mcp/tools/explain.ts +4 -0
  69. package/src/mcp/tools/file-outline.ts +7 -3
  70. package/src/mcp/tools/git-files.ts +73 -0
  71. package/src/mcp/tools/graft-diff.ts +12 -4
  72. package/src/mcp/tools/map.ts +92 -38
  73. package/src/mcp/tools/monitor-pause.ts +18 -0
  74. package/src/mcp/tools/monitor-resume.ts +18 -0
  75. package/src/mcp/tools/monitor-start.ts +20 -0
  76. package/src/mcp/tools/monitor-stop.ts +18 -0
  77. package/src/mcp/tools/precision-match.ts +51 -0
  78. package/src/mcp/tools/precision-query.ts +127 -0
  79. package/src/mcp/tools/precision.ts +312 -0
  80. package/src/mcp/tools/run-capture.ts +126 -44
  81. package/src/mcp/tools/safe-read.ts +14 -12
  82. package/src/mcp/tools/since.ts +7 -2
  83. package/src/mcp/tools/state.ts +11 -3
  84. package/src/mcp/tools/stats.ts +5 -1
  85. package/src/mcp/tools/workspace-authorizations.ts +14 -0
  86. package/src/mcp/tools/workspace-authorize.ts +20 -0
  87. package/src/mcp/tools/workspace-bind.ts +25 -0
  88. package/src/mcp/tools/workspace-rebind.ts +25 -0
  89. package/src/mcp/tools/workspace-revoke.ts +18 -0
  90. package/src/mcp/tools/workspace-status.ts +12 -0
  91. package/src/mcp/warp-pool.ts +36 -0
  92. package/src/mcp/workspace-router.ts +984 -0
  93. package/src/operations/file-outline.ts +12 -2
  94. package/src/operations/graft-diff.ts +56 -10
  95. package/src/operations/safe-read.ts +27 -4
  96. package/src/operations/state.ts +6 -9
  97. package/src/parser/lang.ts +19 -3
  98. package/src/parser/outline.ts +191 -2
  99. package/src/parser/types.ts +9 -1
  100. package/src/policy/types.ts +4 -3
  101. package/src/ports/filesystem.ts +1 -0
  102. package/src/ports/git.ts +16 -0
  103. package/src/ports/process-runner.ts +22 -0
  104. package/src/release/security-gate.ts +102 -0
  105. package/src/session/tracker.ts +31 -0
  106. package/src/version.ts +3 -0
  107. package/src/warp/indexer.ts +171 -56
  108. package/src/warp/observers.ts +1 -1
  109. package/src/warp/open.ts +4 -3
  110. package/src/warp/plumbing.d.ts +5 -1
  111. package/src/warp/writer-id.ts +30 -0
package/src/git/diff.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { execFileSync } from "node:child_process";
1
+ import type { GitClient } from "../ports/git.js";
2
2
 
3
3
  export interface ChangedFilesOptions {
4
4
  cwd: string;
5
+ git: GitClient;
5
6
  base?: string | undefined;
6
7
  head?: string | undefined;
7
8
  }
@@ -13,26 +14,26 @@ export class GitError extends Error {
13
14
  }
14
15
  }
15
16
 
16
- function git(args: string[], cwd: string): string {
17
- return execFileSync("git", args, {
18
- cwd,
19
- encoding: "utf-8",
20
- stdio: ["pipe", "pipe", "pipe"],
21
- });
17
+ async function git(gitClient: GitClient, args: readonly string[], cwd: string): Promise<string> {
18
+ const result = await gitClient.run({ args, cwd });
19
+ if (result.error !== undefined || result.status !== 0) {
20
+ throw result.error ?? new Error(result.stderr.trim() || `git exited with status ${String(result.status)}`);
21
+ }
22
+ return result.stdout;
22
23
  }
23
24
 
24
- function refExists(ref: string, cwd: string): boolean {
25
+ async function refExists(ref: string, cwd: string, gitClient: GitClient): Promise<boolean> {
25
26
  try {
26
- git(["rev-parse", "--verify", ref], cwd);
27
+ await git(gitClient, ["rev-parse", "--verify", ref], cwd);
27
28
  return true;
28
29
  } catch {
29
30
  return false;
30
31
  }
31
32
  }
32
33
 
33
- function objectExists(ref: string, filePath: string, cwd: string): boolean {
34
+ async function objectExists(ref: string, filePath: string, cwd: string, gitClient: GitClient): Promise<boolean> {
34
35
  try {
35
- git(["cat-file", "-e", `${ref}:${filePath}`], cwd);
36
+ await git(gitClient, ["cat-file", "-e", `${ref}:${filePath}`], cwd);
36
37
  return true;
37
38
  } catch {
38
39
  return false;
@@ -47,14 +48,14 @@ function objectExists(ref: string, filePath: string, cwd: string): boolean {
47
48
  * Throws GitError for invalid refs or non-git directories.
48
49
  * Returns empty array only when there are genuinely no changes.
49
50
  */
50
- export function getChangedFiles(opts: ChangedFilesOptions): string[] {
51
+ export async function getChangedFiles(opts: ChangedFilesOptions): Promise<string[]> {
51
52
  const base = opts.base ?? "HEAD";
52
53
  const args = opts.head !== undefined
53
- ? ["diff", "--name-only", base, opts.head]
54
- : ["diff", "--name-only", base];
54
+ ? ["diff-tree", "--no-commit-id", "--name-only", "-r", base, opts.head]
55
+ : ["diff-index", "--name-only", base, "--"];
55
56
 
56
57
  try {
57
- const output = git(args, opts.cwd).trim();
58
+ const output = (await git(opts.git, args, opts.cwd)).trim();
58
59
  if (output === "") return [];
59
60
  return output.split("\n");
60
61
  } catch (err: unknown) {
@@ -71,24 +72,27 @@ export function getChangedFiles(opts: ChangedFilesOptions): string[] {
71
72
  * Uses `git rev-parse --verify` and `git cat-file -e` for stable
72
73
  * detection — no error message parsing.
73
74
  */
74
- export function getFileAtRef(
75
+ export async function getFileAtRef(
75
76
  ref: string,
76
77
  filePath: string,
77
- cwd: string,
78
- ): string | null {
78
+ opts: {
79
+ cwd: string;
80
+ git: GitClient;
81
+ },
82
+ ): Promise<string | null> {
79
83
  // Validate the ref exists (stable probe, no message parsing)
80
- if (!refExists(ref, cwd)) {
84
+ if (!(await refExists(ref, opts.cwd, opts.git))) {
81
85
  throw new GitError(`ref does not exist: ${ref}`);
82
86
  }
83
87
 
84
88
  // Check if the object exists at this ref (stable probe)
85
- if (!objectExists(ref, filePath, cwd)) {
89
+ if (!(await objectExists(ref, filePath, opts.cwd, opts.git))) {
86
90
  return null; // Clean absence — file not in this ref
87
91
  }
88
92
 
89
93
  // Object exists — read it
90
94
  try {
91
- return git(["show", `${ref}:${filePath}`], cwd);
95
+ return await git(opts.git, ["show", `${ref}:${filePath}`], opts.cwd);
92
96
  } catch (err: unknown) {
93
97
  const msg = err instanceof Error ? err.message : String(err);
94
98
  throw new GitError(`git show ${ref}:${filePath} failed: ${msg}`);
@@ -0,0 +1,56 @@
1
+ import * as path from "node:path";
2
+
3
+ export const TARGET_GIT_TRANSITION_HOOKS = [
4
+ "post-checkout",
5
+ "post-merge",
6
+ "post-rewrite",
7
+ ] as const;
8
+
9
+ export const TARGET_GIT_HOOK_MARKER = "graft-target-repo-git-hook";
10
+
11
+ export function resolveGitHooksPath(
12
+ worktreeRoot: string,
13
+ gitCommonDir: string,
14
+ configuredCoreHooksPath: string | null,
15
+ ): string {
16
+ if (configuredCoreHooksPath === null) {
17
+ return path.join(gitCommonDir, "hooks");
18
+ }
19
+ return path.isAbsolute(configuredCoreHooksPath)
20
+ ? configuredCoreHooksPath
21
+ : path.resolve(worktreeRoot, configuredCoreHooksPath);
22
+ }
23
+
24
+ export function isRecognizedTargetGitHook(
25
+ content: string,
26
+ hookName: (typeof TARGET_GIT_TRANSITION_HOOKS)[number],
27
+ ): boolean {
28
+ return content.includes(`# ${TARGET_GIT_HOOK_MARKER}:${hookName}`);
29
+ }
30
+
31
+ export function isTargetGitTransitionHookName(
32
+ value: string,
33
+ ): value is (typeof TARGET_GIT_TRANSITION_HOOKS)[number] {
34
+ return TARGET_GIT_TRANSITION_HOOKS.includes(value as (typeof TARGET_GIT_TRANSITION_HOOKS)[number]);
35
+ }
36
+
37
+ export function buildTargetGitHookScript(
38
+ hookName: (typeof TARGET_GIT_TRANSITION_HOOKS)[number],
39
+ ): string {
40
+ return [
41
+ "#!/bin/sh",
42
+ `# ${TARGET_GIT_HOOK_MARKER}:${hookName}`,
43
+ "set -eu",
44
+ "GRAFT_WORKTREE_ROOT=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"",
45
+ "node -e '",
46
+ "const fs = require(\"node:fs\");",
47
+ "const path = require(\"node:path\");",
48
+ "const [hookName, worktreeRoot, ...hookArgs] = process.argv.slice(1);",
49
+ "const runtimeDir = path.join(worktreeRoot, \".graft\", \"runtime\");",
50
+ "fs.mkdirSync(runtimeDir, { recursive: true });",
51
+ "const event = { hookName, hookArgs, worktreeRoot, observedAt: new Date().toISOString() };",
52
+ "fs.appendFileSync(path.join(runtimeDir, \"git-transitions.ndjson\"), JSON.stringify(event) + \"\\n\");",
53
+ `' "${hookName}" "$GRAFT_WORKTREE_ROOT" "$@"`,
54
+ "",
55
+ ].join("\n");
56
+ }
@@ -2,102 +2,49 @@
2
2
  // PostToolUse hook for Read — educates the agent on context cost
3
3
  // ---------------------------------------------------------------------------
4
4
  //
5
- // After a Read completes, evaluates what safe_read would have done and
6
- // tells the agent the cost difference. Does not block — just feedback.
5
+ // After a Read completes, evaluates whether a large JS/TS file bypassed
6
+ // graft's governed path and tells the agent the cost difference.
7
+ // Does not block — just feedback.
7
8
  //
8
9
  // The agent sees messages like:
9
- // "[graft] You just read 450 lines (18KB). safe_read would have
10
- // returned a 2KB outline, saving 16KB of context."
10
+ // "[graft] This large code read bypassed graft's governed path ...
11
+ // safe_read would have returned a 2KB outline, saving 16KB."
11
12
  //
12
13
  // This teaches the agent to prefer graft's MCP tools voluntarily.
13
14
  //
14
15
  // Invoked as: node --import tsx src/hooks/posttooluse-read.ts
15
16
  // Receives JSON on stdin from Claude Code hooks system.
16
17
  // ---------------------------------------------------------------------------
17
-
18
- import * as fs from "node:fs";
19
- import * as path from "node:path";
20
- import { evaluatePolicy, STATIC_THRESHOLDS } from "../policy/evaluate.js";
21
- import { ContentResult, RefusedResult } from "../policy/types.js";
22
- import { loadGraftignore } from "../policy/graftignore.js";
23
- import { HookInput, HookOutput, safeRelativePath, runHook } from "./shared.js";
18
+ import { HookInput, HookOutput, runHook } from "./shared.js";
19
+ import { renderOversizedReadEducation } from "./read-messages.js";
20
+ import {
21
+ inspectHookRead,
22
+ } from "./read-governor.js";
24
23
 
25
24
  export { HookInput, HookOutput };
26
25
 
27
26
  export async function handlePostReadHook(input: HookInput): Promise<HookOutput> {
28
- const filePath = input.tool_input.file_path;
29
-
30
- // Path outside project — no feedback
31
- const relPath = safeRelativePath(input.cwd, filePath);
32
- if (relPath === null) {
33
- return new HookOutput(0, "");
34
- }
35
-
36
- // Read file to get dimensions
37
- let rawContent: string;
38
- try {
39
- rawContent = fs.readFileSync(filePath, "utf-8");
40
- } catch {
27
+ const inspection = inspectHookRead(input);
28
+ if (!inspection?.isGovernedCodeRead()) {
41
29
  return new HookOutput(0, "");
42
30
  }
43
31
 
44
- const lines = rawContent.split("\n");
45
- const bytes = Buffer.byteLength(rawContent, "utf-8");
46
-
47
- // Load .graftignore patterns
48
- let graftignorePatterns: string[] | undefined;
49
- try {
50
- const ignoreFile = fs.readFileSync(
51
- path.join(input.cwd, ".graftignore"),
52
- "utf-8",
53
- );
54
- graftignorePatterns = loadGraftignore(ignoreFile);
55
- } catch {
56
- // No .graftignore
57
- }
58
-
59
- // Evaluate what safe_read would have done
60
- const policy = evaluatePolicy(
61
- { path: relPath, lines: lines.length, bytes },
62
- { graftignorePatterns },
32
+ const { extractOutlineForFile } = await import("../parser/outline.js");
33
+ const outline = extractOutlineForFile(
34
+ inspection.absolutePath,
35
+ inspection.rawContent,
63
36
  );
64
-
65
- // Small file — no feedback needed, Read was the right call
66
- if (policy instanceof ContentResult) {
67
- return new HookOutput(0, "");
68
- }
69
-
70
- // Refused — PreToolUse should have caught this, but just in case
71
- if (policy instanceof RefusedResult) {
72
- return new HookOutput(0, "");
73
- }
74
-
75
- // Outline projection — the agent just dumped a large file into context
76
- // when safe_read would have returned a compact outline
77
- const { detectLang } = await import("../parser/lang.js");
78
- const lang = detectLang(filePath);
79
- if (lang === null) {
80
- // Non-JS/TS — no outline available, Read was reasonable
37
+ if (outline === null) {
81
38
  return new HookOutput(0, "");
82
39
  }
83
40
 
84
41
  const { CanonicalJsonCodec } = await import("../adapters/canonical-json.js");
85
- const { extractOutline } = await import("../parser/outline.js");
86
42
  const codec = new CanonicalJsonCodec();
87
- const outline = extractOutline(rawContent, lang);
88
43
  const outlineBytes = Buffer.byteLength(codec.encode(outline), "utf-8");
89
- const saved = bytes - outlineBytes;
90
- const savedKb = (saved / 1024).toFixed(1);
91
- const bytesKb = (bytes / 1024).toFixed(1);
92
-
93
- return new HookOutput(0, [
94
- `[graft] You just read ${String(lines.length)} lines (${bytesKb}KB) into context.`,
95
- `safe_read would have returned a structural outline (${String(outlineBytes)} bytes),`,
96
- `saving ${savedKb}KB of context. Threshold: ${String(STATIC_THRESHOLDS.lines)} lines / ${String(STATIC_THRESHOLDS.bytes / 1024)}KB.`,
97
- "",
98
- "Consider using graft's safe_read tool for large files —",
99
- "it returns outlines with jump tables for targeted read_range.",
100
- ].join("\n"));
44
+ return new HookOutput(
45
+ 0,
46
+ renderOversizedReadEducation(inspection, outlineBytes),
47
+ );
101
48
  }
102
49
 
103
50
  // ---------------------------------------------------------------------------
@@ -1,83 +1,47 @@
1
1
  // ---------------------------------------------------------------------------
2
- // PreToolUse hook for Read — blocks banned files only
2
+ // PreToolUse hook for Read — blocks banned files and redirects large code reads
3
3
  // ---------------------------------------------------------------------------
4
4
  //
5
5
  // Intercepts Claude Code's Read tool and evaluates graft policy:
6
6
  // - Refused (banned file): exit 2 — hard block with refusal reason
7
+ // - Oversized JS/TS file: exit 2 — redirect to graft's bounded-read path
7
8
  // - Everything else: exit 0 — let native Read proceed
8
9
  //
9
10
  // Banned files (.env, binaries, lockfiles, minified, build output,
10
- // .graftignore matches) are the only hard enforcement. Large file
11
- // governance is handled by the PostToolUse hook via education.
11
+ // .graftignore matches) stay hard blocks. Large JS/TS files now route
12
+ // through graft's governed path before native Read can dump them into
13
+ // context. PostToolUse remains a backstop if an oversized code read
14
+ // still slips through.
12
15
  //
13
16
  // Invoked as: node --import tsx src/hooks/pretooluse-read.ts
14
17
  // Receives JSON on stdin from Claude Code hooks system.
15
18
  // ---------------------------------------------------------------------------
16
-
17
- import * as fs from "node:fs";
18
- import * as path from "node:path";
19
- import { evaluatePolicy } from "../policy/evaluate.js";
20
19
  import { RefusedResult } from "../policy/types.js";
21
- import { loadGraftignore } from "../policy/graftignore.js";
22
- import { HookInput, HookOutput, safeRelativePath, runHook } from "./shared.js";
20
+ import {
21
+ renderGovernedReadRedirect,
22
+ renderRefusedReadMessage,
23
+ } from "./read-messages.js";
24
+ import {
25
+ inspectHookRead,
26
+ } from "./read-governor.js";
27
+ import { HookInput, HookOutput, runHook } from "./shared.js";
23
28
 
24
29
  export { HookInput, HookOutput };
25
30
 
26
31
  export function handleReadHook(input: HookInput): HookOutput {
27
- const filePath = input.tool_input.file_path;
28
-
29
- // Path outside project — let Read handle it, not our concern
30
- const relPath = safeRelativePath(input.cwd, filePath);
31
- if (relPath === null) {
32
+ const inspection = inspectHookRead(input);
33
+ if (inspection === null) {
32
34
  return new HookOutput(0, "");
33
35
  }
34
36
 
35
- // Read file to get dimensions for policy
36
- let rawContent: string;
37
- try {
38
- rawContent = fs.readFileSync(filePath, "utf-8");
39
- } catch {
40
- // File errors (ENOENT, EACCES, EISDIR) — let Read handle natively
41
- return new HookOutput(0, "");
37
+ if (inspection.policy instanceof RefusedResult) {
38
+ return new HookOutput(2, renderRefusedReadMessage(inspection.policy));
42
39
  }
43
40
 
44
- const lines = rawContent.split("\n");
45
- const bytes = Buffer.byteLength(rawContent, "utf-8");
46
-
47
- // Load .graftignore patterns
48
- let graftignorePatterns: string[] | undefined;
49
- try {
50
- const ignoreFile = fs.readFileSync(
51
- path.join(input.cwd, ".graftignore"),
52
- "utf-8",
53
- );
54
- graftignorePatterns = loadGraftignore(ignoreFile);
55
- } catch {
56
- // No .graftignore — that's fine
57
- }
58
-
59
- // Evaluate policy
60
- const policy = evaluatePolicy(
61
- { path: relPath, lines: lines.length, bytes },
62
- { graftignorePatterns },
63
- );
64
-
65
- // Only block refused files — everything else passes through
66
- if (policy instanceof RefusedResult) {
67
- const nextSteps = policy.next.map((n) => ` - ${n}`).join("\n");
68
- return new HookOutput(2, [
69
- `[graft] Refused: ${policy.reason}`,
70
- policy.reasonDetail,
71
- "",
72
- "Next steps:",
73
- nextSteps,
74
- "",
75
- "Graft tools: use file_outline to see the file's structure,",
76
- "or safe_read for a policy-aware read with caching.",
77
- ].join("\n"));
41
+ if (inspection.isGovernedCodeRead()) {
42
+ return new HookOutput(2, renderGovernedReadRedirect(inspection));
78
43
  }
79
44
 
80
- // Content or outline — let Read proceed. PostToolUse will educate.
81
45
  return new HookOutput(0, "");
82
46
  }
83
47
 
@@ -0,0 +1,95 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { SupportedLang } from "../parser/lang.js";
4
+ import { detectLang } from "../parser/lang.js";
5
+ import { evaluatePolicy } from "../policy/evaluate.js";
6
+ import type { PolicyResult } from "../policy/types.js";
7
+ import { loadGraftignore } from "../policy/graftignore.js";
8
+ import { HookInput, safeRelativePath } from "./shared.js";
9
+
10
+ export class HookReadInspection {
11
+ readonly absolutePath: string;
12
+ readonly relativePath: string;
13
+ readonly rawContent: string;
14
+ readonly lines: number;
15
+ readonly bytes: number;
16
+ readonly lang: SupportedLang | null;
17
+ readonly policy: PolicyResult;
18
+
19
+ constructor(opts: {
20
+ absolutePath: string;
21
+ relativePath: string;
22
+ rawContent: string;
23
+ lines: number;
24
+ bytes: number;
25
+ lang: SupportedLang | null;
26
+ policy: PolicyResult;
27
+ }) {
28
+ if (opts.absolutePath.length === 0) {
29
+ throw new Error("HookReadInspection: absolutePath must be non-empty");
30
+ }
31
+ if (opts.relativePath.length === 0) {
32
+ throw new Error("HookReadInspection: relativePath must be non-empty");
33
+ }
34
+ if (opts.lines < 0) {
35
+ throw new Error("HookReadInspection: lines must be non-negative");
36
+ }
37
+ if (opts.bytes < 0) {
38
+ throw new Error("HookReadInspection: bytes must be non-negative");
39
+ }
40
+
41
+ this.absolutePath = opts.absolutePath;
42
+ this.relativePath = opts.relativePath;
43
+ this.rawContent = opts.rawContent;
44
+ this.lines = opts.lines;
45
+ this.bytes = opts.bytes;
46
+ this.lang = opts.lang;
47
+ this.policy = opts.policy;
48
+ Object.freeze(this);
49
+ }
50
+
51
+ isGovernedCodeRead(): boolean {
52
+ return this.lang !== null && this.policy.projection === "outline";
53
+ }
54
+ }
55
+
56
+ function loadGraftignorePatterns(cwd: string): string[] | undefined {
57
+ try {
58
+ const ignoreFile = fs.readFileSync(path.join(cwd, ".graftignore"), "utf-8");
59
+ return loadGraftignore(ignoreFile);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ export function inspectHookRead(input: HookInput): HookReadInspection | null {
66
+ const absolutePath = input.tool_input.file_path;
67
+ const relativePath = safeRelativePath(input.cwd, absolutePath);
68
+ if (relativePath === null) {
69
+ return null;
70
+ }
71
+
72
+ let rawContent: string;
73
+ try {
74
+ rawContent = fs.readFileSync(absolutePath, "utf-8");
75
+ } catch {
76
+ return null;
77
+ }
78
+
79
+ const lines = rawContent.split("\n").length;
80
+ const bytes = Buffer.byteLength(rawContent, "utf-8");
81
+ const policy = evaluatePolicy(
82
+ { path: relativePath, lines, bytes },
83
+ { graftignorePatterns: loadGraftignorePatterns(input.cwd) },
84
+ );
85
+
86
+ return new HookReadInspection({
87
+ absolutePath,
88
+ relativePath,
89
+ rawContent,
90
+ lines,
91
+ bytes,
92
+ lang: detectLang(absolutePath),
93
+ policy,
94
+ });
95
+ }
@@ -0,0 +1,53 @@
1
+ import { STATIC_THRESHOLDS } from "../policy/evaluate.js";
2
+ import { RefusedResult } from "../policy/types.js";
3
+ import { HookReadInspection } from "./read-governor.js";
4
+
5
+ function formatKilobytes(bytes: number): string {
6
+ return (bytes / 1024).toFixed(1);
7
+ }
8
+
9
+ export function renderRefusedReadMessage(policy: RefusedResult): string {
10
+ const nextSteps = policy.next.map((step) => ` - ${step}`).join("\n");
11
+ return [
12
+ `[graft] Refused: ${policy.reason}`,
13
+ policy.reasonDetail,
14
+ "",
15
+ "Next steps:",
16
+ nextSteps,
17
+ "",
18
+ "Graft tools: use file_outline to see the file's structure,",
19
+ "or safe_read for a policy-aware read with caching.",
20
+ ].join("\n");
21
+ }
22
+
23
+ export function renderGovernedReadRedirect(
24
+ inspection: HookReadInspection,
25
+ ): string {
26
+ return [
27
+ `[graft] Governed read: ${inspection.relativePath}`,
28
+ `Native Read would dump ${String(inspection.lines)} lines (${formatKilobytes(inspection.bytes)}KB) into context.`,
29
+ "This large JS/TS file should go through graft's bounded-read path instead.",
30
+ "",
31
+ "Next steps:",
32
+ ` - Call safe_read on ${inspection.relativePath}`,
33
+ " - Use read_range with jump table entries for the exact region you need",
34
+ " - Use file_outline if you only need symbol structure",
35
+ "",
36
+ `Threshold: ${String(STATIC_THRESHOLDS.lines)} lines / ${String(STATIC_THRESHOLDS.bytes / 1024)}KB.`,
37
+ ].join("\n");
38
+ }
39
+
40
+ export function renderOversizedReadEducation(
41
+ inspection: HookReadInspection,
42
+ outlineBytes: number,
43
+ ): string {
44
+ const savedBytes = inspection.bytes - outlineBytes;
45
+ return [
46
+ `[graft] This large code read bypassed graft's governed path for ${inspection.relativePath}.`,
47
+ `safe_read would have returned a structural outline (${String(outlineBytes)} bytes) instead of ${String(inspection.lines)} lines (${formatKilobytes(inspection.bytes)}KB),`,
48
+ `saving ${formatKilobytes(savedBytes)}KB of context. Threshold: ${String(STATIC_THRESHOLDS.lines)} lines / ${String(STATIC_THRESHOLDS.bytes / 1024)}KB.`,
49
+ "",
50
+ "If this was unexpected, verify the Claude PreToolUse hook is installed and active.",
51
+ "Use read_range with jump table entries after safe_read for targeted follow-up.",
52
+ ].join("\n");
53
+ }
@@ -0,0 +1,123 @@
1
+ import type { McpToolName } from "../contracts/output-schemas.js";
2
+
3
+ export const BURDEN_KINDS = ["read", "search", "shell", "state", "diagnostic"] as const;
4
+
5
+ export type BurdenKind = typeof BURDEN_KINDS[number];
6
+
7
+ export interface BurdenBucket {
8
+ readonly calls: number;
9
+ readonly bytesReturned: number;
10
+ }
11
+
12
+ export type BurdenByKind = Record<BurdenKind, BurdenBucket>;
13
+
14
+ const ZERO_BUCKET: BurdenBucket = Object.freeze({ calls: 0, bytesReturned: 0 });
15
+
16
+ const TOOL_BURDEN_KIND: Record<McpToolName, BurdenKind> = {
17
+ safe_read: "read",
18
+ file_outline: "read",
19
+ read_range: "read",
20
+ changed_since: "read",
21
+ graft_diff: "search",
22
+ graft_since: "search",
23
+ graft_map: "search",
24
+ code_show: "search",
25
+ code_find: "search",
26
+ code_refs: "search",
27
+ daemon_repos: "diagnostic",
28
+ daemon_status: "diagnostic",
29
+ daemon_sessions: "diagnostic",
30
+ daemon_monitors: "diagnostic",
31
+ monitor_start: "diagnostic",
32
+ monitor_pause: "diagnostic",
33
+ monitor_resume: "diagnostic",
34
+ monitor_stop: "diagnostic",
35
+ workspace_authorize: "diagnostic",
36
+ workspace_authorizations: "diagnostic",
37
+ workspace_revoke: "diagnostic",
38
+ workspace_bind: "diagnostic",
39
+ workspace_status: "diagnostic",
40
+ causal_status: "diagnostic",
41
+ causal_attach: "diagnostic",
42
+ activity_view: "diagnostic",
43
+ workspace_rebind: "diagnostic",
44
+ run_capture: "shell",
45
+ state_save: "state",
46
+ state_load: "state",
47
+ set_budget: "diagnostic",
48
+ explain: "diagnostic",
49
+ doctor: "diagnostic",
50
+ stats: "diagnostic",
51
+ };
52
+
53
+ export function emptyBurdenByKind(): BurdenByKind {
54
+ return {
55
+ read: ZERO_BUCKET,
56
+ search: ZERO_BUCKET,
57
+ shell: ZERO_BUCKET,
58
+ state: ZERO_BUCKET,
59
+ diagnostic: ZERO_BUCKET,
60
+ };
61
+ }
62
+
63
+ export function cloneBurdenByKind(source: Readonly<BurdenByKind>): BurdenByKind {
64
+ return {
65
+ read: { ...source.read },
66
+ search: { ...source.search },
67
+ shell: { ...source.shell },
68
+ state: { ...source.state },
69
+ diagnostic: { ...source.diagnostic },
70
+ };
71
+ }
72
+
73
+ export function freezeBurdenByKind(source: BurdenByKind): Readonly<BurdenByKind> {
74
+ for (const kind of BURDEN_KINDS) {
75
+ Object.freeze(source[kind]);
76
+ }
77
+ return Object.freeze(source);
78
+ }
79
+
80
+ export function burdenKindForTool(tool: McpToolName): BurdenKind {
81
+ return TOOL_BURDEN_KIND[tool];
82
+ }
83
+
84
+ export function isNonReadBurdenKind(kind: BurdenKind): boolean {
85
+ return kind !== "read";
86
+ }
87
+
88
+ export function projectBurdenByKind(
89
+ source: Readonly<BurdenByKind>,
90
+ tool: McpToolName,
91
+ returnedBytes: number,
92
+ ): Readonly<BurdenByKind> {
93
+ const next = cloneBurdenByKind(source);
94
+ const kind = burdenKindForTool(tool);
95
+ const current = next[kind];
96
+ next[kind] = {
97
+ calls: current.calls + 1,
98
+ bytesReturned: current.bytesReturned + returnedBytes,
99
+ };
100
+ return freezeBurdenByKind(next);
101
+ }
102
+
103
+ export function totalNonReadBytesReturned(source: Readonly<BurdenByKind>): number {
104
+ return BURDEN_KINDS
105
+ .filter((kind) => isNonReadBurdenKind(kind))
106
+ .reduce((sum, kind) => sum + source[kind].bytesReturned, 0);
107
+ }
108
+
109
+ export function topBurdenKind(
110
+ source: Readonly<BurdenByKind>,
111
+ ): { kind: BurdenKind; calls: number; bytesReturned: number } | null {
112
+ let best: { kind: BurdenKind; calls: number; bytesReturned: number } | null = null;
113
+
114
+ for (const kind of BURDEN_KINDS) {
115
+ const bucket = source[kind];
116
+ if (bucket.bytesReturned === 0) continue;
117
+ if (best === null || bucket.bytesReturned > best.bytesReturned) {
118
+ best = { kind, calls: bucket.calls, bytesReturned: bucket.bytesReturned };
119
+ }
120
+ }
121
+
122
+ return best;
123
+ }