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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,26 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.12.27] - 2026-11-06
13
+
14
+ ### Fixed
15
+ - **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.
16
+ - **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.
17
+
18
+ ### Changed
19
+ - **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.
20
+ - **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).
21
+
22
+ ## [0.12.26] - 2026-11-06
23
+
24
+ ### Fixed
25
+ - **A read-only turn is no longer sent through three full-history retries by an unrelated writer** - the done gate counts a `bash` call as a file change when the workspace digest differs before and after it. In a shared directory (`/tmp`) a daemon heartbeat rewritten every 60 s during a 120 s `find /` flipped that digest, so a turn of `pwd`/`jeo-skill doctor` ended `UNVERIFIED` after three correction rounds (226k input tokens for `sleep 70`; the same prompt in an empty directory cost 32k). Three changes: (1) when the cwd *is* a shared scratch root (`$HOME`, `/`, a system temp dir) `bash` and `computer` calls are no longer snapshot-bracketed - the diff there cannot be attributed to one call and no project verifier exists - with one notice and `workspace: { mutation: false, skipped: "scratch" }` on the result; built-in `write`/`edit` and project directories nested inside a scratch root stay gated; (2) evidence that comes only from a shell/computer bracket ("shell-only": no built-in mutator, debug, monitor or job ran) earns one correction instead of three, counted apart from todo/hook nudges, and launch's `onBeforeDone` restatement of the gate (`MUTATION_NUDGE_PREFIX`) no longer disables that cap; (3) a passing verification now also resets the definite-mutation evidence, so a later unrelated write is judged as shell-only again. Re-run with an unrelated writer in a nested project dir: one correction, 47.8k tokens.
26
+
27
+ ### Changed
28
+ - **The done gate names the paths a shell call changed** - a snapshot mismatch is diffed per path (`snapshotChangedPaths`, `formatSnapshotChange`; the snapshot now keeps a relative-path to fingerprint map, digest arithmetic unchanged) and the unverified / stale-verification messages say `the host saw these paths change: modified heartbeat.json`, so the user and model can tell the command's writes from someone else's. The note no longer suggests that replying "read-only" can clear the gate - only a passing verification does - and `workspace snapshot inconclusive` notices and `done deferred` counters show the effective limit (`1/1`).
29
+ - **Transcripts record the workspace evidence** - each bash/computer tool result persists `workspace: { mutation, changed?, inconclusive?, skipped? }` in the session (never sent to a provider), so a rejected completion can be audited from the transcript; before, 1,221 sessions could not show which call latched the mutation flag.
30
+ - `formatRejectedCompletion` pluralizes (`after 1 correction.`).
31
+
12
32
  ## [0.12.25] - 2026-11-06
13
33
 
14
34
  ### Changed
@@ -2762,7 +2782,8 @@ _Initial release._
2762
2782
  ### Added
2763
2783
  - Initial jeo-code agent and CLI.
2764
2784
 
2765
- [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.25...HEAD
2785
+ [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.26...HEAD
2786
+ [0.12.26]: https://github.com/akillness/jeo-code/releases/tag/v0.12.26
2766
2787
  [0.12.25]: https://github.com/akillness/jeo-code/releases/tag/v0.12.25
2767
2788
  [0.12.24]: https://github.com/akillness/jeo-code/releases/tag/v0.12.24
2768
2789
  [0.12.23]: https://github.com/akillness/jeo-code/releases/tag/v0.12.23
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.27]** (2026-11-06)
466
+ - **[0.12.26]** (2026-11-06)
465
467
  - **[0.12.25]** (2026-11-06)
466
468
  - **[0.12.24]** (2026-11-06)
467
- - **[0.12.23]** (2026-11-06)
468
- - **[0.12.22]** (2026-11-04)
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.27]** (2026-11-06)
521
+ - **[0.12.26]** (2026-11-06)
520
522
  - **[0.12.25]** (2026-11-06)
521
523
  - **[0.12.24]** (2026-11-06)
