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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,16 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.12.26] - 2026-11-06
13
+
14
+ ### Fixed
15
+ - **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.
16
+
17
+ ### Changed
18
+ - **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`).
19
+ - **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.
20
+ - `formatRejectedCompletion` pluralizes (`after 1 correction.`).
21
+
12
22
  ## [0.12.25] - 2026-11-06
13
23
 
14
24
  ### Changed
@@ -2762,7 +2772,8 @@ _Initial release._
2762
2772
  ### Added
2763
2773
  - Initial jeo-code agent and CLI.
2764
2774
 
2765
- [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.25...HEAD
2775
+ [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.26...HEAD
2776
+ [0.12.26]: https://github.com/akillness/jeo-code/releases/tag/v0.12.26
2766
2777
  [0.12.25]: https://github.com/akillness/jeo-code/releases/tag/v0.12.25
2767
2778
  [0.12.24]: https://github.com/akillness/jeo-code/releases/tag/v0.12.24
2768
2779
  [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.26]** (2026-11-06)
465
466
  - **[0.12.25]** (2026-11-06)
466
467
  - **[0.12.24]** (2026-11-06)
467
468
  - **[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.26]** (2026-11-06)
520
521
  - **[0.12.25]** (2026-11-06)
521
522
  - **[0.12.24]** (2026-11-06)
522
523
  - **[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.26]** (2026-11-06)
509
510
  - **[0.12.25]** (2026-11-06)
510
511
  - **[0.12.24]** (2026-11-06)
511
512
  - **[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.26]** (2026-11-06)
463
464
  - **[0.12.25]** (2026-11-06)
464
465
  - **[0.12.24]** (2026-11-06)
465
466
  - **[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.26",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
 
@@ -30,7 +30,7 @@ The core runtime loop, tool registry, session management, and state persistence
30
30
  | `json.ts` | Brief description of purpose |
31
31
  | `loop.ts` | The primary execution loop orchestrating model calls and tool execution |
32
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) |
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 |
34
34
  | `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
