jev-agent-tools 0.1.4 → 0.2.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 (131) hide show
  1. package/CHANGELOG.md +70 -1
  2. package/CONTRIBUTING.md +40 -0
  3. package/README.md +42 -9
  4. package/SECURITY.md +27 -0
  5. package/dist/adapters/analysis-context.js +75 -0
  6. package/dist/adapters/ask-files.js +189 -0
  7. package/dist/adapters/ask-proof.js +144 -0
  8. package/dist/adapters/ask-syntax.js +385 -0
  9. package/dist/adapters/canonical-path.js +17 -0
  10. package/dist/adapters/command.js +181 -0
  11. package/dist/adapters/docs.js +172 -0
  12. package/dist/adapters/exec.js +207 -0
  13. package/dist/adapters/files.js +293 -0
  14. package/dist/adapters/find.js +122 -0
  15. package/dist/adapters/git-base.js +26 -0
  16. package/dist/adapters/git-inventory.js +71 -0
  17. package/dist/adapters/git.js +439 -0
  18. package/dist/adapters/locate-file.js +159 -0
  19. package/dist/adapters/output-lines.js +46 -0
  20. package/dist/adapters/private-storage.js +98 -0
  21. package/dist/adapters/risk-callers.js +426 -0
  22. package/dist/adapters/runner-version.js +78 -0
  23. package/dist/adapters/shell.js +76 -0
  24. package/dist/adapters/syntax.js +187 -0
  25. package/dist/adapters/test-inventory.js +131 -0
  26. package/dist/adapters/usage.js +20 -0
  27. package/dist/adapters/utf8.js +47 -0
  28. package/dist/configuration.js +257 -0
  29. package/dist/constants.js +119 -0
  30. package/dist/core/ask-closure.js +282 -0
  31. package/dist/core/ask-proof.js +1 -0
  32. package/dist/core/ask-references.js +194 -0
  33. package/dist/core/asks.js +436 -0
  34. package/dist/core/batches.js +65 -0
  35. package/dist/core/command-output.js +224 -0
  36. package/dist/core/diff.js +178 -0
  37. package/dist/core/docs.js +302 -0
  38. package/dist/core/find.js +108 -0
  39. package/dist/core/git.js +1 -0
  40. package/dist/core/imports.js +550 -0
  41. package/dist/core/integrity.js +45 -0
  42. package/dist/core/lexical.js +132 -0
  43. package/dist/core/locate.js +169 -0
  44. package/dist/core/output.js +120 -0
  45. package/dist/core/pointer.js +29 -0
  46. package/dist/core/risk-callers.js +851 -0
  47. package/dist/core/runner-version.js +45 -0
  48. package/dist/core/sections.js +230 -0
  49. package/dist/core/state.js +44 -0
  50. package/dist/core/syntax.js +1 -0
  51. package/dist/core/test-commands.js +334 -0
  52. package/dist/core/test-coverage.js +74 -0
  53. package/dist/core/test-discovery.js +1382 -0
  54. package/dist/core/test-evidence.js +527 -0
  55. package/dist/core/test-state.js +81 -0
  56. package/dist/core/truncate.js +12 -0
  57. package/dist/core/units.js +349 -0
  58. package/dist/describe.js +23 -0
  59. package/dist/guide.js +33 -0
  60. package/dist/host.js +24 -0
  61. package/dist/jev/client.js +434 -0
  62. package/dist/jev/pool.js +54 -0
  63. package/dist/jev/types.js +1 -0
  64. package/dist/mcp/main.js +124 -0
  65. package/dist/mcp/protocol.js +187 -0
  66. package/dist/mcp/tools.js +116 -0
  67. package/dist/presets/docs.js +62 -0
  68. package/dist/presets/risk.js +179 -0
  69. package/dist/presets/spec.js +81 -0
  70. package/dist/presets/witnesses.js +249 -0
  71. package/dist/render.js +42 -0
  72. package/dist/result.js +3 -0
  73. package/dist/runtime.js +1 -0
  74. package/dist/session.js +147 -0
  75. package/dist/texts/ask-files.js +1 -0
  76. package/dist/texts/ask.js +2 -0
  77. package/dist/texts/check-diff.js +17 -0
  78. package/dist/texts/configuration.js +1 -0
  79. package/dist/texts/find.js +14 -0
  80. package/dist/texts/guide.js +16 -0
  81. package/dist/texts/locate.js +10 -0
  82. package/dist/texts/select-tests.js +2 -0
  83. package/dist/tools/ask-files.js +217 -0
  84. package/dist/tools/ask-schema.js +70 -0
  85. package/dist/tools/ask.js +686 -0
  86. package/dist/tools/check-diff.js +402 -0
  87. package/dist/tools/docs-check.js +299 -0
  88. package/dist/tools/find.js +389 -0
  89. package/dist/tools/locate.js +303 -0
  90. package/dist/tools/select-tests.js +567 -0
  91. package/dist/tools/spec-check.js +166 -0
  92. package/docs/adr/0001-strict-typescript-pure-core-offline-tests.md +31 -0
  93. package/docs/adr/0002-one-http-protocol-across-hosts.md +17 -0
  94. package/docs/adr/0003-explicit-scope-conservative-automation.md +19 -0
  95. package/docs/adr/0004-compiled-typed-intents.md +19 -0
  96. package/docs/adr/0005-evidence-construction-before-judgment.md +19 -0
  97. package/docs/adr/0006-visible-uncertainty-constrained-controls.md +21 -0
  98. package/docs/adr/0007-bounded-evidence-visible-limits.md +21 -0
  99. package/docs/adr/0008-static-test-discovery-conservative-plans.md +19 -0
  100. package/docs/adr/0009-session-cache-requested-model-identity.md +17 -0
  101. package/docs/adr/0010-mcp-server-thin-host.md +23 -0
  102. package/docs/agent-instructions.md +91 -0
  103. package/docs/design.md +3 -3
  104. package/docs/mcp.md +231 -0
  105. package/package.json +19 -4
  106. package/server.json +57 -0
  107. package/src/adapters/canonical-path.ts +18 -0
  108. package/src/adapters/command.ts +7 -4
  109. package/src/adapters/exec.ts +226 -0
  110. package/src/adapters/private-storage.ts +143 -0
  111. package/src/adapters/risk-callers.ts +4 -2
  112. package/src/adapters/shell.ts +97 -0
  113. package/src/configuration.ts +39 -12
  114. package/src/constants.ts +11 -0
  115. package/src/core/command-output.ts +17 -1
  116. package/src/host.ts +11 -0
  117. package/src/jev/client.ts +12 -0
  118. package/src/jev/types.ts +6 -0
  119. package/src/mcp/main.ts +135 -0
  120. package/src/mcp/protocol.ts +282 -0
  121. package/src/mcp/tools.ts +166 -0
  122. package/src/session.ts +59 -0
  123. package/src/setup.ts +13 -5
  124. package/src/tools/ask-files.ts +5 -7
  125. package/src/tools/ask.ts +26 -22
  126. package/src/tools/check-diff.ts +8 -5
  127. package/src/tools/docs-check.ts +1 -0
  128. package/src/tools/find.ts +5 -2
  129. package/src/tools/locate.ts +5 -8
  130. package/src/tools/select-tests.ts +7 -4
  131. package/src/tools/spec-check.ts +1 -0
