Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions hosts/workbuddy/transcript.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Copyright (c) 2026 EvoMap

import { CHECK_COMMAND_MAX_CHARS } from '../../lib/turn/record.js';
import { isVerificationCommand, verificationFailureOf } from '../../lib/verification.js';
import { isVerificationCommand, verificationExitOf, verificationFailureOf } from '../../lib/verification.js';

// FinalStop carries neither the answer nor the turn's tool calls, so a turn is read off the session transcript: one
// JSON record per line, `function_call` / `function_call_result` pairs joined by `callId`, messages with a `role`.
Expand Down Expand Up @@ -88,14 +88,11 @@ const exitCodeOf = (result) => {
return exit ? Number(exit[1]) : null;
};

function shellFailed(result) {
if (result.status !== 'completed') return true;
return verificationFailureOf({ exit: exitCodeOf(result), text: shellOutputOf(result.text) });
}

function checkOf(call) {
const failed = shellFailed(call.result);
return { command: call.args.command.slice(0, CHECK_COMMAND_MAX_CHARS), exit: exitCodeOf(call.result), failed };
const command = call.args.command;
const exit = verificationExitOf(command, exitCodeOf(call.result));
const failed = call.result.status !== 'completed' || verificationFailureOf({ exit, text: shellOutputOf(call.result.text) });
return { command: command.slice(0, CHECK_COMMAND_MAX_CHARS), exit, failed };
}

const isCheck = (call) => SHELL_TOOLS.has(call.name) && call.args.run_in_background !== true && isVerificationCommand(call.args.command);
Expand Down
6 changes: 5 additions & 1 deletion lib/agent/defaults/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import { AGENT_REPORTED } from '../../app/turn-reports.js';
import { CHECK_COMMAND_MAX_CHARS } from '../../turn/record.js';
import { verificationExitOf, verificationFailureOf } from '../../verification.js';
import { EVENTS } from '../events.js';

// An agent without hooks reaches Evolver through MCP tools. The connection is the session; a turn runs from the
Expand Down Expand Up @@ -58,7 +59,10 @@ export const mcpEvidence = Object.freeze({
turnRecord: (payload) => {
const checks = list(payload.checks)
.filter((check) => typeof check?.command === 'string' && check.command)
.map((check) => ({ command: check.command.slice(0, CHECK_COMMAND_MAX_CHARS), exit: check.exit, failed: check.exit !== 0 }));
.map(({ command, exit }) => {
const checkExit = verificationExitOf(command, exit) ?? null;
return { command: command.slice(0, CHECK_COMMAND_MAX_CHARS), exit: checkExit, failed: verificationFailureOf({ exit: checkExit }) };
});
const files = list(payload.files).filter((file) => typeof file === 'string' && file);
return { checks, commands: checks.map((check) => check.command), paths: files, edits: files, answer: '' };
},
Expand Down
15 changes: 8 additions & 7 deletions lib/turn/record.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 EvoMap

import { isVerificationCommand, verificationFailureOf } from '../verification.js';
import { isVerificationCommand, verificationExitOf, verificationFailureOf } from '../verification.js';

export const CHECK_COMMAND_MAX_CHARS = 2_000;

Expand All @@ -11,8 +11,8 @@ export const CHECK_COMMAND_MAX_CHARS = 2_000;
* { kind: 'edit', paths, lines?, identifiers? }
* { kind: 'tool', command?, paths? }
* { kind: 'answer', text } the turn's final answer, for a host that tells it before the turn's end
* An exit code decides a check when the host gives one; the output is read too, since a failing run piped
* through `tail` still exits zero. Without an exit code or a supported passing summary, `failed: null` keeps
* An exit code decides a check when it can be attributed to that check; a failing run piped through `tail`
* can still exit zero. Without an attributable exit or a supported passing summary, `failed: null` keeps
* the check's result unknown, and `failed: true` is a run the host saw fail without giving its code (a timeout, an
* error step); no exit code is made up for either. A command still running has no verdict: a later note with its `callId`
* settles it, and one still pending when the turn ends counts as not run. `label` names a check to the person, and
Expand Down Expand Up @@ -40,14 +40,15 @@ export function turnRecordOf(notes) {
if (note.callId) pending.add(note.callId);
continue;
}
const failed = checkFailed(note);
record.checks.push({ command: note.command.slice(0, CHECK_COMMAND_MAX_CHARS), exit: note.exit ?? null, failed, ...(note.label && { label: note.label }) });
const exit = verificationExitOf(note.command, note.exit) ?? null;
const failed = checkFailed(note, exit);
record.checks.push({ command: note.command.slice(0, CHECK_COMMAND_MAX_CHARS), exit, failed, ...(note.label && { label: note.label }) });
}
return record;
}

