jeo-code 0.12.26 → 0.12.28

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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,21 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.12.28] - 2026-11-07
13
+
14
+ ### Fixed
15
+ - **A successful `bun install -g` on Windows no longer reports "Failed to install update."** - `runInstall` treated an unreadable active version (`jeo --version` returning null) exactly like a positively stale binary, so it burned the `--force` retry and failed a landed install. A null is now trusted as success (only a real older version string retries/fails), and `readActiveVersion` retries the `jeo.exe` and `cmd /c` spellings on win32, which a bare `["jeo", ...]` spawn often cannot resolve.
16
+
17
+ ## [0.12.27] - 2026-11-06
18
+
19
+ ### Fixed
20
+ - **A stale managed container no longer blocks every sandbox launch** - `validateManagedRoots` inspects every labelled container, including exited ones, and canonicalized each bind source. A leaked container whose temp workspace had been deleted made `realpath` throw `ENOENT`, so every later launch failed with that opaque error. A vanished source (`ENOENT`, `ENOTDIR`) now resolves through its nearest existing ancestor (lexically for `ENOTDIR`) for the overlap check, which stays enforced; `EACCES`, `ELOOP` and invalid metadata still fail closed.
21
+ - **An unlistable directory or an unhashable fresh file no longer makes the whole workspace snapshot inconclusive** - a non-root directory that cannot be listed is recorded as `u:<rel>:<mode>:<ctimeNs>:<ino>` and its children skipped (a `chmod` or any entry change in it still flips the digest); a fresh file over the racy byte caps, unopenable or changed while read is recorded by its stat fingerprint plus `:x`. Both count in the new `degraded` field. An unreadable root and the entry and time caps still fail the snapshot. Residual risk, documented in the header: rewriting an existing child's content inside a write-only directory with no `chmod` and no entry change is not detected.
22
+
23
+ ### Changed
24
+ - **The done gate recognizes far more real verification commands** - `parseVerifier` accepts `bash -c`, `time`, quoted arguments, `cd "x y" &&`, package-manager flags (`pnpm -r`, `--filter`, `--prefix`, `--cwd`, `yarn workspace`), `make ci|verify`, gradle, mvn, dotnet, rspec, phpunit, playwright and the silent linters ruff, mypy, flake8, golangci-lint, staticcheck, `prettier --check` and `cargo fmt --check`. Direct forms are trusted on tool success. Weak forms whose exit status is not the verifier's own (`| tail`, bare `| tee`, `|| true`, `; echo x`, `set -e;`, `cd x;`, a subshell) count only for test runners, and only with a positive pass summary, no failure marker anywhere in the output and the trailing `echo` text discounted. `make`, `mvn`, `gradle`, `rake` and `dotnet` accept only allow-listed targets, so `make ci deploy` is not a verifier. A direct run that reports zero tests (`0 pass`, `# pass 0`, `Tests run: 0`, `collected 0 items`) is no longer evidence.
25
+ - **A shell call from a shared scratch directory is bracketed on the project it works in** - from `$HOME`, `/` or a system temp dir, `cd <project> && sed -i ...`, or a `cwd`/`subdir` argument naming a project, is measured on the project root (the outermost directory with a `.git`, `package.json`, `Cargo.toml`, ... marker below any scratch root, so `cd app/pkg && echo x > ../src/a.ts` measures `app`) instead of being skipped, so a real unverified edit made that way blocks `done`. If that root cannot be measured BEFORE the call (over the entry or time cap), the call is skipped with one notice, as before, so a big or busy directory such as `/opt/homebrew` never blocks a read-only turn; if it is measurable before but not after, the call counts as a change. Unmeasured by design: a project over the 50k-entry cap (or one inflated earlier in the turn by a call that was not bracketed, such as a recognized `make build`), and a small project nested under a larger marker root. The transcript records `workspace.target` (relative to the cwd, `<outside cwd>` otherwise).
26
+
12
27
  ## [0.12.26] - 2026-11-06
13
28
 
14
29
  ### Fixed
package/README.ja.md CHANGED
@@ -462,10 +462,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
462
462
 
463
463
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
464
464
  - **[Unreleased]**
