jeo-code 0.12.20 → 0.12.21

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,14 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.12.21] - 2026-11-04
13
+
14
+ ### Fixed
15
+ - **Completion gate no longer blocks read-only turns** - a `bash` or `computer` call now counts as a workspace mutation only when a host-side snapshot of the session workspace taken right before and after the call differs (`.git`, `.jeo` and `node_modules` excluded; content changes to an already-modified file are detected). Read-only commands such as `date`, `ls -a`, `osascript` or opening a browser tab complete without a test run. A failed or over-cap snapshot still counts as a mutation, and `write`, `edit`, mutating `debug` actions and `job`/`monitor` starts are unchanged.
16
+ - **Wrapped verification commands are recognized** - `cd <dir> && <verifier>`, `VAR=value` and `env -u NAME` prefixes, `timeout N`, `2>&1`, `uv run` / `poetry run` / `pipenv run`, absolute or relative paths to a verifier binary, and the `rtk` / `rtk test` wrappers now count as verification. Pipes, `;`, `||`, other redirections, command substitution and the existing mutating or non-executing flag exclusions are still rejected.
17
+ - **Unverified state no longer leaks across turns** - an earlier turn's unverified mutation blocks completion of later turns only while a `/goal` is active; ordinary turns and messenger thread follow-ups are judged on their own evidence.
18
+ - **Exhausted completions keep the answer** - when the gate rejects `done` after the maximum corrections, the result stays incomplete but shows the model's last answer under an `UNVERIFIED` marker with the gate reason, instead of replacing it. Messenger workers send that marked answer with a failure reaction (new `JEO_MESSENGER_UNVERIFIED` worker line) instead of the generic "The request could not be completed", and keep serving follow-ups. Route fallback, failover hints and the task tool's rate-limit reroute ignore rejected completions.
19
+
12
20
  ## [0.12.20] - 2026-11-04
13
21
 
14
22
  The GitHub-only 0.12.19 release was not published to npm (its publish run failed a welcome-banner test); its changes, listed under [0.12.19], are included in this release.
@@ -2716,7 +2724,8 @@ _Initial release._
2716
2724
  ### Added
2717
2725
  - Initial jeo-code agent and CLI.
2718
2726
 
