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.
@@ -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,
@@ -71,57 +82,308 @@ export const GUARD_LIMITS = Object.freeze({
71
82
  /**
72
83
  * Recognized standalone verification commands. Matching the full command prevents
73
84
  * filenames, prose, and shell output from impersonating verification evidence.
85
+ *
86
+ * Two tables share one argument grammar: TEST_CORES run tests and print a pass summary, so output can
87
+ * corroborate them; SILENT_CORES (type checkers, linters, formatters in check mode, builds) report only through
88
+ * their exit status and print little or nothing on success.
89
+ */
90
+ // Argument tail: plain tokens or balanced quoted strings (`pytest -k "foo and bar"`); `$`, backticks and
91
+ // backslashes never appear inside it, so nothing in a quoted argument can expand or escape.
92
+ const ARG = String.raw`(?:\s+(?:[A-Za-z0-9_./:@+*?,=-]+|"[^"$\x60\\\r\n]*"|'[^'$\x60\\\r\n]*'))*`;
93
+ const PM_VALUE = String.raw`[A-Za-z0-9_./:@+*?,~^-]+`;
94
+ // Workspace / directory selection that may sit between a package manager and its script.
95
+ const PM_OPT = String.raw`(?:\s+(?:-r|--recursive|--workspaces|--silent|-s|--quiet|-q)|\s+(?:--filter|-F|--prefix|--cwd|-C|--workspace|-w|--dir)(?:=|\s+)${PM_VALUE})*`;
96
+ const PM = String.raw`(?:(?:bun|npm|pnpm|yarn)${PM_OPT}|yarn\s+workspace\s+[A-Za-z0-9_./@-]+)(?:\s+(?:run|exec|x|dlx))?${PM_OPT}`;
97
+ // make/just/rake/gradle/mvn take TARGETS (further commands), not script arguments: `make ci deploy` would run
98
+ // `deploy`. Their tails are therefore a closed grammar: any flag (judged by TASK_DENY below) together with the value a
99
+ // value-taking flag consumes, NAME=VALUE, and allow-listed targets. Any other bare token rejects the command.
100
+ const TARGETS = ["test", "tests", "check", "ci", "verify", "lint", "typecheck", "type-check", "vet", "clean", "build", "compile", "all", "unit", "integration", "e2e", "coverage", "format-check", "spec"];
101
+ const BUILD_TEST_TARGETS = ["test", "tests", "ci", "verify", "spec", "unit", "integration", "e2e", "coverage"];
102
+ const BUILD_SILENT_TARGETS = ["check", "lint", "typecheck", "type-check", "vet", "build", "compile", "format-check"];
103
+ // NAME=VALUE: never an update/snapshot/skip switch and never one of make's program-selecting variables.
104
+ const ASSIGN = String.raw`(?![A-Za-z0-9_]*(?:UPDATE|SNAPSHOT|GOLDEN|SKIP|IGNORE)[A-Za-z0-9_]*=)(?!(?:SHELL|MAKE|MAKEFLAGS|MAKEFILES)=)[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_./:@+,%=-]*`;
105
+ const TASK_VALUE = String.raw`[A-Za-z0-9_./:@+*?,~^#!%=-]`;
106
+ const QUOTED_VALUE = String.raw`(?:${TASK_VALUE}+|"[^"$\x60\\\r\n]*"|'[^'$\x60\\\r\n]*')`;
107
+ const DOTNET_PROJECT = String.raw`(?!-)[A-Za-z0-9_~-]*[./][A-Za-z0-9_./~-]*`;
108
+ interface TaskSpec {
109
+ /** Flags that consume the next token (or `=value`) as their argument, as a regex alternation. */
110
+ value: string;
111
+ /** Flags whose argument is an OPTIONAL number (`make -j` vs `make -j 4`). */
112
+ numeric?: string;
113
+ /** Whether bare `NAME=VALUE` tokens are accepted (make, just, rake). */
114
+ assign?: boolean;
115
+ /** Whether targets may carry a Gradle project path (`:app:test`); only the LAST segment must be allow-listed. */
116
+ path?: boolean;
117
+ /** Value token shape; defaults to unquoted TASK_VALUE. */
118
+ token?: string;
119
+ }
120
+ const MAKE_SPEC: TaskSpec = { value: "-C|--directory|-f|--file|--makefile|-I|--include-dir", numeric: "-j|--jobs|-l|--load-average|--max-load", assign: true };
121
+ const JUST_SPEC: TaskSpec = { value: "-f|--justfile|-d|--working-directory", assign: true };
122
+ const RAKE_SPEC: TaskSpec = { value: "-f|--rakefile|-I|--libdir|-R|--rakelibdir", numeric: "-j|--jobs", assign: true };
123
+ const GRADLE_SPEC: TaskSpec = { value: "-p|--project-dir|-b|--build-file|-c|--settings-file|-g|--gradle-user-home|--tests|--max-workers|--console|--warning-mode|--project-cache-dir", path: true };
124
+ const MVN_SPEC: TaskSpec = { value: "-pl|--projects|-f|--file|-T|--threads|-P|--activate-profiles|-s|--settings|-gs|--global-settings|-t|--toolchains|-rf|--resume-from|-l|--log-file" };
125
+ const DOTNET_SPEC: TaskSpec = { value: "-c|--configuration|-f|--framework|-v|--verbosity|-l|--logger|--filter|-r|--runtime|-a|--arch|--os|-s|--settings" };
126
+ /**
127
+ * One flag token: a value-taking flag with its argument, an optional-number flag, any other `-x`/`--long[=v]` flag, or
128
+ * (if enabled) NAME=VALUE. The generic alternative excludes the exact names handled by the first two, so every token
129
+ * has ONE parse (`-C test` consumes `test` as the directory) and matching stays linear. Whether a flag is acceptable is
130
+ * decided afterwards by TASK_DENY on the whole command, never by this grammar.
131
+ */
132
+ function taskFlag(spec: TaskSpec): string {
133
+ const token = QUOTED_VALUE;
134
+ const names = spec.numeric ? `${spec.value}|${spec.numeric}` : spec.value;
135
+ const alts = [
136
+ String.raw`(?:${spec.value})(?:=|\s+)${token}`,
137
+ String.raw`(?!(?:${names})(?=[=\s]|$))--?[A-Za-z][\w.:-]*(?:=${TASK_VALUE}*)?`,
138
+ ];
139
+ if (spec.numeric) alts.push(String.raw`(?:${spec.numeric})(?:(?:=|\s+)\d+(?:\.\d+)?)?`);
140
+ if (spec.assign) alts.push(ASSIGN);
141
+ return alts.join("|");
142
+ }
143
+ /**
144
+ * `head (flag | non-primary target)* PRIMARY (flag | allow-listed target)*`. The leading run excludes the primary
145
+ * targets so the split point is unique; every token after `head` must be a flag, NAME=VALUE or an allow-listed target.
74
146
  */
75
- export const VERIFY_SIGNAL_RE = /^(?:(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?(?:test|tests|typecheck|lint|build|check|spec|tsc|eslint|vitest|jest)(?::[\w-]+)?|(?:npx|bunx)\s+(?:tsc|eslint|vitest|jest|pytest)|(?:tsc|eslint|vitest|jest|pytest)|(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:(?:-m\s+)?pytest|-m\s+(?:unittest|py_compile|mypy|flake8|ruff\s+check))|node\s+(?:--test|--check|-c)|deno\s+(?:test|check|lint)|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|(?:make|just)\s+(?:test|check|lint|build|typecheck))(?:\s+[A-Za-z0-9_./:@+*?,=-]+)*$/i;
76
- const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--co|--show-?config|--init|--dry-run|--no-run|--passWithNoTests|--if-present)(?=$|\s|=)/i;
77
- const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--write|--update|-u|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
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;
147
+ function taskCore(head: string, spec: TaskSpec, primary: readonly string[]): string {
148
+ const flag = taskFlag(spec);
149
+ const pre = spec.path ? String.raw`(?:(?!-)[\w.-]*:)*` : "";
150
+ const rest = TARGETS.filter(target => !primary.includes(target)).join("|");
151
+ return String.raw`${head}(?:\s+(?:${flag}|${pre}(?:${rest})))*\s+${pre}(?:${primary.join("|")})(?:\s+(?:${flag}|${pre}(?:${TARGETS.join("|")})))*`;
152
+ }
153
+ const PRETTIER = String.raw`(?:(?:npx|bunx)\s+|(?:pnpm|npm|yarn|bun)\s+(?:exec|x|dlx)\s+)?prettier`;
154
+ const TEST_CORES: readonly string[] = [
155
+ String.raw`${PM}\s+(?:test|tests|spec|vitest|jest)(?::[\w-]+)?`,
156
+ String.raw`(?:npx|bunx)\s+(?:vitest|jest|pytest)`,
157
+ String.raw`(?:(?:npx|bunx)|(?:pnpm|npm|yarn|bun)\s+(?:exec|x|dlx))\s+playwright\s+test`,
158
+ String.raw`(?:vitest|jest|pytest|phpunit|playwright\s+test)`,
159
+ String.raw`(?:bundle\s+exec\s+)?rspec`,
160
+ String.raw`(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:(?:-m\s+)?pytest|-m\s+unittest)`,
161
+ String.raw`node\s+--test`,
162
+ String.raw`deno\s+test`,
163
+ String.raw`cargo\s+test`,
164
+ String.raw`go\s+test`,
165
+ ];
166
+ const SILENT_CORES: readonly string[] = [
167
+ String.raw`${PM}\s+(?:typecheck|lint|build|check|tsc|eslint)(?::[\w-]+)?`,
168
+ String.raw`(?:npx|bunx)\s+(?:tsc|eslint)`,
169
+ String.raw`(?:tsc|eslint|mypy|flake8|staticcheck|ruff\s+check|golangci-lint\s+run)`,
170
+ String.raw`(?:python(?:3(?:\.\d+)?)?|uv)\s+-m\s+(?:py_compile|mypy|flake8|ruff\s+check)`,
171
+ String.raw`node\s+(?:--check|-c)`,
172
+ String.raw`deno\s+(?:check|lint)`,
173
+ String.raw`cargo\s+(?:check|build|clippy)`,
174
+ String.raw`cargo\s+fmt(?=(?:\s+\S+)*\s+--check(?:\s|$))`,
175
+ String.raw`go\s+(?:vet|build)`,
176
+ String.raw`${PRETTIER}(?=(?:\s+\S+)*\s+(?:--check|-c|--list-different|-l)(?:\s|$))`,
177
+ ];
178
+ // Task-runner cores carry their own closed tail (see taskCore); VERIFY_SIGNAL_RE must not append ARG to them.
179
+ const STRICT_TEST_CORES: readonly string[] = [
180
+ taskCore("make", MAKE_SPEC, BUILD_TEST_TARGETS),
181
+ taskCore("just", JUST_SPEC, BUILD_TEST_TARGETS),
182
+ taskCore(String.raw`(?:bundle\s+exec\s+)?rake`, RAKE_SPEC, ["test", "tests", "spec"]),
183
+ taskCore("(?:gradle|gradlew)", GRADLE_SPEC, ["test"]),
184
+ taskCore("(?:mvn|mvnw)", MVN_SPEC, ["test", "verify"]),
185
+ String.raw`dotnet\s+test(?:\s+(?:${taskFlag(DOTNET_SPEC)}|${DOTNET_PROJECT}))*`,
186
+ ];
187
+ const STRICT_SILENT_CORES: readonly string[] = [
188
+ taskCore("make", MAKE_SPEC, BUILD_SILENT_TARGETS),
189
+ taskCore("just", JUST_SPEC, BUILD_SILENT_TARGETS),
190
+ taskCore("(?:gradle|gradlew)", GRADLE_SPEC, ["check", "build"]),
191
+ ];
192
+ // Loose cores match case-insensitively; the task-runner cores stay case-sensitive because their flags differ by case
193
+ // (`mvn -B` batch vs `-b` builder, `-P` profile vs `-p`, `make -C` vs `-c`): `i` would let one swallow the target.
194
+ /** Anchored match of every recognized verifier core (test runners and silent checkers) with its tail. */
195
+ export const VERIFY_SIGNAL_RE = new RegExp(`^(?:(?i:${[...TEST_CORES, ...SILENT_CORES].join("|")})${ARG}|${[...STRICT_TEST_CORES, ...STRICT_SILENT_CORES].join("|")})$`);
196
+ /** Cores that run tests and print a pass summary: the only ones whose output can stand in for a trustworthy exit status. */
197
+ const TEST_CORE_RE = new RegExp(`^(?:(?i:${TEST_CORES.join("|")})${ARG}|${STRICT_TEST_CORES.join("|")})$`);
198
+ // `-x test` (gradle skip) and `-u` (jest update) are lowercase-only: mvn's `-X` (debug) and `-U` (update dependencies) are not them.
199
+ const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--co|--show-?config|--show-settings|--show-files|--init|--dry-run|--no-run|--passWithNoTests|--if-present|-DskipTests|-Dmaven\.test\.skip|-Dmaven\.test\.failure\.ignore|-Dtest\.failure\.ignore|-DfailIfNoTests|-Dsurefire\.failIfNoSpecifiedTests|(?-i:-x)\s+test)(?=$|\s|=)/i;
200
+ const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--fix-only|--unsafe-fixes|--add-noqa|--install-types|-fix|--write|--update|(?-i:-u)|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
201
+ // Judged on the first script token after any manager options, so `pnpm --filter web test:fix` is still caught.
202
+ const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\b(?:\s+\S+)*?\s+[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
203
+ // Any short-flag cluster containing `w` (`-w`, `-cw`, `-wc`) writes files; `--write` is caught by MUTATING_VERIFY_FLAG_RE.
204
+ const PRETTIER_WRITE_RE = new RegExp(String.raw`^${PRETTIER}\b.*(?:^|\s)-[A-Za-z]*w[A-Za-z]*(?=\s|$)`, "i");
205
+ // Per-tool flag deny-list, judged on the command text with quotes turned into spaces. The same letter means different
206
+ // things per tool (`-t` touches in make, selects toolchains in mvn; `-i` ignores errors in make, logs in gradle), so
207
+ // each tool has its own list. Denied: dry-run / listing / touch / "what if" modes and anything that skips tests, masks a
208
+ // failing exit status, or rewrites lock files. make/just/rake may bundle short flags (`-kn`), so those match clusters.
209
+ const PROP_DENY_RE = /(?:^|\s)(?:-D|-P|--define|--project-prop|--system-prop|--activate-profiles|-p:)\s*\S*?(?:skip|ignore|failure|dry.?run|update|snapshot|golden|exec=true)/i;
210
+ const TASK_DENY: readonly { head: RegExp; deny: readonly RegExp[] }[] = [
211
+ { head: /^make\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[ntqiWo][A-Za-z]*|--(?:dry-run|just-print|recon|touch|question|what-if|new-file|assume-new|old-file|assume-old|ignore-errors|eval))(?=\s|=|$)/] },
212
+ { head: /^just\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[nlse][A-Za-z]*|--(?:dry-run|list|show|evaluate|edit|init|dump|summary|choose|fmt|variables|changelog|completions|command))(?=\s|=|$)/] },
213
+ { head: /^(?:bundle\s+exec\s+)?rake\b/, deny: [/(?:^|\s)(?:-[A-Za-z]*[nTPDWperEhHV][A-Za-z]*|--(?:dry-run|tasks|prereqs|describe|where|execute|execute-print|execute-continue|require|help|version))(?=\s|=|$)/] },
214
+ { head: /^(?:gradle|gradlew)\b/, deny: [/(?:^|\s)(?:-[mta](?=\s|$)|-[xI]\S*|--(?:dry-run|exclude-task|continuous|init-script|scan|write-locks|update-locks|write-verification-metadata|refresh-dependencies|export-keys|no-rebuild)(?=\s|=|$))/, PROP_DENY_RE] },
215
+ { head: /^(?:mvn|mvnw)\b/, deny: [/(?:^|\s)(?:-v|--version|-h|--help|-fn|--fail-never)(?=\s|=|$)/, PROP_DENY_RE] },
216
+ { head: /^dotnet\b/, deny: [/(?:^|\s)(?:-t|--list-tests|-h|--help|-\?)(?=\s|=|$)/, PROP_DENY_RE] },
217
+ ];
79
218
 
