jeo-code 0.12.26 → 0.12.27

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Which directory the engine's before/after workspace snapshot should measure for ONE shell call.
3
+ *
4
+ * A shared scratch cwd ($HOME, `/`, a system temp dir) is never bracketed itself: unrelated processes write there, so
5
+ * a digest change cannot be blamed on the call. But a model often sits in such a cwd and edits a real project
6
+ * through it (`cd ~/work/app && sed -i …`, or a bash `cwd`/`subdir` argument), and that edit would otherwise be
7
+ * invisible to the done gate. This resolves the PROJECT the call is aimed at (the outermost directory with a project
8
+ * marker above the target, so `cd app/pkg && echo X > ../src/a.ts` measures `app`) so the bracket can measure it.
9
+ *
10
+ * Everything here fails SAFE: an unresolvable, nonexistent, scratch, marker-less or ambiguous target means "no nested
11
+ * target" (the caller keeps today's scratch skip), and nothing throws.
12
+ */
13
+ import { existsSync, realpathSync, statSync } from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+ import { isSharedScratchRoot } from "./workspace-snapshot";
17
+
18
+ /** Leading `cd a && cd b; …` hops followed at most this far; anything beyond is simply not modelled. */
19
+ const MAX_CD_HOPS = 8;
20
+
21
+ /** How far above a target the project-marker search climbs before giving up. */
22
+ const MAX_MARKER_LEVELS = 12;
23
+
24
+ /**
25
+ * Files/dirs whose presence says "this is a project". A nested target is bracketed only when it (or an ancestor below
26
+ * the scratch root) has one: `cd ~/.cache && ls` or `cd /var/log && tail x` is ambient state, not a project, and a
27
+ * bracket there measures unrelated churn (or hits the entry cap) instead of the call's own writes. The OUTERMOST such
28
+ * ancestor is the bracketed root, because a write anywhere in the project (`../src/a.ts`) belongs to the same project.
29
+ */
30
+ const PROJECT_MARKERS: readonly string[] = [
31
+ ".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", "setup.py", "Makefile", "pom.xml",
32
+ "build.gradle", "build.gradle.kts", "Gemfile", "composer.json", "deno.json", "CMakeLists.txt", ".jeo",
33
+ ];
34
+
35
+ /**
36
+ * A leading literal `cd <path>` terminated by `&&`, `;` or a newline. The path is single-quoted, double-quoted
37
+ * without expansion characters, or a bare token drawn from a whitelist (so `$`, backticks, globs, braces, escapes
38
+ * and `||`/`|`/`&` never parse): when it does not match, the hop is not modelled rather than guessed.
39
+ */
40
+ const CD_HOP =
41
+ /^\s*cd[ \t]+(?:'([^']*)'|"([^"$`\\]*)"|([A-Za-z0-9_./~@%+:,][A-Za-z0-9_./~@%+:,-]*))[ \t]*(?:&&|;|\r?\n)/;
42
+
43
+ const realOrResolved = (target: string): string => {
44
+ try { return realpathSync(target); } catch { return path.resolve(target); }
45
+ };
46
+
47
+ const isDirectory = (target: string): boolean => {
48
+ try { return statSync(target).isDirectory(); } catch { return false; }
49
+ };
50
+
51
+ const accountHomedir = (): string => {
52
+ try { return os.userInfo().homedir; } catch { return os.homedir(); }
53
+ };
54
+
55
+ /** True when `child` is `parent` or lies inside it (both already resolved). */
56
+ const within = (parent: string, child: string): boolean => {
57
+ const rel = path.relative(parent, child);
58
+ return rel === "" || !(rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel));
59
+ };
60
+
61
+ /** The roots the climb must not enter and a bracketed root must not cover: the cwd plus the declared/default roots. */
62
+ function scratchRootList(cwd: string, scratchRoots: readonly string[] | undefined): string[] {
63
+ return scratchRoots
64
+ ? [cwd, ...scratchRoots]
65
+ : [cwd, os.homedir(), accountHomedir(), os.tmpdir(), "/tmp", "/var/tmp"];
66
+ }
67
+
68
+ /**
69
+ * True when `target` is a scratch root or CONTAINS one (`/Users` above `$HOME`, `/` above everything). Bracketing an
70
+ * ancestor of a scratch root walks the very tree that made the root unmeasurable (a read-only `cd .. && ls` would hit
71
+ * the entry cap and count as a mutation), so it is skipped exactly like the root itself.
72
+ */
73
+ function coversScratchRoot(target: string, roots: readonly string[]): boolean {
74
+ const here = realOrResolved(target);
75
+ return roots.some(root => within(here, realOrResolved(root)));
76
+ }
77
+
78
+ /**
79
+ * The OUTERMOST ancestor-or-self of `target` holding a project marker, or null. The climb stops BEFORE any scratch
80
+ * root (a `package.json` in `$HOME` must not turn every directory under it into a project) and at the filesystem
81
+ * root, and is bounded to MAX_MARKER_LEVELS. Outermost, not nearest: the deepest directory of a project (`app/pkg`,
82
+ * `/opt/homebrew/var/log`) sees only part of what a command can write, and a bracket on a log directory blames the call
83
+ * for its ambient writers; the project root is where an edit anywhere in the tree shows up.
84
+ */
85
+ function projectRoot(target: string, roots: readonly string[]): string | null {
86
+ let current = realOrResolved(target);
87
+ let root: string | null = null;
88
+ for (let level = 0; level < MAX_MARKER_LEVELS; level++) {
89
+ const parent = path.dirname(current);
90
+ if (parent === current || isSharedScratchRoot(current, roots)) break;
91
+ if (PROJECT_MARKERS.some(marker => existsSync(path.join(current, marker)))) root = current;
92
+ current = parent;
93
+ }
94
+ return root;
95
+ }
96
+
97
+ /** The `a.env` map bash receives, reduced to the string entries the tool actually forwards. */
98
+ function envValue(args: Record<string, any> | undefined, key: string): string | undefined {
99
+ const env = args?.env;
100
+ const own = env && typeof env === "object" && !Array.isArray(env) ? env[key] : undefined;
101
+ return typeof own === "string" ? own : process.env[key];
102
+ }
103
+
104
+ /**
105
+ * One `cd` hop from `dir`. Null when the hop cannot be resolved with certainty: `~user`, a relative path under a
106
+ * set `CDPATH` (bash would search elsewhere), or a target that is not an existing directory.
107
+ */
108
+ function applyHop(dir: string, raw: string, quoted: boolean, args: Record<string, any> | undefined): string | null {
109
+ let target = raw;
110
+ if (!quoted && raw.startsWith("~")) {
111
+ if (raw !== "~" && !raw.startsWith("~/")) return null;
112
+ const home = envValue(args, "HOME") || os.homedir();
113
+ target = path.join(home, raw.slice(1));
114
+ } else if (
115
+ !path.isAbsolute(raw) && !/^\.\.?(?:\/|$)/.test(raw) && (envValue(args, "CDPATH") ?? "") !== ""
116
+ ) {
117
+ return null;
118
+ }
119
+ const next = path.resolve(dir, target);
120
+ return isDirectory(next) ? next : null;
121
+ }
122
+
123
+ /**
124
+ * The directory the snapshot bracket should measure for this ONE call.
125
+ *
126
+ * - Default: `cwd`, unchanged. A non-scratch cwd always brackets itself (targets outside it are not followed), and
127
+ * so does every tool other than `bash`.
128
+ * - `bash` from a shared scratch cwd: resolve the effective run dir from `args.cwd ?? args.subdir` (the same
129
+ * precedence the bash tool uses), then follow a LEADING literal `cd <path> &&` / `cd <path>;` chain from there.
130
+ * The last existing directory reached is the TARGET; the bracketed directory is its project root (outermost
131
+ * ancestor-or-self with a marker), unless that root is a scratch root or contains one.
132
+ *
133
+ * Returns `cwd` itself (same string) when there is no nested target; any other return value is the absolute real path
134
+ * of the project root the caller should measure instead. A directory the command creates itself does not exist yet
135
+ * and so yields no target — an accepted gap, since guessing a path that may never exist would measure the wrong
136
+ * tree. A target with no project marker (see PROJECT_MARKERS) is not a project either, and is treated like no target.
137
+ */
138
+ export function snapshotTarget(
139
+ cwd: string,
140
+ tool: string,
141
+ args: Record<string, any> | undefined,
142
+ scratchRoots?: readonly string[],
143
+ ): string {
144
+ try {
145
+ if (tool !== "bash" || !isSharedScratchRoot(cwd, scratchRoots)) return cwd;
146
+ const sub = typeof args?.cwd === "string" ? args.cwd : typeof args?.subdir === "string" ? args.subdir : "";
147
+ let dir = sub ? path.resolve(cwd, sub) : cwd;
148
+ // The bash tool spawns in this directory; if it is missing the call fails before touching anything.
149
+ if (!isDirectory(dir)) return cwd;
150
+ let rest = String(args?.command ?? args?.cmd ?? "");
151
+ for (let hop = 0; hop < MAX_CD_HOPS; hop++) {
152
+ const m = CD_HOP.exec(rest);
153
+ if (!m) break;
154
+ const next = applyHop(dir, m[1] ?? m[2] ?? m[3], m[3] === undefined, args);
155
+ if (next === null) break;
156
+ dir = next;
157
+ rest = rest.slice(m[0].length);
158
+ }
159
+ const roots = scratchRootList(cwd, scratchRoots);
160
+ const root = projectRoot(dir, roots);
161
+ // The climb never enters a scratch root, but a marker above one (`/var/.git` over `/var/tmp`) still yields a
162
+ // root that covers it: that is as unmeasurable as the scratch root itself.
163
+ if (root === null || isSharedScratchRoot(root, roots) || coversScratchRoot(root, roots)) return cwd;
164
+ return root;
165
+ } catch {
166
+ return cwd;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * The form persisted as `workspace.target`: relative to `cwd` when the target lies inside it, else the fixed token
172
+ * `<outside cwd>` — an absolute path outside the workspace has no business in a transcript. Compared by real path, so
173
+ * a cwd reached through a symlink (macOS `/tmp`) still counts as containing its own subdirectories.
174
+ */
175
+ export function describeSnapshotTarget(cwd: string, target: string): string {
176
+ const base = realOrResolved(cwd);
177
+ const real = realOrResolved(target);
178
+ const rel = path.relative(base, real);
179
+ return rel !== "" && within(base, real) ? rel : "<outside cwd>";
180
+ }
181
+
182
+ /** Human-facing name for notices: the relative path inside `cwd`, otherwise only the directory's basename. */
183
+ export function snapshotTargetLabel(cwd: string, target: string): string {
184
+ const base = realOrResolved(cwd);
185
+ const real = realOrResolved(target);
186
+ const rel = path.relative(base, real);
187
+ return rel !== "" && within(base, real) ? rel : path.basename(real);
188
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Host-side workspace fingerprint. The engine brackets bash/computer calls with two snapshots and
3
- * counts the call as a mutation only when they differ (workspaceSnapshotChanged). Failures and caps
4
- * are reported as `ok:false`, which always counts as a mutation (safe side).
3
+ * counts the call as a mutation only when they differ (workspaceSnapshotChanged). A snapshot that
4
+ * cannot be taken is `ok:false` and always counts as a mutation (safe side): an unreadable ROOT, the
5
+ * entry/time caps, and any readdir/lstat/readlink error not listed under "degraded" below.
5
6
  *
6
7
  * Entry strings folded into an order-independent digest:
7
8
  * - directory: `d:<rel>` (presence only; directory mtimes are ignored so creating an excluded
@@ -11,6 +12,25 @@
11
12
  * - regular file with mtime >= anchor - racyWindowMs: `:h<wyhash(content)>` (racy-clean guard
12
13
  * against coarse timestamps; the after-snapshot reuses the before-snapshot's anchor)
13
14
  * - FIFO/socket/device: stat only, never opened
15
+ *
16
+ * Degraded entries keep the snapshot `ok:true`; `degraded` counts them. Both cases lose nothing
17
+ * attributable to a command, so they must not make every bash call look mutating:
18
+ * - a NON-root directory whose listing is denied (EACCES/EPERM): it is keyed
19
+ * `u:<rel>:<mode>:<ctimeNs>:<ino>` (lstat of the directory itself) instead of `d:<rel>` (replacing
20
+ * it in the digest and the path map) and its children are skipped. A same-user process CAN change
21
+ * such a directory (chmod it open, write, chmod it shut), but every entry creation/removal/rename
22
+ * inside it and every chmod updates the directory's own ctime, and the mode is recorded, so the
23
+ * marker flips; so does becoming listable. ENOENT/ENOTDIR (vanished) is skipped as before; any
24
+ * other readdir code stays a failure. Residual, verified only for the cases above: a change that
25
+ * leaves mode, ctime and inode identical (e.g. a rewrite of an existing child's CONTENT inside a
26
+ * `-wx` directory does not touch the directory's ctime) is not seen.
27
+ * - a racy regular file that is over racyMaxFileBytes, would push the hashed total over
28
+ * racyMaxTotalBytes, cannot be opened/read, or changes while being read: stat-only fingerprint plus
29
+ * `:x` (unhashed). Racy files are hashed after the walk in path order, first-fit against the total
30
+ * budget, so the before and after snapshots of an unchanged workspace hash the same files.
31
+ * Residual risk: a same-size rewrite of such a file inside one timestamp tick of the bracket anchor
32
+ * goes unseen. Nanosecond mtime/ctime (APFS, ext4) make that negligible; files within the caps are
33
+ * still content-hashed.
14
34
  */
15
35
  import * as fs from "node:fs/promises";
16
36
  import { constants, realpathSync } from "node:fs";
@@ -26,7 +46,8 @@ export interface WorkspaceSnapshotLimits {
26
46
  maxMs: number;
27
47
  /** Files with mtime within this window of the snapshot start are content-hashed too. */
28
48
  racyWindowMs: number;
29
- /** Per-file and per-snapshot byte caps for racy content hashing. */
49
+ /** Per-file and per-snapshot byte caps for racy content hashing. A file over either cap is not
50
+ * hashed: it degrades to a stat-only `:x` entry (the total bounds hashing work, first-fit). */
30
51
  racyMaxFileBytes: number;
31
52
  racyMaxTotalBytes: number;
32
53
  }
@@ -45,6 +66,8 @@ export const WORKSPACE_SNAPSHOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set([".
45
66
  export type WorkspaceSnapshot =
46
67
  | {
47
68
  ok: true; digest: bigint; entries: number; elapsedMs: number; startedAtMs: number;
69
+ /** Entries recorded without full fidelity (unlistable directory, unhashed racy file). */
70
+ degraded?: number;
48
71
  /** Relative path -> entry fingerprint, so a mismatch can name WHICH paths changed
49
72
  * (`snapshotChangedPaths`). Optional so hand-built snapshots and mocks stay valid. */
50
73
  paths?: ReadonlyMap<string, bigint>;
@@ -81,6 +104,8 @@ async function chunked<T>(items: readonly T[], run: (item: T) => Promise<void>):
81
104
 
82
105
  interface Dir { rel: string; abs: string }
83
106
  interface Leaf extends Dir { dirent: Dirent }
107
+ /** A racy file waiting for the post-walk hashing pass. */
108
+ interface RacyCandidate { leaf: Leaf; stat: BigIntStats; entry: string; hashable: boolean }
84
109
 
85
110
  export async function snapshotWorkspace(
86
111
  cwd: string,
@@ -93,12 +118,14 @@ export async function snapshotWorkspace(
93
118
  const racyThresholdNs = BigInt(Math.floor(anchorMs - limits.racyWindowMs)) * 1_000_000n;
94
119
  let digest = 0n;
95
120
  let entries = 0;
96
- let racyBytes = 0;
121
+ let degraded = 0;
97
122
 
98
123
  const paths = new Map<string, bigint>();
99
124
  const record = (entry: string, rel: string): void => {
100
125
  const hash = BigInt(Bun.hash.wyhash(entry));
101
- digest = (digest + hash) & DIGEST_MASK;
126
+ // A path recorded twice (the `u:` marker over its directory's `d:` entry) replaces the first, so
127
+ // digest stays the sum of the path map. BigInt `&` on a negative value wraps as two's complement.
128
+ digest = (digest - (paths.get(rel) ?? 0n) + hash) & DIGEST_MASK;
102
129
  paths.set(rel, hash);
103
130
  };
104
131
  const checkCaps = (): void => {
@@ -106,18 +133,16 @@ export async function snapshotWorkspace(
106
133
  if (performance.now() - startedAt > limits.maxMs) stop("cap", `workspace snapshot exceeded ${limits.maxMs} ms`);
107
134
  };
108
135
 
109
- /** Bounded read of a regular file's content; `null` when it vanished before it could be read. */
110
- const racyHash = async (leaf: Leaf, stat: BigIntStats): Promise<bigint | null> => {
136
+ /** Bounded content hash of a regular file. `"gone"`: vanished before it could be opened. `"unhashed"`:
137
+ * any other failure (permissions, swapped inode, grew or errored while read); the caller degrades. */
138
+ const racyHash = async (leaf: Leaf, stat: BigIntStats): Promise<bigint | "gone" | "unhashed"> => {
111
139
  const size = Number(stat.size);
112
- if (size > limits.racyMaxFileBytes) stop("error", `racy file too large: ${leaf.rel} (${size} bytes)`);
113
- racyBytes += size;
114
- if (racyBytes > limits.racyMaxTotalBytes) stop("error", `racy files too large: more than ${limits.racyMaxTotalBytes} bytes`);
115
140
  let handle: FileHandle;
116
141
  try { handle = await fs.open(leaf.abs, RACY_OPEN_FLAGS); }
117
- catch (error) { if (vanished(error)) return null; return stop("error", `cannot open ${leaf.rel}: ${errorMessage(error)}`); }
142
+ catch (error) { return vanished(error) ? "gone" : "unhashed"; }
118
143
  try {
119
144
  const opened = await handle.stat({ bigint: true });
120
- if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) stop("error", `file changed during workspace snapshot: ${leaf.rel}`);
145
+ if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) return "unhashed";
121
146
  // One spare byte detects growth after lstat without reading past the cap.
122
147
  const buffer = new Uint8Array(size + 1);
123
148
  let read = 0;
@@ -125,14 +150,39 @@ export async function snapshotWorkspace(
125
150
  const { bytesRead } = await handle.read(buffer, read, buffer.length - read, read);
126
151
  if (bytesRead === 0) break;
127
152
  read += bytesRead;
128
- if (read > size) stop("error", `file changed during workspace snapshot: ${leaf.rel}`);
153
+ if (read > size) return "unhashed";
129
154
  }
130
155
  return Bun.hash.wyhash(buffer.subarray(0, read));
156
+ } catch {
157
+ return "unhashed";
131
158
  } finally {
132
159
  await handle.close().catch(() => {});
133
160
  }
134
161
  };
135
162
 
163
+ /** Racy files are collected during the walk and hashed afterwards in path order (see header). */
164
+ const racy: RacyCandidate[] = [];
165
+ const hashRacy = async (): Promise<void> => {
166
+ racy.sort((a, b) => (a.leaf.rel < b.leaf.rel ? -1 : 1));
167
+ let racyBytes = 0;
168
+ for (const candidate of racy) {
169
+ const size = Number(candidate.stat.size);
170
+ candidate.hashable = size <= limits.racyMaxFileBytes && racyBytes + size <= limits.racyMaxTotalBytes;
171
+ if (candidate.hashable) racyBytes += size;
172
+ }
173
+ await chunked(racy, async candidate => {
174
+ checkCaps();
175
+ const hash = candidate.hashable ? await racyHash(candidate.leaf, candidate.stat) : "unhashed";
176
+ if (hash === "gone") { entries -= 1; return; }
177
+ if (hash === "unhashed") {
178
+ degraded += 1;
179
+ record(`${candidate.entry}:x`, candidate.leaf.rel);
180
+ return;
181
+ }
182
+ record(`${candidate.entry}:h${hash}`, candidate.leaf.rel);
183
+ });
184
+ };
185
+
136
186
  const fingerprint = async (leaf: Leaf, queue: Dir[]): Promise<void> => {
137
187
  let stat: BigIntStats;
138
188
  try { stat = await fs.lstat(leaf.abs, { bigint: true }); }
@@ -156,9 +206,8 @@ export async function snapshotWorkspace(
156
206
  return stop("error", `cannot read link ${leaf.rel}: ${errorMessage(error)}`);
157
207
  }
158
208
  } else if (kind === "f" && leaf.dirent.isFile() && stat.mtimeNs >= racyThresholdNs) {
159
- const hash = await racyHash(leaf, stat);
160
- if (hash === null) { entries -= 1; return; }
161
- entry += `:h${hash}`;
209
+ racy.push({ leaf, stat, entry, hashable: false });
210
+ return;
162
211
  }
163
212
  record(entry, leaf.rel);
164
213
  };
@@ -167,8 +216,22 @@ export async function snapshotWorkspace(
167
216
  let dirents: Dirent[];
168
217
  try { dirents = await fs.readdir(dir.abs, { withFileTypes: true }); }
169
218
  catch (error) {
170
- if (dir.rel !== "" && vanished(error)) return;
171
- return stop("error", `cannot read directory ${dir.rel || "."}: ${errorMessage(error)}`);
219
+ if (dir.rel === "") return stop("error", `cannot read directory .: ${errorMessage(error)}`);
220
+ if (vanished(error)) return;
221
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
222
+ if (code === "EACCES" || code === "EPERM") {
223
+ // chmod-open/write/chmod-shut leaves the same mode but a newer ctime, so identity must be part of the marker.
224
+ let stat: BigIntStats;
225
+ try { stat = await fs.lstat(dir.abs, { bigint: true }); }
226
+ catch (statError) {
227
+ if (vanished(statError)) return;
228
+ return stop("error", `cannot stat ${dir.rel}: ${errorMessage(statError)}`);
229
+ }
230
+ degraded += 1;
231
+ record(`u:${dir.rel}:${stat.mode}:${stat.ctimeNs}:${stat.ino}`, dir.rel);
232
+ return;
233
+ }
234
+ return stop("error", `cannot read directory ${dir.rel}: ${errorMessage(error)}`);
172
235
  }
173
236
  for (const dirent of dirents) {
174
237
  const rel = dir.rel ? `${dir.rel}/${dirent.name}` : dirent.name;
@@ -203,7 +266,9 @@ export async function snapshotWorkspace(
203
266
  }
204
267
  level = next;
205
268
  }
206
- return { ok: true, digest, entries, elapsedMs: performance.now() - startedAt, startedAtMs, paths };
269
+ await hashRacy();
270
+ checkCaps();
271
+ return { ok: true, digest, entries, degraded, elapsedMs: performance.now() - startedAt, startedAtMs, paths };
207
272
  } catch (error) {
208
273
  if (error instanceof SnapshotStop) return error.result;
209
274
  return { ok: false, reason: "error", detail: errorMessage(error) };
package/src/ai/types.ts CHANGED
@@ -86,9 +86,13 @@ export interface ToolResultRecord {
86
86
  * the transcript so a completion-gate rejection can be audited afterwards. `mutation` is whether the
87
87
  * snapshot bracket counted the call as a possible file change, `changed` names the paths that differed,
88
88
  * and `inconclusive` is why the bracket could not measure (cap or error; counted as a change). `skipped: "scratch"`
89
- * means the cwd is a shared scratch root, where calls are deliberately not bracketed (nothing was measured).
89
+ * means nothing was measured: the cwd is a shared scratch root where calls are deliberately not bracketed, or (with
90
+ * `target`) a nested project root was already unmeasurable BEFORE the call and failed open.
91
+ * `target` is set only when the bracket aimed at a nested project root instead of the cwd (a bash call from a
92
+ * scratch cwd; the outermost marker directory above the call's target): relative to the cwd when inside it, else
93
+ * the fixed token `<outside cwd>`; `changed` paths are relative to that directory.
90
94
  */
91
- workspace?: { mutation: boolean; changed?: string; inconclusive?: string; skipped?: "scratch" };
95
+ workspace?: { mutation: boolean; changed?: string; inconclusive?: string; skipped?: "scratch"; target?: string };
92
96
  }
93
97
 
94
98
  export interface Usage {