2719
- [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.20...HEAD
2727
+ [Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.21...HEAD
2728
+ [0.12.21]: https://github.com/akillness/jeo-code/releases/tag/v0.12.21
2720
2729
  [0.12.20]: https://github.com/akillness/jeo-code/releases/tag/v0.12.20
2721
2730
  [0.12.19]: https://github.com/akillness/jeo-code/releases/tag/v0.12.19
2722
2731
  [0.12.18]: https://github.com/akillness/jeo-code/releases/tag/v0.12.18
package/README.ja.md CHANGED
@@ -447,10 +447,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
447
447
 
448
448
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
449
449
  - **[Unreleased]**
450
+ - **[0.12.21]** (2026-11-04)
450
451
  - **[0.12.20]** (2026-11-04)
451
452
  - **[0.12.19]** (2026-11-04)
452
453
  - **[0.12.18]** (2026-11-04)
453
- - **[0.12.17]** (2026-11-04)
454
454
 
455
455
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
456
456
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -502,10 +502,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
502
502
 
503
503
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
504
504
  - **[Unreleased]**
505
+ - **[0.12.21]** (2026-11-04)
505
506
  - **[0.12.20]** (2026-11-04)
506
507
  - **[0.12.19]** (2026-11-04)
507
508
  - **[0.12.18]** (2026-11-04)
508
- - **[0.12.17]** (2026-11-04)
509
509
 
510
510
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
511
511
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -491,10 +491,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
491
491
 
492
492
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
493
493
  - **[Unreleased]**
494
+ - **[0.12.21]** (2026-11-04)
494
495
  - **[0.12.20]** (2026-11-04)
495
496
  - **[0.12.19]** (2026-11-04)
496
497
  - **[0.12.18]** (2026-11-04)
497
- - **[0.12.17]** (2026-11-04)
498
498
 
499
499
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
500
500
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -445,10 +445,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
445
445
 
446
446
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
447
447
  - **[Unreleased]**
448
+ - **[0.12.21]** (2026-11-04)
448
449
  - **[0.12.20]** (2026-11-04)
449
450
  - **[0.12.19]** (2026-11-04)
450
451
  - **[0.12.18]** (2026-11-04)
451
- - **[0.12.17]** (2026-11-04)
452
452
 
453
453
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
454
454
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.12.20",
3
+ "version": "0.12.21",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
 
@@ -29,7 +29,8 @@ The core runtime loop, tool registry, session management, and state persistence
29
29
  | `hooks.ts` | Brief description of purpose |
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 (`isVerificationSignal`, `repeatHint`, `classifyDoneGate` — incl. `done_stale_verification` for a passing test/build that predates the last edit) consumed by `engine.ts` |
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
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 |
34
35
  | `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
35
36
  | `memory-graph.ts` | Concept cross-link graph: build/expand (1-hop search), broken-link-tolerant lint, optional graphify detection |
@@ -32,7 +32,8 @@ 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 } from "./loop-guards";
35
+ import { GUARD_LIMITS, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate, formatRejectedCompletion } from "./loop-guards";
36
+ import { defaultWorkspaceSnapshotter, workspaceSnapshotChanged, type WorkspaceSnapshot, type WorkspaceSnapshotter } from "./workspace-snapshot";
36
37
  import { stripLeakedReasoningTags } from "../ai/think-tags";
37
38
 
38
39
  const MID_TASK_PROSE_RE =
@@ -407,6 +408,9 @@ export interface AgentLoopOptions {
407
408
  * just the max-token ceiling. When unset the model-manager falls back to the global config. */
408
409
  reasoningEffort?: import("../ai/types").CallOptions["reasoningEffort"];
409
410
  tools?: Record<string, ToolHandler>;
411
+ /** Host-side workspace fingerprint used to decide whether a bash/computer call mutated the
412
+ * workspace. Defaults to defaultWorkspaceSnapshotter; tests inject fakes. */
413
+ workspaceSnapshot?: WorkspaceSnapshotter;
410
414
  signal?: AbortSignal;
411
415
  events?: AgentLoopEvents;
412
416
  /** Step-budget overrides (gjc-style retry flow). `{ maxExtensions: 0 }` restores the
@@ -461,6 +465,9 @@ export interface AgentLoopResult {
461
465
  * recover from (repeated/cycled/consecutively-failing calls). Lets the caller
462
466
  * capture the stall into failure memory; `undefined` for done/budget/cancelled. */
463
467
  stopClass?: "consecutive_failure" | "cycle" | "repeat";
468
+ /** True when the turn ended because completion was rejected MAX_DONE_CORRECTIONS times; doneReason then
469
+ * carries the last candidate answer under the UNVERIFIED marker. Never set together with done:true. */
470
+ completionRejected?: boolean;
464
471
  }
465
472
 
466
473
 
@@ -538,6 +545,25 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
538
545
  history.unshift({ role: "system", content: opts.systemPrompt });
539
546
  }
540
547
  const tools = opts.tools ?? DEFAULT_TOOLS;
548
+ 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;
552
+ const takeSnapshot = async (racySinceMs?: number): Promise<WorkspaceSnapshot> => {
553
+ if (snapshotCapped) return { ok: false, reason: "cap", detail: "workspace exceeded the snapshot cap earlier this turn" };
554
+ try {
555
+ const snap = await snapshotter(cwd, racySinceMs);
556
+ if (!snap.ok && snap.reason === "cap") snapshotCapped = true;
557
+ return snap;
558
+ } catch (err) {
559
+ return { ok: false, reason: "error", detail: err instanceof Error ? err.message : String(err) };
560
+ }
561
+ };
562
+ // bash/computer calls whose mutation status is decided by a workspace diff. Recognized
563
+ // verifiers are never mutations, and observe-only computer actions are read-only.
564
+ const snapshotGated = (tool: string, args: Record<string, any> | undefined): boolean =>
565
+ (tool === "bash" && !isVerificationSignal(String(args?.command ?? args?.cmd ?? ""))) ||
566
+ (tool === "computer" && computerActionMayAffectState(args));
541
567
  const sandbox = currentSandboxExecution();
542
568
  const parentTools: Record<string, true> = { task: true, subagent: true, irc: true, todo: true, sandbox_request: true };
543
569
  const maxSteps = opts.maxSteps ?? 15;
@@ -663,6 +689,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
663
689
  let verificationCycles = 0;
664
690
  let completionGraceUsed = false;
665
691
  let doneCorrections = 0;
692
+ // Most recent non-empty completion candidate, kept so an exhausted gate still shows it.
693
+ let lastCompletionCandidate = "";
666
694
  // F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
667
695
  // diagnostics the model saw but has not yet resolved (a later clean hook run
668
696
  // clears it). The done guard treats this as "verification missing" — the hook
@@ -1070,6 +1098,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1070
1098
  (hasTools && looksLikeMidTaskProse(trimmed));
1071
1099
  if (trimmed && (parseFailures > MAX_PARSE_BOUNCES || (!trimmed.includes("{") && !midTurnInProgress))) {
1072
1100
  const candidateReason = stripLeakedReasoningTags(trimmed) || trimmed;
1101
+ if (candidateReason.trim()) lastCompletionCandidate = candidateReason;
1073
1102
  if (parseFailures <= MAX_PARSE_BOUNCES && ev.onBeforeDone) {
1074
1103
  const preCheck = await ev.onBeforeDone(candidateReason, {
1075
1104
  sawMutation,
@@ -1078,7 +1107,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1078
1107
  });
1079
1108
  if (preCheck) {
1080
1109
  if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
1081
- return finish({ done: false, steps: step, doneReason: `Stopped: completion rejected after ${doneCorrections} corrections. ${preCheck}` });
1110
+ return finish({
1111
+ done: false,
1112
+ steps: step,
1113
+ completionRejected: true,
1114
+ doneReason: formatRejectedCompletion(lastCompletionCandidate, doneCorrections, preCheck),
1115
+ });
1082
1116
  }
1083
1117
  doneCorrections++;
1084
1118
  ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${GUARD_LIMITS.MAX_DONE_CORRECTIONS}`);
@@ -1169,12 +1203,19 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1169
1203
  sawVerification,
1170
1204
  verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
1171
1205
  };