function checkFailed(note) {
return note.failed === true || verificationFailureOf({ exit: note.exit, text: note.output });
function checkFailed(note, exit = verificationExitOf(note.command, note.exit)) {
return note.failed === true || verificationFailureOf({ exit, text: note.output });
}

/** Whether a tool note is a finished test, build, lint or type-check that failed. */
Expand Down
138 changes: 125 additions & 13 deletions lib/verification.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ import { detectSignals } from './signals.js';
// verdict an automatic reuse report carries is read off the final test, build,
// lint or type-check command, never off how the turn ended.
const VERIFICATION_COMMANDS = [
/\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|type-check|check)\b/,
/\bnode\s+--test\b/,
/\b(?:jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|ruff|mypy)\b/,
/\bgo\s+(?:test|build|vet)\b/,
/\bcargo\s+(?:test|build|check|clippy)\b/,
/\bdotnet\s+(?:test|build)\b/,
/\b(?:mvn|gradle|gradlew)\b/,
/\bmake\b/,
/\bclaude\s+plugin\s+(?:validate|test)\b/,
/^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|type-check|check)\b/,
/^node\s+--test\b/,
/^(?:jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|ruff|mypy)(?:\s|$)/,
/^go\s+(?:test|build|vet)\b/,
/^cargo\s+(?:test|build|check|clippy)\b/,
/^dotnet\s+(?:test|build)\b/,
/^(?:mvn|gradle|gradlew)(?:\s|$)/,
/^make(?:\s|$)/,
/^claude\s+plugin\s+(?:validate|test)\b/,
];

const FAILURE_SIGNALS = new Set(['test_failure', 'log_error']);
Expand All @@ -31,8 +31,99 @@ const HEREDOC = /<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g;

const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3');

