ravensight-playtest 0.1.0 → 0.2.3

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.
@@ -1,4 +1,5 @@
1
- import { readFile, readdir, stat } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { scanForSecrets } from './secretScan.js';
4
5
 
@@ -8,8 +9,16 @@ import { scanForSecrets } from './secretScan.js';
8
9
  * `routing.json`'s `game_profile.triage` allows (and `read_file` alone on
9
10
  * `game_profile.synthesis` and `aggregate.synthesis`).
10
11
  *
11
- * Read-only is enforced here, not asked for in a prompt. There is no write,
12
- * no shell, no network, and:
12
+ * `fix.apply` is the one step that also gets `write_file`, and it gets it
13
+ * through `WRITE_TOOLS` rather than through `PROFILE_TOOLS`: a step that is
14
+ * allowed to read a repository should not gain the ability to write to it
15
+ * because somebody appended to the wrong array. `write_file` is refused
16
+ * outright unless the caller asked for it by name, so `createRepoTools()`
17
+ * with its default tool set answers "there is no tool called write_file",
18
+ * exactly as it did before that tool existed.
19
+ *
20
+ * Read-only is enforced here, not asked for in a prompt. There is no shell,
21
+ * no network, and:
13
22
  *
14
23
  * - every path is resolved and refused if it leaves the root, so `../../`
15
24
  * and a symlink out of the tree both fail;
@@ -33,6 +42,16 @@ export const SKIP_DIRS = Object.freeze([
33
42
  '.venv', 'venv', '__pycache__', '.next', '.cache', 'coverage'
34
43
  ]);
35
44
 
45
+ /**
46
+ * The two names refused at any position in a path, not just as a directory.
47
+ * Both hold the machinery rather than the game: git's own database, and this
48
+ * CLI's journals and reports.
49
+ */
50
+ export const NEVER_TOUCHED = Object.freeze(['.git', '.ravensight']);
51
+
52
+ /** `SKIP_DIRS`, lowercased once, because the comparison is case insensitive. */
53
+ const SKIP_DIRS_LOWER = Object.freeze(SKIP_DIRS.map(name => name.toLowerCase()));
54
+
36
55
  /** Files never read, whatever asks for them. */
