@flyingrobots/graft 0.3.5 → 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 +69 -0
  3. package/CODE_OF_CONDUCT.md +65 -0
  4. package/README.md +153 -17
  5. package/bin/graft.js +4 -11
  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 +17 -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 +86 -0
  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 +67 -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 +704 -45
  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 +136 -0
  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 +49 -0
  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 +513 -0
  108. package/src/warp/observers.ts +105 -0
  109. package/src/warp/open.ts +31 -0
  110. package/src/warp/plumbing.d.ts +15 -0
  111. package/src/warp/writer-id.ts +30 -0
@@ -0,0 +1,252 @@
1
+ import { z } from "zod";
2
+ import { readRange } from "../../operations/read-range.js";
3
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
4
+ import { listProjectFiles } from "./git-files.js";
5
+ import {
6
+ evaluatePrecisionPolicy,
7
+ getIndexedCommitCeilings,
8
+ listTrackedFilesAtRef,
9
+ loadFileContent,
10
+ normalizeRepoPath,
11
+ PrecisionSearchRequest,
12
+ type PrecisionSymbolMatch,
13
+ readRangeFromContent,
14
+ requireRepoPath,
15
+ resolveGitRef,
16
+ searchLiveSymbols,
17
+ searchWarpSymbols,
18
+ } from "./precision.js";
19
+
20
+ interface CodeShowOptions {
21
+ readonly allowWarp: boolean;
22
+ }
23
+
24
+ export async function runCodeShow(
25
+ ctx: ToolContext,
26
+ args: Record<string, unknown>,
27
+ options: CodeShowOptions,
28
+ ) {
29
+ const symbolName = args["symbol"] as string;
30
+ const rawPath = args["path"] as string | undefined;
31
+ const ref = args["ref"] as string | undefined;
32
+ const targetPath = rawPath !== undefined ? normalizeRepoPath(ctx.projectRoot, rawPath) : undefined;
33
+ const repoState = ctx.getRepoState();
34
+ const layer = ref !== undefined
35
+ ? "commit_worldline"
36
+ : repoState.dirty
37
+ ? "workspace_overlay"
38
+ : "ref_view";
39
+
40
+ let resolvedRef: string | undefined;
41
+ if (ref !== undefined) {
42
+ try {
43
+ resolvedRef = await resolveGitRef(ref, ctx.git, ctx.projectRoot);
44
+ } catch (err: unknown) {
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ return ctx.respond("code_show", {
47
+ symbol: symbolName,
48
+ error: message,
49
+ source: "live",
50
+ layer,
51
+ });
52
+ }
53
+ }
54
+
55
+ let locations: PrecisionSymbolMatch[];
56
+ let source: "warp" | "live" = "live";
57
+
58
+ if (resolvedRef !== undefined) {
59
+ if (targetPath !== undefined) {
60
+ try {
61
+ requireRepoPath(ctx.projectRoot, targetPath);
62
+ } catch (err: unknown) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ return ctx.respond("code_show", {
65
+ symbol: symbolName,
66
+ error: message,
67
+ source: "live",
68
+ layer,
69
+ });
70
+ }
71
+ }
72
+
73
+ try {
74
+ const repoPath = targetPath !== undefined ? requireRepoPath(ctx.projectRoot, targetPath) : undefined;
75
+ if (options.allowWarp) {
76
+ const warp = await ctx.getWarp();
77
+ const ceilings = await getIndexedCommitCeilings(warp);
78
+ const ceiling = ceilings.get(resolvedRef);
79
+ if (ceiling !== undefined) {
80
+ locations = await searchWarpSymbols(warp, new PrecisionSearchRequest({
81
+ exactName: symbolName,
82
+ ...(repoPath !== undefined ? { filePath: repoPath } : {}),
83
+ ceiling,
84
+ }));
85
+ source = "warp";
86
+ } else {
87
+ const filePaths = repoPath !== undefined
88
+ ? [repoPath]
89
+ : await listTrackedFilesAtRef("", ctx.git, ctx.projectRoot, resolvedRef);
90
+ locations = await searchLiveSymbols(
91
+ ctx,
92
+ filePaths,
93
+ new PrecisionSearchRequest({ exactName: symbolName }),
94
+ resolvedRef,
95
+ );
96
+ }
97
+ } else {
98
+ const filePaths = repoPath !== undefined
99
+ ? [repoPath]
100
+ : await listTrackedFilesAtRef("", ctx.git, ctx.projectRoot, resolvedRef);
101
+ locations = await searchLiveSymbols(
102
+ ctx,
103
+ filePaths,
104
+ new PrecisionSearchRequest({ exactName: symbolName }),
105
+ resolvedRef,
106
+ );
107
+ }
108
+ } catch {
109
+ const repoPath = targetPath !== undefined ? requireRepoPath(ctx.projectRoot, targetPath) : undefined;
110
+ const filePaths = repoPath !== undefined
111
+ ? [repoPath]
112
+ : await listTrackedFilesAtRef("", ctx.git, ctx.projectRoot, resolvedRef);
113
+ locations = await searchLiveSymbols(
114
+ ctx,
115
+ filePaths,
116
+ new PrecisionSearchRequest({ exactName: symbolName }),
117
+ resolvedRef,
118
+ );
119
+ source = "live";
120
+ }
121
+ } else {
122
+ const filePaths = targetPath !== undefined
123
+ ? [targetPath]
124
+ : await listProjectFiles("", ctx.projectRoot, ctx.git);
125
+ locations = await searchLiveSymbols(
126
+ ctx,
127
+ filePaths,
128
+ new PrecisionSearchRequest({ exactName: symbolName }),
129
+ );
130
+ }
131
+
132
+ const visibleLocations: PrecisionSymbolMatch[] = [];
133
+ const fileCache = new Map<string, string>();
134
+ let firstRefusal:
135
+ | {
136
+ path: string;
137
+ reason: string;
138
+ reasonDetail: string;
139
+ next: readonly string[];
140
+ actual: { lines: number; bytes: number };
141
+ }
142
+ | undefined;
143
+
144
+ for (const location of locations) {
145
+ let content = fileCache.get(location.path);
146
+ if (content === undefined) {
147
+ const loaded = await loadFileContent(ctx, location.path, resolvedRef);
148
+ if (loaded === null) continue;
149
+ fileCache.set(location.path, loaded);
150
+ content = loaded;
151
+ }
152
+
153
+ const refusal = evaluatePrecisionPolicy(ctx, location.path, content);
154
+ if (refusal !== null) {
155
+ firstRefusal ??= refusal;
156
+ continue;
157
+ }
158
+
159
+ visibleLocations.push(location);
160
+ }
161
+
162
+ if (visibleLocations.length === 0) {
163
+ if (firstRefusal !== undefined) {
164
+ return ctx.respond("code_show", {
165
+ path: firstRefusal.path,
166
+ projection: "refused",
167
+ reason: firstRefusal.reason,
168
+ reasonDetail: firstRefusal.reasonDetail,
169
+ next: [...firstRefusal.next],
170
+ actual: firstRefusal.actual,
171
+ source,
172
+ layer,
173
+ });
174
+ }
175
+
176
+ return ctx.respond("code_show", {
177
+ symbol: symbolName,
178
+ error: `Symbol '${symbolName}' not found`,
179
+ source,
180
+ layer,
181
+ });
182
+ }
183
+
184
+ if (visibleLocations.length > 1) {
185
+ return ctx.respond("code_show", {
186
+ symbol: symbolName,
187
+ ambiguous: true,
188
+ matches: visibleLocations,
189
+ source,
190
+ layer,
191
+ });
192
+ }
193
+
194
+ const loc = visibleLocations[0];
195
+ if (loc?.startLine === undefined || loc.endLine === undefined) {
196
+ return ctx.respond("code_show", {
197
+ symbol: symbolName,
198
+ kind: loc?.kind,
199
+ signature: loc?.signature,
200
+ path: loc?.path,
201
+ exported: loc?.exported,
202
+ error: "Symbol found but line range unavailable — use read_range with file_outline",
203
+ source,
204
+ layer,
205
+ });
206
+ }
207
+
208
+ const content = fileCache.get(loc.path) ?? await loadFileContent(ctx, loc.path, resolvedRef);
209
+ if (content === null) {
210
+ return ctx.respond("code_show", {
211
+ symbol: symbolName,
212
+ error: `File '${loc.path}' is no longer readable`,
213
+ source,
214
+ layer,
215
+ });
216
+ }
217
+
218
+ const rangeResult = resolvedRef !== undefined
219
+ ? readRangeFromContent(loc.path, content, loc.startLine, loc.endLine)
220
+ : await readRange(ctx.resolvePath(loc.path), loc.startLine, loc.endLine, { fs: ctx.fs });
221
+
222
+ return ctx.respond("code_show", {
223
+ symbol: loc.name,
224
+ kind: loc.kind,
225
+ signature: loc.signature,
226
+ path: loc.path,
227
+ exported: loc.exported,
228
+ startLine: loc.startLine,
229
+ endLine: loc.endLine,
230
+ content: rangeResult.content,
231
+ truncated: rangeResult.truncated ?? false,
232
+ ...(rangeResult.clipped === true ? { clipped: true } : {}),
233
+ source,
234
+ layer,
235
+ });
236
+ }
237
+
238
+ export const codeShowTool: ToolDefinition = {
239
+ name: "code_show",
240
+ description:
241
+ "Focus on a symbol by name and return its source code in one call. " +
242
+ "Provide a path to target a specific file, or omit to search the " +
243
+ "project. Returns source, signature, and location.",
244
+ schema: {
245
+ symbol: z.string(),
246
+ path: z.string().optional(),
247
+ ref: z.string().optional(),
248
+ },
249
+ createHandler(ctx: ToolContext): ToolHandler {
250
+ return (args) => runCodeShow(ctx, args, { allowWarp: true });
251
+ },
252
+ };
@@ -0,0 +1,14 @@
1
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
2
+
3
+ export const daemonMonitorsTool: ToolDefinition = {
4
+ name: "daemon_monitors",
5
+ description:
6
+ "List daemon-managed persistent repo monitors with lifecycle, backlog, and recent failure state.",
7
+ createHandler(ctx: ToolContext): ToolHandler {
8
+ return async () => {
9
+ return ctx.respond("daemon_monitors", {
10
+ monitors: await ctx.listDaemonMonitors(),
11
+ });
12
+ };
13
+ },
14
+ };
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const daemonReposTool: ToolDefinition = {
5
+ name: "daemon_repos",
6
+ description:
7
+ "List authorized canonical repos with bounded worktree, session, backlog, and monitor summary for daemon-wide inspection.",
8
+ schema: {
9
+ repoId: z.string().optional(),
10
+ cwd: z.string().optional(),
11
+ },
12
+ createHandler(ctx: ToolContext): ToolHandler {
13
+ return async (args) => {
14
+ return ctx.respond("daemon_repos", {
15
+ ...await ctx.listDaemonRepos({
16
+ repoId: args["repoId"] as string | undefined,
17
+ cwd: args["cwd"] as string | undefined,
18
+ }),
19
+ });
20
+ };
21
+ },
22
+ };
@@ -0,0 +1,14 @@
1
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
2
+
3
+ export const daemonSessionsTool: ToolDefinition = {
4
+ name: "daemon_sessions",
5
+ description:
6
+ "List active daemon sessions with bind state, workspace identity, and capability posture, without exposing session-local receipts or shell output.",
7
+ createHandler(ctx: ToolContext): ToolHandler {
8
+ return async () => {
9
+ return ctx.respond("daemon_sessions", {
10
+ sessions: await ctx.listDaemonSessions(),
11
+ });
12
+ };
13
+ },
14
+ };
@@ -0,0 +1,12 @@
1
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
2
+
3
+ export const daemonStatusTool: ToolDefinition = {
4
+ name: "daemon_status",
5
+ description:
6
+ "Return daemon-wide health, authorization counts, and default capability posture for the local control plane.",
7
+ createHandler(ctx: ToolContext): ToolHandler {
8
+ return async () => {
9
+ return ctx.respond("daemon_status", { ...await ctx.getDaemonStatus() });
10
+ };
11
+ },
12
+ };
@@ -1,19 +1,62 @@
1
1
  import { STATIC_THRESHOLDS } from "../../policy/evaluate.js";