1206
+ const candidate = stripLeakedReasoningTags(String(toolCalls[0].arguments?.reason ?? ""));
1207
+ if (candidate.trim()) lastCompletionCandidate = candidate;
1172
1208
  const doneGate = classifyDoneGate({ ...evidence, pendingHookFailure });
1173
1209
  const nudge = await ev.onBeforeDone?.((toolCalls[0].arguments?.reason as string) ?? "", evidence);
1174
1210
  const rejection = [doneGate.block ? doneGate.message : "", nudge].filter(Boolean).join("\n\n");
1175
1211
  if (rejection) {
1176
1212
  if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
1177
- return finish({ done: false, steps: step, doneReason: `Stopped: completion rejected after ${doneCorrections} corrections. ${rejection}` });
1213
+ return finish({
1214
+ done: false,
1215
+ steps: step,
1216
+ completionRejected: true,
1217
+ doneReason: formatRejectedCompletion(lastCompletionCandidate, doneCorrections, rejection),
1218
+ });
1178
1219
  }
1179
1220
  doneCorrections++;
1180
1221
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
@@ -1338,6 +1379,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1338
1379
  success = false;
1339
1380
  output = preHookResult.error + (preHookResult.output ? `\n${preHookResult.output}` : "");
1340
1381
  } else {
1382
+ // Taken after pre-tool hooks so hook writes are never attributed to the call.
1383
+ const before = snapshotGated(tool, args) ? await takeSnapshot() : undefined;
1341
1384
  try {
1342
1385
  const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
1343
1386
  const sandboxedHandler = sandbox && !Object.hasOwn(parentTools, tool) && !SANDBOX_MEDIATED_TOOLS.has(handler)
@@ -1362,6 +1405,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1362
1405
  ((tool === "monitor" || tool === "job") && String(args?.action ?? "").trim().toLowerCase() === "start");
1363
1406
  if (explicitSignal || knownMutatingCall) mutationMayHaveOccurred = true;
1364
1407
  }
1408
+ if (before) {
1409
+ // bash/computer: the workspace diff is the sole mutation authority (overrides
1410
+ // handler flags and the catch heuristic). Both sides share one racy anchor so
1411
+ // an unchanged recently-modified file is hashed on both sides or neither.
1412
+ mutationMayHaveOccurred = before.ok ? workspaceSnapshotChanged(before, await takeSnapshot(before.startedAtMs)) : true;
1413
+ }
1365
1414
  }
