diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 83df468ad..edf7b81da 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -6,7 +6,7 @@ import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { parseArgs, splitRawArgumentString } from "./lib/args.mjs"; +import { parseArgs, splitRawArgumentString, splitRawArgumentStringWithSpans } from "./lib/args.mjs"; import { buildPersistentTaskThreadName, DEFAULT_CONTINUE_PROMPT, @@ -138,8 +138,60 @@ function normalizeArgv(argv) { return argv; } +function normalizeAdversarialReviewArgv(argv) { + if (argv.length !== 1) return argv; + + const [raw] = argv; + if (!raw || !raw.trim()) return []; + + const tokens = splitRawArgumentStringWithSpans(raw); + const normalized = []; + const valueOptions = new Set(["base", "scope", "model", "cwd"]); + const booleanOptions = new Set(["json", "background", "wait"]); + const aliasMap = { C: "cwd", m: "model" }; + + for (let index = 0; index < tokens.length;) { + const token = tokens[index]; + if (token.value === "--") { + const focusStart = tokens[index + 1]?.start; + if (focusStart !== undefined) normalized.push("--", raw.slice(focusStart)); + return normalized; + } + + let key = null; + let hasInlineValue = false; + if (token.value.startsWith("--")) { + const [rawKey, inlineValue] = token.value.slice(2).split("=", 2); + key = aliasMap[rawKey] ?? rawKey; + hasInlineValue = inlineValue !== undefined; + } else if (token.value.startsWith("-") && token.value !== "-") { + key = aliasMap[token.value.slice(1)] ?? token.value.slice(1); + } + + if (booleanOptions.has(key)) { + normalized.push(token.value); + index += 1; + continue; + } + if (valueOptions.has(key)) { + normalized.push(token.value); + index += 1; + if (!hasInlineValue && index < tokens.length) { + normalized.push(tokens[index].value); + index += 1; + } + continue; + } + + normalized.push(raw.slice(token.start)); + return normalized; + } + return normalized; +} + function parseCommandInput(argv, config = {}) { - return parseArgs(normalizeArgv(argv), { + const argvNormalizer = config.argvNormalizer ?? normalizeArgv; + return parseArgs(argvNormalizer(argv), { ...config, aliasMap: { C: "cwd", @@ -715,7 +767,10 @@ async function handleReviewCommand(argv, config) { booleanOptions: ["json", "background", "wait"], aliasMap: { m: "model" - } + }, + ...(config.opaqueFocus + ? { argvNormalizer: normalizeAdversarialReviewArgv, stopAtFirstPositional: true } + : {}) }); const cwd = resolveCommandCwd(options); @@ -1037,7 +1092,8 @@ async function main() { break; case "adversarial-review": await handleReviewCommand(argv, { - reviewName: "Adversarial Review" + reviewName: "Adversarial Review", + opaqueFocus: true }); break; case "task": diff --git a/plugins/codex/scripts/lib/args.mjs b/plugins/codex/scripts/lib/args.mjs index 6b1518502..d95a1379c 100644 --- a/plugins/codex/scripts/lib/args.mjs +++ b/plugins/codex/scripts/lib/args.mjs @@ -5,6 +5,7 @@ export function parseArgs(argv, config = {}) { const options = {}; const positionals = []; let passthrough = false; + const stopAtFirstPositional = Boolean(config.stopAtFirstPositional); for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; @@ -21,6 +22,7 @@ export function parseArgs(argv, config = {}) { if (!token.startsWith("-") || token === "-") { positionals.push(token); + passthrough = stopAtFirstPositional; continue; } @@ -46,6 +48,7 @@ export function parseArgs(argv, config = {}) { } positionals.push(token); + passthrough = stopAtFirstPositional; continue; } @@ -68,18 +71,21 @@ export function parseArgs(argv, config = {}) { } positionals.push(token); + passthrough = stopAtFirstPositional; } return { options, positionals }; } -export function splitRawArgumentString(raw) { +export function splitRawArgumentStringWithSpans(raw) { const tokens = []; let current = ""; let quote = null; let escaping = false; + let tokenStart = null; - for (const character of raw) { + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; if (escaping) { current += character; escaping = false; @@ -87,6 +93,7 @@ export function splitRawArgumentString(raw) { } if (character === "\\") { + tokenStart ??= index; escaping = true; continue; } @@ -101,28 +108,29 @@ export function splitRawArgumentString(raw) { } if (character === "'" || character === "\"") { + tokenStart ??= index; quote = character; continue; } if (/\s/.test(character)) { - if (current) { - tokens.push(current); + if (tokenStart !== null) { + tokens.push({ value: current, start: tokenStart, end: index }); current = ""; + tokenStart = null; } continue; } + tokenStart ??= index; current += character; } - if (escaping) { - current += "\\"; - } - - if (current) { - tokens.push(current); - } - + if (escaping) current += "\\"; + if (tokenStart !== null) tokens.push({ value: current, start: tokenStart, end: raw.length }); return tokens; } + +export function splitRawArgumentString(raw) { + return splitRawArgumentStringWithSpans(raw).map((token) => token.value); +} diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 8f276835b..224e10df5 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -386,6 +386,47 @@ test("adversarial review renders structured findings over app-server turn/start" assert.match(result.stdout, /Missing empty-state guard/); }); +test("adversarial review keeps long option examples in a single focus argument", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + const focus = "Review the build script command build_v6_artifact.py --variant v6.2 --model pm_v6_2"; + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const result = run("node", [SCRIPT, "adversarial-review", focus], { + cwd: repo, + env: buildEnv(binDir) + }); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(statePath, "utf8")); + assert.equal(fakeState.lastTurnStart.model, null); + assert.match(fakeState.lastTurnStart.prompt, new RegExp(focus.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); +}); + +test("adversarial review preserves backslashes, quotes, and whitespace in focus text", () => { + const repo = makeTempDir(); + const binDir = makeTempDir(); + const statePath = path.join(binDir, "fake-codex-state.json"); + const focus = 'Review C:\\temp\\foo and keep "quoted spacing" exactly'; + installFakeCodex(binDir); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "README.md"), "hello again\n"); + + const result = run("node", [SCRIPT, "adversarial-review", focus], { cwd: repo, env: buildEnv(binDir) }); + assert.equal(result.status, 0, result.stderr); + const prompt = JSON.parse(fs.readFileSync(statePath, "utf8")).lastTurnStart.prompt; + assert.match(prompt, new RegExp(focus.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); +}); + test("adversarial review accepts the same base-branch targeting as review", () => { const repo = makeTempDir(); const binDir = makeTempDir();