@@ -0,0 +1,293 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, readlink, realpath, } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
5
+ import { ASK_MAX_FILES, FIND_EXCERPT_CHARS, FIND_POINTER_EXCERPT_CHARS, FIND_READ_CHUNK_BYTES, STATE_MAX_CHARS, TIMEOUT_MS, } from "../constants.js";
6
+ import { createExcerptCollector } from "../core/find.js";
7
+ import { createUtf8Decoder, decodeUtf8 } from "./utf8.js";
8
+ export async function checkFileAdmission(cwd, path, exec, signal, inventory) {
9
+ if (path.split(/[\\/]/).includes(".git"))
10
+ return { ok: false, error: `${path}: .git metadata: not sent to Jev` };
11
+ if (inventory)
12
+ return inventory.has(path)
13
+ ? { ok: true }
14
+ : { ok: false, error: `${path}: gitignored: not sent to Jev` };
15
+ if (!exec)
16
+ return { ok: true };
17
+ const listed = await exec("git", [
18
+ "ls-files",
19
+ "--cached",
20
+ "--others",
21
+ "--exclude-standard",
22
+ "-z",
23
+ "--",
24
+ path,
25
+ ], { cwd, timeout: TIMEOUT_MS, signal });
26
+ if (listed.killed)
27
+ return { ok: false, error: `${path}: file admission interrupted` };
28
+ if (listed.code === 0 && !listed.stdout.split("\0").includes(path))
29
+ return { ok: false, error: `${path}: gitignored: not sent to Jev` };
30
+ return { ok: true };
31
+ }
32
+ export async function resolveInsideRepo(root, path, options = {}) {
33
+ if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
34
+ return {
35
+ ok: false,
36
+ error: `Path must remain inside the repository: ${path}.`,
37
+ };
38
+ try {
39
+ const base = await realpath(root);
40
+ const requested = resolve(base, path);
41
+ const rel = relative(base, requested);
42
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
43
+ return { ok: false, error: `Path escapes the repository: ${path}.` };
44
+ let current = base;
45
+ for (const part of rel.split(sep).filter(Boolean)) {
46
+ current = resolve(current, part);
47
+ if ((await lstat(current)).isSymbolicLink()) {
48
+ if (options.allowLeafSymlink && current === requested)
49
+ return { ok: true, abs: requested, rel: rel.replaceAll(sep, "/") };
50
+ return { ok: false, error: `Symbolic links are not read: ${path}.` };
51
+ }
52
+ }
53
+ const abs = await realpath(requested);
54
+ const actual = relative(base, abs);
55
+ if (actual === ".." || actual.startsWith(`..${sep}`) || isAbsolute(actual))
56
+ return { ok: false, error: `Path escapes the repository: ${path}.` };
57
+ return { ok: true, abs, rel: actual.replaceAll(sep, "/") };
58
+ }
59
+ catch (error) {
60
+ return { ok: false, error: `Cannot resolve ${path}: ${String(error)}` };
61
+ }
62
+ }
63
+ /** Leaf-link metadata is admitted without following the target. */
64
+ export async function admitRepoMetadata(cwd, path, exec, signal, inventory) {
65
+ const location = await resolveInsideRepo(cwd, path, {
66
+ allowLeafSymlink: true,
67
+ });
68
+ if (!location.ok)
69
+ return location;
70
+ const admission = await checkFileAdmission(cwd, location.rel, exec, signal, inventory);
71
+ return admission.ok ? location : admission;
72
+ }
73
+ /** History is admitted by its tracked base tree, independent of worktree ignores or links. */
74
+ export async function admitGitPath(cwd, path, base, exec, signal, inventory) {
75
+ if (isAbsolute(path) || path.split(/[\\/]/).includes(".."))
76
+ return {
77
+ ok: false,
78
+ error: `Path must remain inside the repository: ${path}.`,
79
+ };
80
+ const admission = await checkFileAdmission(cwd, path, undefined, signal);
81
+ if (!admission.ok)
82
+ return admission;
83
+ // ls-tree cannot descend through a symlink or gitlink ancestor in the base tree.
84
+ let entry = inventory?.get(path);
85
+ if (!inventory) {
86
+ const tree = await exec("git", ["ls-tree", "-z", "--full-tree", "--end-of-options", base, "--", path], { cwd, timeout: TIMEOUT_MS, signal });
87
+ if (tree.code !== 0 || tree.killed)
88
+ return { ok: false, error: `${path}: not a file at comparison base` };
89
+ entry = tree.stdout
90
+ .split("\0")
91
+ .find((item) => item.slice(item.indexOf("\t") + 1) === path);
92
+ }
93
+ if (!entry || !/^(?:100644|100755|120000) blob /.test(entry))
94
+ return { ok: false, error: `${path}: not a file at comparison base` };
95
+ return { ok: true };
96
+ }
97
+ /** The only content opener for repository files. Callers own and close the handle. */
98
+ export async function openRepoFile(cwd, path, options = {}) {
99
+ try {
100
+ options.signal?.throwIfAborted();
101
+ const location = await resolveInsideRepo(cwd, path);
102
+ if (!location.ok)
103
+ return location;
104
+ const admission = await checkFileAdmission(cwd, location.rel, options.exec, options.signal, options.inventory);
105
+ if (!admission.ok)
106
+ return admission;
107
+ const handle = await open(location.abs, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
108
+ try {
109
+ const stat = await handle.stat();
110
+ if (!stat.isFile()) {
111
+ await handle.close();
112
+ return { ok: false, error: `Not a file: ${path}.` };
113
+ }
114
+ return { ok: true, handle, size: stat.size };
115
+ }
116
+ catch (error) {
117
+ await handle.close();
118
+ throw error;
119
+ }
120
+ }
121
+ catch (error) {
122
+ return { ok: false, error: `Cannot read ${path}: ${String(error)}` };
123
+ }
124
+ }
125
+ export async function collectFiles(cwd, paths, signal, options = {}) {
126
+ if (paths.length > ASK_MAX_FILES)
127
+ return {
128
+ ok: false,
129
+ error: `paths exceeds ${ASK_MAX_FILES} files; split paths into several calls.`,
130
+ };
131
+ const results = await Promise.all(paths.map(async (path) => {
132
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
133
+ return {
134
+ ok: false,
135
+ error: `Internal URLs are not files; use read for ${path}.`,
136
+ };
137
+ let handle;
138
+ try {
139
+ signal?.throwIfAborted();
140
+ const opened = await openRepoFile(cwd, path, {
141
+ exec: options.exec,
142
+ signal,
143
+ inventory: options.inventory,
144
+ });
145
+ if (!opened.ok)
146
+ return opened;
147
+ handle = opened.handle;
148
+ const bytes = Buffer.alloc(Math.min(opened.size + 1, STATE_MAX_CHARS * 4 + 1));
149
+ let count = 0;
150
+ while (count < bytes.length) {
151
+ signal?.throwIfAborted();
152
+ const read = await handle.read(bytes, count, bytes.length - count, null);
153
+ if (!read.bytesRead)
154
+ break;
155
+ count += read.bytesRead;
156
+ }
157
+ if (count === bytes.length)
158
+ return {
159
+ ok: false,
160
+ error: `File exceeds ${STATE_MAX_CHARS} chars: ${path}; files are never truncated. Split paths into several calls.`,
161
+ };
162
+ const decoded = decodeUtf8(bytes.subarray(0, count));
163
+ if (!decoded.ok)
164
+ return {
165
+ ok: false,
166
+ invalidUtf8: true,
167
+ error: `${path} is not UTF-8 text; provide a UTF-8 text file instead.`,
168
+ };
169
+ const text = decoded.text;
170
+ if (count === bytes.length || text.length > STATE_MAX_CHARS)
171
+ return {
172
+ ok: false,
173
+ error: `File exceeds ${STATE_MAX_CHARS} chars: ${path}; files are never truncated. Split paths into several calls.`,
174
+ };
175
+ if (!text.length && !options.allowEmpty)
176
+ return { ok: false, error: `${path} is empty.` };
177
+ return {
178
+ ok: true,
179
+ path,
180
+ text,
181
+ identity: {
182
+ requestedPath: path,
183
+ resolvedPath: resolve(cwd, path),
184
+ insertedPath: resolve(cwd, path),
185
+ content: text,
186
+ readSha256: createHash("sha256")
187
+ .update(bytes.subarray(0, count))
188
+ .digest("hex"),
189
+ insertedSha256: createHash("sha256").update(text).digest("hex"),
190
+ },
191
+ };
192
+ }
193
+ catch (error) {
194
+ return {
195
+ ok: false,
196
+ error: `Cannot read ${path}: ${String(error)}`,
197
+ };
198
+ }
199
+ finally {
200
+ await handle?.close();
201
+ }
202
+ }));
203
+ const failure = results.find((result) => !result.ok && !(options.skipInvalidUtf8 && "invalidUtf8" in result));
204
+ if (failure && !failure.ok)
205
+ return failure;
206
+ return {
207
+ ok: true,
208
+ skipped: results.flatMap((result) => !result.ok && "invalidUtf8" in result ? [result.error] : []),
209
+ identities: results.flatMap((result) => result.ok ? [result.identity] : []),
210
+ files: Object.fromEntries(results.flatMap((result) => result.ok ? [[result.path, result.text]] : [])),
211
+ };
212
+ }
213
+ export async function collectSearchExcerpts(cwd, paths, keywords, signal, exec) {
214
+ const files = {}, pointers = {};
215
+ const ignored = [];
216
+ const limits = [];
217
+ const results = await Promise.all(paths.map(async (path) => {
218
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path))
219
+ return {
220
+ ok: false,
221
+ error: `Internal URLs are not files; use read for ${path}.`,
222
+ };
223
+ try {
224
+ const location = await admitRepoMetadata(cwd, path, exec, signal);
225
+ if (!location.ok) {
226
+ ignored.push(path);
227
+ return { ok: true };
228
+ }
229
+ if ((await lstat(location.abs)).isSymbolicLink()) {
230
+ const target = await readlink(location.abs);
231
+ files[path] = target;
232
+ pointers[path] = target;
233
+ return { ok: true };
234
+ }
235
+ const opened = await openRepoFile(cwd, path, { exec, signal });
236
+ if (!opened.ok) {
237
+ ignored.push(path);
238
+ return { ok: true };
239
+ }
240
+ const handle = opened.handle;
241
+ const content = createExcerptCollector(keywords, FIND_EXCERPT_CHARS);
242
+ const pointer = createExcerptCollector(keywords, FIND_POINTER_EXCERPT_CHARS);
243
+ const decode = createUtf8Decoder();
244
+ const stream = handle.createReadStream({
245
+ highWaterMark: FIND_READ_CHUNK_BYTES,
246
+ signal,
247
+ });
248
+ for await (const chunk of stream) {
249
+ const decoded = decode(chunk);
250
+ if (!decoded.ok) {
251
+ limits.push({ path, reason: decoded.error });
252
+ ignored.push(path);
253
+ stream.destroy();
254
+ return { ok: true };
255
+ }
256
+ const text = decoded.text;
257
+ if (text.includes("\0")) {
258
+ ignored.push(path);
259
+ stream.destroy();
260
+ return { ok: true };
261
+ }
262
+ content.push(text);
263
+ pointer.push(text);
264
+ }
265
+ const end = decode();
266
+ if (!end.ok) {
267
+ limits.push({ path, reason: end.error });
268
+ ignored.push(path);
269
+ return { ok: true };
270
+ }
271
+ content.push(end.text);
272
+ pointer.push(end.text);
273
+ const excerpt = content.finish();
274
+ if (!excerpt.length) {
275
+ ignored.push(path);
276
+ return { ok: true };
277
+ }
278
+ files[path] = excerpt;
279
+ pointers[path] = pointer.finish();
280
+ return { ok: true };
281
+ }
282
+ catch (error) {
283
+ if (signal?.aborted)
284
+ return { ok: false, error: String(error) };
285
+ ignored.push(path);
286
+ return { ok: true };
287
+ }
288
+ }));
289
+ const failure = results.find((result) => !result.ok);
290
+ return failure && !failure.ok
291
+ ? failure
292
+ : { ok: true, files, pointers, ignored, limits };
293
+ }
@@ -0,0 +1,122 @@
1
+ import { FIND_GREP_PAGE_SIZE, FIND_PATH_WEIGHT, TIMEOUT_MS, } from "../constants.js";
2
+ import { pathAllowed } from "../core/find.js";
3
+ export async function prefilter(exec, cwd, keywords, limit, scope, exclude, signal, native = true) {
4
+ try {
5
+ const listed = await exec("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd, timeout: TIMEOUT_MS, signal });
6
+ if (listed.killed || listed.code !== 0)
7
+ return {
8
+ ok: false,
9
+ error: `Cannot list repository files: ${listed.stderr || "git interrupted"}`,
10
+ };
11
+ const paths = [
12
+ ...new Set(listed.stdout
13
+ .split("\0")
14
+ .filter((path) => path && pathAllowed(path, scope, exclude))),
15
+ ];
16
+ if (!paths.length)
17
+ return { ok: true, paths: [], scopeFiles: 0 };
18
+ const allowed = new Set(paths);
19
+ let finder;
20
+ if (native) {
21
+ try {
22
+ // Static loading would break hosts where this optional native package is absent.
23
+ const { FileFinder } = await import("@ff-labs/fff-node");
24
+ const created = FileFinder.create({
25
+ basePath: cwd,
26
+ disableWatch: true,
27
+ });
28
+ if (created.ok) {
29
+ finder = created.value;
30
+ const ready = await finder.waitForScan(TIMEOUT_MS);
31
+ if (!ready.ok || !ready.value) {
32
+ finder.destroy();
33
+ finder = undefined;
34
+ }
35
+ }
36
+ }
37
+ catch {
38
+ /* Optional native package is unavailable in this host. */
39
+ }
40
+ }
41
+ if (!finder) {
42
+ const available = await exec("rg", ["--version"], {
43
+ cwd,
44
+ timeout: TIMEOUT_MS,
45
+ signal,
46
+ });
47
+ if (available.killed || available.code !== 0)
48
+ return { ok: false, error: "rg is required when fff is unavailable" };
49
+ }
50
+ try {
51
+ const matches = await Promise.all(keywords.map(async (word) => {
52
+ signal?.throwIfAborted();
53
+ if (finder) {
54
+ const found = new Set();
55
+ let cursor = null;
56
+ do {
57
+ const result = finder.multiGrep({
58
+ patterns: [word],
59
+ smartCase: true,
60
+ maxMatchesPerFile: 1,
61
+ pageSize: FIND_GREP_PAGE_SIZE,
62
+ cursor,
63
+ });
64
+ if (!result.ok)
65
+ throw new Error(result.error);
66
+ for (const item of result.value.items)
67
+ if (allowed.has(item.relativePath))
68
+ found.add(item.relativePath);
69
+ cursor = result.value.nextCursor;
70
+ } while (cursor);
71
+ return found;
72
+ }
73
+ const result = await exec("rg", [
74
+ "--files-with-matches",
75
+ "--hidden",
76
+ "--null",
77
+ "--fixed-strings",
78
+ "--ignore-case",
79
+ "--",
80
+ word,
81
+ ".",
82
+ ], { cwd, timeout: TIMEOUT_MS, signal });
83
+ if (result.killed ||
84
+ (result.code !== 0 && result.code !== 1) ||
85
+ (result.code === 1 && result.stderr.trim()))
86
+ throw new Error(result.stderr || "rg interrupted");
87
+ return new Set(result.stdout
88
+ .split("\0")
89
+ .map((path) => path.replace(/^\.\//u, ""))
90
+ .filter((path) => allowed.has(path)));
91
+ }));
92
+ const scores = paths.map((path) => ({
93
+ path,
94
+ score: keywords.reduce((sum, word, index) => {
95
+ const matchesForWord = matches[index];
96
+ if (!matchesForWord)
97
+ return sum;
98
+ const weight = Math.log(1 + paths.length / (1 + matchesForWord.size));
99
+ return (sum +
100
+ weight *
101
+ (Number(matchesForWord.has(path)) +
102
+ FIND_PATH_WEIGHT * Number(path.toLowerCase().includes(word))));
103
+ }, 0),
104
+ }));
105
+ scores.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
106
+ return {
107
+ ok: true,
108
+ paths: scores
109
+ .filter((item) => item.score > 0)
110
+ .slice(0, limit)
111
+ .map((item) => item.path),
112
+ scopeFiles: paths.length,
113
+ };
114
+ }
115
+ finally {
116
+ finder?.destroy();
117
+ }
118
+ }
119
+ catch (error) {
120
+ return { ok: false, error: `Cannot prefilter files: ${String(error)}` };
121
+ }
122
+ }
@@ -0,0 +1,26 @@
1
+ import { TIMEOUT_MS } from "../constants.js";
2
+ export async function resolveBase(exec, cwd, base, signal) {
3
+ if (!base)
4
+ return { ok: true, base: "HEAD" };
5
+ try {
6
+ const result = await exec("git", ["merge-base", "--", "HEAD", base], {
7
+ cwd,
8
+ timeout: TIMEOUT_MS,
9
+ signal,
10
+ });
11
+ if (result.killed)
12
+ return { ok: false, error: "Git interrupted (cancelled or timed out)." };
13
+ if (result.code === 0 && result.stdout.trim())
14
+ return { ok: true, base: result.stdout.trim() };
15
+ return {
16
+ ok: false,
17
+ error: `Cannot resolve base=${base}: history may be truncated or no shared ancestor is available. Run git fetch --unshallow (or git fetch --deepen=<count>), or choose a base that is an ancestor of HEAD.${result.stderr.trim() ? ` Git: ${result.stderr.trim()}` : ""}`,
18
+ };
19
+ }
20
+ catch (error) {
21
+ return {
22
+ ok: false,
23
+ error: `Unable to resolve base=${base}: ${String(error)}`,
24
+ };
25
+ }
26
+ }
@@ -0,0 +1,71 @@
1
+ /** Fresh per tool call: HEAD/index timestamps cannot detect untracked or ignore changes. */
2
+ export function shareGitInventory(exec) {
3
+ const snapshots = new Map();
4
+ return async (command, args, options) => {
5
+ const offset = args.indexOf("ls-files");
6
+ if (command !== "git" && command !== "rg")
7
+ snapshots.clear();
8
+ if (command !== "git" || offset === -1)
9
+ return exec(command, args, options);
10
+ const flags = args.slice(offset + 1);
11
+ if (!flags.includes("-z") ||
12
+ flags.includes("--ignored") ||
13
+ flags.some((flag) => flag.startsWith("--with-tree")))
14
+ return exec(command, args, options);
15
+ const separator = flags.indexOf("--");
16
+ const filters = separator === -1 ? [] : flags.slice(separator + 1);
17
+ if (filters.some((path) => !path.startsWith(":(top,literal)") &&
18
+ !path.startsWith(":(top,exclude,literal)") &&
19
+ /[*?[\]:]/.test(path)))
20
+ return exec(command, args, options);
21
+ const allowed = new Set([
22
+ "-z",
23
+ "--cached",
24
+ "--others",
25
+ "--exclude-standard",
26
+ "--full-name",
27
+ ]);
28
+ if ((separator === -1 ? flags : flags.slice(0, separator)).some((flag) => !allowed.has(flag)))
29
+ return exec(command, args, options);
30
+ const key = `${options.cwd}\0${flags.includes("--full-name")}`;
31
+ let snapshot = snapshots.get(key);
32
+ if (!snapshot) {
33
+ snapshot = exec("git", [
34
+ "ls-files",
35
+ "--cached",
36
+ "--others",
37
+ "--exclude-standard",
38
+ ...(flags.includes("--full-name") ? ["--full-name"] : []),
39
+ "-t",
40
+ "-z",
41
+ ], options);
42
+ snapshots.set(key, snapshot);
43
+ }
44
+ const result = await snapshot;
45
+ if (result.code !== 0 || result.killed)
46
+ return result;
47
+ const entries = result.stdout.split("\0").filter(Boolean);
48
+ const othersOnly = flags.includes("--others") && !flags.includes("--cached");
49
+ const trackedOnly = !flags.includes("--others");
50
+ const include = filters.filter((path) => !path.startsWith(":(top,exclude,literal)"));
51
+ const exclude = filters
52
+ .filter((path) => path.startsWith(":(top,exclude,literal)"))
53
+ .map((path) => path.slice(":(top,exclude,literal)".length));
54
+ const matches = (path, filter) => {
55
+ const literal = filter.replace(/^:\(top,literal\)/, "");
56
+ return path === literal || path.startsWith(`${literal}/`);
57
+ };
58
+ return {
59
+ ...result,
60
+ stdout: entries
61
+ .filter((entry) => !(othersOnly && entry[0] !== "?") &&
62
+ !(trackedOnly && entry[0] === "?"))
63
+ .map((entry) => entry.slice(2))
64
+ .filter((path) => (!include.length ||
65
+ include.some((filter) => matches(path, filter))) &&
66
+ !exclude.some((filter) => matches(path, filter)))
67
+ .map((path) => `${path}\0`)
68
+ .join(""),
69
+ };
70
+ };
71
+ }