@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
@@ -0,0 +1,312 @@
1
+ import * as path from "node:path";
2
+ import type WarpApp from "@git-stunts/git-warp";
3
+ import { getFileAtRef, GitError } from "../../git/diff.js";
4
+ import { detectLang } from "../../parser/lang.js";
5
+ import { extractOutline } from "../../parser/outline.js";
6
+ import type { JumpEntry, OutlineEntry } from "../../parser/types.js";
7
+ import { allSymbolsLens, fileSymbolsLens, symbolByNameLens } from "../../warp/observers.js";
8
+ import type { GitClient } from "../../ports/git.js";
9
+ import type { ToolContext } from "../context.js";
10
+ import { evaluateMcpRefusal, type McpPolicyRefusal } from "../policy.js";
11
+ import { PrecisionSearchRequest, type RankedPrecisionSymbolMatch } from "./precision-query.js";
12
+ import { PrecisionSymbolMatch } from "./precision-match.js";
13
+
14
+ const MAX_RANGE_LINES = 250;
15
+
16
+ export { PrecisionSearchRequest } from "./precision-query.js";
17
+ export { PrecisionSymbolMatch } from "./precision-match.js";
18
+ export type PrecisionPolicyRefusal = McpPolicyRefusal;
19
+
20
+ async function git(gitClient: GitClient, args: readonly string[], cwd: string): Promise<string> {
21
+ const result = await gitClient.run({ args, cwd });
22
+ if (result.error !== undefined || result.status !== 0) {
23
+ throw result.error ?? new Error(result.stderr.trim() || `git exited with status ${String(result.status)}`);
24
+ }
25
+ return result.stdout;
26
+ }
27
+
28
+ function buildJumpLookup(
29
+ jumpTable: readonly JumpEntry[],
30
+ ): Map<string, { start: number; end: number }[]> {
31
+ const lookup = new Map<string, { start: number; end: number }[]>();
32
+ for (const entry of jumpTable) {
33
+ const existing = lookup.get(entry.symbol) ?? [];
34
+ existing.push({ start: entry.start, end: entry.end });
35
+ lookup.set(entry.symbol, existing);
36
+ }
37
+ return lookup;
38
+ }
39
+
40
+ function decodeSymbolPath(nodeId: string): string | null {
41
+ if (!nodeId.startsWith("sym:")) return null;
42
+ const lastColon = nodeId.lastIndexOf(":");
43
+ if (lastColon <= "sym:".length) return null;
44
+ return nodeId.slice("sym:".length, lastColon);
45
+ }
46
+
47
+ function toMatch(
48
+ nodeId: string,
49
+ props: Record<string, unknown>,
50
+ ): PrecisionSymbolMatch | null {
51
+ const name = props["name"];
52
+ const kind = props["kind"];
53
+ const path = decodeSymbolPath(nodeId);
54
+ if (typeof name !== "string" || typeof kind !== "string" || path === null) {
55
+ return null;
56
+ }
57
+
58
+ return new PrecisionSymbolMatch({
59
+ name,
60
+ kind,
61
+ path,
62
+ ...(typeof props["signature"] === "string" ? { signature: props["signature"] } : {}),
63
+ exported: props["exported"] === true,
64
+ ...(typeof props["startLine"] === "number" ? { startLine: props["startLine"] } : {}),
65
+ ...(typeof props["endLine"] === "number" ? { endLine: props["endLine"] } : {}),
66
+ });
67
+ }
68
+
69
+
70
+ export function normalizeRepoPath(projectRoot: string, input: string): string {
71
+ if (!path.isAbsolute(input)) return input;
72
+ const rel = path.relative(projectRoot, input);
73
+ if (rel === "") return ".";
74
+ return rel.startsWith("..") ? input : rel;
75
+ }
76
+
77
+ export function requireRepoPath(projectRoot: string, input: string): string {
78
+ const normalized = normalizeRepoPath(projectRoot, input);
79
+ if (path.isAbsolute(normalized)) {
80
+ throw new Error(`Path must be inside the repository for git-ref queries: ${input}`);
81
+ }
82
+ return normalized;
83
+ }
84
+
85
+ export async function resolveGitRef(ref: string, gitClient: GitClient, cwd: string): Promise<string> {
86
+ try {
87
+ return (await git(gitClient, ["rev-parse", "--verify", ref], cwd)).trim();
88
+ } catch {
89
+ throw new GitError(`ref does not exist: ${ref}`);
90
+ }
91
+ }
92
+
93
+ export async function listTrackedFilesAtRef(
94
+ dirPath: string,
95
+ gitClient: GitClient,
96
+ cwd: string,
97
+ ref: string,
98
+ ): Promise<string[]> {
99
+ try {
100
+ const args = dirPath.length > 0
101
+ ? ["ls-tree", "-r", "--name-only", ref, "--", dirPath]
102
+ : ["ls-tree", "-r", "--name-only", ref];
103
+ const output = (await git(gitClient, args, cwd)).trim();
104
+ return output.length === 0 ? [] : output.split("\n");
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
110
+ export async function isWorkingTreeDirty(gitClient: GitClient, cwd: string): Promise<boolean> {
111
+ try {
112
+ return (await git(gitClient, ["status", "--porcelain"], cwd)).trim().length > 0;
113
+ } catch {
114
+ return true;
115
+ }
116
+ }
117
+
118
+ export async function getIndexedCommitCeilings(warp: WarpApp): Promise<ReadonlyMap<string, number>> {
119
+ const { receipts } = await warp.core().materialize({ receipts: true });
120
+ const ceilings = new Map<string, number>();
121
+
122
+ for (const receipt of receipts) {
123
+ const commitAdd = receipt.ops.find((op) =>
124
+ op.op === "NodeAdd" &&
125
+ op.result === "applied" &&
126
+ op.target.startsWith("commit:")
127
+ );
128
+ if (commitAdd !== undefined) {
129
+ ceilings.set(commitAdd.target.slice("commit:".length), receipt.lamport);
130
+ }
131
+ }
132
+
133
+ return ceilings;
134
+ }
135
+
136
+ export function collectSymbols(
137
+ entries: readonly OutlineEntry[],
138
+ filePath: string,
139
+ jumpTable: readonly JumpEntry[],
140
+ jumpCursor: Map<string, number> = new Map<string, number>(),
141
+ ): PrecisionSymbolMatch[] {
142
+ const jumpLookup = buildJumpLookup(jumpTable);
143
+ const results: PrecisionSymbolMatch[] = [];
144
+
145
+ for (const entry of entries) {
146
+ const candidates = jumpLookup.get(entry.name) ?? [];
147
+ const jumpIndex = jumpCursor.get(entry.name) ?? 0;
148
+ const jump = candidates[jumpIndex];
149
+ if (jump !== undefined) {
150
+ jumpCursor.set(entry.name, jumpIndex + 1);
151
+ }
152
+ results.push(new PrecisionSymbolMatch({
153
+ name: entry.name,
154
+ kind: entry.kind,
155
+ path: filePath,
156
+ exported: entry.exported,
157
+ ...(entry.signature !== undefined ? { signature: entry.signature } : {}),
158
+ ...(jump?.start !== undefined ? { startLine: jump.start } : {}),
159
+ ...(jump?.end !== undefined ? { endLine: jump.end } : {}),
160
+ }));
161
+
162
+ if (entry.children !== undefined && entry.children.length > 0) {
163
+ results.push(...collectSymbols(entry.children, filePath, jumpTable, jumpCursor));
164
+ }
165
+ }
166
+
167
+ return results;
168
+ }
169
+
170
+ export async function loadFileContent(
171
+ ctx: ToolContext,
172
+ filePath: string,
173
+ ref?: string,
174
+ ): Promise<string | null> {
175
+ if (ref !== undefined) {
176
+ return getFileAtRef(ref, filePath, { cwd: ctx.projectRoot, git: ctx.git });
177
+ }
178
+
179
+ try {
180
+ return await ctx.fs.readFile(ctx.resolvePath(filePath), "utf-8");
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ export function evaluatePrecisionPolicy(
187
+ ctx: ToolContext,
188
+ filePath: string,
189
+ content: string,
190
+ ): PrecisionPolicyRefusal | null {
191
+ const actual = {
192
+ lines: content.split("\n").length,
193
+ bytes: Buffer.byteLength(content),
194
+ };
195
+ return evaluateMcpRefusal(ctx, filePath, actual);
196
+ }
197
+
198
+ export async function searchWarpSymbols(
199
+ warp: WarpApp,
200
+ request: PrecisionSearchRequest,
201
+ ): Promise<PrecisionSymbolMatch[]> {
202
+ const lensMode = request.selectLens();
203
+ if (lensMode === "file" && request.filePath === undefined) {
204
+ throw new Error("PrecisionSearchRequest selected file lens without filePath");
205
+ }
206
+ if (lensMode === "exact" && request.exactName === undefined) {
207
+ throw new Error("PrecisionSearchRequest selected exact lens without exactName");
208
+ }
209
+ let lens;
210
+ if (lensMode === "file") {
211
+ const filePath = request.filePath;
212
+ if (filePath === undefined) {
213
+ throw new Error("PrecisionSearchRequest selected file lens without filePath");
214
+ }
215
+ lens = fileSymbolsLens(filePath);
216
+ } else if (lensMode === "exact") {
217
+ const exactName = request.exactName;
218
+ if (exactName === undefined) {
219
+ throw new Error("PrecisionSearchRequest selected exact lens without exactName");
220
+ }
221
+ lens = symbolByNameLens(exactName);
222
+ } else {
223
+ lens = allSymbolsLens();
224
+ }
225
+ const observer = await warp.observer(
226
+ lens,
227
+ request.ceiling !== undefined ? { source: { kind: "live", ceiling: request.ceiling } } : undefined,
228
+ );
229
+ const nodeIds = await observer.getNodes();
230
+
231
+ const matches = await Promise.all(nodeIds.map(async (nodeId) => {
232
+ const props = await observer.getNodeProps(nodeId);
233
+ if (props === null) return null;
234
+ const match = toMatch(nodeId, props);
235
+ if (match === null) return null;
236
+ return request.rank(match);
237
+ }));
238
+
239
+ const visibleMatches = matches.filter((match): match is RankedPrecisionSymbolMatch => match !== null);
240
+ return request.sort(visibleMatches);
241
+ }
242
+
243
+ export async function searchLiveSymbols(
244
+ ctx: ToolContext,
245
+ filePaths: readonly string[],
246
+ request: PrecisionSearchRequest,
247
+ ref?: string,
248
+ ): Promise<PrecisionSymbolMatch[]> {
249
+ const matches: RankedPrecisionSymbolMatch[] = [];
250
+
251
+ for (const filePath of filePaths) {
252
+ const lang = detectLang(filePath);
253
+ if (lang === null) continue;
254
+
255
+ const content = await loadFileContent(ctx, filePath, ref);
256
+ if (content === null) continue;
257
+
258
+ const result = extractOutline(content, lang);
259
+ const symbols = collectSymbols(result.entries, filePath, result.jumpTable ?? []);
260
+
261
+ for (const symbol of symbols) {
262
+ const ranked = request.rank(symbol);
263
+ if (ranked !== null) matches.push(ranked);
264
+ }
265
+ }
266
+
267
+ return request.sort(matches);
268
+ }
269
+
270
+ export function readRangeFromContent(
271
+ filePath: string,
272
+ content: string,
273
+ start: number,
274
+ end: number,
275
+ ): {
276
+ path: string;
277
+ content?: string | undefined;
278
+ startLine?: number | undefined;
279
+ endLine?: number | undefined;
280
+ truncated?: boolean | undefined;
281
+ clipped?: boolean | undefined;
282
+ reason?: string | undefined;
283
+ } {
284
+ if (start > end) {
285
+ return { path: filePath, reason: "INVALID_RANGE" };
286
+ }
287
+
288
+ const allLines = content.split("\n");
289
+ const totalLines = allLines.length;
290
+ let effectiveEnd = end;
291
+ let truncated = false;
292
+ let clipped = false;
293
+
294
+ if (effectiveEnd - start + 1 > MAX_RANGE_LINES) {
295
+ effectiveEnd = start + MAX_RANGE_LINES - 1;
296
+ truncated = true;
297
+ }
298
+
299
+ if (effectiveEnd > totalLines) {
300
+ effectiveEnd = totalLines;
301
+ clipped = true;
302
+ }
303
+
304
+ return {
305
+ path: filePath,
306
+ content: allLines.slice(start - 1, effectiveEnd).join("\n"),
307
+ startLine: start,
308
+ endLine: effectiveEnd,
309
+ ...(truncated ? { truncated: true, reason: "RANGE_EXCEEDED" } : {}),
310
+ ...(clipped ? { clipped: true } : {}),
311
+ };
312
+ }
@@ -1,66 +1,148 @@
1
1
  import * as path from "node:path";
2
- import { execFileSync } from "node:child_process";
3
2
  import { z } from "zod";
4
3
  import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
5
4
 
5
+ const RUN_CAPTURE_POLICY_BOUNDARY = {
6
+ kind: "shell_escape_hatch",
7
+ boundedReadContract: false,
8
+ policyEnforced: false,
9
+ } as const;
10
+
11
+ const PRIVATE_KEY_BLOCK_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
12
+ const SECRET_ASSIGNMENT_RE = /(\b(?:api[_-]?key|access[_-]?token|auth(?:orization)?|bearer|token|secret|password|passwd|private[_-]?key|client[_-]?secret|session[_-]?key)\b\s*[:=]\s*)([^\r\n]+)/gi;
13
+ const BEARER_TOKEN_RE = /(\bbearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi;
14
+
15
+ function tailOutput(output: string, tail: number): {
16
+ readonly tailed: string;
17
+ readonly totalLines: number;
18
+ } {
19
+ const lines = output.split("\n");
20
+ return {
21
+ tailed: lines.slice(-tail).join("\n"),
22
+ totalLines: lines.length,
23
+ };
24
+ }
25
+
26
+ function renderCaptureError(result: {
27
+ readonly status: number | null;
28
+ readonly stderr: string;
29
+ readonly error?: Error;
30
+ }): string {
31
+ if (result.error !== undefined) {
32
+ return result.error.message;
33
+ }
34
+ const stderr = result.stderr.trim();
35
+ if (stderr.length > 0) {
36
+ return stderr;
37
+ }
38
+ return `Command exited with status ${String(result.status)}`;
39
+ }
40
+
41
+ function redactForLog(output: string): {
42
+ readonly value: string;
43
+ readonly redactions: number;
44
+ } {
45
+ let redactions = 0;
46
+ let value = output.replace(PRIVATE_KEY_BLOCK_RE, () => {
47
+ redactions++;
48
+ return "[REDACTED PRIVATE KEY BLOCK]";
49
+ });
50
+ value = value.replace(SECRET_ASSIGNMENT_RE, (_match, prefix: string) => {
51
+ redactions++;
52
+ return `${prefix}[REDACTED]`;
53
+ });
54
+ value = value.replace(BEARER_TOKEN_RE, (_match, prefix: string) => {
55
+ redactions++;
56
+ return `${prefix}[REDACTED]`;
57
+ });
58
+ return { value, redactions };
59
+ }
60
+
61
+ async function persistCaptureLog(ctx: ToolContext, output: string): Promise<{
62
+ readonly logPath: string | null;
63
+ readonly logRedactions: number;
64
+ }> {
65
+ if (!ctx.runCapture.persistLogs) {
66
+ return { logPath: null, logRedactions: 0 };
67
+ }
68
+
69
+ const logPath = path.join(ctx.graftDir, "logs", "capture.log");
70
+ const persisted = ctx.runCapture.redactLogs ? redactForLog(output) : { value: output, redactions: 0 };
71
+
72
+ try {
73
+ await ctx.fs.mkdir(path.dirname(logPath), { recursive: true });
74
+ await ctx.fs.writeFile(logPath, persisted.value, "utf-8");
75
+ return {
76
+ logPath,
77
+ logRedactions: persisted.redactions,
78
+ };
79
+ } catch {
80
+ return { logPath: null, logRedactions: 0 };
81
+ }
82
+ }
83
+
6
84
  export const runCaptureTool: ToolDefinition = {
7
85
  name: "run_capture",
8
86
  description:
9
87
  "Execute a shell command and return the last N lines of output " +
10
- "(default 60). Full output saved to .graft/logs/capture.log for " +
11
- "follow-up read_range calls.",
88
+ "(default 60). Responses include an explicit policy boundary. " +
89
+ "Persisted logs can be disabled and obvious secrets are redacted " +
90
+ "before writing to .graft/logs/capture.log.",
12
91
  schema: { command: z.string(), tail: z.number().optional() },
13
92
  createHandler(ctx: ToolContext): ToolHandler {
14
93
  return async (args) => {
15
94
  const command = args["command"] as string;
16
95
  const tail = Math.max(1, Math.floor((args["tail"] as number | undefined) ?? 60));
17
- // execFileSync is intentional: MCP tool calls are sequential per-session,
18
- // and synchronous execution simplifies stdout/stderr capture with timeout.
19
- let output: string;
20
- try {
21
- output = execFileSync("sh", ["-c", command], {
22
- cwd: ctx.projectRoot,
23
- encoding: "utf-8",
24
- timeout: 30000,
25
- stdio: ["pipe", "pipe", "pipe"],
26
- maxBuffer: 10 * 1024 * 1024,
27
- });
28
- } catch (err: unknown) {
29
- const msg = err instanceof Error ? err.message : String(err);
30
- const stdout = (err as { stdout?: string }).stdout ?? "";
31
- const stderr = (err as { stderr?: string }).stderr ?? "";
32
- // Return whatever stdout was captured before failure
33
- const tailed = typeof stdout === "string"
34
- ? stdout.split("\n").slice(-tail).join("\n")
35
- : "";
36
- const totalLines = typeof stdout === "string" ? stdout.split("\n").length : 0;
96
+ if (!ctx.runCapture.enabled) {
37
97
  return ctx.respond("run_capture", {
38
- error: msg,
39
- output: tailed,
40
- totalLines,
41
- tailedLines: Math.min(tail, totalLines),
42
- truncated: totalLines > tail,
43
- stderr: typeof stderr === "string" ? stderr.slice(0, 2000) : "",
98
+ output: "",
99
+ totalLines: 0,
100
+ tailedLines: 0,
101
+ logPath: null,
102
+ logRedactions: 0,
103
+ logPersistenceEnabled: false,
104
+ truncated: false,
105
+ disabled: true,
106
+ error: "run_capture is disabled by configuration",
107
+ policyBoundary: RUN_CAPTURE_POLICY_BOUNDARY,
44
108
  });
45
109
  }
110
+ const result = ctx.process.run({
111
+ command: "sh",
112
+ args: ["-c", command],
113
+ cwd: ctx.projectRoot,
114
+ timeoutMs: 30000,
115
+ maxBufferBytes: 10 * 1024 * 1024,
116
+ });
46
117
 
47
- const lines = output.split("\n");
48
- const tailed = lines.slice(-tail).join("\n");
49
- const logPath = path.join(ctx.graftDir, "logs", "capture.log");
50
- let logWriteSucceeded = true;
51
- try {
52
- await ctx.fs.mkdir(path.dirname(logPath), { recursive: true });
53
- await ctx.fs.writeFile(logPath, output, "utf-8");
54
- } catch {
55
- // Log persistence failure must not mask a successful command
56
- logWriteSucceeded = false;
118
+ if (result.error !== undefined || result.status !== 0) {
119
+ const tailedOutput = tailOutput(result.stdout, tail);
120
+ return ctx.respond("run_capture", {
121
+ error: renderCaptureError(result),
122
+ output: tailedOutput.tailed,
123
+ totalLines: tailedOutput.totalLines,
124
+ tailedLines: Math.min(tail, tailedOutput.totalLines),
125
+ logPath: null,
126
+ logRedactions: 0,
127
+ logPersistenceEnabled: ctx.runCapture.persistLogs,
128
+ truncated: tailedOutput.totalLines > tail,
129
+ stderr: result.stderr.slice(0, 2000),
130
+ policyBoundary: RUN_CAPTURE_POLICY_BOUNDARY,
131
+ });
57
132
  }
133
+
134
+ const output = result.stdout;
135
+ const tailedOutput = tailOutput(output, tail);
136
+ const persisted = await persistCaptureLog(ctx, output);
58
137
  return ctx.respond("run_capture", {
59
- output: tailed,
60
- totalLines: lines.length,
61
- tailedLines: Math.min(tail, lines.length),
62
- logPath: logWriteSucceeded ? logPath : null,
63
- truncated: lines.length > tail,
138
+ output: tailedOutput.tailed,
139
+ totalLines: tailedOutput.totalLines,
140
+ tailedLines: Math.min(tail, tailedOutput.totalLines),
141
+ logPath: persisted.logPath,
142
+ logRedactions: persisted.logRedactions,
143
+ logPersistenceEnabled: ctx.runCapture.persistLogs,
144
+ truncated: tailedOutput.totalLines > tail,
145
+ policyBoundary: RUN_CAPTURE_POLICY_BOUNDARY,
64
146
  });
65
147
  };
66
148
  },
@@ -1,11 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { safeRead } from "../../operations/safe-read.js";
3
3
  import type { SafeReadResult } from "../../operations/safe-read.js";
4
- import { evaluatePolicy } from "../../policy/evaluate.js";
5
4
  import { RefusedResult } from "../../policy/types.js";
6
5
  import { diffOutlines } from "../../parser/diff.js";
7
6
  import { CachedFile } from "../cached-file.js";
8
7
  import type { Metrics } from "../metrics.js";
8
+ import { evaluateMcpPolicy, toPolicyPath } from "../policy.js";
9
9
  import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
10
10
 
11
11
  const PROJECTION_METRICS: Readonly<Record<string, ((m: Metrics) => void) | undefined>> = {
@@ -32,21 +32,18 @@ export const safeReadTool: ToolDefinition = {
32
32
  // eliminating TOCTOU races where the file changes between reads.
33
33
  let cf: CachedFile | null = null;
34
34
  try {
35
- const rawContent = ctx.fs.readFileSync(filePath, "utf-8");
35
+ const rawContent = await ctx.fs.readFile(filePath, "utf-8");
36
36
  cf = new CachedFile(filePath, rawContent);
37
37
  } catch {
38
38
  // File doesn't exist or can't be read — proceed to safeRead for error handling
39
39
  }
40
40
 
41
41
  // Check cache if we could read the file
42
- if (cf !== null) {
42
+ if (cf?.supportsOutline === true) {
43
43
  const cacheResult = ctx.cache.check(filePath, cf.rawContent);
44
44
  if (cacheResult.hit) {
45
45
  // Defense: re-check policy before returning cached data.
46
- const policy = evaluatePolicy(
47
- { path: filePath, lines: cf.actual.lines, bytes: cf.actual.bytes },
48
- { sessionDepth: ctx.session.getSessionDepth(), budgetRemaining: ctx.session.getBudget()?.remaining },
49
- );
46
+ const policy = evaluateMcpPolicy(ctx, filePath, cf.actual);
50
47
  if (policy instanceof RefusedResult) {
51
48
  ctx.metrics.recordRefusal();
52
49
  return ctx.respond("safe_read", {
@@ -77,10 +74,7 @@ export const safeReadTool: ToolDefinition = {
77
74
  // File changed since last observation — compute structural diff
78
75
  if (cacheResult.stale !== null) {
79
76
  // Defense: re-check policy before returning structural data.
80
- const policy = evaluatePolicy(
81
- { path: filePath, lines: cf.actual.lines, bytes: cf.actual.bytes },
82
- { sessionDepth: ctx.session.getSessionDepth(), budgetRemaining: ctx.session.getBudget()?.remaining },
83
- );
77
+ const policy = evaluateMcpPolicy(ctx, filePath, cf.actual);
84
78
  if (policy instanceof RefusedResult) {
85
79
  ctx.metrics.recordRefusal();
86
80
  return ctx.respond("safe_read", {
@@ -117,6 +111,8 @@ export const safeReadTool: ToolDefinition = {
117
111
  codec: ctx.codec,
118
112
  content: cf?.rawContent,
119
113
  intent: args["intent"] as string | undefined,
114
+ policyPath: toPolicyPath(ctx.projectRoot, filePath),
115
+ graftignorePatterns: [...ctx.graftignorePatterns],
120
116
  sessionDepth: ctx.session.getSessionDepth(),
121
117
  budgetRemaining: ctx.session.getBudget()?.remaining,
122
118
  });
@@ -125,7 +121,13 @@ export const safeReadTool: ToolDefinition = {
125
121
 
126
122
  // Record observation for cacheable projections — uses CachedFile
127
123
  // outline (no re-read) to eliminate the snapshot race.
128
- if (cf !== null && result.actual !== undefined && CACHEABLE_PROJECTIONS.has(result.projection)) {
124
+ if (
125
+ cf !== null &&
126
+ cf.supportsOutline &&
127
+ result.actual !== undefined &&
128
+ CACHEABLE_PROJECTIONS.has(result.projection) &&
129
+ result.reason !== "UNSUPPORTED_LANGUAGE"
130
+ ) {
129
131
  ctx.cache.record(filePath, cf.hash, cf.outline, cf.jumpTable, result.actual);
130
132
  }
131
133
 
@@ -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 sinceTool: ToolDefinition = {
6
7
  name: "graft_since",
@@ -13,15 +14,18 @@ export const sinceTool: ToolDefinition = {
13
14
  head: z.string().optional(),
14
15
  },
15
16
  createHandler(ctx: ToolContext): ToolHandler {
16
- return (args) => {
17
+ return async (args) => {
17
18
  const base = args["base"] as string;
18
19
  const head = (args["head"] as string | undefined) ?? "HEAD";
19
20
 
20
- const result = graftDiff({
21
+ const result = await graftDiff({
21
22
  cwd: ctx.projectRoot,
22
23
  fs: ctx.fs,
24
+ git: ctx.git,
25
+ resolveWorkingTreePath: (filePath) => ctx.resolvePath(filePath),
23
26
  base,
24
27
  head,
28
+ refusalCheck: (filePath, actual) => evaluateMcpRefusal(ctx, filePath, actual),
25
29
  });
26
30
 
27
31
  // Aggregate symbol-level changes across all files
@@ -38,6 +42,7 @@ export const sinceTool: ToolDefinition = {
38
42
  return ctx.respond("graft_since", {
39
43
  ...result,
40
44
  summary: `+${String(totalAdded)} added, -${String(totalRemoved)} removed, ~${String(totalChanged)} changed across ${String(result.files.length)} files`,
45
+ layer: "ref_view",
41
46
  });
42
47
  };
43
48
  },
@@ -1,5 +1,6 @@
1
+ import * as path from "node:path";
1
2
  import { z } from "zod";
2
- import { stateSave, stateLoad } from "../../operations/state.js";
3
+ import { STATE_FILENAME, stateSave, stateLoad } from "../../operations/state.js";
3
4
  import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
4
5
 
5
6
  export const stateSaveTool: ToolDefinition = {
@@ -10,7 +11,11 @@ export const stateSaveTool: ToolDefinition = {
10
11
  schema: { content: z.string() },
11
12
  createHandler(ctx: ToolContext): ToolHandler {
12
13
  return async (args) => {
13
- const result = await stateSave(args["content"] as string, { graftDir: ctx.graftDir, fs: ctx.fs });
14
+ const result = await stateSave(args["content"] as string, {
15
+ stateDir: ctx.graftDir,
16
+ statePath: path.join(ctx.graftDir, STATE_FILENAME),
17
+ fs: ctx.fs,
18
+ });
14
19
  return ctx.respond("state_save", result as Record<string, unknown>);
15
20
  };
16
21
  },
@@ -23,7 +28,10 @@ export const stateLoadTool: ToolDefinition = {
23
28
  "been saved.",
24
29
  createHandler(ctx: ToolContext): ToolHandler {
25
30
  return async () => {
26
- const result = await stateLoad({ graftDir: ctx.graftDir, fs: ctx.fs });
31
+ const result = await stateLoad({
32
+ statePath: path.join(ctx.graftDir, STATE_FILENAME),
33
+ fs: ctx.fs,
34
+ });
27
35
  return ctx.respond("state_load", result as Record<string, unknown>);
28
36
  };
29
37
  },
@@ -1,10 +1,11 @@
1
+ import { totalNonReadBytesReturned } from "../burden.js";
1
2
  import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
2
3
 
3
4
  export const statsTool: ToolDefinition = {
4
5
  name: "stats",
5
6
  description:
6
7
  "Decision metrics for the current session. Total reads, outlines, " +
7
- "refusals, cache hits, and bytes avoided.",
8
+ "refusals, cache hits, bytes avoided, and burden by tool kind.",
8
9
  createHandler(ctx: ToolContext): ToolHandler {
9
10
  return () => {
10
11
  const snap = ctx.metrics.snapshot();
@@ -13,7 +14,10 @@ export const statsTool: ToolDefinition = {
13
14
  totalOutlines: snap.outlines,
14
15
  totalRefusals: snap.refusals,
15
16
  totalCacheHits: snap.cacheHits,
17
+ totalBytesReturned: snap.bytesReturned,
16
18
  totalBytesAvoidedByCache: snap.bytesAvoided,
19
+ totalNonReadBytesReturned: totalNonReadBytesReturned(snap.burdenByKind),
20
+ burdenByKind: snap.burdenByKind,
17
21
  });
18
22
  };
19
23
  },
@@ -0,0 +1,14 @@
1
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
2
+
3
+ export const workspaceAuthorizationsTool: ToolDefinition = {
4
+ name: "workspace_authorizations",
5
+ description:
6
+ "List daemon-authorized workspaces, their capability posture, and active bound-session counts.",
7
+ createHandler(ctx: ToolContext): ToolHandler {
8
+ return async () => {
9
+ return ctx.respond("workspace_authorizations", {
10
+ workspaces: await ctx.listWorkspaceAuthorizations(),
11
+ });
12
+ };
13
+ },
14
+ };