465
+ - **[0.12.28]** (2026-11-07)
466
+ - **[0.12.27]** (2026-11-06)
465
467
  - **[0.12.26]** (2026-11-06)
466
468
  - **[0.12.25]** (2026-11-06)
467
- - **[0.12.24]** (2026-11-06)
468
- - **[0.12.23]** (2026-11-06)
469
469
 
470
470
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
471
471
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -517,10 +517,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
517
517
 
518
518
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
519
519
  - **[Unreleased]**
520
+ - **[0.12.28]** (2026-11-07)
521
+ - **[0.12.27]** (2026-11-06)
520
522
  - **[0.12.26]** (2026-11-06)
521
523
  - **[0.12.25]** (2026-11-06)
522
- - **[0.12.24]** (2026-11-06)
523
- - **[0.12.23]** (2026-11-06)
524
524
 
525
525
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
526
526
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -506,10 +506,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
506
506
 
507
507
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
508
508
  - **[Unreleased]**
509
+ - **[0.12.28]** (2026-11-07)
510
+ - **[0.12.27]** (2026-11-06)
509
511
  - **[0.12.26]** (2026-11-06)
510
512
  - **[0.12.25]** (2026-11-06)
511
- - **[0.12.24]** (2026-11-06)
512
- - **[0.12.23]** (2026-11-06)
513
513
 
514
514
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
515
515
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -460,10 +460,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
460
460
 
461
461
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
462
462
  - **[Unreleased]**
463
+ - **[0.12.28]** (2026-11-07)
464
+ - **[0.12.27]** (2026-11-06)
463
465
  - **[0.12.26]** (2026-11-06)
464
466
  - **[0.12.25]** (2026-11-06)
465
- - **[0.12.24]** (2026-11-06)
466
- - **[0.12.23]** (2026-11-06)
467
467
 
468
468
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
469
469
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.12.26",
3
+ "version": "0.12.28",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
 
@@ -29,8 +29,9 @@ The core runtime loop, tool registry, session management, and state persistence
29
29
  | `hooks.ts` | Command-hook runtime: `loadHooks` (user registry `~/.jeo/hooks.json` ⊕ `config.json`/project `.jeo/hooks.json`, with the `hooks.enabled=false` kill switch and project `enabled:false` opt-out; the registry is skipped in sandboxed sessions), `runPreToolHooks` (veto by exit code or structured `permissionDecision`, `additionalContext` passthrough), `runPostTurnHooksForBatch`, `runPostImplementationHooks`, and the per-turn `runPrePromptHooks` (context injected by `commands/launch.ts` into the prompt message) / `runTurnEndHooks` (final reply to ingesters); payloads follow the Claude hook protocol on the host (`hook_event_name`, `session_id`, `cwd`, `tool_name`/`tool_input`) and stay legacy-shaped in a sandbox |
30
30
  | `json.ts` | Brief description of purpose |
31
31
  | `loop.ts` | The primary execution loop orchestrating model calls and tool execution |