522
- - **[0.12.23]** (2026-11-06)
523
- - **[0.12.22]** (2026-11-04)
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.27]** (2026-11-06)
510
+ - **[0.12.26]** (2026-11-06)
509
511
  - **[0.12.25]** (2026-11-06)
510
512
  - **[0.12.24]** (2026-11-06)
511
- - **[0.12.23]** (2026-11-06)
512
- - **[0.12.22]** (2026-11-04)
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.27]** (2026-11-06)
464
+ - **[0.12.26]** (2026-11-06)
463
465
  - **[0.12.25]** (2026-11-06)
464
466
  - **[0.12.24]** (2026-11-06)
465
- - **[0.12.23]** (2026-11-06)
466
- - **[0.12.22]** (2026-11-04)
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.25",
3
+ "version": "0.12.27",
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) |
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 |
@@ -32,8 +32,12 @@ import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSigna
32
32
 
33
33
  import { historyTokens, trimToolResultsInPlace, stripReasoningArtifactsInPlace } from "./compaction";
34
34
  import { jeoEnv } from "../util/env";
35
- import { GUARD_LIMITS, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate, formatRejectedCompletion } from "./loop-guards";
36
- import { defaultWorkspaceSnapshotter, workspaceSnapshotChanged, type WorkspaceSnapshot, type WorkspaceSnapshotter } from "./workspace-snapshot";
35
+ import { GUARD_LIMITS, MUTATION_NUDGE_PREFIX, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate, formatRejectedCompletion } from "./loop-guards";
36
+ import {
37
+ defaultWorkspaceSnapshotter, formatSnapshotChange, isSharedScratchRoot, snapshotChangedPaths, workspaceSnapshotChanged,
38
+ type WorkspaceSnapshot, type WorkspaceSnapshotter,
39
+ } from "./workspace-snapshot";
40
+ import { describeSnapshotTarget, snapshotTarget, snapshotTargetLabel } from "./snapshot-target";
37
41
  import { stripLeakedReasoningTags } from "../ai/think-tags";
38
42
 
39
43
  const MID_TASK_PROSE_RE =