35
  | `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
36
36
  | `memory-graph.ts` | Concept cross-link graph: build/expand (1-hop search), broken-link-tolerant lint, optional graphify detection |
@@ -32,8 +32,11 @@ 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";
37
40
  import { stripLeakedReasoningTags } from "../ai/think-tags";
38
41
 
39
42
  const MID_TASK_PROSE_RE =
@@ -411,6 +414,9 @@ export interface AgentLoopOptions {
411
414
  /** Host-side workspace fingerprint used to decide whether a bash/computer call mutated the
412
415
  * workspace. Defaults to defaultWorkspaceSnapshotter; tests inject fakes. */
413
416
  workspaceSnapshot?: WorkspaceSnapshotter;
417
+ /** Directories treated as shared scratch roots (default: `$HOME`, `/`, system temp dirs). Where the cwd IS one of
418
+ * these, bash/computer calls are not snapshot-bracketed. Tests inject a hermetic root instead of touching `$TMPDIR`. */
419
+ scratchRoots?: readonly string[];
414
420
  signal?: AbortSignal;
415
421
  events?: AgentLoopEvents;
416
422
  /** Step-budget overrides (gjc-style retry flow). `{ maxExtensions: 0 }` restores the
@@ -575,9 +581,22 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
575
581
  };
576
582
  // bash/computer calls whose mutation status is decided by a workspace diff. Recognized
577
583
  // verifiers are never mutations, and observe-only computer actions are read-only.
578
- const snapshotGated = (tool: string, args: Record<string, any> | undefined): boolean =>
584
+ // A shared scratch root ($HOME, /, the system temp dir) is written by unrelated processes (daemon heartbeats,
585
+ // logs, other agents) and holds no project verifier, so a snapshot diff there cannot be attributed to one shell
586
+ // 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.
588
+ const scratchCwd = isSharedScratchRoot(cwd, opts.scratchRoots);
589
+ let scratchNoted = false;
590
+ const snapshotCandidate = (tool: string, args: Record<string, any> | undefined): boolean =>
579
591
  (tool === "bash" && !isVerificationSignal(String(args?.command ?? args?.cmd ?? ""))) ||
580
592
  (tool === "computer" && computerActionMayAffectState(args));
593
+ const snapshotGated = (tool: string, args: Record<string, any> | undefined): boolean =>
594
+ !scratchCwd && snapshotCandidate(tool, args);
595
+ // Mutation evidence that came ONLY from a shell/computer snapshot bracket, kept apart from definite mutators
596
+ // (write/edit/eval/…): an unrelated writer in the cwd is indistinguishable from the command's own writes, so a
597
+ // shell-only block gets one correction (naming the paths) instead of the full budget.
598
+ let definiteMutation = false;
599
+ const shellChanges: string[] = [];
581
600
  const sandbox = currentSandboxExecution();
582
601
  const parentTools: Record<string, true> = { task: true, subagent: true, irc: true, todo: true, sandbox_request: true };
583
602
  const maxSteps = opts.maxSteps ?? 15;
@@ -703,6 +722,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
703
722
  let verificationCycles = 0;
704
723
  let completionGraceUsed = false;
705
724
  let doneCorrections = 0;
725
+ // Corrections spent on a shell-only block, counted apart so an earlier todo/hook nudge cannot use up the one
726
+ // correction that names the changed paths.
727
+ let shellOnlyCorrections = 0;
706
728
  // Most recent non-empty completion candidate, kept so an exhausted gate still shows it.
707
729
  let lastCompletionCandidate = "";
708
730
  // F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
@@ -1219,11 +1241,23 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1219
1241
  };
1220
1242
  const candidate = stripLeakedReasoningTags(String(toolCalls[0].arguments?.reason ?? ""));
1221
1243
  if (candidate.trim()) lastCompletionCandidate = candidate;
1222
- const doneGate = classifyDoneGate({ ...evidence, pendingHookFailure, unmeasured: unmeasuredSnapshot ?? undefined });
1244
+ const doneGate = classifyDoneGate({
1245
+ ...evidence,
1246
+ pendingHookFailure,
1247
+ unmeasured: unmeasuredSnapshot ?? undefined,
1248
+ shellChanged: !definiteMutation && shellChanges.length ? shellChanges.slice(-3).join(" | ") : undefined,
1249
+ });
1223
1250
  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");
1251
+ // launch's onBeforeDone restates the mutation gate in its own words when the gate blocks; the engine's message
1252
+ // names the paths, so the restatement is dropped and does not count as an independent reason to keep going.
1253
+ const independentNudge = nudge && !(doneGate.block && nudge.startsWith(MUTATION_NUDGE_PREFIX)) ? nudge : "";
1254
+ const rejection = [doneGate.block ? doneGate.message : "", independentNudge].filter(Boolean).join("\n\n");
1225
1255
  if (rejection) {
1226
- if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
1256
+ // Shell-only evidence (no built-in mutator succeeded) cannot tell the command's writes from an unrelated
1257
+ // process's, so it earns one correction naming the paths; every other block keeps the full budget.
1258
+ const shellOnlyBlock = doneGate.block && doneGate.state !== "done_hook_failing" && !definiteMutation && !independentNudge;
1259
+ const correctionLimit = shellOnlyBlock ? GUARD_LIMITS.MAX_SHELL_ONLY_DONE_CORRECTIONS : GUARD_LIMITS.MAX_DONE_CORRECTIONS;
1260
+ if (shellOnlyBlock ? shellOnlyCorrections >= correctionLimit : doneCorrections >= correctionLimit) {
1227
1261
  return finish({
1228
1262
  done: false,
1229
1263
  steps: step,
@@ -1232,9 +1266,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1232
1266
  });
1233
1267
  }
1234
1268
  doneCorrections++;
1269
+ if (shellOnlyBlock) shellOnlyCorrections++;
1235
1270
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
1236
1271
  history.push({ role: "user", content: rejection });
1237
- ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${GUARD_LIMITS.MAX_DONE_CORRECTIONS}`);
1272
+ ev.onNotice?.(`done deferred — completion correction ${shellOnlyBlock ? shellOnlyCorrections : doneCorrections}/${correctionLimit}`);
1238
1273
  step++;
1239
1274
  continue;
1240
1275
  }
@@ -1371,6 +1406,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1371
1406
  let output: string;
1372
1407
  let images: import("../ai/types").ImageAttachment[] | undefined;
1373
1408
  let mutationMayHaveOccurred: boolean | undefined;