32
- | `loop-guards.ts` | Intermediate-judgment classification (gjc ultragoal-guard parity): named `GuardState` taxonomy, `GUARD_LIMITS` thresholds, and pure classifiers (`verifierCores`/`isVerificationSignal` — recognizes wrapped verifiers such as `cd x && bun test`, env/`timeout`/`uv run`/`rtk` prefixes and `2>&1`, never pipes or `;` — `repeatHint`, `classifyDoneGate` incl. `done_stale_verification`, and `formatRejectedCompletion`, which keeps the last candidate answer under an `UNVERIFIED` marker when corrections are exhausted) consumed by `engine.ts` |
33
- | `workspace-snapshot.ts` | Host-side workspace fingerprint (`snapshotWorkspace`, `defaultWorkspaceSnapshotter`, `workspaceSnapshotChanged`): bounded lstat walk excluding `.git`/`.jeo`/`node_modules`, racy content hash for files modified near the bracket anchor; the engine brackets non-verifier `bash` and state-affecting `computer` calls with it so only a real workspace change counts as a mutation (snapshot error or cap counts as a mutation); an ok snapshot keeps a relative-path fingerprint map (`snapshotChangedPaths`/`formatSnapshotChange` name what changed) and `isSharedScratchRoot` marks `$HOME`, `/` and system temp dirs where `bash`/`computer` calls are not bracketed |
32
+ | `loop-guards.ts` | Intermediate-judgment classification (gjc ultragoal-guard parity): named `GuardState` taxonomy, `GUARD_LIMITS` thresholds, and pure classifiers (`verifierCores`/`isVerificationSignal` — `parseVerifier` recognizes wrapped verifiers such as `cd x && bun test`, `bash -c`, env/`timeout`/`time`/`uv run`/`rtk` prefixes and `2>&1`; direct forms are trusted on tool success, while weak forms whose exit status is not the verifier's own (`| tail`, bare `| tee`, `|| true`, `; echo x`, `set -e;`, `cd x;`, a subshell) count only for test runners and only with a positive pass summary, no failure marker anywhere in the output and the trailing `echo` text discounted; `make`/`mvn`/`gradle`/`rake` accept only allow-listed targets — `repeatHint`, `classifyDoneGate` incl. `done_stale_verification`, and `formatRejectedCompletion`, which keeps the last candidate answer under an `UNVERIFIED` marker when corrections are exhausted) consumed by `engine.ts` |
33
+ | `workspace-snapshot.ts` | Host-side workspace fingerprint (`snapshotWorkspace`, `defaultWorkspaceSnapshotter`, `workspaceSnapshotChanged`): bounded lstat walk excluding `.git`/`.jeo`/`node_modules`, racy content hash for files modified near the bracket anchor; the engine brackets non-verifier `bash` and state-affecting `computer` calls with it so only a real workspace change counts as a mutation (snapshot error or cap counts as a mutation); an unlistable non-root directory (`u:<rel>:<mode>:<ctimeNs>:<ino>`) or a racy file that cannot be hashed (stat fingerprint plus `:x`) degrades to a marker and is counted in `degraded` instead of failing the snapshot; an ok snapshot keeps a relative-path fingerprint map (`snapshotChangedPaths`/`formatSnapshotChange` name what changed) and `isSharedScratchRoot` marks `$HOME`, `/` and system temp dirs where `bash`/`computer` calls are not bracketed unless `snapshot-target.ts` finds a nested project |
34
+ | `snapshot-target.ts` | `snapshotTarget`/`describeSnapshotTarget`: for a `bash` call from a shared scratch cwd, resolves the project directory the call works in (`args.cwd`/`args.subdir`, then a leading literal `cd <path> &&`/`;`, up to 8 hops) and returns it only when it has a project marker (`.git`, `package.json`, `Cargo.toml`, …) below any scratch root; the engine brackets that directory and fails OPEN (skipped, one notice) when it cannot be measured, so a big or busy directory never blocks a turn |
34
35
  | `memory.ts` | OKF concept-bundle memory: session distill, query-aware budget injection with a **hybrid reranker ported from memsearch** — `priorityOrder` fuses two ranked channels via `reciprocalRankFusion` (RRF, k=60): lexical IDF relevance (`buildCorpusStats`/`scoreConcept`, the sparse/BM25 channel so rare discriminating terms steer retrieval) ⊕ graph proximity (`graphProximityOrder`, the local dense/semantic-neighbour channel — a hub linked from multiple query hits surfaces even with no keyword of its own); all embedding-free, deterministic, atop the failure-first tier + pinned-invariant reserved budget. Plus mid-loop `recordFailedAttempt` capture, shared `upsertConceptFile` writer, legacy MEMORY.md migration (`migrateLegacyMemory`) + `JEO_MEMORY_LEGACY` rollback toggle |
35
36
  | `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
36
37
  | `memory-graph.ts` | Concept cross-link graph: build/expand (1-hop search), broken-link-tolerant lint, optional graphify detection |
@@ -37,6 +37,7 @@ import {
37
37
  defaultWorkspaceSnapshotter, formatSnapshotChange, isSharedScratchRoot, snapshotChangedPaths, workspaceSnapshotChanged,
38
38
  type WorkspaceSnapshot, type WorkspaceSnapshotter,
39
39
  } from "./workspace-snapshot";
40
+ import { describeSnapshotTarget, snapshotTarget, snapshotTargetLabel } from "./snapshot-target";
40
41
  import { stripLeakedReasoningTags } from "../ai/think-tags";
41
42
 
42
43
  const MID_TASK_PROSE_RE =
@@ -552,9 +553,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
552
553
  }
553
554
  const tools = opts.tools ?? DEFAULT_TOOLS;
554
555
  const snapshotter: WorkspaceSnapshotter = opts.workspaceSnapshot ?? defaultWorkspaceSnapshotter;
555
- // Turn-scoped memo: once the workspace is over the snapshot cap, gated calls count as
556
- // mutations without re-walking a tree that is known to be too large.
557
- let snapshotCapped = false;
556
+ // Turn-scoped memo, keyed per bracketed directory, so one oversized directory never poisons the measurement of the
557
+ // cwd or of another target. For the cwd any cap is remembered (gated calls count as mutations without re-walking a
558
+ // tree known to be too large). For a NESTED target only a cap seen on the BEFORE side is remembered: that directory
559
+ // was already unmeasurable before the call ran, so nothing in it is attributable. An AFTER-side cap is never
560
+ // remembered, because it may have been this very call's files that pushed the tree over the cap — a later small
561
+ // write in the same directory must still be judged on its own, not skipped by that memo.
562
+ const cappedDirs = new Set<string>();
558
563
  // Why a bash/computer bracket could not be measured (cap or error), when one failed. The call still
559
564
  // counts as a possible mutation, but the user and the done gate are told the real reason instead of
560
565
  // only "files were modified" — a busy or oversized cwd (e.g. $HOME, /tmp) looks identical otherwise.
@@ -569,29 +574,49 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
569
574
  }
570
575
  return snap;
571
576
  };
572
- const takeSnapshot = async (racySinceMs?: number): Promise<WorkspaceSnapshot> => {
573
- if (snapshotCapped) return { ok: false, reason: "cap", detail: "workspace exceeded the snapshot cap earlier this turn" };
577
+ // A BEFORE-side failure on a NESTED target is returned as-is (not noted as unmeasured): the bracket then fails open
578
+ // for it, because before nested targeting those calls were skipped as scratch, and a read-only `cd ~/.cache && ls`
579
+ // must not start counting as a mutation just because that directory is big or busy. An AFTER-side failure is noted
580
+ // like the cwd's: the directory WAS measurable a moment ago, so files appeared during the call (fail-safe).
581
+ // Exception: a directory this turn already made unmeasurable (`grownDirs`, an after-side failure with a good before)
582
+ // never fails open again — it was this session's own writes that inflated it, so a later call there (even after a
583
+ // passing verification) must not be waved through just because the tree is now too big to measure.
584
+ const grownDirs = new Set<string>();
585
+ const takeSnapshot = async (dir: string, side: "before" | "after", racySinceMs?: number): Promise<WorkspaceSnapshot> => {
586
+ const nested = dir !== cwd;
587
+ const failOpen = nested && side === "before" && !grownDirs.has(dir);
588
+ const memoApplies = !nested || side === "before";
589
+ if (memoApplies && cappedDirs.has(dir)) {
590
+ const memo: WorkspaceSnapshot = { ok: false, reason: "cap", detail: "workspace exceeded the snapshot cap earlier this turn" };
591
+ return nested && !failOpen ? noteUnmeasured(memo) : memo;
592
+ }
574
593
  try {
575
- const snap = await snapshotter(cwd, racySinceMs);
576
- if (!snap.ok && snap.reason === "cap") snapshotCapped = true;
577
- return noteUnmeasured(snap);
594
+ const snap = await snapshotter(dir, racySinceMs);
595
+ if (memoApplies && !snap.ok && snap.reason === "cap") cappedDirs.add(dir);
596
+ return failOpen ? snap : noteUnmeasured(snap);
578
597
  } catch (err) {
579
- return noteUnmeasured({ ok: false, reason: "error", detail: err instanceof Error ? err.message : String(err) });
598
+ const failure: WorkspaceSnapshot = { ok: false, reason: "error", detail: err instanceof Error ? err.message : String(err) };
599
+ return failOpen ? failure : noteUnmeasured(failure);
580
600
  }
581
601
  };
602
+ const unmeasurableNested = new Set<string>();
582
603
  // bash/computer calls whose mutation status is decided by a workspace diff. Recognized
583
604
  // verifiers are never mutations, and observe-only computer actions are read-only.
584
605
  // A shared scratch root ($HOME, /, the system temp dir) is written by unrelated processes (daemon heartbeats,
585
606
  // logs, other agents) and holds no project verifier, so a snapshot diff there cannot be attributed to one shell
586
607
  // call: bash and computer calls are not bracketed (a built-in write/edit still counts as a definite mutation).
587
- // A project directory nested inside a scratch root stays gated.
608
+ // The one exception is a bash call whose literal target (`cd ~/work/app && …`, a `cwd`/`subdir` argument) is a
609
+ // project (it, or an ancestor below the scratch root, holds a marker such as `.git`/`package.json`) nested under
610
+ // the scratch cwd: `snapshotTarget` names the outermost such project root and the bracket measures THAT directory,
611
+ // so an edit made through a scratch cwd (even from a subdirectory, `cd app/pkg && echo X > ../src/a.ts`) is not
612
+ // invisible. If that root was already unmeasurable BEFORE the call it is skipped like scratch (fail open); one that
613
+ // becomes unmeasurable DURING the call counts as a mutation. A project directory used as cwd stays gated as
614
+ // before, and there an unmeasurable bracket counts as a mutation.
588
615
  const scratchCwd = isSharedScratchRoot(cwd, opts.scratchRoots);
589
616
  let scratchNoted = false;
590
617
  const snapshotCandidate = (tool: string, args: Record<string, any> | undefined): boolean =>
591
618
  (tool === "bash" && !isVerificationSignal(String(args?.command ?? args?.cmd ?? ""))) ||
592
619
  (tool === "computer" && computerActionMayAffectState(args));
593
- const snapshotGated = (tool: string, args: Record<string, any> | undefined): boolean =>
594
- !scratchCwd && snapshotCandidate(tool, args);
595
620
  // Mutation evidence that came ONLY from a shell/computer snapshot bracket, kept apart from definite mutators
596
621
  // (write/edit/eval/…): an unrelated writer in the cwd is indistinguishable from the command's own writes, so a
597
622
  // shell-only block gets one correction (naming the paths) instead of the full budget.
@@ -1430,15 +1455,23 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1430
1455
  success = false;
1431
1456
  output = preHookResult.error + (preHookResult.output ? `\n${preHookResult.output}` : "");
1432
1457
  } else {
1433
- if (scratchCwd && snapshotCandidate(tool, args)) {
1434
- workspace = { mutation: false, skipped: "scratch" };
1435
- if (!scratchNoted) {
1436
- scratchNoted = true;
1437
- ev.onNotice?.(`cwd ${cwd} is a shared scratch directory: shell and computer calls here are not checked for file changes`);
1458
+ // Which directory this call's bracket measures. Undefined = skip: a scratch cwd with no resolvable
1459
+ // nested project target (`snapshotTarget` returns `cwd` itself exactly when it found none).
1460
+ let bracketDir: string | undefined;
1461
+ if (snapshotCandidate(tool, args)) {
1462
+ const dir = scratchCwd ? snapshotTarget(cwd, tool, args, opts.scratchRoots) : cwd;
1463
+ if (scratchCwd && dir === cwd) {
1464
+ workspace = { mutation: false, skipped: "scratch" };
1465
+ if (!scratchNoted) {
1466
+ scratchNoted = true;
1467
+ ev.onNotice?.(`cwd ${cwd} is a shared scratch directory: shell and computer calls here are not checked for file changes`);
1468
+ }
1469
+ } else {
1470
+ bracketDir = dir;
1438
1471
  }
1439
1472
  }
1440
1473
  // Taken after pre-tool hooks so hook writes are never attributed to the call.
1441
- const before = snapshotGated(tool, args) ? await takeSnapshot() : undefined;
1474
+ const before = bracketDir !== undefined ? await takeSnapshot(bracketDir, "before") : undefined;
1442
1475
  try {
1443
1476
  const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
1444
1477
  const sandboxedHandler = sandbox && !Object.hasOwn(parentTools, tool) && !SANDBOX_MEDIATED_TOOLS.has(handler)
@@ -1463,20 +1496,36 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1463
1496
  ((tool === "monitor" || tool === "job") && String(args?.action ?? "").trim().toLowerCase() === "start");
1464
1497
  if (explicitSignal || knownMutatingCall) mutationMayHaveOccurred = true;
1465
1498
  }
1466
- if (before) {
1499
+ if (before && bracketDir !== undefined) {
1467
1500
  // bash/computer: the workspace diff is the sole mutation authority (overrides
1468
1501
  // handler flags and the catch heuristic). Both sides share one racy anchor so
1469
1502
  // an unchanged recently-modified file is hashed on both sides or neither.
1470
- const after = before.ok ? await takeSnapshot(before.startedAtMs) : undefined;
1471
- mutationMayHaveOccurred = after ? workspaceSnapshotChanged(before, after) : true;
1472
- bracketed = true;
1503
+ const after = before.ok ? await takeSnapshot(bracketDir, "after", before.startedAtMs) : undefined;
1473
1504
  const failed = !before.ok ? before : after && !after.ok ? after : undefined;
1474
- const change = after ? snapshotChangedPaths(before, after) : null;
1475
- workspace = {
1476
- mutation: mutationMayHaveOccurred,
1477
- ...(mutationMayHaveOccurred && change ? { changed: formatSnapshotChange(change) } : {}),
1478
- ...(failed && !failed.ok ? { inconclusive: `${failed.reason}: ${failed.detail}` } : {}),
1479
- };
1505
+ const nested = bracketDir !== cwd;
1506
+ if (nested && after && !after.ok) grownDirs.add(bracketDir);
1507
+ if (nested && !before.ok && !grownDirs.has(bracketDir)) {
1508
+ // Fail open ONLY when the nested target was already unmeasurable BEFORE the call (cap, time cap,
1509
+ // error): nothing in it is attributable, and it behaves like today's scratch skip so a big read-only
1510
+ // directory cannot block a finished answer. A failing AFTER snapshot (before was ok) falls through
1511
+ // below and counts as a mutation: files appeared during the call.
1512
+ workspace = { mutation: false, skipped: "scratch", target: describeSnapshotTarget(cwd, bracketDir) };
1513
+ if (!unmeasurableNested.has(bracketDir)) {
1514
+ unmeasurableNested.add(bracketDir);
1515
+ ev.onNotice?.(`nested target ${snapshotTargetLabel(cwd, bracketDir)} could not be measured; treated as scratch`);
1516
+ }
1517
+ } else {
1518
+ mutationMayHaveOccurred = after ? workspaceSnapshotChanged(before, after) : true;
1519
+ bracketed = true;
1520
+ const change = after ? snapshotChangedPaths(before, after) : null;
1521
+ workspace = {
1522
+ mutation: mutationMayHaveOccurred,
1523
+ // Paths stay relative to the bracketed directory; `target` says which directory that was.
1524
+ ...(mutationMayHaveOccurred && change ? { changed: formatSnapshotChange(change) } : {}),
1525
+ ...(failed && !failed.ok ? { inconclusive: `${failed.reason}: ${failed.detail}` } : {}),
1526
+ ...(nested ? { target: describeSnapshotTarget(cwd, bracketDir) } : {}),
1527
+ };
1528
+ }
1480
1529
  }
1481
1530
  // A pre-tool hook's structured `additionalContext` rides along with the result
1482
1531
  // so the model sees it next to the output it concerns.
@@ -1699,7 +1748,11 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1699
1748
  sawMutation = true;
1700
1749
  lastMutationSeq = ++mutVerSeq;
1701
1750
  if (results[i].bracketed !== true) definiteMutation = true;
1702
- else if (results[i].workspace?.changed) shellChanges.push(results[i].workspace!.changed!);
1751
+ else if (results[i].workspace?.changed) {
1752
+ const w = results[i].workspace!;
1753
+ // A nested bracket's paths are relative to that directory, so the gate message names it.
1754
+ shellChanges.push(w.target ? `${w.changed} (in ${w.target})` : w.changed!);
1755
+ }
1703
1756
  }
1704
1757
  } else if (t === "debug" && MUTATING_DEBUG_ACTIONS[String(toolCalls[i].arguments?.action ?? "")] === true) {
1705
1758
  sawMutation = true;