@@ -411,6 +415,9 @@ export interface AgentLoopOptions {
411
415
  /** Host-side workspace fingerprint used to decide whether a bash/computer call mutated the
412
416
  * workspace. Defaults to defaultWorkspaceSnapshotter; tests inject fakes. */
413
417
  workspaceSnapshot?: WorkspaceSnapshotter;
418
+ /** Directories treated as shared scratch roots (default: `$HOME`, `/`, system temp dirs). Where the cwd IS one of
419
+ * these, bash/computer calls are not snapshot-bracketed. Tests inject a hermetic root instead of touching `$TMPDIR`. */
420
+ scratchRoots?: readonly string[];
414
421
  signal?: AbortSignal;
415
422
  events?: AgentLoopEvents;
416
423
  /** Step-budget overrides (gjc-style retry flow). `{ maxExtensions: 0 }` restores the
@@ -546,9 +553,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
546
553
  }
547
554
  const tools = opts.tools ?? DEFAULT_TOOLS;
548
555
  const snapshotter: WorkspaceSnapshotter = opts.workspaceSnapshot ?? defaultWorkspaceSnapshotter;
549
- // Turn-scoped memo: once the workspace is over the snapshot cap, gated calls count as
550
- // mutations without re-walking a tree that is known to be too large.
551
- 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>();
552
563
  // Why a bash/computer bracket could not be measured (cap or error), when one failed. The call still
553
564
  // counts as a possible mutation, but the user and the done gate are told the real reason instead of
554
565
  // only "files were modified" — a busy or oversized cwd (e.g. $HOME, /tmp) looks identical otherwise.
@@ -563,21 +574,54 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
563
574
  }
564
575
  return snap;
565
576
  };
566
- const takeSnapshot = async (racySinceMs?: number): Promise<WorkspaceSnapshot> => {
567
- 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
+ }
568
593
  try {
569
- const snap = await snapshotter(cwd, racySinceMs);
570
- if (!snap.ok && snap.reason === "cap") snapshotCapped = true;
571
- 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);
572
597
  } catch (err) {
573
- 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);
574
600
  }
575
601
  };
602
+ const unmeasurableNested = new Set<string>();
576
603
  // bash/computer calls whose mutation status is decided by a workspace diff. Recognized
577
604
  // verifiers are never mutations, and observe-only computer actions are read-only.
578
- const snapshotGated = (tool: string, args: Record<string, any> | undefined): boolean =>
605
+ // A shared scratch root ($HOME, /, the system temp dir) is written by unrelated processes (daemon heartbeats,
606
+ // logs, other agents) and holds no project verifier, so a snapshot diff there cannot be attributed to one shell
607
+ // call: bash and computer calls are not bracketed (a built-in write/edit still counts as a definite mutation).
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.
615
+ const scratchCwd = isSharedScratchRoot(cwd, opts.scratchRoots);
616
+ let scratchNoted = false;
617
+ const snapshotCandidate = (tool: string, args: Record<string, any> | undefined): boolean =>
579
618
  (tool === "bash" && !isVerificationSignal(String(args?.command ?? args?.cmd ?? ""))) ||
580
619
  (tool === "computer" && computerActionMayAffectState(args));
620
+ // Mutation evidence that came ONLY from a shell/computer snapshot bracket, kept apart from definite mutators
621
+ // (write/edit/eval/…): an unrelated writer in the cwd is indistinguishable from the command's own writes, so a
622
+ // shell-only block gets one correction (naming the paths) instead of the full budget.
623
+ let definiteMutation = false;
624
+ const shellChanges: string[] = [];
581
625
  const sandbox = currentSandboxExecution();
582
626
  const parentTools: Record<string, true> = { task: true, subagent: true, irc: true, todo: true, sandbox_request: true };
583
627
  const maxSteps = opts.maxSteps ?? 15;
@@ -703,6 +747,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
703
747
  let verificationCycles = 0;
704
748
  let completionGraceUsed = false;
705
749
  let doneCorrections = 0;
750
+ // Corrections spent on a shell-only block, counted apart so an earlier todo/hook nudge cannot use up the one
751
+ // correction that names the changed paths.
752
+ let shellOnlyCorrections = 0;
706
753
  // Most recent non-empty completion candidate, kept so an exhausted gate still shows it.
707
754
  let lastCompletionCandidate = "";
708
755
  // F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
@@ -1219,11 +1266,23 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1219
1266
  };
1220
1267
  const candidate = stripLeakedReasoningTags(String(toolCalls[0].arguments?.reason ?? ""));
1221
1268
  if (candidate.trim()) lastCompletionCandidate = candidate;
1222
- const doneGate = classifyDoneGate({ ...evidence, pendingHookFailure, unmeasured: unmeasuredSnapshot ?? undefined });
1269
+ const doneGate = classifyDoneGate({
1270
+ ...evidence,
1271
+ pendingHookFailure,
1272
+ unmeasured: unmeasuredSnapshot ?? undefined,
1273
+ shellChanged: !definiteMutation && shellChanges.length ? shellChanges.slice(-3).join(" | ") : undefined,
1274
+ });
1223
1275
  const nudge = await ev.onBeforeDone?.((toolCalls[0].arguments?.reason as string) ?? "", evidence);
1224
- const rejection = [doneGate.block ? doneGate.message : "", nudge].filter(Boolean).join("\n\n");
1276
+ // launch's onBeforeDone restates the mutation gate in its own words when the gate blocks; the engine's message
1277
+ // names the paths, so the restatement is dropped and does not count as an independent reason to keep going.
1278
+ const independentNudge = nudge && !(doneGate.block && nudge.startsWith(MUTATION_NUDGE_PREFIX)) ? nudge : "";
1279
+ const rejection = [doneGate.block ? doneGate.message : "", independentNudge].filter(Boolean).join("\n\n");
1225
1280
  if (rejection) {
1226
- if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
1281
+ // Shell-only evidence (no built-in mutator succeeded) cannot tell the command's writes from an unrelated
1282
+ // process's, so it earns one correction naming the paths; every other block keeps the full budget.
1283
+ const shellOnlyBlock = doneGate.block && doneGate.state !== "done_hook_failing" && !definiteMutation && !independentNudge;
1284
+ const correctionLimit = shellOnlyBlock ? GUARD_LIMITS.MAX_SHELL_ONLY_DONE_CORRECTIONS : GUARD_LIMITS.MAX_DONE_CORRECTIONS;
1285
+ if (shellOnlyBlock ? shellOnlyCorrections >= correctionLimit : doneCorrections >= correctionLimit) {
1227
1286
  return finish({
1228
1287
  done: false,
1229
1288
  steps: step,
@@ -1232,9 +1291,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1232
1291
  });
1233
1292
  }
1234
1293
  doneCorrections++;
1294
+ if (shellOnlyBlock) shellOnlyCorrections++;
1235
1295
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1236
1296
  history.push({ role: "user", content: rejection });
1237
- ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${GUARD_LIMITS.MAX_DONE_CORRECTIONS}`);
1297
+ ev.onNotice?.(`done deferred — completion correction ${shellOnlyBlock ? shellOnlyCorrections : doneCorrections}/${correctionLimit}`);
1238
1298
  step++;
1239
1299
  continue;
1240
1300
  }
@@ -1371,6 +1431,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1371
1431
  let output: string;
1372
1432
  let images: import("../ai/types").ImageAttachment[] | undefined;
1373
1433
  let mutationMayHaveOccurred: boolean | undefined;
1434
+ let workspace: import("../ai/types").ToolResultRecord["workspace"];
1435
+ let bracketed = false;
1374
1436
  if (tool === "done") {
1375
1437
  success = false;
1376
1438
  output = "Error: 'done' can only be called as the single tool invocation, not in a batch. Please send 'done' alone.";
@@ -1393,8 +1455,23 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1393
1455
  success = false;
1394
1456
  output = preHookResult.error + (preHookResult.output ? `\n${preHookResult.output}` : "");
1395
1457
  } else {
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;
1471
+ }
1472
+ }
1396
1473
  // Taken after pre-tool hooks so hook writes are never attributed to the call.
1397
- const before = snapshotGated(tool, args) ? await takeSnapshot() : undefined;
1474
+ const before = bracketDir !== undefined ? await takeSnapshot(bracketDir, "before") : undefined;
1398
1475
  try {
1399
1476
  const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
1400
1477
  const sandboxedHandler = sandbox && !Object.hasOwn(parentTools, tool) && !SANDBOX_MEDIATED_TOOLS.has(handler)
@@ -1419,11 +1496,36 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1419
1496
  ((tool === "monitor" || tool === "job") && String(args?.action ?? "").trim().toLowerCase() === "start");
1420
1497
  if (explicitSignal || knownMutatingCall) mutationMayHaveOccurred = true;
1421
1498
  }
1422
- if (before) {
1499
+ if (before && bracketDir !== undefined) {
1423
1500
  // bash/computer: the workspace diff is the sole mutation authority (overrides
1424
1501
  // handler flags and the catch heuristic). Both sides share one racy anchor so
1425
1502
  // an unchanged recently-modified file is hashed on both sides or neither.
1426
- mutationMayHaveOccurred = before.ok ? workspaceSnapshotChanged(before, await takeSnapshot(before.startedAtMs)) : true;
1503
+ const after = before.ok ? await takeSnapshot(bracketDir, "after", before.startedAtMs) : undefined;
1504
+ const failed = !before.ok ? before : after && !after.ok ? after : undefined;
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
+ }
1427
1529
  }
1428
1530
  // A pre-tool hook's structured `additionalContext` rides along with the result
1429
1531
  // so the model sees it next to the output it concerns.
@@ -1431,7 +1533,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1431
1533
  }
1432
1534
  }
1433
1535
  }
1434
- return { success, output, images, mutationMayHaveOccurred };
1536
+ return { success, output, images, mutationMayHaveOccurred, bracketed, workspace };
1435
1537
  };
1436
1538
 
1437
1539
  const WRITE_TOOLS = new Set(["write", "edit"]);
@@ -1474,7 +1576,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1474
1576
  }
1475
1577
  }
1476
1578
 
1477
- const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[]; mutationMayHaveOccurred?: boolean }[] = Array.from(
1579
+ const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[]; mutationMayHaveOccurred?: boolean; bracketed?: boolean; workspace?: import("../ai/types").ToolResultRecord["workspace"] }[] = Array.from(
1478
1580
  { length: toolCalls.length },
1479
1581
  () => ({ success: false, output: "", executed: false })
1480
1582
  );
@@ -1563,6 +1665,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1563
1665
  id: idFor(idx),
1564
1666
  output: bodies[i],
1565
1667
  isError: !results[idx].success,
1668
+ ...(results[idx].workspace ? { workspace: results[idx].workspace } : {}),
1566
1669
  }));
1567
1670
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf, toolUse);
1568
1671
  const resultMsg: Message = { role: "user", content: resultBlocks.join("\n\n"), toolResults };
@@ -1607,11 +1710,16 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1607
1710
  if (results[i].success && verificationProducedEvidence(command, String(results[i].output ?? ""))) {
1608
1711
  sawVerification = true;
1609
1712
  lastVerificationSeq = ++mutVerSeq;
1610
- if (!pendingHookFailure) doneCorrections = 0;
1713
+ // A passing verification supersedes the evidence before it: what happens next is judged on its own.
1714
+ definiteMutation = false;
1715
+ shellChanges.length = 0;
1716
+ unmeasuredSnapshot = null;
1717
+ if (!pendingHookFailure) { doneCorrections = 0; shellOnlyCorrections = 0; }
1611
1718
  } else {
1612
1719
  sawVerification = false;
1613
1720
  if (results[i].mutationMayHaveOccurred === true) {
1614
1721
  sawMutation = true;
1722
+ definiteMutation = true; // a verifier is never snapshot-bracketed: this flag came from the handler itself
1615
1723
  lastMutationSeq = ++mutVerSeq;
1616
1724
  }
1617
1725
  }
@@ -1619,6 +1727,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1619
1727
  }
1620
1728
  if (t === "todo" && results[i].success && doneCorrections > 0 && (!sawMutation || (sawVerification && lastMutationSeq < lastVerificationSeq)) && !pendingHookFailure) {
1621
1729
  doneCorrections = 0;
1730
+ shellOnlyCorrections = 0;
1622
1731
  }
1623
1732
 
1624
1733
  const output = String(results[i].output ?? "").trim();
@@ -1638,19 +1747,28 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1638
1747
  if (results[i].success || results[i].mutationMayHaveOccurred === true) {
1639
1748
  sawMutation = true;
1640
1749
  lastMutationSeq = ++mutVerSeq;
1750
+ if (results[i].bracketed !== true) definiteMutation = true;
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
+ }
1641
1756
  }
1642
1757
  } else if (t === "debug" && MUTATING_DEBUG_ACTIONS[String(toolCalls[i].arguments?.action ?? "")] === true) {
1643
1758
  sawMutation = true;
1759
+ definiteMutation = true;
1644
1760
  lastMutationSeq = ++mutVerSeq;
1645
1761
  } else if (t === "monitor" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
1646
1762
  // Starting a background monitor can mutate the workspace asynchronously;
1647
1763
  // read-only monitor controls (list/tail/await/cancel) do not invalidate verification.
1648
1764
  sawMutation = true;
1765
+ definiteMutation = true;
1649
1766
  lastMutationSeq = ++mutVerSeq;
1650
1767
  } else if (t === "job" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
1651
1768
  // Starting a background shell may mutate the workspace asynchronously;
1652
1769
  // read-only job controls (list/tail/await/cancel) do not invalidate verification.
1653
1770
  sawMutation = true;
1771
+ definiteMutation = true;
1654
1772
  lastMutationSeq = ++mutVerSeq;
1655
1773
  }
1656
1774
  }