// Keep separators inside strings, backticks and comments out of the command scan. A name in an argument is no
// check. This recognises direct commands and known wrappers; it does not evaluate arbitrary shell control flow.
// An escaped separator stays in the shell word and makes an immediately following hash literal.
const SHELL_TOKEN = /\\(?:[ \t;&|()]#|[\s\S])|\x60[^\x60]*(?:\x60|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|\x60[\s\S]|[^"\\\x60])*(?:"|$)|(?<![^\s;&|()])#[^\r\n]*|\$\(|[()]/g;
const WINDOWS_PATH = /\b[A-Za-z]:\\(?:[\w.-]+\\)*[\w.-]+/g;

function withoutShellData(command, quotedTokens) {
const shell = withoutHeredocBodies(command);
const input = shell.replace(WINDOWS_PATH, (path) => path.replaceAll('\\', '/'));
let depth = 0;
let start = 0;
let output = '';
for (const { 0: token, index } of input.matchAll(SHELL_TOKEN)) {
if (depth === 0) {
if (token === '(' || token === ')') continue;
const quoted = token[0] === '"' || token[0] === "'";
const masked = quoted ? '\0' + (quotedTokens.push(shell.slice(index, index + token.length)) - 1) + '\0' : '\0';
output += input.slice(start, index) + masked;
start = index + token.length;
}
if (token === '$(' || (depth > 0 && token === '(')) depth += 1;
if (depth > 0 && token === ')') {
depth -= 1;
if (depth === 0) start = index + 1;
}
}
return depth === 0 ? output + input.slice(start) : output;
}

const WRAPPERS = [
/^(?:[A-Za-z_]\w*=\S*\s+)+/,
/^env\s+(?:(?:-i|--ignore-environment)\s+|(?:-u|--unset|-C|--chdir)(?:=\S+|\s+\S+)\s+|[A-Za-z_]\w*=\S*\s+)*(?:--\s+)?/,
/^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/,
/^npm\s+exec\s+(?:(?:-y|--yes|--no)\s+|--package(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/,
/^pnpm\s+(?:(?:-r|--recursive)\s+)?exec\s+(?:(?:-r|--recursive)\s+)?(?:--\s+)?/,
/^corepack\s+(?=(?:npm|pnpm|yarn)\s)/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pnpm exec 执行的验证命令不再被识别

🟠 严重程度:低(P3)
本 PR 将验证命令匹配改为命令起始位置匹配,并通过 WRAPPERS 展开包装器;列表只包含 npm exec,未包含语义等价的 pnpm exec。因此 pnpm exec vitest run 不匹配 pnpm test/build/lint/... 正则,也不会继续解析到 vitest,被错误判为非验证命令。

Finding:finding_5d50dae441e6bf74fcad15c0

/^command\s+(?:-p\s+)?(?:--\s+)?/,
/^sudo\s+(?:(?:-n|--non-interactive)\s+|(?:-u|--user|-g|--group)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/,
/^time\s+(?:(?:-p|--portability)\s+)?(?:--\s+)?(?:!\s+)?/,
/^(?:nohup|bunx|exec)\s+(?:--\s+)?/,
/^timeout\s+(?:(?:--foreground|--preserve-status)\s+|(?:-s|--signal)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?\d+(?:\.\d+)?[smhd]?\s+/,
/^(?:uv|poetry)\s+run\s+/,
/^python(?:3(?:\.\d+)?)?\s+-m\s+(?=pytest(?:\s|$))/,
/^yarn\s+(?=(?:jest|vitest)(?:\s|$))/,
];
const executableName = (path) => path.split('/').at(-1).replace(/\.(?:exe|cmd|bat)$/i, '');

function literalExecutable(token) {
const quote = token[0];
if ((quote !== "'" && quote !== '"') || token.length < 2 || !token.endsWith(quote)) return null;
const path = token.slice(1, -1);
const windows = /^[A-Za-z]:[\\/]/.test(path);
if (quote === '"' && (/[#$%!\x60"]/.test(path) || (!windows && path.includes('\\')))) return null;
const name = executableName(windows ? path.replaceAll('\\', '/') : path);
return /^[\w.-]+$/.test(name) ? name : null;
}

function withoutExecutablePath(command, quotedTokens) {
return command.replace(/^\S+/, (token) => {
const quoted = /^\0(\d+)\0$/.exec(token);
if (quoted) return literalExecutable(quotedTokens[Number(quoted[1])]) ?? token;
return token.includes('\0') ? token : executableName(token);
});
}

function executableCommandOf(part, quotedTokens) {
let check = part.trim().replace(/^(?:(?:if|then|elif|else|do|!)\s+|[({]\s*)+/, '');
while (check) {
// An assignment may contain slashes but is still a wrapper before the executable.
if (!/^[A-Za-z_]\w*=/.test(check)) check = withoutExecutablePath(check, quotedTokens);
const wrapper = WRAPPERS.find((pattern) => pattern.test(check));
if (!wrapper) break;
check = check.replace(wrapper, '');
}
return check;
}

// Only unwrap a whole literal argument. Double-quoted expansions belong to the outer shell.
function literalScriptOf(check, quotedTokens) {
const argument = /^(?:bash|sh)[ \t]+-l?c[ \t]+\0(\d+)\0$/.exec(check);
if (!argument) return null;
const script = /^(?:'([^']*)'|"([^"\x60$\\]*)")$/.exec(quotedTokens[Number(argument[1])]);
return script ? script[1] ?? script[2] : null;
}

export function isVerificationCommand(command) {
return typeof command === 'string' && VERIFICATION_COMMANDS.some((pattern) => pattern.test(withoutHeredocBodies(command)));
if (typeof command !== 'string' || command.includes('\0')) return false;
const quotedTokens = [];
return withoutShellData(command, quotedTokens).split(/[;&|\r\n]+/).some((part) => {
const check = executableCommandOf(part, quotedTokens);
const script = literalScriptOf(check, quotedTokens);
return script === null ? VERIFICATION_COMMANDS.some((pattern) => pattern.test(check)) : isVerificationCommand(script);
});
}

/** @param {{ isError?: boolean, text?: string }} result */
Expand All @@ -48,10 +139,31 @@ const PASSED_COUNT = /^\s*(?:#|ℹ)\s+pass\s+[1-9]\d*\b/m;
const ZERO_FAILED_COUNT = /^\s*(?:#|ℹ)\s+fail\s+0\b/m;
const FAILED_COUNT = /^\s*(?:#|ℹ)\s+(?:fail|cancelled)\s+[1-9]\d*\b/m;

const SHELL_CONTROL = /[;&|()\r\n]|(?:^|[ \t])!(?:[ \t]|$)|^(?:if|then|elif|else|do|\{)(?:\s|$)/;

/** A host's numeric exit belongs to a check only when shell control flow cannot hide the result. */
export function verificationExitOf(command, exit) {
if (!Number.isInteger(exit) || typeof command !== 'string') return exit;
if (command.includes('\0')) return null;
const quotedTokens = [];
const shell = withoutShellData(command, quotedTokens).replace(/[<>]&|&>|>\|/g, ' ').trim();
let direct = shell;
if (SHELL_CONTROL.test(shell)) {
// A successful directory change followed by one check implies the check ran and passed.
const [before, after, extra] = shell.split('&&', 3);
if (exit !== 0 || !after || extra !== undefined || !/^cd\s+\S/.test(before)
|| SHELL_CONTROL.test(before) || SHELL_CONTROL.test(after.trim())) return null;
direct = after;
}
const script = literalScriptOf(executableCommandOf(direct, quotedTokens), quotedTokens);
return script === null ? exit : verificationExitOf(script, exit);
}

/** A finished check's failed flag: true = failed, false = passed, null = unknown. */
export function verificationFailureOf({ exit, text }) {
export function verificationFailureOf({ command, exit, text }) {
const output = String(text ?? '');
if (verificationFailed({ isError: Number.isInteger(exit) && exit !== 0, text: output }) || FAILED_COUNT.test(output)) return true;
if (exit === 0 || (PASSED_COUNT.test(output) && ZERO_FAILED_COUNT.test(output))) return false;
const checkExit = verificationExitOf(command, exit);
if (verificationFailed({ isError: Number.isInteger(checkExit) && checkExit !== 0, text: output }) || FAILED_COUNT.test(output)) return true;
if (checkExit === 0 || (PASSED_COUNT.test(output) && ZERO_FAILED_COUNT.test(output))) return false;
return null;
}
9 changes: 9 additions & 0 deletions test/agent/driver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ test('the MCP profile turns tool calls into a turn whose report is agent-reporte
assert.match(status.content[0].text, /verified turn waiting to be distilled/);
});

test('a successful compound command reported through MCP does not open a distillation candidate', async () => {
const made = memoryPorts();
const { driver } = driverOf(mcpGeneric, made);
await driver.handle('evolver_recall', { session_id: 'm1', turn_id: 't1', task: PROMPT });
const reported = await driver.handle('evolver_report', { session_id: 'm1', turn_id: 't1', outcome: 'success', summary: 'Fixed the retries.', checks: [{ command: 'true || npm test', exit: 0 }], files: ['lib/redis.js'] });
assert.equal(reported.content[0].text, 'Reported.');
assert.equal((await made.ports.candidates.read()).m1, undefined);
});

test('a session start answers the engine notice once, and a session end schedules distillation', async () => {
const made = memoryPorts();
made.ports.engine.takeUnannounced = async () => ({ status: 'install_failed', error: 'npm missing', at: 1 });
Expand Down
27 changes: 27 additions & 0 deletions test/app/turn-lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ test('a recalled strategy ends with the spec line, and its turn end reports it w
assert.ok(scheduled.includes('drain'));
});

test('printing a check command does not verify a turn or raise its reuse evidence', async () => {
const { ports } = memoryPorts();
await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, ...WAIT });
await toolFinished(ports, { sessionId: 's1', note: { kind: 'edit', paths: ['/proj/lib/redis.js'] } });
await toolFinished(ports, { sessionId: 's1', note: {
kind: 'shell', command: 'echo "npm test"', exit: 0, output: 'npm test', label: 'Run tests',
} });
const { record, reports } = await end(ports, 's1');
assert.deepEqual(record.checks, []);
assert.equal((await ports.candidates.read()).s1, undefined);
assert.equal(reports[0].evidence.tier, 'none');
assert.equal(reports[0].evidence.check, undefined);
});

test('a successful shell wrapper does not verify a turn when its check may have been skipped', async () => {
const { ports } = memoryPorts();
const command = 'true || npm test';
await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, ...WAIT });
await toolFinished(ports, { sessionId: 's1', note: { kind: 'edit', paths: ['/proj/lib/redis.js'] } });
await toolFinished(ports, { sessionId: 's1', note: { kind: 'shell', command, exit: 0 } });
const { record, reports } = await end(ports, 's1');
assert.deepEqual(record.checks, [{ command, exit: null, failed: null }]);
assert.equal((await ports.candidates.read()).s1, undefined);
assert.equal(reports[0].evidence.tier, 'none');
assert.equal(reports[0].evidence.check, undefined);
});

test('a verified turn becomes the candidate; a correction drops it before revising the reports', async () => {
const { ports, queued } = memoryPorts();
await verifiedTurn(ports, 's1');
Expand Down
4 changes: 2 additions & 2 deletions test/hosts/antigravity/readers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ test('a turn record pairs each call with its result in step order: commands, che
assert.deepEqual(record.checks, [
{ command: 'node --test', exit: 1, failed: true },
{ command: 'npm run lint', exit: 0, failed: false },
{ command: 'node --test 2>&1 | tail -5', exit: 0, failed: false },
{ command: 'node --test 2>&1 | tail -5', exit: null, failed: false },
], 'the build moved to the background has no verdict');
assert.deepEqual(record.paths, ['/workspace/project/src/index.js', '/workspace/project/src/lru.js', '/workspace/project/src/index.js']);
assert.deepEqual(record.edits, ['/workspace/project/src/lru.js'], 'the failed replacement edited nothing');
Expand All @@ -48,7 +48,7 @@ test('an exit code decides a check; without one an ERROR step fails it and the o
assert.deepEqual(checksOf(turn(call(1, 'npm test'), row({ step_index: 2, source: 'MODEL', type: 'RUN_COMMAND', status: 'ERROR', error: 'exit status 2' }))), [{ command: 'npm test', exit: 2, failed: true }]);
assert.deepEqual(checksOf(turn(call(1, 'npm test'), row({ step_index: 2, source: 'MODEL', type: 'RUN_COMMAND', status: 'ERROR' }))), [{ command: 'npm test', exit: null, failed: true }]);
assert.deepEqual(checksOf(turn(call(1, 'npm test'), row({ step_index: 2, source: 'MODEL', type: 'RUN_COMMAND', content: 'done' }))), [{ command: 'npm test', exit: null, failed: null }]);
assert.deepEqual(checksOf(turn(call(1, 'npm test | tail -3'), row({ step_index: 2, source: 'MODEL', type: 'RUN_COMMAND', content: 'The command exited with code 0.\nOutput:\nAssertionError: expected 1 to equal 2' }))), [{ command: 'npm test | tail -3', exit: 0, failed: true }]);
assert.deepEqual(checksOf(turn(call(1, 'npm test | tail -3'), row({ step_index: 2, source: 'MODEL', type: 'RUN_COMMAND', content: 'The command exited with code 0.\nOutput:\nAssertionError: expected 1 to equal 2' }))), [{ command: 'npm test | tail -3', exit: null, failed: true }]);
assert.deepEqual(checksOf(turn(call(1, 'npm test'))), [], 'a call no result answered was not run');
});

Expand Down
2 changes: 1 addition & 1 deletion test/hosts/dsh/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ test('a verified turn is reported with its last check and becomes the session\'s
const candidate = stateFile(proxy, 'dsh-distill-pending.json')[agent.session.id];
assert.equal(candidate.prompt, PROMPT);
assert.deepEqual(candidate.model, { provider: 'deepseek-official', model: 'deepseek-flash' });
assert.deepEqual(candidate.checks, [{ command: 'npm test 2>&1 | tail -5', exit: 0, failed: false }]);
assert.deepEqual(candidate.checks, [{ command: 'npm test 2>&1 | tail -5', exit: null, failed: false }]);
assert.equal(candidate.answer, 'Set maxRetriesPerRequest to null; the suite passes.');
assert.equal(candidate.pid, process.pid);
});
Expand Down
Loading
Loading