jeo-code 0.12.24 → 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
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Read-only view of the shared bun global install (`~/.bun/install/global`) that `jeo update`
3
- * writes into with `bun install -g jeo-code@V`. That directory also holds other tools (jeopi,
4
- * gajae-code, ...), so `jeo update` must prove it leaves every other top-level package alone.
3
+ * writes into with `bun install -g jeo-code@V`. That directory also holds other tools
4
+ * (gajae-code, ...), so `jeo update` must prove it leaves every other top-level package alone.
5
5
  *
6
6
  * This module only ever reads: it never writes, deletes, renames, spawns or executes anything.
7
7
  * Restore commands are formatted as strings for a human to run.
@@ -120,7 +120,7 @@ export interface ManifestDeps {
120
120
 
121
121
  /**
122
122
  * Top-level dependencies of the global package.json. Empty names and non-string specs are dropped into
123
- * `ignored` (an older jeopi wrote a `"": "."` dependency that no lockfile could ever cover). `external` is
123
+ * `ignored` (another tool's older updater wrote a `"": "."` dependency that no lockfile could ever cover). `external` is
124
124
  * every name except jeo-code, in manifest order. An absent `dependencies` means zero dependencies.
125
125
  */
126
126
  export function readManifestDeps(dir: string): { ok: true; deps: ManifestDeps } | { ok: false; error: string } {
@@ -243,13 +243,11 @@ export function formatRestoreCommands(cs: readonly ExternalChange[]): string[] {
243
243
  });
244
244
  }
245
245
 
246
- const PRIMARY_TOOL = "jeopi-cli";
247
-
248
- /** `shared global unchanged: 3 packages (jeopi-cli X, a Y, b Z, ...)`; jeopi-cli leads, then ascending, at most 3 shown. */
246
+ /** `shared global unchanged: 3 packages (a X, b Y, c Z, ...)`: ascending by name, at most 3 shown. */
249
247
  export function formatUnchangedSummary(after: InstalledSnapshot): string {
250
248
  const present = Object.entries(after)
251
249
  .filter((e): e is [string, string] => e[0] !== SELF_PACKAGE && e[1] !== null)
252
- .sort(([a], [b]) => (a === PRIMARY_TOOL ? -1 : b === PRIMARY_TOOL ? 1 : a < b ? -1 : a > b ? 1 : 0));
250
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
253
251
  const count = `${present.length} ${present.length === 1 ? "package" : "packages"}`;
254
252
  if (present.length === 0) return `shared global unchanged: ${count}`;
255
253
  const shown = present.slice(0, 3).map(([name, version]) => `${name} ${version}`);