1366
1415
  }
1367
1416
  }
@@ -1528,6 +1577,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1528
1577
  // mutation evidence before it, but anything after that verification remains
1529
1578
  // stale. Verification commands are shell calls, so do not let a wrapper's
1530
1579
  // mutationMayHaveOccurred flag turn the verifier itself into a later mutation.
1580
+ // Non-verifier bash and state-changing computer calls carry an explicit
1581
+ // mutationMayHaveOccurred from the workspace snapshot bracket in executeTool,
1582
+ // which flows through the structured-mutation branch below.
1531
1583
  for (let i = 0; i < toolCalls.length; i++) {
1532
1584
  if (!results[i].executed) continue;
1533
1585
  const t = toolCalls[i].tool;
@@ -1573,10 +1625,6 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1573
1625
  } else if (t === "debug" && MUTATING_DEBUG_ACTIONS[String(toolCalls[i].arguments?.action ?? "")] === true) {
1574
1626
  sawMutation = true;
1575
1627
  lastMutationSeq = ++mutVerSeq;
1576
- } else if (t === "computer" && computerActionMayAffectState(toolCalls[i].arguments)) {
1577
- // Failed UI actions may have partially changed external state.
1578
- sawMutation = true;
1579
- lastMutationSeq = ++mutVerSeq;
1580
1628
  } else if (t === "monitor" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
1581
1629
  // Starting a background monitor can mutate the workspace asynchronously;
1582
1630
  // read-only monitor controls (list/tail/await/cancel) do not invalidate verification.
@@ -1587,10 +1635,6 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1587
1635
  // read-only job controls (list/tail/await/cancel) do not invalidate verification.
1588
1636
  sawMutation = true;
1589
1637
  lastMutationSeq = ++mutVerSeq;
1590
- } else if (t === "bash") {
1591
- // Even simple commands can run shell startup hooks or inherited functions.
1592
- sawMutation = true;
1593
- lastMutationSeq = ++mutVerSeq;
1594
1638
  }
1595
1639
  }
