jeo-code 0.12.25 → 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,11 +12,31 @@
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
- import { constants } from "node:fs";
36
+ import { constants, realpathSync } from "node:fs";
17
37
  import type { BigIntStats, Dirent } from "node:fs";
18
38
  import type { FileHandle } from "node:fs/promises";
39
+ import * as os from "node:os";
19
40
  import * as path from "node:path";
20
41
 
21
42
  export interface WorkspaceSnapshotLimits {
@@ -25,7 +46,8 @@ export interface WorkspaceSnapshotLimits {
25
46
  maxMs: number;
26
47
  /** Files with mtime within this window of the snapshot start are content-hashed too. */
27
48
  racyWindowMs: number;
28
- /** 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). */
29
51
  racyMaxFileBytes: number;
30
52
  racyMaxTotalBytes: number;
31
53
  }
@@ -42,7 +64,14 @@ export const WORKSPACE_SNAPSHOT_LIMITS: Readonly<WorkspaceSnapshotLimits> = Obje
42
64
  export const WORKSPACE_SNAPSHOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set([".git", ".jeo", "node_modules"]);
43
65
 
44
66
  export type WorkspaceSnapshot =
45
- | { ok: true; digest: bigint; entries: number; elapsedMs: number; startedAtMs: number }
67
+ | {
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;
71
+ /** Relative path -> entry fingerprint, so a mismatch can name WHICH paths changed
72
+ * (`snapshotChangedPaths`). Optional so hand-built snapshots and mocks stay valid. */
73
+ paths?: ReadonlyMap<string, bigint>;
74
+ }
46
75
  | { ok: false; reason: "error" | "cap"; detail: string };
47
76
 
48
77
  /** `racySinceMs`: anchor for the racy content-hash predicate. The engine passes the BEFORE snapshot's
@@ -75,6 +104,8 @@ async function chunked<T>(items: readonly T[], run: (item: T) => Promise<void>):
75
104
 
76
105
  interface Dir { rel: string; abs: string }
77
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 }
78
109
 
79
110
  export async function snapshotWorkspace(
80
111
  cwd: string,
@@ -87,26 +118,31 @@ export async function snapshotWorkspace(
87
118
  const racyThresholdNs = BigInt(Math.floor(anchorMs - limits.racyWindowMs)) * 1_000_000n;
88
119
  let digest = 0n;
89
120
  let entries = 0;
90
- let racyBytes = 0;
121
+ let degraded = 0;
91
122
 
92
- const record = (entry: string): void => { digest = (digest + Bun.hash.wyhash(entry)) & DIGEST_MASK; };
123
+ const paths = new Map<string, bigint>();
124
+ const record = (entry: string, rel: string): void => {
125
+ const hash = BigInt(Bun.hash.wyhash(entry));
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;
129
+ paths.set(rel, hash);
130
+ };
93
131
  const checkCaps = (): void => {
94
132
  if (entries > limits.maxEntries) stop("cap", `workspace has more than ${limits.maxEntries} entries`);
95
133
  if (performance.now() - startedAt > limits.maxMs) stop("cap", `workspace snapshot exceeded ${limits.maxMs} ms`);
96
134
  };
97
135
 
98
- /** Bounded read of a regular file's content; `null` when it vanished before it could be read. */
99
- 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"> => {
100
139
  const size = Number(stat.size);
101
- if (size > limits.racyMaxFileBytes) stop("error", `racy file too large: ${leaf.rel} (${size} bytes)`);
102
- racyBytes += size;
103
- if (racyBytes > limits.racyMaxTotalBytes) stop("error", `racy files too large: more than ${limits.racyMaxTotalBytes} bytes`);
104
140
  let handle: FileHandle;
105
141
  try { handle = await fs.open(leaf.abs, RACY_OPEN_FLAGS); }
106
- catch (error) { if (vanished(error)) return null; return stop("error", `cannot open ${leaf.rel}: ${errorMessage(error)}`); }
142
+ catch (error) { return vanished(error) ? "gone" : "unhashed"; }
107
143
  try {
108
144
  const opened = await handle.stat({ bigint: true });
109
- 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";
110
146
  // One spare byte detects growth after lstat without reading past the cap.
111
147
  const buffer = new Uint8Array(size + 1);
112
148
  let read = 0;
@@ -114,14 +150,39 @@ export async function snapshotWorkspace(
114
150
  const { bytesRead } = await handle.read(buffer, read, buffer.length - read, read);
115
151
  if (bytesRead === 0) break;
116
152
  read += bytesRead;
117
- if (read > size) stop("error", `file changed during workspace snapshot: ${leaf.rel}`);
153
+ if (read > size) return "unhashed";
118
154
  }
119
155
  return Bun.hash.wyhash(buffer.subarray(0, read));
156
+ } catch {
157
+ return "unhashed";
120
158
  } finally {
121
159
  await handle.close().catch(() => {});
122
160
  }
123
161
  };