80
219
  // ── 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]`
220
+ // The done-gate trusts the shell's exit status of a verifier. A form whose status is NOT the verifier's own can
221
+ // report success while the verifier failed, so it is "weak": it counts ONLY when the OUTPUT shows positive pass
222
+ // evidence AND no failure marker (see verificationProducedEvidence). Direct forms keep tool success + evidence.
223
+ //
224
+ // command := "(" body ")" | body one outer subshell level, always weak; `2>&1` tokens removed
225
+ // body := ( (set|cd) ";" )* [cdSeg "&&"] pipeline ( "&&" pipeline )* [ ("||" | ";") trailer ]
226
+ // pipeline := verifierSeg ( "|" display )* display := tail [-N | -n N] | tee [-a] (never a file, never head)
227
+ // trailer := true | ":" | echo <plain words> echo only at the top level; its text must hold no digit/pass/ok/success
228
+ // verifierSeg:= prefix* core | (bash|sh|zsh|dash) -c "<body>" nested -c is judged on the inner body, one level deep
229
+ // prefix := `NAME=VALUE`, `env [-u NAME]... [NAME=VALUE]...`, `timeout N[s|m]`, `time [-p]`,
230
+ // `uv|poetry|pipenv run`, `rtk [test]` each propagates the verifier's exit status
86
231
  // core := first `/`-bearing token replaced by its basename, then VERIFY_SIGNAL_RE with no
