@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,18 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const monitorPauseTool: ToolDefinition = {
5
+ name: "monitor_pause",
6
+ description:
7
+ "Pause the repo-scoped background index monitor for an authorized workspace.",
8
+ schema: {
9
+ cwd: z.string(),
10
+ },
11
+ createHandler(ctx: ToolContext): ToolHandler {
12
+ return async (args) => {
13
+ return ctx.respond("monitor_pause", { ...await ctx.pauseMonitor({
14
+ cwd: args["cwd"] as string,
15
+ }) });
16
+ };
17
+ },
18
+ };
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const monitorResumeTool: ToolDefinition = {
5
+ name: "monitor_resume",
6
+ description:
7
+ "Resume a paused repo-scoped background index monitor for an authorized workspace.",
8
+ schema: {
9
+ cwd: z.string(),
10
+ },
11
+ createHandler(ctx: ToolContext): ToolHandler {
12
+ return async (args) => {
13
+ return ctx.respond("monitor_resume", { ...await ctx.resumeMonitor({
14
+ cwd: args["cwd"] as string,
15
+ }) });
16
+ };
17
+ },
18
+ };
@@ -0,0 +1,20 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const monitorStartTool: ToolDefinition = {
5
+ name: "monitor_start",
6
+ description:
7
+ "Start or resume the repo-scoped background index monitor for an authorized workspace.",
8
+ schema: {
9
+ cwd: z.string(),
10
+ pollIntervalMs: z.number().int().positive().optional(),
11
+ },
12
+ createHandler(ctx: ToolContext): ToolHandler {
13
+ return async (args) => {
14
+ return ctx.respond("monitor_start", { ...await ctx.startMonitor({
15
+ cwd: args["cwd"] as string,
16
+ pollIntervalMs: args["pollIntervalMs"] as number | undefined,
17
+ }) });
18
+ };
19
+ },
20
+ };
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
3
+
4
+ export const monitorStopTool: ToolDefinition = {
5
+ name: "monitor_stop",
6
+ description:
7
+ "Stop the repo-scoped background index monitor for an authorized workspace.",
8
+ schema: {
9
+ cwd: z.string(),
10
+ },
11
+ createHandler(ctx: ToolContext): ToolHandler {
12
+ return async (args) => {
13
+ return ctx.respond("monitor_stop", { ...await ctx.stopMonitor({
14
+ cwd: args["cwd"] as string,
15
+ }) });
16
+ };
17
+ },
18
+ };
@@ -0,0 +1,51 @@
1
+ export class PrecisionSymbolMatch {
2
+ readonly name: string;
3
+ readonly kind: string;
4
+ readonly path: string;
5
+ readonly signature?: string;
6
+ readonly exported: boolean;
7
+ readonly startLine?: number;
8
+ readonly endLine?: number;
9
+
10
+ constructor(opts: {
11
+ name: string;
12
+ kind: string;
13
+ path: string;
14
+ signature?: string;
15
+ exported: boolean;
16
+ startLine?: number;
17
+ endLine?: number;
18
+ }) {
19
+ if (opts.name.trim().length === 0) {
20
+ throw new Error("PrecisionSymbolMatch: name must be non-empty");
21
+ }
22
+ if (opts.kind.trim().length === 0) {
23
+ throw new Error("PrecisionSymbolMatch: kind must be non-empty");
24
+ }
25
+ if (opts.path.trim().length === 0) {
26
+ throw new Error("PrecisionSymbolMatch: path must be non-empty");
27
+ }
28
+ if (opts.startLine !== undefined && (!Number.isInteger(opts.startLine) || opts.startLine < 1)) {
29
+ throw new Error("PrecisionSymbolMatch: startLine must be an integer >= 1");
30
+ }
31
+ if (opts.endLine !== undefined && (!Number.isInteger(opts.endLine) || opts.endLine < 1)) {
32
+ throw new Error("PrecisionSymbolMatch: endLine must be an integer >= 1");
33
+ }
34
+ if (
35
+ opts.startLine !== undefined &&
36
+ opts.endLine !== undefined &&
37
+ opts.endLine < opts.startLine
38
+ ) {
39
+ throw new Error("PrecisionSymbolMatch: endLine must be >= startLine");
40
+ }
41
+
42
+ this.name = opts.name.trim();
43
+ this.kind = opts.kind.trim();
44
+ this.path = opts.path.trim();
45
+ this.exported = opts.exported;
46
+ if (opts.signature !== undefined) this.signature = opts.signature;
47
+ if (opts.startLine !== undefined) this.startLine = opts.startLine;
48
+ if (opts.endLine !== undefined) this.endLine = opts.endLine;
49
+ Object.freeze(this);
50
+ }
51
+ }
@@ -0,0 +1,127 @@
1
+ import picomatch from "picomatch";
2
+ import { PrecisionSymbolMatch } from "./precision-match.js";
3
+
4
+ type SymbolQueryMode = "glob" | "plain";
5
+
6
+ export class RankedPrecisionSymbolMatch {
7
+ readonly match: PrecisionSymbolMatch;
8
+ readonly score: number;
9
+
10
+ constructor(opts: {
11
+ match: PrecisionSymbolMatch;
12
+ score: number;
13
+ }) {
14
+ if (opts.score < 0) {
15
+ throw new Error("RankedPrecisionSymbolMatch: score must be >= 0");
16
+ }
17
+ this.match = opts.match;
18
+ this.score = opts.score;
19
+ Object.freeze(this);
20
+ }
21
+ }
22
+
23
+ class PrecisionSymbolQuery {
24
+ readonly text: string;
25
+ readonly mode: SymbolQueryMode;
26
+ readonly #globMatcher?: (name: string) => boolean;
27
+
28
+ constructor(query: string) {
29
+ const normalizedQuery = query.trim();
30
+ if (normalizedQuery.length === 0) {
31
+ throw new Error("PrecisionSymbolQuery: query must be non-empty");
32
+ }
33
+ this.text = normalizedQuery;
34
+ if (picomatch.scan(normalizedQuery).isGlob) {
35
+ this.mode = "glob";
36
+ this.#globMatcher = picomatch(normalizedQuery, { nocase: true });
37
+ } else {
38
+ this.mode = "plain";
39
+ }
40
+ Object.freeze(this);
41
+ }
42
+
43
+ score(name: string): number | null {
44
+ if (this.mode === "glob") {
45
+ return this.#globMatcher?.(name) === true ? 0 : null;
46
+ }
47
+
48
+ const loweredName = name.toLowerCase();
49
+ const loweredQuery = this.text.toLowerCase();
50
+ if (name === this.text) return 0;
51
+ if (loweredName === loweredQuery) return 1;
52
+ if (name.startsWith(this.text)) return 2;
53
+ if (loweredName.startsWith(loweredQuery)) return 3;
54
+ return loweredName.includes(loweredQuery) ? 4 : null;
55
+ }
56
+ }
57
+
58
+ export class PrecisionSearchRequest {
59
+ readonly exactName?: string;
60
+ readonly query?: PrecisionSymbolQuery;
61
+ readonly kind?: string;
62
+ readonly filePath?: string;
63
+ readonly pathPrefix?: string;
64
+ readonly ceiling?: number;
65
+
66
+ constructor(opts: {
67
+ exactName?: string;
68
+ query?: string;
69
+ kind?: string;
70
+ filePath?: string;
71
+ pathPrefix?: string;
72
+ ceiling?: number;
73
+ }) {
74
+ const exactName = opts.exactName?.trim();
75
+ const query = opts.query?.trim();
76
+ const kind = opts.kind?.trim().toLowerCase();
77
+ const filePath = opts.filePath?.trim();
78
+ const pathPrefix = opts.pathPrefix?.trim();
79
+
80
+ if ((exactName?.length ?? 0) === 0 && (query?.length ?? 0) === 0) {
81
+ throw new Error("PrecisionSearchRequest: exactName or query is required");
82
+ }
83
+ if (
84
+ opts.ceiling !== undefined &&
85
+ (!Number.isInteger(opts.ceiling) || opts.ceiling < 1)
86
+ ) {
87
+ throw new Error("PrecisionSearchRequest: ceiling must be an integer >= 1");
88
+ }
89
+
90
+ if (exactName !== undefined && exactName.length > 0) this.exactName = exactName;
91
+ if (query !== undefined && query.length > 0) this.query = new PrecisionSymbolQuery(query);
92
+ if (kind !== undefined && kind.length > 0) this.kind = kind;
93
+ if (filePath !== undefined && filePath.length > 0) this.filePath = filePath;
94
+ if (pathPrefix !== undefined && pathPrefix.length > 0) this.pathPrefix = pathPrefix;
95
+ if (opts.ceiling !== undefined) this.ceiling = opts.ceiling;
96
+ Object.freeze(this);
97
+ }
98
+
99
+ selectLens(): "file" | "exact" | "all" {
100
+ if (this.filePath !== undefined) return "file";
101
+ if (this.exactName !== undefined) return "exact";
102
+ return "all";
103
+ }
104
+
105
+ rank(match: PrecisionSymbolMatch): RankedPrecisionSymbolMatch | null {
106
+ if (this.exactName !== undefined && match.name !== this.exactName) return null;
107
+ if (this.kind !== undefined && match.kind.toLowerCase() !== this.kind) return null;
108
+ if (this.filePath !== undefined && match.path !== this.filePath) return null;
109
+ if (this.pathPrefix !== undefined && !match.path.startsWith(this.pathPrefix)) return null;
110
+ if (this.query === undefined) {
111
+ return new RankedPrecisionSymbolMatch({ match, score: 0 });
112
+ }
113
+ const score = this.query.score(match.name);
114
+ return score === null ? null : new RankedPrecisionSymbolMatch({ match, score });
115
+ }
116
+
117
+ sort(matches: readonly RankedPrecisionSymbolMatch[]): PrecisionSymbolMatch[] {
118
+ return [...matches]
119
+ .sort((a, b) =>
120
+ a.score - b.score ||
121
+ (this.query?.mode === "plain" ? a.match.name.length - b.match.name.length : 0) ||
122
+ a.match.path.localeCompare(b.match.path) ||
123
+ a.match.name.localeCompare(b.match.name)
124
+ )
125
+ .map((entry) => entry.match);
126
+ }
127
+ }
@@ -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
+ }