124
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
+
125
186
  const fingerprint = async (leaf: Leaf, queue: Dir[]): Promise<void> => {
126
187
  let stat: BigIntStats;
127
188
  try { stat = await fs.lstat(leaf.abs, { bigint: true }); }
@@ -132,7 +193,7 @@ export async function snapshotWorkspace(
132
193
  if (stat.isDirectory()) {
133
194
  // Unreported or raced dirent type: a real directory is never fingerprinted by metadata.
134
195
  if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(leaf.dirent.name)) { entries -= 1; return; }
135
- record(`d:${leaf.rel}`);
196
+ record(`d:${leaf.rel}`, leaf.rel);
136
197
  queue.push(leaf);
137
198
  return;
138
199
  }
@@ -145,19 +206,32 @@ export async function snapshotWorkspace(
145
206
  return stop("error", `cannot read link ${leaf.rel}: ${errorMessage(error)}`);
146
207
  }
147
208
  } else if (kind === "f" && leaf.dirent.isFile() && stat.mtimeNs >= racyThresholdNs) {
148
- const hash = await racyHash(leaf, stat);
149
- if (hash === null) { entries -= 1; return; }
150
- entry += `:h${hash}`;
209
+ racy.push({ leaf, stat, entry, hashable: false });
210
+ return;
151
211
  }
152
- record(entry);
212
+ record(entry, leaf.rel);
153
213
  };
154
214
 