37
56
  export const SECRET_FILE_PATTERNS = Object.freeze([
38
57
  /(^|\/)\.env($|\.)/i,
@@ -49,7 +68,13 @@ export const MAX_READ_BYTES = 120000;
49
68
  export const MAX_GLOB_RESULTS = 400;
50
69
 
51
70
  /** How many matching lines a grep answers with. */
52
- export const MAX_GREP_RESULTS = 200;
71
+ /**
72
+ * Sixty, not two hundred: at three hundred characters a line the old cap
73
+ * let one grep put 60 KB into the context, and forty greps in one apply loop
74
+ * ate the whole model budget without a write. A capped answer says so, so
75
+ * the model narrows rather than repeats.
76
+ */
77
+ export const MAX_GREP_RESULTS = 60;
53
78
 
54
79
  /** The tool definitions for `game_profile.triage`. */
55
80
  export const PROFILE_TOOLS = Object.freeze([
@@ -95,6 +120,60 @@ export const PROFILE_TOOLS = Object.freeze([
95
120
  /** The subset allowed on the synthesis steps. */
96
121
  export const READ_ONLY_TOOLS = Object.freeze(PROFILE_TOOLS.filter(tool => tool.name === 'read_file'));
97
122
 
123
+ /**
124
+ * A NUL byte, built rather than written, so this source file stays plain text
125
+ * that a diff and a code review can read.
126
+ */
127
+ const NUL = String.fromCharCode(0);
128
+
129
+ /** How many bytes one `write_file` may put on disk. */
130
+ export const MAX_WRITE_BYTES = 200000;
131
+
132
+ /** How many distinct paths one run may write. A fix is a patch, not a rewrite. */
133
+ export const MAX_WRITES_PER_RUN = 20;
134
+
135
+ /**
136
+ * The two writing tools, defined here so nothing else can invent a third.
137
+ *
138
+ * `edit_file` exists because whole-file writes do not survive contact with a
139
+ * real codebase: the first production fix attempt read an 84 KB C++ file and
140
+ * then could not send it back inside the step's output ceiling, so its one
141
+ * write arrived as malformed input and the attempt ended with nothing on the
142
+ * branch. An exact-text replacement is a few hundred bytes for the same edit.
143
+ */
144
+ export const EDIT_FILE_TOOL = Object.freeze({
145
+ name: 'edit_file',
146
+ description: 'Replace one exact passage of a text file in the repository with new text. Paths are relative to the repository root. old_text must appear exactly once in the file (or set replace_all for every occurrence); copy it verbatim from read_file, whitespace included. This is the tool for changing a file that already exists.',
147
+ input_schema: {
148
+ type: 'object',
149
+ additionalProperties: false,
150
+ required: ['path', 'old_text', 'new_text'],
151
+ properties: {
152
+ path: { type: 'string' },
153
+ old_text: { type: 'string' },
154
+ new_text: { type: 'string' },
155
+ replace_all: { type: 'boolean' }
156
+ }
157
+ }
158
+ });
159
+
160
+ export const WRITE_FILE_TOOL = Object.freeze({
161
+ name: 'write_file',
162
+ description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root. Use it for a new file or a small one; for a change to an existing file use edit_file, because the content you send here is the whole file.',
163
+ input_schema: {
164
+ type: 'object',
165
+ additionalProperties: false,
166
+ required: ['path', 'content'],
167
+ properties: {
168
+ path: { type: 'string' },
169
+ content: { type: 'string' }
170
+ }
171
+ }
172
+ });
173
+
174
+ /** The tool definitions for `fix.apply`: the read-only three, plus the two writes. */
175
+ export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, EDIT_FILE_TOOL, WRITE_FILE_TOOL]);
176
+
98
177
  /**
99
178
  * Compile a glob into a regular expression. Supports `**`, `*` and `?`,
100
179
  * which covers every pattern the profile skill actually asks for
@@ -133,6 +212,58 @@ export function globToRegExp(pattern) {
133
212
  return new RegExp(`^${source}$`);
134
213
  }
135
214
 
215
+ /**
216
+ * Whether a path lives inside a directory the walk never enters.
217
+ *
218
+ * `listFiles` already skips these, so a read of one answers nothing useful;
219
+ * a write to one is worse than useless. `.git` is the reason this check exists
220
+ * at all: a model that can write into `.git` can rewrite history without ever
221
+ * running git, and this CLI never rewrites history.
222
+ *
223
+ * Two rules, not one:
224
+ *
225
+ * - The names are compared **case insensitively**, because macOS and Windows
226
+ * filesystems are. `.GIT/hooks/pre-commit` is `.git/hooks/pre-commit` on the
227
+ * developer's disk, and a case-sensitive check let exactly that through:
228
+ * the hook was written, and the diff then reported no change, so the unit
229
+ * was refunded while the repository had been modified.
230
+ * - `.git` and `.ravensight` are refused at **any** position, the last one
231
+ * included. A file called `.git` is a gitfile whose contents repoint the
232
+ * repository at another directory, so writing one is as bad as writing
233
+ * inside `.git` itself. Every other name only matters as a directory: a
234
+ * source file called `build` is ordinary.
235
+ *
236
+ * @param {string} relativePath
237
+ * @returns {string|null} the offending component, or null
238
+ */
239
+ export function skippedDirIn(relativePath) {
240
+ const raw = String(relativePath).split(path.sep).join('/');
241
+ // A trailing slash means the caller is naming a directory, which is how
242
+ // `git status --porcelain` reports an untracked one (`?? node_modules/`).
243
+ // Then every segment is a directory; otherwise the last one is a file name.
244
+ const namesADirectory = raw.endsWith('/');
245
+ const segments = raw.split('/').filter(Boolean);
246
+ const lowered = segments.map(segment => segment.toLowerCase());
247
+
248
+ const always = lowered.findIndex(segment => NEVER_TOUCHED.includes(segment));
249
+ if (always !== -1) return segments[always];
250
+
251
+ const directories = namesADirectory ? lowered : lowered.slice(0, -1);
252
+ const at = directories.findIndex(segment => SKIP_DIRS_LOWER.includes(segment));
253
+ return at === -1 ? null : segments[at];
254
+ }
255
+
256
+ /**
257
+ * Whether one directory name is one the walk skips. Used by `listFiles`, which
258
+ * has the name on its own and needs the same case rules.
259
+ *
260
+ * @param {string} name
261
+ * @returns {boolean}
262
+ */
263
+ export function isSkippedDirName(name) {
264
+ return SKIP_DIRS_LOWER.includes(String(name).toLowerCase());
265
+ }
266
+
136
267
  /**
137
268
  * @param {string} relativePath
138
269
  * @returns {boolean}
@@ -144,7 +275,11 @@ export function isSecretPath(relativePath) {
144
275
 
145
276
  /**
146
277
  * Resolve a model-supplied path against the root, refusing anything that
147
- * escapes it.
278
+ * escapes it **lexically**.
279
+ *
280
+ * This is the cheap half of the check and not the whole of it: `../` and an
281
+ * absolute path are caught here, a symbolic link is not. Every caller that
282
+ * then touches the filesystem goes through `resolveInsideReal` as well.
148
283
  *
149
284
  * @param {string} root
150
285
  * @param {string} candidate
@@ -159,6 +294,54 @@ export function resolveInside(root, candidate) {
159
294
  return target;
160
295
  }
161
296
 
297
+ /**
298
+ * The other half: refuse a path that leaves the repository through a link.
299
+ *
300
+ * `resolveInside` compares strings, and a string comparison cannot see that
301
+ * `shared/` is a link to somewhere else entirely. A repository with such a
302
+ * link, which is ordinary in a monorepo or a shared-asset layout, would
303
+ * otherwise let a write land outside the tree with every lexical check
304
+ * passing. So every component from the root down that already exists is
305
+ * checked with `lstat` and refused by name if it is a link, the target
306
+ * included; components that do not exist yet are safe, because `mkdir` creates
307
+ * real directories and never links. The deepest component that does exist is
308
+ * then compared against the root's own real path, which also covers a root
309
+ * reached through a link itself (every temp directory on macOS is one).
310
+ *
311
+ * @param {string} root
312
+ * @param {string} candidate
313
+ * @returns {Promise<{absolute: string, relative: string}>}
314
+ */
315
+ export async function resolveInsideReal(root, candidate) {
316
+ const base = path.resolve(root);
317
+ const target = resolveInside(base, candidate);
318
+ const realRoot = await realpath(base);
319
+
320
+ const parts = path.relative(base, target).split(path.sep).filter(Boolean);
321
+ let walked = base;
322
+ for (const part of parts) {
323
+ const next = path.join(walked, part);
324
+ let info;
325
+ try {
326
+ info = await lstat(next);
327
+ } catch {
328
+ // This component does not exist yet, so nothing below it does either.
329
+ break;
330
+ }
331
+ if (info.isSymbolicLink()) {
332
+ const named = path.relative(base, next).split(path.sep).join('/');
333
+ throw new Error(`${candidate} goes through ${named}, which is a symbolic link, and a link can point out of the repository`);
334
+ }
335
+ walked = next;
336
+ }
337
+
338
+ const real = await realpath(walked);
339
+ if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
340
+ throw new Error(`${candidate} resolves outside the repository`);
341
+ }
342
+ return { absolute: target, relative: path.relative(base, target).split(path.sep).join('/') };
343
+ }
344
+
162
345
  /**
163
346
  * @param {string} root
164
347
  * @param {Object} [opts]
@@ -180,7 +363,7 @@ export async function listFiles(root, { limit = 20000 } = {}) {
180
363
  if (entry.isSymbolicLink()) continue;
181
364
  const absolute = path.join(dir, entry.name);
182
365
  if (entry.isDirectory()) {
183
- if (SKIP_DIRS.includes(entry.name)) continue;
366
+ if (isSkippedDirName(entry.name) || NEVER_TOUCHED.includes(entry.name.toLowerCase())) continue;
184
367
  await walk(absolute);
185
368
  continue;
186
369
  }
@@ -200,6 +383,9 @@ export async function listFiles(root, { limit = 20000 } = {}) {
200
383
  */
201
384
  export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
202
385
  let cachedFiles = null;
386
+ const canWrite = tools.some(tool => tool.name === 'write_file');
387
+ /** The distinct paths this run has written, which is what the cap counts. */
388
+ const written = new Set();
203
389
 
204
390
  async function files() {
205
391
  if (!cachedFiles) cachedFiles = await listFiles(root);
@@ -209,6 +395,11 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
209
395
  return {
210
396
  tools,
211
397
 
398
+ /** Repository-relative paths this tool set has written, in write order. */
399
+ get written() {
400
+ return [...written];
401
+ },
402
+
212
403
  /**
213
404
  * @param {Object} toolUse
214
405
  */
@@ -216,6 +407,25 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
216
407
  const input = toolUse.input || {};
217
408
  try {
218
409
  if (toolUse.name === 'read_file') return { result: ok(toolUse.id, await doRead(root, input)) };
410
+ if (toolUse.name === 'write_file' && canWrite) {
411
+ // A refusal comes back as an `is_error` result naming the rule,
412
+ // never as a thrown error: the model has to be able to read why and
413
+ // try a legal write instead, and a thrown error would end the run.
414
+ const planned = await planWrite(root, input, written);
415
+ if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
416
+ await doWrite(planned.absolute, input.content);
417
+ written.add(planned.relative);
418
+ const left = MAX_WRITES_PER_RUN - written.size;
419
+ return { result: ok(toolUse.id, `Wrote ${planned.relative} (${planned.bytes} bytes). ${left} more file(s) may be written in this run.`) };
420
+ }
421
+ if (toolUse.name === 'edit_file' && canWrite) {
422
+ const planned = await planEdit(root, input, written);
423
+ if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
424
+ await doWrite(planned.absolute, planned.content);
425
+ written.add(planned.relative);
426
+ const left = MAX_WRITES_PER_RUN - written.size;
427
+ return { result: ok(toolUse.id, `Edited ${planned.relative}: ${planned.replaced} occurrence(s) replaced, now ${planned.bytes} bytes. ${left} more file(s) may be written in this run.`) };
428
+ }
219
429
  if (toolUse.name === 'glob') {
220
430
  const matcher = globToRegExp(input.pattern);
221
431
  const matched = (await files()).filter(file => matcher.test(file)).slice(0, MAX_GLOB_RESULTS);
@@ -231,11 +441,18 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
231
441
  }
232
442
 
233
443
  async function doRead(root, input) {
234
- const relative = String(input.path || '');
235
- if (isSecretPath(relative)) {
236
- return `Refused: ${relative} is a credential file and is never read. Note in warnings that credentials are checked into the repository, without quoting anything.`;
444
+ const asked = String(input.path || '');
445
+ if (isSecretPath(asked)) {
446
+ return `Refused: ${asked} is a credential file and is never read. Note in warnings that credentials are checked into the repository, without quoting anything.`;
447
+ }
448
+ // The same real-path check the write path makes, for the same reason: a
449
+ // symlinked directory would otherwise let a read leave the repository, and
450
+ // `/etc/passwd` read through `shared/` is still a read of `/etc/passwd`.
451
+ const { absolute, relative } = await resolveInsideReal(root, asked);
452
+ const skipped = skippedDirIn(relative);
453
+ if (skipped) {
454
+ return `Refused: ${relative} is under ${skipped}, which is not part of the game's source.`;
237
455
  }
238
- const absolute = resolveInside(root, relative);
239
456
  const info = await stat(absolute);
240
457
  if (!info.isFile()) throw new Error(`${relative} is not a file`);
241
458
  const text = await readFile(absolute, 'utf8');
@@ -253,6 +470,131 @@ async function doRead(root, input) {
253
470
  return slice.length > MAX_READ_BYTES ? `${slice.slice(0, MAX_READ_BYTES)}\n... [truncated]` : slice;
254
471
  }
255
472
 
473
+ /**
474
+ * Every rule a `write_file` has to pass, and the sentence that names the one
475
+ * it failed. The rules are checked in the order that is cheapest to answer
476
+ * and hardest to argue with: where the file is, then what is in it, then how
477
+ * many files this run has already touched.
478
+ *
479
+ * The path is canonicalised before any rule is applied and before it counts
480
+ * against the twenty file cap, so `./src/a.gd`, `src/./a.gd` and
481
+ * `src/ui/../a.gd` are one file rather than three.
482
+ *
483
+ * @param {string} root
484
+ * @param {Object} input the model's tool input
485
+ * @param {Set<string>} written the distinct paths written so far
486
+ * @returns {Promise<{relative: string, absolute: string, bytes: number}|{refusal: string}>}
487
+ */
488
+ export async function planWrite(root, input, written = new Set()) {
489
+ const asked = String((input && input.path) || '').trim();
490
+ if (!asked) return { refusal: 'Refused: write_file needs a path relative to the repository root.' };
491
+
492
+ let absolute;
493
+ let relative;
494
+ try {
495
+ // The real check rather than the lexical one: a link is how a write leaves
496
+ // a tree that every string comparison says it is inside.
497
+ ({ absolute, relative } = await resolveInsideReal(root, asked));
498
+ } catch (error) {
499
+ return { refusal: `Refused: ${String(error && error.message ? error.message : error)}. A fix only ever writes inside the repository itself.` };
500
+ }
501
+ if (!relative) return { refusal: 'Refused: that is the repository root, not a file in it.' };
502
+
503
+ if (isSecretPath(relative)) {
504
+ return { refusal: `Refused: ${relative} is a credential file, and those are never read or written.` };
505
+ }
506
+ const skipped = skippedDirIn(relative);
507
+ if (skipped) {
508
+ return { refusal: `Refused: ${relative} is under ${skipped}, which is generated, vendored or this tool's own bookkeeping, and is never written.` };
509
+ }
510
+
511
+ const content = input && input.content;
512
+ if (typeof content !== 'string') {
513
+ return { refusal: 'Refused: content has to be a string. Send the whole file as text.' };
514
+ }
515
+ if (content.includes(NUL)) {
516
+ return { refusal: 'Refused: content holds a NUL byte, so that is not a text file. Only text files are written.' };
517
+ }
518
+ const bytes = Buffer.byteLength(content, 'utf8');
519
+ if (bytes > MAX_WRITE_BYTES) {
520
+ return { refusal: `Refused: ${relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}. Change less of it, or change a smaller file.` };
521
+ }
522
+ if (!written.has(relative) && written.size >= MAX_WRITES_PER_RUN) {
523
+ return { refusal: `Refused: this run has already written ${written.size} files and may write at most ${MAX_WRITES_PER_RUN}. Finish with the files you have already changed.` };
524
+ }
525
+ return { relative, absolute, bytes };
526
+ }
527
+
528
+ /**
529
+ * Temp file then rename, in the target's own directory, so an interrupted
530
+ * write leaves the developer's file exactly as it was rather than half of a
531
+ * new one. Rename inside one directory is atomic everywhere this CLI runs.
532
+ *
533
+ * @param {string} absolute
534
+ * @param {string} content
535
+ */
536
+ /**
537
+ * Everything `planWrite` checks about the path, then the replacement itself:
538
+ * the file has to exist and be text, `old_text` has to be found exactly once
539
+ * unless `replace_all`, and the result has to fit the same byte ceiling a
540
+ * whole write does. Refusals name the count found, because "not found" and
541
+ * "found three times" call for different next moves.
542
+ *
543
+ * @param {string} root
544
+ * @param {Object} input
545
+ * @param {Set<string>} written
546
+ * @returns {Promise<{refusal: string}|{relative: string, absolute: string, content: string, bytes: number, replaced: number}>}
547
+ */
548
+ export async function planEdit(root, input, written = new Set()) {
549
+ const oldText = input && input.old_text;
550
+ const newText = input && input.new_text;
551
+ if (typeof oldText !== 'string' || oldText.length === 0) {
552
+ return { refusal: 'Refused: edit_file needs old_text, the exact passage to replace, copied verbatim from read_file.' };
553
+ }
554
+ if (typeof newText !== 'string') return { refusal: 'Refused: new_text has to be a string (it may be empty to delete the passage).' };
555
+ // The path rules are the write rules; the placeholder content only has to be
556
+ // a string so the shared checks run.
557
+ const planned = await planWrite(root, { path: input && input.path, content: '' }, written);
558
+ if (planned.refusal) return { refusal: planned.refusal.replace('write_file', 'edit_file') };
559
+
560
+ let current;
561
+ try {
562
+ current = await readFile(planned.absolute, 'utf8');
563
+ } catch {
564
+ return { refusal: `Refused: ${planned.relative} does not exist yet. Use write_file to create a new file.` };
565
+ }
566
+ if (current.includes(NUL)) return { refusal: `Refused: ${planned.relative} is not a text file.` };
567
+
568
+ const occurrences = current.split(oldText).length - 1;
569
+ if (occurrences === 0) {
570
+ return { refusal: `Refused: old_text was not found in ${planned.relative}. Read the file again and copy the passage exactly, whitespace included.` };
571
+ }
572
+ if (occurrences > 1 && !(input && input.replace_all)) {
573
+ return { refusal: `Refused: old_text appears ${occurrences} times in ${planned.relative}. Include more surrounding lines so it is unique, or set replace_all.` };
574
+ }
575
+ const content = input && input.replace_all ? current.split(oldText).join(newText) : current.replace(oldText, () => newText);
576
+ if (content.includes(NUL)) return { refusal: 'Refused: new_text holds a NUL byte, so that is not text.' };
577
+ const bytes = Buffer.byteLength(content, 'utf8');
578
+ if (bytes > MAX_WRITE_BYTES) {
579
+ return { refusal: `Refused: ${planned.relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}.` };
580
+ }
581
+ return { relative: planned.relative, absolute: planned.absolute, content, bytes, replaced: input && input.replace_all ? occurrences : 1 };
582
+ }
583
+
584
+ async function doWrite(absolute, content) {
585
+ await mkdir(path.dirname(absolute), { recursive: true });
586
+ const tmp = `${absolute}.${process.pid}.${randomUUID()}.tmp`;
587
+ await writeFile(tmp, content, 'utf8');
588
+ try {
589
+ await rename(tmp, absolute);
590
+ } catch (error) {
591
+ // A failed rename would otherwise leave the half-named temp file in the
592
+ // developer's source tree, where the next glob would find it.
593
+ await unlink(tmp).catch(() => {});
594
+ throw error;
595
+ }
596
+ }
597
+
256
598
  async function doGrep(root, all, input) {
257
599
  const regex = new RegExp(String(input.pattern), 'i');
258
600
  const matcher = input.glob ? globToRegExp(input.glob) : null;
@@ -276,11 +618,30 @@ async function doGrep(root, all, input) {
276
618
  if (regex.test(lines[i])) hits.push(`${relative}:${i + 1}: ${lines[i].trim().slice(0, 300)}`);
277
619
  }
278
620
  }
279
- return hits.length > 0 ? hits.join('\n') : '(no matches)';
621
+ if (hits.length === 0) return '(no matches)';
622
+ const capped = hits.length >= MAX_GREP_RESULTS;
623
+ return capped
624
+ ? `${hits.join('\n')}\n(stopped at ${MAX_GREP_RESULTS} matches; narrow the pattern or add a glob)`
625
+ : hits.join('\n');
280
626
  }
281
627
 
282
628
  function ok(id, text, isError = false) {
283
629
  return { type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }], ...(isError ? { is_error: true } : {}) };
284
630
  }
285
631
 
286
- export default { createRepoTools, PROFILE_TOOLS, READ_ONLY_TOOLS, globToRegExp, listFiles, resolveInside, isSecretPath };
632
+ export default {
633
+ createRepoTools,
634
+ PROFILE_TOOLS,
635
+ READ_ONLY_TOOLS,
636
+ WRITE_TOOLS,
637
+ EDIT_FILE_TOOL,
638
+ planEdit,
639
+ globToRegExp,
640
+ listFiles,
641
+ planWrite,
642
+ resolveInside,
643
+ resolveInsideReal,
644
+ isSecretPath,
645
+ isSkippedDirName,
646
+ skippedDirIn
647
+ };