87
232
  // 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.
233
+ // Weak = any pipe, `||`/`;` trailer, `set`/`cd ;` prefix or subshell. A weak form is recognized only when EVERY core
234
+ // is a test runner (TEST_CORE_RE): a silent linter has no output to corroborate it, and `grep`/`awk`/`sed`/`sort`/
235
+ // `cut`/`head` stages, `rm`, `git commit`, installers or any other command never qualify as display or trailer.
236
+ // make/just/rake/gradle/mvn/dotnet cores use a closed tail (see taskCore): any flag not on that tool's TASK_DENY list,
237
+ // its value, NAME=VALUE and allow-listed targets; any other bare target (deploy, install, publish, db:drop) rejects.
238
+ // Only plain tokens or balanced quotes are accepted: `$`, backticks, backslashes, redirections and grouping
239
+ // outside the single subshell are rejected before any stage is read.
90
240
  const STDERR_MERGE_RE = /(^|\s)2>&1(?=\s|$)/g;
91
241
  const ASSIGN_PREFIX_RE = /^([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*\s+/;
92
242
  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
243
  const TIMEOUT_PREFIX_RE = /^timeout\s+\d+[sm]?\s+/;
94
244
  const RUNNER_PREFIX_RE = /^(?:uv|poetry|pipenv)\s+run\s+/;
95
245
  const RTK_PREFIX_RE = /^rtk\s+(?:test\s+)?/;
96
- const CD_SEGMENT_RE = /^cd\s+[A-Za-z0-9_./:@+,=~-]+$/;
246
+ const TIME_PREFIX_RE = /^time\s+(?:-p\s+)?/;
247
+ const CD_SEGMENT_RE = /^cd\s+(?:[A-Za-z0-9_./:@+,=~-]+|"[^"$`\\]*"|'[^'$`\\]*')$/;
248
+ const SET_STMT_RE = /^set(?:\s+(?:-[A-Za-z]+|-o\s+pipefail|pipefail))+$/;
97
249
  const BINARY_PATH_RE = /^([A-Za-z0-9_.@+-]*\/[A-Za-z0-9_./@+-]*)(?=\s|$)/;
250
+ const SHELL_C_RE = /^(?:ba|z|da)?sh\s+(?:-o\s+pipefail\s+)?-[elux]*c\s+(?:"([^"]*)"|'([^']*)')$/;
251
+ // `head` is not display-only (it closes the pipe early, hiding a later failure) and `tee <file>` could overwrite a
252
+ // tracked file through a symlink: a verified call is never snapshot-bracketed, so that write would be invisible.
253
+ const DISPLAY_STAGE_RE = /^(?:tail(?:\s+(?:-\d+|-n\s*\d+))?|tee(?:\s+-a)?)$/;
254
+ /** Echo text that could read as a pass summary (or contains a count) is refused rather than discounted. */
255
+ const ECHO_PASSLIKE_RE = /\d|\bpass(?:ed|ing)?\b|\bok\b|success/i;
256
+ const TRAILER_RE = /^(?:true|:|echo((?:\s+(?:[A-Za-z0-9_.,:@%+=\/][A-Za-z0-9_.,:@%+=\/-]*|"[^"$`\\]*"|'[^'$`\\]*'))*))$/;
98
257
  /** Env names that are the env-form of an update/snapshot flag (parity with MUTATING_VERIFY_FLAG_RE). */
99
258
  const MUTATING_VERIFY_ENV_RE = /UPDATE|SNAPSHOT|GOLDEN/i;
100
259
  const ENV_ASSIGNMENT_NAME_RE = /([A-Za-z_][A-Za-z0-9_]*)=/g;
101
260
 
261
+ /** Result of {@link parseVerifier}: the verifier invocations and how far the shell's exit status can be trusted. */
262
+ export interface VerifierParse {
263
+ /** Normalized verifier invocations (wrapper prefixes, `cd`, pipes and trailers stripped). */
264
+ cores: string[];
265
+ /** True when the shell's exit status IS the verifier's own, so tool success is itself the pass signal. */
266
+ trusted: boolean;
267
+ /** Literal text a trailing `echo` prints; the evidence check discounts it so it cannot fake a pass line. */
268
+ echoed: string | null;
269
+ }
270
+
271
+ interface Chain { cores: string[]; weak: boolean; echoed: string | null }
272
+
273
+ /**
274
+ * Parse a command as a recognized verification, or null when it is not one. See the grammar above for what is
275
+ * accepted and which forms are weak (`trusted: false`).
276
+ */
277
+ export function parseVerifier(cmd: string): VerifierParse | null {
278
+ let command = cmd.trim().replace(STDERR_MERGE_RE, "$1").trim();
279
+ if (!command) return null;
280
+ let wrapped = false;
281
+ if (command.startsWith("(") && command.endsWith(")")) {
282
+ command = command.slice(1, -1).trim();
283
+ wrapped = true;
284
+ }
285
+ const chain = parseChain(command, false);
286
+ if (!chain) return null;
287
+ const weak = wrapped || chain.weak;
288
+ if (weak && !chain.cores.every(core => TEST_CORE_RE.test(core))) return null;
289
+ return { cores: chain.cores, trusted: !weak, echoed: chain.echoed };
290
+ }
291
+
102
292
  /**
103
293
  * 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,
294
+ * recognized verification. Wrapper prefixes and a leading `cd <path> &&` are stripped,
105
295
  * so flag/script exclusions are judged on the verifier itself, never on the wrappers.
106
296
  */
107
297
  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;
298
+ return parseVerifier(cmd)?.cores ?? null;
299
+ }
300
+
301
+ /**
302
+ * Split on top-level `&&`, `||`, `|` and `;` while leaving quoted text intact. Rejects anything that could change
303
+ * what runs: expansion, escapes, newlines, redirections, grouping and a lone `&` outside quotes.
304
+ */
305
+ function splitShell(command: string): { stages: string[]; ops: string[] } | null {
306
+ const stages: string[] = [];
307
+ const ops: string[] = [];
308
+ let start = 0;
309
+ let quote = "";
310
+ for (let i = 0; i < command.length; i++) {
311
+ const ch = command[i];
312
+ if (ch === "$" || ch === "`" || ch === "\\" || ch === "\n" || ch === "\r") return null;
313
+ if (quote) {
314
+ if (ch === quote) quote = "";
315
+ continue;
316
+ }
317
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
318
+ if ("<>(){}".includes(ch)) return null;
319
+ let op: string;
320
+ if (ch === "&") {
321
+ if (command[i + 1] !== "&") return null;
322
+ op = "&&";
323
+ } else if (ch === "|") op = command[i + 1] === "|" ? "||" : "|";
324
+ else if (ch === ";") op = ";";
325
+ else continue;
326
+ stages.push(command.slice(start, i).trim());
327
+ ops.push(op);
328
+ i += op.length - 1;
329
+ start = i + 1;
330
+ }
331
+ if (quote) return null;
332
+ stages.push(command.slice(start).trim());
333
+ return stages.some(stage => !stage) ? null : { stages, ops };
334
+ }
335
+
336
+ function parseChain(command: string, nested: boolean): Chain | null {
337
+ const shell = splitShell(command);
338
+ if (!shell) return null;
339
+ const { stages, ops } = shell;
340
+ const last = stages.length - 1;
341
+ let p = 0;
342
+ let weak = false;
343
+ // `set -e;` / `cd x;` change state only; they cannot mask the next command's status, but are not the verifier's own.
344
+ while (p < last && ops[p] === ";" && (SET_STMT_RE.test(stages[p]) || CD_SEGMENT_RE.test(stages[p]))) { weak = true; p++; }
345
+ if (p < last && ops[p] === "&&" && CD_SEGMENT_RE.test(stages[p])) p++;
115
346
  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);
347
+ let echoed: string | null = null;
348
+ for (;;) {
349
+ const stage = parseStage(stages[p], nested);
350
+ if (!stage) return null;
351
+ cores.push(...stage.cores);
352
+ if (stage.weak) weak = true;
353
+ p++;
354
+ while (p <= last && ops[p - 1] === "|") {
355
+ if (!DISPLAY_STAGE_RE.test(stages[p])) return null;
356
+ weak = true;
357
+ p++;
358
+ }
359
+ if (p > last) break;
360
+ if (ops[p - 1] === "&&") continue;
361
+ // `||` or `;`: exactly one trailing stage that cannot run anything else.
362
+ if (p !== last) return null;
363
+ const trailer = TRAILER_RE.exec(stages[p]);
364
+ if (!trailer) return null;
365
+ if (trailer[1] !== undefined) {
366
+ if (nested) return null; // only a top-level echo is the tail of the whole output, where it can be discounted
367
+ echoed = [...trailer[1].matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(t => t[1] ?? t[2] ?? t[3]).join(" ");
368
+ if (ECHO_PASSLIKE_RE.test(echoed)) return null;
369
+ }
370
+ weak = true;
371
+ break;
120
372
  }
121
- return cores.length ? cores : null;
373
+ return { cores, weak, echoed };
122
374
  }
123
375
 
124
- function verifierCore(segment: string): string | null {
376
+ function parseStage(segment: string, nested: boolean): Chain | null {
377
+ const rest = unwrap(segment);
378
+ if (rest === null) return null;
379
+ const shell = SHELL_C_RE.exec(rest);
380
+ if (shell) return nested ? null : parseChain(shell[1] ?? shell[2], true);
381
+ const core = verifierCore(rest);
382
+ return core ? { cores: [core], weak: false, echoed: null } : null;
383
+ }
384
+
385
+ /** Strip exit-code-propagating wrapper prefixes and normalize a path-qualified binary; null on a mutating env. */
386
+ function unwrap(segment: string): string | null {
125
387
  let rest = segment;
126
388
  for (;;) { // terminates: every successful match strictly shortens `rest`
127
389
  const assign = ASSIGN_PREFIX_RE.exec(rest);
@@ -136,7 +398,7 @@ function verifierCore(segment: string): string | null {
136
398
  rest = rest.slice(env[0].length);
137
399
  continue;
138
400
  }
139
- const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest);
401
+ const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest) ?? TIME_PREFIX_RE.exec(rest);
140
402
  if (other) {
141
403
  rest = rest.slice(other[0].length);
142
404
  continue;
@@ -144,37 +406,110 @@ function verifierCore(segment: string): string | null {
144
406
  break;
145
407
  }
146
408
  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;
409
+ return bin ? bin[1].slice(bin[1].lastIndexOf("/") + 1) + rest.slice(bin[1].length) : rest;
410
+ }
411
+
412
+ function verifierCore(rest: string): string | null {
413
+ // Flag exclusions are judged with quote characters turned into spaces: `bun test "--update-snapshots"` must not hide
414
+ // the flag behind a quote. A quoted `-k` expression that merely contains a flag-like word fails closed.
415
+ const flat = rest.replace(/["']/g, " ");
416
+ const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(flat)
417
+ || /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(flat)
418
+ || PRETTIER_WRITE_RE.test(flat);
419
+ const task = TASK_DENY.find(entry => entry.head.test(rest));
420
+ return !shortNoOp && VERIFY_SIGNAL_RE.test(rest) && !NONEXECUTING_VERIFY_FLAG_RE.test(flat)
421
+ && !MUTATING_VERIFY_FLAG_RE.test(flat) && !MUTATING_VERIFY_SCRIPT_RE.test(flat)
422
+ && !task?.deny.some(re => re.test(flat)) ? rest : null;
152
423
  }
153
424
 
154
425
  /**
155
426
  * True only when the command itself is a recognized verifier invocation (or a pure `&&`
156
427
  * 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".
428
+ * prefixes — see {@link parseVerifier}, which also admits the weak forms). Output is never what
429
+ * RECOGNIZES a command: arbitrary commands can print words such as "tests passed".
159
430
  */
160
431
  export function isVerificationSignal(cmd: string): boolean {
161
- return verifierCores(cmd) !== null;
432
+ return parseVerifier(cmd) !== null;
433
+ }
434
+
435
+ // Output evidence. Every pattern is anchored to a summary position so a test TITLE cannot impersonate a summary.
436
+ const PASS_LINE_RES: readonly RegExp[] = [
437
+ // bun "12 pass", mocha "12 passing", jest "Tests: 1 failed, 4 passed", vitest "Tests 5 passed", pytest "== 5 passed",
438
+ // playwright "5 passed (3s)", deno "ok | 3 passed"
439
+ /^[ \t=]*(?:ok[ \t]*\|[ \t]*)?(?:Tests?:?[ \t]+)?(?:\d+ [A-Za-z]+(?:[ \t]*[,|][ \t]*|[ \t]+))*[1-9]\d* pass(?:ed|ing)?\b/,
440
+ /^\s*(?:\u2139|#)\s*pass\s+[1-9]\d*\b/, // node --test
441
+ /^ok\s+\S+\s+(?:\(cached\)|[\d.]+s)/, // go (the `[no tests ...]` forms are excluded by the caller)
442
+ /^\s*--- PASS:/, // go test -v
443
+ /^\s*[1-9]\d* examples?, 0 failures?/, // rspec
444
+ /Tests run:\s*[1-9]\d*,\s*Failures:\s*0,\s*Errors:\s*0/, // maven
445
+ /^\s*Passed!.*\bPassed:\s*[1-9]\d*/, // dotnet
446
+ /^OK \([1-9]\d* tests?,/, // phpunit
447
+ ];
448
+ const FAILURE_LINE_RES: readonly RegExp[] = [
449
+ /(?:^|[^\d])[1-9]\d*\s+(?:fail(?:ed|ing|ures?)?|errors?)\b/i,
450
+ /\b(?:Failures|Errors|Failed):\s*[1-9]/,
451
+ /^\s*\(fail\)/,
452
+ /^\s*(?:FAIL|FAILED|ERROR)\b/,
453
+ /^\s*--- FAIL/,
454
+ /^\s*panic:|panicked at/,
455
+ /test result: FAILED|\.\.\. FAILED\b|^failures:\s*$/, // cargo
456
+ /^\s*error: .*(?:target|test).*failed|\bcould not compile\b/i, // cargo: the FAILED line can scroll out of a `tail` window
457
+ /^\[ERROR\]|BUILD (?:FAILED|FAILURE)|^FAILURE:/, // maven / gradle
458
+ /Tests run:.*(?:Failures|Errors): [1-9]/, // maven
459
+ /\b(?:ERRORS|FAILURES)!/, // phpunit
460
+ /^\s*Failed!/, // dotnet
461
+ /^\s*(?:\u2139|#)\s*fail\s+[1-9]/,
462
+ /\bSegmentation fault\b|\bBus error\b|\bKilled\b|\btimed out\b/,
463
+ /\berror TS\d+/,
464
+ ];
465
+ /** A line a runner prints for a test that PASSED: its title may legitimately mention "errors" or "failed". */
466
+ const PASSED_TITLE_RE = /^\s*(?:\((?:pass|skip|todo)\)|[\u2713\u2714\u221a])/;
467
+ /** Explicit "nothing ran" output; only consulted when no positive pass summary is present. */
468
+ const NO_TESTS_RE = /(?:^|[^\d])0 pass(?:ed|ing)?\b|(?:^|[^\d])0 examples?\b|\bno tests? (?:ran|found|to run|executed)\b|\bno test files found\b|\bNo test is available\b|\bcollected 0 items\b|\bRan 0 tests\b|\bTests run:\s*0\b|(?:\u2139|#)\s*pass\s+0\b/i;
469
+ const ANSI_RE = /\u001b\[[0-9;?]*[ -\/]*[@-~]/g;
470
+
471
+ function hasPassSummary(text: string): boolean {
472
+ if (/\bBUILD SUCCESSFUL\b/.test(text) && !/\bNO-SOURCE\b/.test(text)) return true; // gradle prints no counts
473
+ if (/test result: ok\.\s+[1-9]\d* passed/.test(text)) return true;
474
+ return text.split(/\r?\n/).some(line => PASS_LINE_RES.some(re => re.test(line)) && !/\[no tests to run\]|\[no test files\]/i.test(line));
162
475
  }
163
476
 
164
477
  /**
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.
478
+ * Drop the text a trailing `echo` printed so it cannot supply (or hide) a pass line. The bash tool joins stdout then
479
+ * stderr, so the echo can sit mid-output: every whole-line occurrence goes, and so does a glued trailing suffix.
480
+ */
481
+ function withoutEchoText(text: string, echoed: string | null): string {
482
+ if (!echoed) return text;
483
+ const kept = text.split(/\r?\n/).filter(line => line.trim() !== echoed).join("\n");
484
+ const trimmed = kept.trimEnd();
485
+ return trimmed.endsWith(echoed) ? trimmed.slice(0, trimmed.length - echoed.length) : kept;
486
+ }
487
+
488
+ /**
489
+ * Output-side judgment of a verifier run the caller already knows succeeded (exit 0).
490
+ * - Go and Cargo need real test-run evidence, judged on the verifier cores so a wrapped
491
+ * `timeout 60 go test ./...` or `cd x && cargo test` still needs it.
492
+ * - A direct test runner whose output says nothing ran (`0 pass`, "no tests ran") is not evidence unless a positive
493
+ * pass summary is also present; silent linters and empty output stay evidence on tool success alone.
494
+ * - A weak form (see the grammar above) has no trustworthy exit status: it needs a positive pass summary and no
495
+ * failure marker anywhere in the output.
168
496
  */
169
497
  export function verificationProducedEvidence(cmd: string, output: string): boolean {
170
- const cores = verifierCores(cmd) ?? [cmd.trim()];
498
+ const parsed = parseVerifier(cmd);
499
+ const cores = parsed?.cores ?? [cmd.trim()];
500
+ const text = withoutEchoText(output.replace(ANSI_RE, ""), parsed?.echoed ?? null);
171
501
  if (cores.some(c => /^go\s+test(?:\s|$)/.test(c))) {
172
- const summaries = output.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
502
+ const summaries = text.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
173
503
  if (!summaries.some(line => !/\[no tests to run\]|\[no test files\]/i.test(line))) return false;
174
504
  }
175
505
  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;
177
- return true;
506
+ && ![...text.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0)) return false;
507
+ if (!parsed) return true;
508
+ if (!parsed.trusted) {
509
+ const failed = text.split(/\r?\n/).some(line => !PASSED_TITLE_RE.test(line) && FAILURE_LINE_RES.some(re => re.test(line)));
510
+ return hasPassSummary(text) && !failed;
511
+ }
512
+ return !(cores.some(c => TEST_CORE_RE.test(c)) && !hasPassSummary(text) && NO_TESTS_RE.test(text));
178
513
  }
179
514
 
180
515
  /** Marker that prefixes a candidate answer the completion gate never accepted. */
@@ -185,7 +520,7 @@ export const UNVERIFIED_MARKER = "UNVERIFIED";
185
520
  * model's last candidate answer, never presents it as accepted, and keeps the gate reason.
186
521
  */
187
522
  export function formatRejectedCompletion(candidate: string, corrections: number, rejection: string): string {
188
- const stop = `Stopped: completion rejected after ${corrections} corrections. ${rejection}`;
523
+ const stop = `Stopped: completion rejected after ${corrections} correction${corrections === 1 ? "" : "s"}. ${rejection}`;
189
524
  const answer = candidate.trim();
190
525
  if (!answer) return stop;
191
526
  return `${UNVERIFIED_MARKER} - the completion gate did not accept this answer; treat it as unconfirmed.\n\n${answer}\n\n${stop}`;
@@ -239,6 +574,11 @@ export interface DoneGateInput {
239
574
  * instead of claiming files were modified.
240
575
  */
241
576
  unmeasured?: string;
577
+ /**
578
+ * Paths a shell call's before/after snapshot saw change (`modified a.json; added b.txt`), when the evidence
579
+ * came from a shell bracket. Lets the model and user judge whether the command or an unrelated process wrote them.
580
+ */
581
+ shellChanged?: string;
242
582
  }
243
583
 
244
584
  /** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
@@ -263,10 +603,15 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
263
603
  const stale = input.sawVerification && input.verificationStale === true;
264
604
  const block = input.sawMutation && (!input.sawVerification || stale || hookFailing);
265
605
  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."
606
+ // A reply alone never clears this gate: only a passing verification does, or the budget running out.
607
+ 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.";
608
+ const changedNote = input.shellChanged
609
+ ? ` Note: around a shell/computer call the host saw these paths change: ${input.shellChanged}. ` +
610
+ `Something other than your commands may have written them (another process, or your own background job). ${escape}`
269
611
  : "";
612
+ const unmeasuredNote = changedNote + (input.unmeasured
613
+ ? ` 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}`
614
+ : "");
270
615
  if (hookFailing) {
271
616
  return {
272
617
  state: "done_hook_failing",
@@ -168,7 +168,21 @@ export function createDockerBackend(options: DockerBackendOptions = {}): Sandbox
168
168
  if (mount.Type === "tmpfs" && mount.Destination === "/tmp") continue;
169
169
  const normalizedSource = typeof mount.Source === "string" ? normalizeMountSource(mount.Source) : "";
170
170
  if (mount.Type !== "bind" || !normalizedSource || !path.isAbsolute(normalizedSource)) throw new Error("SANDBOX_UNAVAILABLE: unknown managed mount metadata");
171
- const source = await canonicalSandboxPath(normalizedSource);
171
+ let source: string;
172
+ try { source = await canonicalSandboxPath(normalizedSource); }
173
+ catch (error) {
174
+ // A leaked/exited managed container whose temp workspace was deleted must not wedge every later launch.
175
+ // Still guard identical-or-disjoint overlap: resolve its nearest existing ancestor (so aliases such as
176
+ // macOS /var -> /private/var compare equal to canonical candidates), and compare lexically when even that
177
+ // cannot resolve (ENOTDIR). Every other failure (EACCES, loops, invalid metadata) still fails closed.
178
+ const lost = (value: unknown): boolean => { const code = (value as NodeJS.ErrnoException).code; return code === "ENOENT" || code === "ENOTDIR"; };
179
+ if (!lost(error)) throw error;
180
+ try { source = await canonicalSandboxPath(normalizedSource, false); }
181
+ catch (inner) {
182
+ if (!lost(inner)) throw inner;
183
+ source = path.resolve(normalizedSource);
184
+ }
185
+ }
172
186
  if (sources.some(candidate => candidate !== source && (sandboxPathContains(candidate, source) || sandboxPathContains(source, candidate)))) {
173
187
  throw new Error("SANDBOX_MOUNT_OVERLAP: managed mount roots must be identical or disjoint");
174
188
  }