155
215
  const list = async (dir: Dir, next: Dir[], leaves: Leaf[]): Promise<void> => {
156
216
  let dirents: Dirent[];
157
217
  try { dirents = await fs.readdir(dir.abs, { withFileTypes: true }); }
158
218
  catch (error) {
159
- if (dir.rel !== "" && vanished(error)) return;
160
- 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)}`);
161
235
  }
162
236
  for (const dirent of dirents) {
163
237
  const rel = dir.rel ? `${dir.rel}/${dirent.name}` : dirent.name;
@@ -165,7 +239,7 @@ export async function snapshotWorkspace(
165
239
  if (dirent.isDirectory()) {
166
240
  if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(dirent.name)) continue;
167
241
  entries += 1;
168
- record(`d:${rel}`);
242
+ record(`d:${rel}`, rel);
169
243
  next.push(child);
170
244
  } else {
171
245
  entries += 1;
@@ -192,7 +266,9 @@ export async function snapshotWorkspace(
192
266
  }
193
267
  level = next;
194
268
  }
195
- return { ok: true, digest, entries, elapsedMs: performance.now() - startedAt, startedAtMs };
269
+ await hashRacy();
270
+ checkCaps();
271
+ return { ok: true, digest, entries, degraded, elapsedMs: performance.now() - startedAt, startedAtMs, paths };
196
272
  } catch (error) {
197
273
  if (error instanceof SnapshotStop) return error.result;
198
274
  return { ok: false, reason: "error", detail: errorMessage(error) };
@@ -207,3 +283,66 @@ export function workspaceSnapshotChanged(before: WorkspaceSnapshot, after: Works
207
283
  if (!before.ok || !after.ok) return true;
208
284
  return before.digest !== after.digest || before.entries !== after.entries;
209
285
  }
286
+
287
+ /** Result of comparing two snapshots path by path (sorted, relative to the workspace root). */
288
+ export interface SnapshotPathChange {
289
+ added: string[];
290
+ removed: string[];
291
+ modified: string[];
292
+ }
293
+
294
+ /**
295
+ * Paths that differ between two successful snapshots. Null when either side cannot say (a failed
296
+ * snapshot, or a hand-built one without a per-path map) — callers then fall back to the bare
297
+ * digest verdict. Directories are reported by name too; their mtimes are not fingerprinted.
298
+ */
299
+ export function snapshotChangedPaths(before: WorkspaceSnapshot, after: WorkspaceSnapshot): SnapshotPathChange | null {
300
+ if (!before.ok || !after.ok || !before.paths || !after.paths) return null;
301
+ const added: string[] = [];
302
+ const modified: string[] = [];
303
+ const removed: string[] = [];
304
+ for (const [rel, hash] of after.paths) {
305
+ const prev = before.paths.get(rel);
306
+ if (prev === undefined) added.push(rel);
307
+ else if (prev !== hash) modified.push(rel);
308
+ }
309
+ for (const rel of before.paths.keys()) if (!after.paths.has(rel)) removed.push(rel);
310
+ return { added: added.sort(), removed: removed.sort(), modified: modified.sort() };
311
+ }
312
+
313
+ /** `modified a/b.json; added c.txt (+3 more)` — at most `limit` paths, for notices and gate messages. */
314
+ export function formatSnapshotChange(change: SnapshotPathChange, limit = 5): string {
315
+ const items = [
316
+ ...change.modified.map(p => `modified ${p}`),
317
+ ...change.added.map(p => `added ${p}`),
318
+ ...change.removed.map(p => `removed ${p}`),
319
+ ];
320
+ if (items.length === 0) return "no path-level difference";
321
+ const shown = items.slice(0, limit).join("; ");
322
+ return items.length > limit ? `${shown} (+${items.length - limit} more)` : shown;
323
+ }
324
+
325
+ const realOrResolved = (target: string): string => {
326
+ try { return realpathSync(target); } catch { return path.resolve(target); }
327
+ };
328
+
329
+ function accountHomedir(): string {
330
+ try { return os.userInfo().homedir; } catch { return os.homedir(); }
331
+ }
332
+
333
+ function defaultScratchRoots(cwd: string): string[] {
334
+ // `os.homedir()` / `os.tmpdir()` follow $HOME / $TMPDIR, so the account's real home is listed too.
335
+ return [os.homedir(), accountHomedir(), path.parse(path.resolve(cwd)).root, os.tmpdir(), "/tmp", "/var/tmp"];
336
+ }
337
+
338
+ /**
339
+ * True when `cwd` IS a shared scratch root — `$HOME`, the filesystem root, or a system temp directory —
340
+ * rather than a directory inside one. Those roots are written by unrelated processes (daemon heartbeats,
341
+ * logs, other agents), are often too large or partly unreadable to snapshot, and hold no project whose
342
+ * verifier could ever run, so a snapshot diff there cannot be attributed to one shell call. A project
343
+ * directory nested under them (a `mkdtemp` dir, `~/work/app`) is NOT a scratch root and stays gated.
344
+ */
345
+ export function isSharedScratchRoot(cwd: string, roots: readonly string[] = defaultScratchRoots(cwd)): boolean {
346
+ const here = realOrResolved(cwd);
347
+ return roots.some(root => realOrResolved(root) === here);
348
+ }
package/src/ai/types.ts CHANGED
@@ -81,6 +81,18 @@ export interface ToolResultRecord {
81
81
  id: string;
82
82
  output: string;
83
83
  isError: boolean;
84
+ /**
85
+ * Host-side workspace evidence for a shell/computer call. Never sent to a provider; persisted with
86
+ * the transcript so a completion-gate rejection can be audited afterwards. `mutation` is whether the
87
+ * snapshot bracket counted the call as a possible file change, `changed` names the paths that differed,
88
+ * and `inconclusive` is why the bracket could not measure (cap or error; counted as a change). `skipped: "scratch"`
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.
94
+ */
95
+ workspace?: { mutation: boolean; changed?: string; inconclusive?: string; skipped?: "scratch"; target?: string };
84
96
  }
85
97
 
86
98
  export interface Usage {
@@ -3,6 +3,7 @@ import { emitKeypressEvents } from "node:readline";
3
3
  import { StringDecoder } from "node:string_decoder";
4
4
  import { PassThrough } from "node:stream";
5
5
  import { createHash } from "node:crypto";
6
+ import { MUTATION_NUDGE_PREFIX } from "../agent/loop-guards";
6
7
  import { runAgentLoop, DEFAULT_TOOLS, SANDBOX_MEDIATED_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, KARPATHY_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, isSafetyFallbackBail, stripSafetyFallbackTag, type AgentLoopEvents } from "../agent/engine";
7
8
  import { computerSupervisor } from "../agent/computer-supervisor";
8
9
  import { executeComputerAction } from "./computer";
@@ -2001,7 +2002,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
2001
2002
  const carriedPending = turnPendingMutation || (goalState !== null && pendingMutationVerification);
2002
2003
  if ((carriedPending || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
2003
2004
  return (
2004
- `Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification. ` +
2005
+ `${MUTATION_NUDGE_PREFIX} ` +
2005
2006
  `Run a relevant test/build/typecheck/lint after the last change (e.g. 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done again.`
2006
2007
  );
2007
2008
  }