1409
+ let workspace: import("../ai/types").ToolResultRecord["workspace"];
1410
+ let bracketed = false;
1374
1411
  if (tool === "done") {
1375
1412
  success = false;
1376
1413
  output = "Error: 'done' can only be called as the single tool invocation, not in a batch. Please send 'done' alone.";
@@ -1393,6 +1430,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1393
1430
  success = false;
1394
1431
  output = preHookResult.error + (preHookResult.output ? `\n${preHookResult.output}` : "");
1395
1432
  } 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`);
1438
+ }
1439
+ }
1396
1440
  // Taken after pre-tool hooks so hook writes are never attributed to the call.
1397
1441
  const before = snapshotGated(tool, args) ? await takeSnapshot() : undefined;
1398
1442
  try {
@@ -1423,7 +1467,16 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1423
1467
  // bash/computer: the workspace diff is the sole mutation authority (overrides
1424
1468
  // handler flags and the catch heuristic). Both sides share one racy anchor so
1425
1469
  // an unchanged recently-modified file is hashed on both sides or neither.
1426
- mutationMayHaveOccurred = before.ok ? workspaceSnapshotChanged(before, await takeSnapshot(before.startedAtMs)) : true;
1470
+ const after = before.ok ? await takeSnapshot(before.startedAtMs) : undefined;
1471
+ mutationMayHaveOccurred = after ? workspaceSnapshotChanged(before, after) : true;
1472
+ bracketed = true;
1473
+ 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
+ };
1427
1480
  }
1428
1481
  // A pre-tool hook's structured `additionalContext` rides along with the result
1429
1482
  // so the model sees it next to the output it concerns.
@@ -1431,7 +1484,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1431
1484
  }
1432
1485
  }
1433
1486
  }
1434
- return { success, output, images, mutationMayHaveOccurred };
1487
+ return { success, output, images, mutationMayHaveOccurred, bracketed, workspace };
1435
1488
  };
1436
1489
 
1437
1490
  const WRITE_TOOLS = new Set(["write", "edit"]);
@@ -1474,7 +1527,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1474
1527
  }
1475
1528
  }
1476
1529
 
1477
- const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[]; mutationMayHaveOccurred?: boolean }[] = Array.from(
1530
+ 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
1531
  { length: toolCalls.length },
1479
1532
  () => ({ success: false, output: "", executed: false })
1480
1533
  );
@@ -1563,6 +1616,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1563
1616
  id: idFor(idx),
1564
1617
  output: bodies[i],
1565
1618
  isError: !results[idx].success,
1619
+ ...(results[idx].workspace ? { workspace: results[idx].workspace } : {}),
1566
1620
  }));
1567
1621
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf, toolUse);
1568
1622
  const resultMsg: Message = { role: "user", content: resultBlocks.join("\n\n"), toolResults };
@@ -1607,11 +1661,16 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1607
1661
  if (results[i].success && verificationProducedEvidence(command, String(results[i].output ?? ""))) {
1608
1662
  sawVerification = true;
1609
1663
  lastVerificationSeq = ++mutVerSeq;
1610
- if (!pendingHookFailure) doneCorrections = 0;
1664
+ // A passing verification supersedes the evidence before it: what happens next is judged on its own.
1665
+ definiteMutation = false;
1666
+ shellChanges.length = 0;
1667
+ unmeasuredSnapshot = null;
1668
+ if (!pendingHookFailure) { doneCorrections = 0; shellOnlyCorrections = 0; }
1611
1669
  } else {
1612
1670
  sawVerification = false;
1613
1671
  if (results[i].mutationMayHaveOccurred === true) {
1614
1672
  sawMutation = true;
1673
+ definiteMutation = true; // a verifier is never snapshot-bracketed: this flag came from the handler itself
1615
1674
  lastMutationSeq = ++mutVerSeq;
1616
1675
  }
1617
1676
  }
@@ -1619,6 +1678,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1619
1678
  }
1620
1679
  if (t === "todo" && results[i].success && doneCorrections > 0 && (!sawMutation || (sawVerification && lastMutationSeq < lastVerificationSeq)) && !pendingHookFailure) {
1621
1680
  doneCorrections = 0;
1681
+ shellOnlyCorrections = 0;
1622
1682
  }
1623
1683
 
1624
1684
  const output = String(results[i].output ?? "").trim();
@@ -1638,19 +1698,24 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1638
1698
  if (results[i].success || results[i].mutationMayHaveOccurred === true) {
1639
1699
  sawMutation = true;
1640
1700
  lastMutationSeq = ++mutVerSeq;
1701
+ if (results[i].bracketed !== true) definiteMutation = true;
1702
+ else if (results[i].workspace?.changed) shellChanges.push(results[i].workspace!.changed!);
1641
1703
  }
1642
1704
  } else if (t === "debug" && MUTATING_DEBUG_ACTIONS[String(toolCalls[i].arguments?.action ?? "")] === true) {
1643
1705
  sawMutation = true;
1706
+ definiteMutation = true;
1644
1707
  lastMutationSeq = ++mutVerSeq;
1645
1708
  } else if (t === "monitor" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
1646
1709
  // Starting a background monitor can mutate the workspace asynchronously;
1647
1710
  // read-only monitor controls (list/tail/await/cancel) do not invalidate verification.
1648
1711
  sawMutation = true;
1712
+ definiteMutation = true;
1649
1713
  lastMutationSeq = ++mutVerSeq;
1650
1714
  } else if (t === "job" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
1651
1715
  // Starting a background shell may mutate the workspace asynchronously;
1652
1716
  // read-only job controls (list/tail/await/cancel) do not invalidate verification.
1653
1717
  sawMutation = true;
1718
+ definiteMutation = true;
1654
1719
  lastMutationSeq = ++mutVerSeq;
1655
1720
  }
1656
1721
  }
@@ -36,6 +36,13 @@ export type GuardState =
36
36
  * Kept in one frozen object so the limits are discoverable and testable instead of
37
37
  * sprinkled as bare literals through the loop body.
38
38
  */
39
+ /**
40
+ * Prefix of the launch-level `onBeforeDone` nudge that merely restates the mutation-verification gate. The engine's
41
+ * own `classifyDoneGate` already blocks (and explains) that case, so the engine must not count the restatement as an
42
+ * independent block — otherwise the shell-only correction cap could never apply in the interactive loop.
43
+ */
44
+ export const MUTATION_NUDGE_PREFIX = "Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification.";
45
+
39
46
  export const GUARD_LIMITS = Object.freeze({
40
47
  /** Identical step repeats tolerated before a consolidated stop (with corrections en route). */
41
48
  MAX_REPEAT: 4,
@@ -43,6 +50,10 @@ export const GUARD_LIMITS = Object.freeze({
43
50
  MAX_FAILURES: 5,
44
51
  /** Rejected completion attempts that may request a correction before stopping incomplete. */
45
52
  MAX_DONE_CORRECTIONS: 3,
53
+ /** Same, when the only mutation evidence is a shell/computer snapshot bracket (no built-in mutator
54
+ * succeeded). An unrelated process writing in the cwd looks identical to the command's own writes, so
55
+ * one correction states the paths and asks for verification; a second full-history retry only burns tokens. */
56
+ MAX_SHELL_ONLY_DONE_CORRECTIONS: 1,
46
57
  /** Context-mutating refusal-ladder rungs (resend → reset → guidance strip) before
47
58
  * recovery switches to unbounded backoff resends (gjc parity: never terminal). */
48
59
  MAX_REFUSAL_RETRIES: 3,
@@ -185,7 +196,7 @@ export const UNVERIFIED_MARKER = "UNVERIFIED";
185
196
  * model's last candidate answer, never presents it as accepted, and keeps the gate reason.
186
197
  */
187
198
  export function formatRejectedCompletion(candidate: string, corrections: number, rejection: string): string {
188
- const stop = `Stopped: completion rejected after ${corrections} corrections. ${rejection}`;
199
+ const stop = `Stopped: completion rejected after ${corrections} correction${corrections === 1 ? "" : "s"}. ${rejection}`;
189
200
  const answer = candidate.trim();
190
201
  if (!answer) return stop;
191
202
  return `${UNVERIFIED_MARKER} - the completion gate did not accept this answer; treat it as unconfirmed.\n\n${answer}\n\n${stop}`;
@@ -239,6 +250,11 @@ export interface DoneGateInput {
239
250
  * instead of claiming files were modified.
240
251
  */
241
252
  unmeasured?: string;
253
+ /**
254
+ * Paths a shell call's before/after snapshot saw change (`modified a.json; added b.txt`), when the evidence
255
+ * came from a shell bracket. Lets the model and user judge whether the command or an unrelated process wrote them.
256
+ */
257
+ shellChanged?: string;
242
258
  }
243
259
 
244
260
  /** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
@@ -263,10 +279,15 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
263
279
  const stale = input.sawVerification && input.verificationStale === true;
264
280
  const block = input.sawMutation && (!input.sawVerification || stale || hookFailing);
265
281
  if (!block) return { state: "done_ok", block: false, message: "" };
266
- const unmeasuredNote = input.unmeasured
267
- ? ` Note: the host could not measure a shell call's effect on the workspace (${input.unmeasured}), so it is counted as a possible file change; ` +
268
- "if every command you ran was read-only, say so in your reply."
282
+ // A reply alone never clears this gate: only a passing verification does, or the budget running out.
283
+ const escape = "Only a passing verification command clears this check; if none applies, the answer is returned labelled UNVERIFIED once the correction budget is spent.";
284
+ const changedNote = input.shellChanged
285
+ ? ` Note: around a shell/computer call the host saw these paths change: ${input.shellChanged}. ` +
286
+ `Something other than your commands may have written them (another process, or your own background job). ${escape}`
269
287
  : "";
288
+ const unmeasuredNote = changedNote + (input.unmeasured
289
+ ? ` Note: the host could not measure a shell/computer call's effect on the workspace (${input.unmeasured}), so it is counted as a possible file change. ${escape}`
290
+ : "");
270
291
  if (hookFailing) {
271
292
  return {
272
293
  state: "done_hook_failing",