jeo-code 0.12.25 → 0.12.26

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.
@@ -13,9 +13,10 @@
13
13
  * - FIFO/socket/device: stat only, never opened
14
14
  */
15
15
  import * as fs from "node:fs/promises";
16
- import { constants } from "node:fs";
16
+ import { constants, realpathSync } from "node:fs";
17
17
  import type { BigIntStats, Dirent } from "node:fs";
18
18
  import type { FileHandle } from "node:fs/promises";
19
+ import * as os from "node:os";
19
20
  import * as path from "node:path";
20
21
 
21
22
  export interface WorkspaceSnapshotLimits {
@@ -42,7 +43,12 @@ export const WORKSPACE_SNAPSHOT_LIMITS: Readonly<WorkspaceSnapshotLimits> = Obje
42
43
  export const WORKSPACE_SNAPSHOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set([".git", ".jeo", "node_modules"]);
43
44
 
44
45
  export type WorkspaceSnapshot =
45
- | { ok: true; digest: bigint; entries: number; elapsedMs: number; startedAtMs: number }
46
+ | {
47
+ ok: true; digest: bigint; entries: number; elapsedMs: number; startedAtMs: number;
48
+ /** Relative path -> entry fingerprint, so a mismatch can name WHICH paths changed
49
+ * (`snapshotChangedPaths`). Optional so hand-built snapshots and mocks stay valid. */
50
+ paths?: ReadonlyMap<string, bigint>;
51
+ }
46
52
  | { ok: false; reason: "error" | "cap"; detail: string };
47
53
 
48
54
  /** `racySinceMs`: anchor for the racy content-hash predicate. The engine passes the BEFORE snapshot's
@@ -89,7 +95,12 @@ export async function snapshotWorkspace(
89
95
  let entries = 0;
90
96
  let racyBytes = 0;
91
97
 
92
- const record = (entry: string): void => { digest = (digest + Bun.hash.wyhash(entry)) & DIGEST_MASK; };
98
+ const paths = new Map<string, bigint>();
99
+ const record = (entry: string, rel: string): void => {
100
+ const hash = BigInt(Bun.hash.wyhash(entry));
101
+ digest = (digest + hash) & DIGEST_MASK;
102
+ paths.set(rel, hash);
103
+ };
93
104
  const checkCaps = (): void => {
94
105
  if (entries > limits.maxEntries) stop("cap", `workspace has more than ${limits.maxEntries} entries`);
95
106
  if (performance.now() - startedAt > limits.maxMs) stop("cap", `workspace snapshot exceeded ${limits.maxMs} ms`);
@@ -132,7 +143,7 @@ export async function snapshotWorkspace(
132
143
  if (stat.isDirectory()) {
133
144
  // Unreported or raced dirent type: a real directory is never fingerprinted by metadata.
134
145
  if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(leaf.dirent.name)) { entries -= 1; return; }
135
- record(`d:${leaf.rel}`);
146
+ record(`d:${leaf.rel}`, leaf.rel);
136
147
  queue.push(leaf);
137
148
  return;
138
149
  }
@@ -149,7 +160,7 @@ export async function snapshotWorkspace(
149
160
  if (hash === null) { entries -= 1; return; }
150
161
  entry += `:h${hash}`;
151
162
  }
152
- record(entry);
163
+ record(entry, leaf.rel);
153
164
  };
154
165
 
155
166
  const list = async (dir: Dir, next: Dir[], leaves: Leaf[]): Promise<void> => {
@@ -165,7 +176,7 @@ export async function snapshotWorkspace(
165
176
  if (dirent.isDirectory()) {
166
177
  if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(dirent.name)) continue;
167
178
  entries += 1;
168
- record(`d:${rel}`);
179
+ record(`d:${rel}`, rel);
169
180
  next.push(child);
170
181
  } else {
171
182
  entries += 1;
@@ -192,7 +203,7 @@ export async function snapshotWorkspace(
192
203
  }
193
204
  level = next;
194
205
  }
195
- return { ok: true, digest, entries, elapsedMs: performance.now() - startedAt, startedAtMs };
206
+ return { ok: true, digest, entries, elapsedMs: performance.now() - startedAt, startedAtMs, paths };
196
207
  } catch (error) {
197
208
  if (error instanceof SnapshotStop) return error.result;
198
209
  return { ok: false, reason: "error", detail: errorMessage(error) };
@@ -207,3 +218,66 @@ export function workspaceSnapshotChanged(before: WorkspaceSnapshot, after: Works
207
218
  if (!before.ok || !after.ok) return true;
208
219
  return before.digest !== after.digest || before.entries !== after.entries;
209
220
  }
221
+
222
+ /** Result of comparing two snapshots path by path (sorted, relative to the workspace root). */
223
+ export interface SnapshotPathChange {
224
+ added: string[];
225
+ removed: string[];
226
+ modified: string[];
227
+ }
228
+
229
+ /**
230
+ * Paths that differ between two successful snapshots. Null when either side cannot say (a failed
231
+ * snapshot, or a hand-built one without a per-path map) — callers then fall back to the bare
232
+ * digest verdict. Directories are reported by name too; their mtimes are not fingerprinted.
233
+ */
234
+ export function snapshotChangedPaths(before: WorkspaceSnapshot, after: WorkspaceSnapshot): SnapshotPathChange | null {
235
+ if (!before.ok || !after.ok || !before.paths || !after.paths) return null;
236
+ const added: string[] = [];
237
+ const modified: string[] = [];
238
+ const removed: string[] = [];
239
+ for (const [rel, hash] of after.paths) {
240
+ const prev = before.paths.get(rel);
241
+ if (prev === undefined) added.push(rel);
242
+ else if (prev !== hash) modified.push(rel);
243
+ }
244
+ for (const rel of before.paths.keys()) if (!after.paths.has(rel)) removed.push(rel);
245
+ return { added: added.sort(), removed: removed.sort(), modified: modified.sort() };
246
+ }
247
+
248
+ /** `modified a/b.json; added c.txt (+3 more)` — at most `limit` paths, for notices and gate messages. */
249
+ export function formatSnapshotChange(change: SnapshotPathChange, limit = 5): string {
250
+ const items = [
251
+ ...change.modified.map(p => `modified ${p}`),
252
+ ...change.added.map(p => `added ${p}`),
253
+ ...change.removed.map(p => `removed ${p}`),
254
+ ];
255
+ if (items.length === 0) return "no path-level difference";
256
+ const shown = items.slice(0, limit).join("; ");
257
+ return items.length > limit ? `${shown} (+${items.length - limit} more)` : shown;
258
+ }
259
+
260
+ const realOrResolved = (target: string): string => {
261
+ try { return realpathSync(target); } catch { return path.resolve(target); }
262
+ };
263
+
264
+ function accountHomedir(): string {
265
+ try { return os.userInfo().homedir; } catch { return os.homedir(); }
266
+ }
267
+
268
+ function defaultScratchRoots(cwd: string): string[] {
269
+ // `os.homedir()` / `os.tmpdir()` follow $HOME / $TMPDIR, so the account's real home is listed too.
270
+ return [os.homedir(), accountHomedir(), path.parse(path.resolve(cwd)).root, os.tmpdir(), "/tmp", "/var/tmp"];
271
+ }
272
+
273
+ /**
274
+ * True when `cwd` IS a shared scratch root — `$HOME`, the filesystem root, or a system temp directory —
275
+ * rather than a directory inside one. Those roots are written by unrelated processes (daemon heartbeats,
276
+ * logs, other agents), are often too large or partly unreadable to snapshot, and hold no project whose
277
+ * verifier could ever run, so a snapshot diff there cannot be attributed to one shell call. A project
278
+ * directory nested under them (a `mkdtemp` dir, `~/work/app`) is NOT a scratch root and stays gated.
279
+ */
280
+ export function isSharedScratchRoot(cwd: string, roots: readonly string[] = defaultScratchRoots(cwd)): boolean {
281
+ const here = realOrResolved(cwd);
282
+ return roots.some(root => realOrResolved(root) === here);
283
+ }
package/src/ai/types.ts CHANGED
@@ -81,6 +81,14 @@ 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 the cwd is a shared scratch root, where calls are deliberately not bracketed (nothing was measured).
90
+ */
91
+ workspace?: { mutation: boolean; changed?: string; inconclusive?: string; skipped?: "scratch" };
84
92
  }
85
93
 
86
94
  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
  }