2
+ import { topBurdenKind, totalNonReadBytesReturned } from "../burden.js";
3
+ import { buildRuntimeStagedTarget } from "../runtime-staged-target.js";
4
+ import { deriveCausalSurfaceNextAction } from "../semantic-transition-guidance.js";
2
5
  import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
6
 
4
7
  export const doctorTool: ToolDefinition = {
5
8
  name: "doctor",
6
9
  description:
7
10
  "Runtime health check. Shows project root, parser status, active " +
8
- "thresholds, session depth, and message count.",
11
+ "thresholds, session depth, message count, and burden summary.",
9
12
  createHandler(ctx: ToolContext): ToolHandler {
10
- return () => {
13
+ return async () => {
14
+ const repoState = ctx.getRepoState();
15
+ const causalContext = ctx.getCausalContext();
16
+ const workspaceOverlayFooting = await ctx.getWorkspaceOverlayFooting();
17
+ const metrics = ctx.metrics.snapshot();
18
+ const topBurden = topBurdenKind(metrics.burdenByKind);
19
+ const persistedLocalHistory = await ctx.getPersistedLocalHistorySummary();
20
+ const repoConcurrency = await ctx.getRepoConcurrencySummary();
21
+ const recommendedNextAction = deriveCausalSurfaceNextAction(
22
+ persistedLocalHistory.nextAction,
23
+ repoState.semanticTransition,
24
+ repoConcurrency,
25
+ );
11
26
  return ctx.respond("doctor", {
12
27
  projectRoot: ctx.projectRoot,
13
28
  parserHealthy: true,
14
29
  thresholds: { lines: STATIC_THRESHOLDS.lines, bytes: STATIC_THRESHOLDS.bytes },
15
30
  sessionDepth: ctx.session.getSessionDepth(),
16
31
  totalMessages: ctx.session.getMessageCount(),
32
+ burdenSummary: {
33
+ totalBytesReturned: metrics.bytesReturned,
34
+ totalNonReadBytesReturned: totalNonReadBytesReturned(metrics.burdenByKind),
35
+ topKind: topBurden?.kind ?? null,
36
+ topBytesReturned: topBurden?.bytesReturned ?? 0,
37
+ topCalls: topBurden?.calls ?? 0,
38
+ },
39
+ runtimeObservability: ctx.observability,
40
+ causalContext,
41
+ latestReadEvent: persistedLocalHistory.latestReadEvent,
42
+ latestStageEvent: persistedLocalHistory.latestStageEvent,
43
+ latestTransitionEvent: persistedLocalHistory.latestTransitionEvent,
44
+ repoConcurrency,
45
+ checkoutEpoch: repoState.checkoutEpoch,
46
+ lastTransition: repoState.lastTransition,
47
+ semanticTransition: repoState.semanticTransition,
48
+ workspaceOverlayId: repoState.workspaceOverlayId,
49
+ workspaceOverlay: repoState.workspaceOverlay,
50
+ workspaceOverlayFooting,
51
+ stagedTarget: buildRuntimeStagedTarget(
52
+ ctx.getWorkspaceStatus(),
53
+ causalContext,
54
+ repoState,
55
+ persistedLocalHistory.attribution,
56
+ ),
57
+ attribution: persistedLocalHistory.attribution,
58
+ persistedLocalHistory,
59
+ recommendedNextAction,
17
60
  });
18
61
  };
19
62
  },
@@ -38,6 +38,10 @@ const EXPLANATIONS: Readonly<Record<string, { meaning: string; action: string }>
38
38
  meaning: "File exceeds the budget-proportional byte cap. No single read may consume more than 5% of remaining budget.",
39
39
  action: "Use file_outline or read_range for targeted reads. Consider whether this file is worth the budget cost.",
40
40
  },
41
+ UNSUPPORTED_LANGUAGE: {
42
+ meaning: "The file type has no parser-backed structural outline in the current build of Graft.",
43
+ action: "Use read_range or a full read when appropriate. Do not treat the empty outline as parser-derived symbol structure.",
44
+ },
41
45
  GRAFTIGNORE: {
42
46
  meaning: "File matches a pattern in .graftignore.",
43
47
  action: "Check .graftignore if you believe this file should be readable.",
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { fileOutline } from "../../operations/file-outline.js";
3
+ import { detectStructuredFormat } from "../../parser/lang.js";
3
4
  import { hashContent } from "../cache.js";
4
5
  import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
5
6
 
@@ -10,6 +11,7 @@ export const fileOutlineTool: ToolDefinition = {
10
11
  "exports. Includes a jump table mapping each symbol to its line range " +
11
12
  "for targeted read_range follow-ups.",
12
13
  schema: { path: z.string() },
14
+ policyCheck: true,
13
15
  createHandler(ctx: ToolContext): ToolHandler {
14
16
  return async (args) => {
15
17
  const filePath = ctx.resolvePath(args["path"] as string);
@@ -17,12 +19,14 @@ export const fileOutlineTool: ToolDefinition = {
17
19
  // Check cache
18
20
  let rawContent: string | null = null;
19
21
  try {
20
- rawContent = ctx.fs.readFileSync(filePath, "utf-8");
22
+ rawContent = await ctx.fs.readFile(filePath, "utf-8");
21
23
  } catch {
22
24
  // proceed to fileOutline for error handling
23
25
  }
24
26
 
25
- if (rawContent !== null) {
27
+ const outlineSupported = detectStructuredFormat(filePath) !== null;
28
+
29
+ if (rawContent !== null && outlineSupported) {
26
30
  const cacheResult = ctx.cache.check(filePath, rawContent);
27
31
  if (cacheResult.hit) {
28
32
  cacheResult.obs.touch();
@@ -41,7 +45,7 @@ export const fileOutlineTool: ToolDefinition = {
41
45
  ctx.metrics.recordOutline();
42
46
 
43
47
  // Record observation
44
- if (rawContent !== null) {
48
+ if (rawContent !== null && outlineSupported && result.reason !== "UNSUPPORTED_LANGUAGE") {
45
49
  ctx.cache.record(
46
50
  filePath,
47
51
  hashContent(rawContent),
@@ -0,0 +1,73 @@
1
+ import type { GitClient } from "../../ports/git.js";
2
+
3
+ type GitFileListMode = "tracked" | "project";
4
+
5
+ export class GitFileQuery {
6
+ readonly cwd: string;
7
+ readonly dirPath: string;
8
+ readonly mode: GitFileListMode;
9
+
10
+ constructor(opts: {
11
+ cwd: string;
12
+ dirPath: string;
13
+ mode: GitFileListMode;
14
+ }) {
15
+ if (opts.cwd.trim().length === 0) {
16
+ throw new Error("GitFileQuery: cwd must be non-empty");
17
+ }
18
+ this.cwd = opts.cwd;
19
+ this.dirPath = opts.dirPath.trim();
20
+ this.mode = opts.mode;
21
+ Object.freeze(this);
22
+ }
23
+
24
+ static tracked(cwd: string, dirPath: string): GitFileQuery {
25
+ return new GitFileQuery({ cwd, dirPath, mode: "tracked" });
26
+ }
27
+
28
+ static project(cwd: string, dirPath: string): GitFileQuery {
29
+ return new GitFileQuery({ cwd, dirPath, mode: "project" });
30
+ }
31
+
32
+ toArgs(): string[] {
33
+ if (this.mode === "tracked") {
34
+ return this.dirPath.length > 0 ? ["ls-files", "--", this.dirPath] : ["ls-files"];
35
+ }
36
+
37
+ return this.dirPath.length > 0
38
+ ? ["ls-files", "--cached", "--others", "--exclude-standard", "--", this.dirPath]
39
+ : ["ls-files", "--cached", "--others", "--exclude-standard"];
40
+ }
41
+ }
42
+
43
+ export class GitFileList {
44
+ readonly paths: readonly string[];
45
+
46
+ constructor(paths: readonly string[]) {
47
+ this.paths = Object.freeze([...paths]);
48
+ Object.freeze(this);
49
+ }
50
+ }
51
+
52
+ export async function listGitFiles(query: GitFileQuery, git: GitClient): Promise<GitFileList> {
53
+ try {
54
+ const result = await git.run({ args: query.toArgs(), cwd: query.cwd });
55
+ if (result.error !== undefined || result.status !== 0) {
56
+ throw result.error ?? new Error(result.stderr.trim() || `git exited with status ${String(result.status)}`);
57
+ }
58
+ const output = result.stdout.trim();
59
+ const paths = output.length === 0 ? [] : output.split("\n");
60
+ return new GitFileList(paths);
61
+ } catch (err: unknown) {
62
+ const message = err instanceof Error ? err.message : String(err);
63
+ throw new Error(`git file listing failed: ${message}`, { cause: err });
64
+ }
65
+ }
66
+
67
+ export async function listTrackedFiles(dirPath: string, cwd: string, git: GitClient): Promise<string[]> {
68
+ return [...(await listGitFiles(GitFileQuery.tracked(cwd, dirPath), git)).paths];
69
+ }
70
+
71
+ export async function listProjectFiles(dirPath: string, cwd: string, git: GitClient): Promise<string[]> {
72
+ return [...(await listGitFiles(GitFileQuery.project(cwd, dirPath), git)).paths];
73
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { graftDiff } from "../../operations/graft-diff.js";
3
3
  import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
4
+ import { evaluateMcpRefusal } from "../policy.js";
4
5
 
5
6
  export const graftDiffTool: ToolDefinition = {
6
7
  name: "graft_diff",
@@ -10,15 +11,22 @@ export const graftDiffTool: ToolDefinition = {
10
11
  "tree vs HEAD.",
11
12
  schema: { base: z.string().optional(), head: z.string().optional(), path: z.string().optional() },
12
13
  createHandler(ctx: ToolContext): ToolHandler {
13
- return (args) => {
14
- const result = graftDiff({
14
+ return async (args) => {
15
+ const head = args["head"] as string | undefined;
16
+ const result = await graftDiff({
15
17
  cwd: ctx.projectRoot,
16
18
  fs: ctx.fs,
19
+ git: ctx.git,
20
+ resolveWorkingTreePath: (filePath) => ctx.resolvePath(filePath),
17
21
  base: args["base"] as string | undefined,
18
- head: args["head"] as string | undefined,
22
+ head,
19
23
  path: args["path"] as string | undefined,
24
+ refusalCheck: (filePath, actual) => evaluateMcpRefusal(ctx, filePath, actual),
25
+ });
26
+ return ctx.respond("graft_diff", {
27
+ ...result,
28
+ layer: head === undefined ? "workspace_overlay" : "ref_view",
20
29
  });
21
- return ctx.respond("graft_diff", result);
22
30
  };
23
31
  },
24
32
  };
@@ -0,0 +1,136 @@
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 { GitFileQuery, listGitFiles } from "./git-files.js";
7
+ import { evaluateMcpRefusal, type McpPolicyRefusal } from "../policy.js";
8
+ import { collectSymbols, normalizeRepoPath } from "./precision.js";
9
+
10
+ class StructuralMapRequest {
11
+ readonly directory: string;
12
+
13
+ constructor(args: Record<string, unknown>, projectRoot: string) {
14
+ const rawPath = args["path"];
15
+ if (rawPath !== undefined && typeof rawPath !== "string") {
16
+ throw new Error("StructuralMapRequest: path must be a string when provided");
17
+ }
18
+ this.directory = rawPath !== undefined && rawPath.trim().length > 0
19
+ ? normalizeRepoPath(projectRoot, rawPath)
20
+ : ".";
21
+ Object.freeze(this);
22
+ }
23
+
24
+ toGitFileQuery(projectRoot: string): GitFileQuery {
25
+ return GitFileQuery.project(projectRoot, this.directory === "." ? "" : this.directory);
26
+ }
27
+ }
28
+
29
+ class StructuralMapSymbol {
30
+ readonly name: string;
31
+ readonly kind: string;
32
+ readonly signature?: string;
33
+ readonly exported: boolean;
34
+ readonly startLine?: number;
35
+ readonly endLine?: number;
36
+
37
+ constructor(opts: {
38
+ name: string;
39
+ kind: string;
40
+ signature?: string;
41
+ exported: boolean;
42
+ startLine?: number;
43
+ endLine?: number;
44
+ }) {
45
+ this.name = opts.name;
46
+ this.kind = opts.kind;
47
+ this.exported = opts.exported;
48
+ if (opts.signature !== undefined) this.signature = opts.signature;
49
+ if (opts.startLine !== undefined) this.startLine = opts.startLine;
50
+ if (opts.endLine !== undefined) this.endLine = opts.endLine;
51
+ Object.freeze(this);
52
+ }
53
+ }
54
+
55
+ class StructuralMapFile {
56
+ path: string;
57
+ lang: string;
58
+ symbols: readonly StructuralMapSymbol[];
59
+
60
+ constructor(opts: {
61
+ path: string;
62
+ lang: string;
63
+ symbols: readonly StructuralMapSymbol[];
64
+ }) {
65
+ this.path = opts.path;
66
+ this.lang = opts.lang;
67
+ this.symbols = Object.freeze([...opts.symbols]);
68
+ Object.freeze(this);
69
+ }
70
+ }
71
+
72
+ export const mapTool: ToolDefinition = {
73
+ name: "graft_map",
74
+ description:
75
+ "Structural map of a directory — all files and their symbols " +
76
+ "(function signatures, class shapes, exports) in one call. " +
77
+ "Uses tree-sitter to parse the working tree directly.",
78
+ schema: {
79
+ path: z.string().optional(),
80
+ },
81
+ createHandler(ctx: ToolContext): ToolHandler {
82
+ return async (args) => {
83
+ const request = new StructuralMapRequest(args, ctx.projectRoot);
84
+
85
+ const filePaths = (await listGitFiles(request.toGitFileQuery(ctx.projectRoot), ctx.git)).paths;
86
+ const files: StructuralMapFile[] = [];
87
+ const refused: McpPolicyRefusal[] = [];
88
+
89
+ for (const filePath of filePaths) {
90
+ let content: string;
91
+ try {
92
+ content = await ctx.fs.readFile(path.join(ctx.projectRoot, filePath), "utf-8");
93
+ } catch {
94
+ continue;
95
+ }
96
+
97
+ const actual = {
98
+ lines: content.split("\n").length,
99
+ bytes: Buffer.byteLength(content),
100
+ };
101
+ const refusal = evaluateMcpRefusal(ctx, filePath, actual);
102
+ if (refusal !== null) {
103
+ refused.push(refusal);
104
+ continue;
105
+ }
106
+
107
+ const lang = detectLang(filePath);
108
+ if (lang === null) continue;
109
+
110
+ const result = extractOutline(content, lang);
111
+ const symbols = collectSymbols(result.entries, filePath, result.jumpTable ?? []).map((symbol) =>
112
+ new StructuralMapSymbol({
113
+ name: symbol.name,
114
+ kind: symbol.kind,
115
+ exported: symbol.exported,
116
+ ...(symbol.signature !== undefined ? { signature: symbol.signature } : {}),
117
+ ...(symbol.startLine !== undefined ? { startLine: symbol.startLine } : {}),
118
+ ...(symbol.endLine !== undefined ? { endLine: symbol.endLine } : {}),
119
+ })
120
+ );
121
+
122
+ files.push(new StructuralMapFile({ path: filePath, lang, symbols }));
123
+ }
124
+
125
+ files.sort((a, b) => a.path.localeCompare(b.path));
126
+ const totalSymbols = files.reduce((n, f) => n + f.symbols.length, 0);
127
+
128
+ return ctx.respond("graft_map", {
129
+ directory: request.directory,
130
+ files,
131
+ ...(refused.length > 0 ? { refused } : {}),
132
+ summary: `${String(files.length)} files, ${String(totalSymbols)} symbols`,
133
+ });
134
+ };
135
+ },
136
+ };