1596
1640
  await ev.onEvidence?.({
@@ -77,40 +77,120 @@ const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--l
77
77
  const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--write|--update|-u|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
78
78
  const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
79
79
 
80
- function isSingleVerificationSegment(segment: string): boolean {
81
- const command = segment.trim();
82
- if (!command) return false;
83
- const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(command)
84
- || /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(command);
85
- return !shortNoOp && !/[;&|<>`$(){}\n\r]/.test(command) && VERIFY_SIGNAL_RE.test(command) && !NONEXECUTING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_SCRIPT_RE.test(command);
80
+ // ── Verifier command grammar ──────────────────────────────────────────────────
81
+ // command := segment ( "&&" segment )* after removing every standalone `2>&1` token
82
+ // segment[0] := cdSeg | verifierSeg cdSeg only as the first segment, and only when >=1 segment follows
83
+ // segment[i] := verifierSeg (i >= 1)
84
+ // verifierSeg:= prefix* core prefixes: `NAME=VALUE`, `env [-u NAME]... [NAME=VALUE]...`,
85
+ // `timeout N[s|m]`, `uv|poetry|pipenv run`, `rtk [test]`
86
+ // core := first `/`-bearing token replaced by its basename, then VERIFY_SIGNAL_RE with no
87
+ // non-executing / mutating flag, no mutating script, and no short no-op form.
88
+ // Only plain tokens are accepted: `$`, backticks, quotes and parentheses never match the token classes
89
+ // and are rejected by the global metacharacter check. Every prefix propagates the verifier's exit code.
90
+ const STDERR_MERGE_RE = /(^|\s)2>&1(?=\s|$)/g;
91
+ const ASSIGN_PREFIX_RE = /^([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*\s+/;
92
+ const ENV_PREFIX_RE = /^env(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)*(?:\s+([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*)*\s+/;
93
+ const TIMEOUT_PREFIX_RE = /^timeout\s+\d+[sm]?\s+/;
94
+ const RUNNER_PREFIX_RE = /^(?:uv|poetry|pipenv)\s+run\s+/;
95
+ const RTK_PREFIX_RE = /^rtk\s+(?:test\s+)?/;
96
+ const CD_SEGMENT_RE = /^cd\s+[A-Za-z0-9_./:@+,=~-]+$/;
97
+ const BINARY_PATH_RE = /^([A-Za-z0-9_.@+-]*\/[A-Za-z0-9_./@+-]*)(?=\s|$)/;
98
+ /** Env names that are the env-form of an update/snapshot flag (parity with MUTATING_VERIFY_FLAG_RE). */
99
+ const MUTATING_VERIFY_ENV_RE = /UPDATE|SNAPSHOT|GOLDEN/i;
100
+ const ENV_ASSIGNMENT_NAME_RE = /([A-Za-z_][A-Za-z0-9_]*)=/g;
101
+
102
+ /**
103
+ * Normalized verifier invocations (cores) of a command, or null when the command is not a
104
+ * pure recognized verification. Wrapper prefixes and a leading `cd <path> &&` are stripped,
105
+ * so flag/script exclusions are judged on the verifier itself, never on the wrappers.
106
+ */
107
+ export function verifierCores(cmd: string): string[] | null {
108
+ const command = cmd.trim().replace(STDERR_MERGE_RE, "$1").trim();
109
+ if (!command) return null;
110
+ // Pipes, `;`, `||`, redirections, substitution, grouping and multi-line scripts.
111
+ if (/[;&|<>`$(){}\n\r]/.test(command.replace(/&&/g, " "))) return null;
112
+ const segments = command.split("&&").map(s => s.trim());
113
+ if (segments.some(s => !s)) return null;
114
+ const start = segments.length > 1 && CD_SEGMENT_RE.test(segments[0]) ? 1 : 0;
115
+ const cores: string[] = [];
116
+ for (const segment of segments.slice(start)) {
117
+ const core = verifierCore(segment);
118
+ if (!core) return null;
119
+ cores.push(core);
120
+ }
121
+ return cores.length ? cores : null;
122
+ }
123
+
124
+ function verifierCore(segment: string): string | null {
125
+ let rest = segment;
126
+ for (;;) { // terminates: every successful match strictly shortens `rest`
127
+ const assign = ASSIGN_PREFIX_RE.exec(rest);
128
+ if (assign) {
129
+ if (MUTATING_VERIFY_ENV_RE.test(assign[1])) return null;
130
+ rest = rest.slice(assign[0].length);
131
+ continue;
132
+ }
133
+ const env = ENV_PREFIX_RE.exec(rest);
134
+ if (env) {
135
+ if ([...env[0].matchAll(ENV_ASSIGNMENT_NAME_RE)].some(m => MUTATING_VERIFY_ENV_RE.test(m[1]))) return null;
136
+ rest = rest.slice(env[0].length);
137
+ continue;
138
+ }
139
+ const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest);
140
+ if (other) {
141
+ rest = rest.slice(other[0].length);
142
+ continue;
143
+ }
144
+ break;
145
+ }
146
+ const bin = BINARY_PATH_RE.exec(rest);
147
+ if (bin) rest = bin[1].slice(bin[1].lastIndexOf("/") + 1) + rest.slice(bin[1].length);
148
+ const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(rest)
149
+ || /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(rest);
150
+ return !shortNoOp && VERIFY_SIGNAL_RE.test(rest) && !NONEXECUTING_VERIFY_FLAG_RE.test(rest)
151
+ && !MUTATING_VERIFY_FLAG_RE.test(rest) && !MUTATING_VERIFY_SCRIPT_RE.test(rest) ? rest : null;
86
152
  }
