@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
@@ -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
 
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ import { graftDiff } from "../../operations/graft-diff.js";
3
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
4
+ import { evaluateMcpRefusal } from "../policy.js";
5
+
6
+ export const sinceTool: ToolDefinition = {
7
+ name: "graft_since",
8
+ description:
9
+ "Structural changes since a git ref. Shows symbols added, removed, " +
10
+ "and changed per file — not line hunks. Includes per-file summary " +
11
+ "lines for quick triage. Defaults to HEAD as the comparison target.",
12
+ schema: {
13
+ base: z.string(),
14
+ head: z.string().optional(),
15
+ },
16
+ createHandler(ctx: ToolContext): ToolHandler {
17
+ return async (args) => {
18
+ const base = args["base"] as string;
19
+ const head = (args["head"] as string | undefined) ?? "HEAD";
20
+
21
+ const result = await graftDiff({
22
+ cwd: ctx.projectRoot,
23
+ fs: ctx.fs,
24
+ git: ctx.git,
25
+ resolveWorkingTreePath: (filePath) => ctx.resolvePath(filePath),
26
+ base,
27
+ head,
28
+ refusalCheck: (filePath, actual) => evaluateMcpRefusal(ctx, filePath, actual),
29
+ });
30
+
31
+ // Aggregate symbol-level changes across all files
32
+ let totalAdded = 0;
33
+ let totalRemoved = 0;
34
+ let totalChanged = 0;
35
+
36
+ for (const file of result.files) {
37
+ totalAdded += file.diff.added.length;
38
+ totalRemoved += file.diff.removed.length;
39
+ totalChanged += file.diff.changed.length;
40
+ }
41
+
42
+ return ctx.respond("graft_since", {
43
+ ...result,
44
+ summary: `+${String(totalAdded)} added, -${String(totalRemoved)} removed, ~${String(totalChanged)} changed across ${String(result.files.length)} files`,
45
+ layer: "ref_view",
46
+ });
47
+ };
48
+ },
49
+ };
@@ -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
+ };
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const workspaceAuthorizeTool: ToolDefinition = {
5
+ name: "workspace_authorize",
6
+ description:
7
+ "Authorize a workspace for daemon binding and optionally change its daemon capability posture.",
8
+ schema: {
9
+ cwd: z.string(),
10
+ runCapture: z.boolean().optional(),
11
+ },
12
+ createHandler(ctx: ToolContext): ToolHandler {
13
+ return async (args) => {
14
+ return ctx.respond("workspace_authorize", { ...await ctx.authorizeWorkspace({
15
+ cwd: args["cwd"] as string,
16
+ runCapture: args["runCapture"] as boolean | undefined,
17
+ }) });
18
+ };
19
+ },
20
+ };
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const workspaceBindTool: ToolDefinition = {
5
+ name: "workspace_bind",
6
+ description:
7
+ "Bind the current daemon session to a workspace by resolving repo and worktree identity server-side.",
8
+ schema: {
9
+ cwd: z.string(),
10
+ worktreeRoot: z.string().optional(),
11
+ gitCommonDir: z.string().optional(),
12
+ repoId: z.string().optional(),
13
+ },
14
+ createHandler(ctx: ToolContext): ToolHandler {
15
+ return async (args) => {
16
+ const result = await ctx.bindWorkspace({
17
+ cwd: args["cwd"] as string,
18
+ worktreeRoot: args["worktreeRoot"] as string | undefined,
19
+ gitCommonDir: args["gitCommonDir"] as string | undefined,
20
+ repoId: args["repoId"] as string | undefined,
21
+ }, "workspace_bind");
22
+ return ctx.respond("workspace_bind", { ...result });
23
+ };
24
+ },
25
+ };
@@ -0,0 +1,25 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const workspaceRebindTool: ToolDefinition = {
5
+ name: "workspace_rebind",
6
+ description:
7
+ "Rebind the current daemon session to a different workspace and start a fresh session-local slice.",
8
+ schema: {
9
+ cwd: z.string(),
10
+ worktreeRoot: z.string().optional(),
11
+ gitCommonDir: z.string().optional(),
12
+ repoId: z.string().optional(),
13
+ },
14
+ createHandler(ctx: ToolContext): ToolHandler {
15
+ return async (args) => {
16
+ const result = await ctx.rebindWorkspace({
17
+ cwd: args["cwd"] as string,
18
+ worktreeRoot: args["worktreeRoot"] as string | undefined,
19
+ gitCommonDir: args["gitCommonDir"] as string | undefined,
20
+ repoId: args["repoId"] as string | undefined,
21
+ }, "workspace_rebind");
22
+ return ctx.respond("workspace_rebind", { ...result });
23
+ };
24
+ },
25
+ };
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const workspaceRevokeTool: ToolDefinition = {
5
+ name: "workspace_revoke",
6
+ description:
7
+ "Revoke daemon authorization for a workspace while leaving any already-open sessions visible to the control plane.",
8
+ schema: {
9
+ cwd: z.string(),
10
+ },
11
+ createHandler(ctx: ToolContext): ToolHandler {
12
+ return async (args) => {
13
+ return ctx.respond("workspace_revoke", { ...await ctx.revokeWorkspace({
14
+ cwd: args["cwd"] as string,
15
+ }) });
16
+ };
17
+ },
18
+ };
@@ -0,0 +1,12 @@
1
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
2
+
3
+ export const workspaceStatusTool: ToolDefinition = {
4
+ name: "workspace_status",
5
+ description:
6
+ "Return the current daemon workspace binding state and resolved capability profile.",
7
+ createHandler(ctx: ToolContext): ToolHandler {
8
+ return () => {
9
+ return ctx.respond("workspace_status", { ...ctx.getWorkspaceStatus() });
10
+ };
11
+ },
12
+ };
@@ -0,0 +1,36 @@
1
+ import type WarpApp from "@git-stunts/git-warp";
2
+ import { DEFAULT_WARP_WRITER_ID } from "../warp/writer-id.js";
3
+
4
+ export interface WarpPool {
5
+ getOrOpen(repoId: string, worktreeRoot: string, writerId?: string): Promise<WarpApp>;
6
+ size(): number;
7
+ }
8
+
9
+ export class InMemoryWarpPool implements WarpPool {
10
+ private readonly opened = new Map<string, Map<string, Promise<WarpApp>>>();
11
+
12
+ constructor(private readonly openWarp: (worktreeRoot: string, writerId: string) => Promise<WarpApp>) {}
13
+
14
+ getOrOpen(repoId: string, worktreeRoot: string, writerId: string = DEFAULT_WARP_WRITER_ID): Promise<WarpApp> {
15
+ const repoHandles = this.opened.get(repoId);
16
+ const cached = repoHandles?.get(writerId);
17
+ if (cached !== undefined) return cached;
18
+
19
+ const nextRepoHandles = repoHandles ?? new Map<string, Promise<WarpApp>>();
20
+ const opened = this.openWarp(worktreeRoot, writerId).catch((error: unknown) => {
21
+ const current = this.opened.get(repoId);
22
+ current?.delete(writerId);
23
+ if (current?.size === 0) {
24
+ this.opened.delete(repoId);
25
+ }
26
+ throw error;
27
+ });
28
+ nextRepoHandles.set(writerId, opened);
29
+ this.opened.set(repoId, nextRepoHandles);
30
+ return opened;
31
+ }
32
+
33
+ size(): number {
34
+ return this.opened.size;
35
+ }
36
+ }