87
153
 
88
154
  /**
89
- * True only when the command itself is a recognized verifier invocation (or a
90
- * pure `&&` chain where every segment is a recognized verifier invocation). Output
91
- * is never evidence: arbitrary commands can print words such as "tests passed".
155
+ * True only when the command itself is a recognized verifier invocation (or a pure `&&`
156
+ * chain of them, optionally led by `cd <path> &&` and wrapped in exit-code-propagating
157
+ * prefixes — see {@link verifierCores}). Output is never evidence: arbitrary commands can
158
+ * print words such as "tests passed".
92
159
  */
93
160
  export function isVerificationSignal(cmd: string): boolean {
94
- const command = cmd.trim();
95
- if (!command) return false;
96
- const withoutAndChain = command.replace(/&&/g, " ");
97
- if (/[;&|<>`$(){}\n\r]/.test(withoutAndChain)) return false;
98
- const segments = command.split("&&").map(s => s.trim());
99
- return segments.length > 0 && segments.every(isSingleVerificationSegment);
161
+ return verifierCores(cmd) !== null;
100
162
  }
101
163
 
102
- /** Require test-run evidence for Go and Cargo's recognized text output. */
164
+ /**
165
+ * Require test-run evidence for Go and Cargo's recognized text output. Judged on the
166
+ * verifier cores, so a wrapped `timeout 60 go test ./...` or `cd x && cargo test` still
167
+ * needs real test evidence.
168
+ */
103
169
  export function verificationProducedEvidence(cmd: string, output: string): boolean {
104
- if (/^go\s+test(?:\s|$)/.test(cmd.trim())) {
170
+ const cores = verifierCores(cmd) ?? [cmd.trim()];
171
+ if (cores.some(c => /^go\s+test(?:\s|$)/.test(c))) {
105
172
  const summaries = output.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
106
- return summaries.some(line => !/\[no tests to run\]|\[no test files\]/i.test(line));
107
- }
108
- if (/^cargo\s+test(?:\s|$)/.test(cmd.trim())) {
109
- return [...output.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0);
173
+ if (!summaries.some(line => !/\[no tests to run\]|\[no test files\]/i.test(line))) return false;
110
174
  }
175
+ if (cores.some(c => /^cargo\s+test(?:\s|$)/.test(c))
176
+ && ![...output.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0)) return false;
111
177
  return true;
112
178
  }
113
179
 
180
+ /** Marker that prefixes a candidate answer the completion gate never accepted. */
181
+ export const UNVERIFIED_MARKER = "UNVERIFIED";
182
+
183
+ /**
184
+ * Result text when completion was rejected GUARD_LIMITS.MAX_DONE_CORRECTIONS times. Keeps the
185
+ * model's last candidate answer, never presents it as accepted, and keeps the gate reason.
186
+ */
187
+ export function formatRejectedCompletion(candidate: string, corrections: number, rejection: string): string {
188
+ const stop = `Stopped: completion rejected after ${corrections} corrections. ${rejection}`;
189
+ const answer = candidate.trim();
190
+ if (!answer) return stop;
191
+ return `${UNVERIFIED_MARKER} - the completion gate did not accept this answer; treat it as unconfirmed.\n\n${answer}\n\n${stop}`;
192
+ }
193
+
114
194
  /**
115
195
  * Result-aware repeat nudge: tells the model WHY repeating the call won't help and what
116
196
  * to try instead, tailored to the repeated tool and its last actual result. When the