From 7390dc04717079ad56d30d02a5567f1762b90ff3 Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Tue, 30 Jun 2026 15:28:37 +1000 Subject: [PATCH 01/11] Fix test paths with spaces Convert file URL references in the command and E2E tests with fileURLToPath instead of reading URL.pathname directly. URL.pathname percent-encodes spaces, which makes child process invocations look for paths such as CLAUDE%20CODE... and fail with MODULE_NOT_FOUND. fileURLToPath preserves the correct filesystem path on local platforms. Verified with the full test suite from the fork path and from a copied checkout under /private/tmp/claude plugin codex final space test. --- tests/commands.test.mjs | 77 ++++++++++++++++++++------------------- tests/e2e-codex-skill.mjs | 3 +- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 8b0abed..f232b13 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -3,9 +3,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import test from "node:test"; -const companion = new URL("../plugins/claude-code-advisor/scripts/claude-companion.mjs", import.meta.url); +const companion = fileURLToPath( + new URL("../plugins/claude-code-advisor/scripts/claude-companion.mjs", import.meta.url) +); function makeFakeClaude(scriptBody) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fake-claude-")); @@ -44,7 +47,7 @@ if (args.includes("-p")) { console.log("{}"); process.exit(0); } console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "setup", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "setup", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -73,7 +76,7 @@ console.error("unsupported"); process.exit(2); CLAUDE_COMPANION_STATE_ROOT: stateRoot, CODEX_THREAD_ID: "thread-a" }; - const stdout = execFileSync(process.execPath, [companion.pathname, "review", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "review", "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -83,7 +86,7 @@ console.error("unsupported"); process.exit(2); assert.equal(payload.status, "completed"); assert.equal(payload.result.findings[0].severity, "MAJOR"); - const result = execFileSync(process.execPath, [companion.pathname, "result", payload.jobId, "--json"], { + const result = execFileSync(process.execPath, [companion, "result", payload.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -105,7 +108,7 @@ console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; - const stdout = execFileSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "advise", "--background", "check architecture", "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -115,7 +118,7 @@ console.error("unsupported"); process.exit(2); assert.equal(payload.status, "running"); assert.equal(payload.claudeSessionId, "bg123"); - const cancel = execFileSync(process.execPath, [companion.pathname, "cancel", payload.jobId, "--json"], { + const cancel = execFileSync(process.execPath, [companion, "cancel", payload.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -123,7 +126,7 @@ console.error("unsupported"); process.exit(2); assert.equal(JSON.parse(cancel).status, "cancelled"); assert.equal(fs.readFileSync(stopLog, "utf8"), "bg123"); - const result = execFileSync(process.execPath, [companion.pathname, "result", payload.jobId, "--json"], { + const result = execFileSync(process.execPath, [companion, "result", payload.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -149,7 +152,7 @@ if (args.includes("-p")) { console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "review", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "review", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -170,7 +173,7 @@ console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; - const launched = execFileSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + const launched = execFileSync(process.execPath, [companion, "advise", "--background", "check architecture", "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -178,7 +181,7 @@ console.error("unsupported"); process.exit(2); const job = JSON.parse(launched); const watched = execFileSync( process.execPath, - [companion.pathname, "monitor", job.jobId, "--interval-ms", "1", "--max-checks", "2", "--json"], + [companion, "monitor", job.jobId, "--interval-ms", "1", "--max-checks", "2", "--json"], { env, cwd: stateRoot, encoding: "utf8" } ); const snapshots = watched.trim().split(/\r?\n/).map((line) => JSON.parse(line)); @@ -187,7 +190,7 @@ console.error("unsupported"); process.exit(2); assert.equal(snapshots[0].active, true); assert.equal(snapshots[0].logs.output, "progress: still working"); - const result = execFileSync(process.execPath, [companion.pathname, "result", job.jobId, "--json"], { + const result = execFileSync(process.execPath, [companion, "result", job.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -215,7 +218,7 @@ console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; - const launched = execFileSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + const launched = execFileSync(process.execPath, [companion, "advise", "--background", "check architecture", "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -224,7 +227,7 @@ console.error("unsupported"); process.exit(2); const watched = execFileSync( process.execPath, [ - companion.pathname, + companion, "monitor", job.jobId, "--interval-ms", @@ -248,7 +251,7 @@ console.error("unsupported"); process.exit(2); const human = execFileSync( process.execPath, - [companion.pathname, "monitor", job.jobId, "--interval-ms", "1", "--max-checks", "1"], + [companion, "monitor", job.jobId, "--interval-ms", "1", "--max-checks", "1"], { env, cwd: stateRoot, encoding: "utf8" } ); assert.match(human, /Last meaningful output: progress: compiling tests/); @@ -280,13 +283,13 @@ console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; - const launched = execFileSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + const launched = execFileSync(process.execPath, [companion, "advise", "--background", "check architecture", "--json"], { env, cwd: stateRoot, encoding: "utf8" }); const job = JSON.parse(launched); - const watched = execFileSync(process.execPath, [companion.pathname, "monitor", job.jobId, "--json"], { + const watched = execFileSync(process.execPath, [companion, "monitor", job.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -298,7 +301,7 @@ console.error("unsupported"); process.exit(2); assert.equal(snapshot.summary.state, "inactive"); assert.equal(snapshot.summary.lastMeaningfulLine, "PASS"); - const result = execFileSync(process.execPath, [companion.pathname, "result", job.jobId, "--json"], { + const result = execFileSync(process.execPath, [companion, "result", job.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -311,7 +314,7 @@ console.error("unsupported"); process.exit(2); test("foreground advise defaults to a larger turn budget", () => { const fake = makeFakeClaudeExpectingMaxTurns(20); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "advise", "check architecture", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "advise", "check architecture", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -325,7 +328,7 @@ test("foreground advise defaults to a larger turn budget", () => { test("foreground do defaults to a larger turn budget", () => { const fake = makeFakeClaudeExpectingMaxTurns(20); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "do", "inspect local code", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "do", "inspect local code", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -339,7 +342,7 @@ test("foreground do defaults to a larger turn budget", () => { test("foreground rescue defaults to a larger turn budget", () => { const fake = makeFakeClaudeExpectingMaxTurns(20); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "rescue", "diagnose the failure", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "rescue", "diagnose the failure", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -355,7 +358,7 @@ test("foreground task max-turn override takes precedence over the default", () = const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const stdout = execFileSync( process.execPath, - [companion.pathname, "do", "--max-turns", "5", "inspect local code", "--json"], + [companion, "do", "--max-turns", "5", "inspect local code", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, @@ -378,7 +381,7 @@ if (args.includes("-p")) { console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "do", "inspect local code", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "do", "inspect local code", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -405,7 +408,7 @@ if (args.includes("-p")) { console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "advise", "check architecture", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "advise", "check architecture", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -431,7 +434,7 @@ if (args[0] === "--bg") { console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "advise", "--background", "check architecture", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -453,7 +456,7 @@ console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); fs.writeFileSync(path.join(stateRoot, ".mcp.json"), '{"mcpServers":{"playwright":{}}}\n', "utf8"); - const result = spawnSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + const result = spawnSync(process.execPath, [companion, "advise", "--background", "check architecture", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -478,7 +481,7 @@ console.error("unsupported"); process.exit(2); fs.mkdirSync(child, { recursive: true }); fs.writeFileSync(path.join(parent, ".mcp.json"), '{"mcpServers":{"playwright":{}}}\n', "utf8"); fs.writeFileSync(path.join(child, ".git"), "gitdir: ../.git/worktrees/task\n", "utf8"); - const result = spawnSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + const result = spawnSync(process.execPath, [companion, "advise", "--background", "check architecture", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: parent }, cwd: child, encoding: "utf8" @@ -506,7 +509,7 @@ console.error("unsupported"); process.exit(2); fs.writeFileSync(path.join(stateRoot, ".mcp.json"), '{"mcpServers":{"playwright":{}}}\n', "utf8"); const stdout = execFileSync( process.execPath, - [companion.pathname, "advise", "--background", "--allow-mcp", "check architecture", "--json"], + [companion, "advise", "--background", "--allow-mcp", "check architecture", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, @@ -534,7 +537,7 @@ if (args.includes("-p")) { console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "do", "inspect local code", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "do", "inspect local code", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -561,7 +564,7 @@ console.error("unsupported"); process.exit(2); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; const stdout = execFileSync( process.execPath, - [companion.pathname, "advise", "--timeout-ms", "50", "check architecture", "--json"], + [companion, "advise", "--timeout-ms", "50", "check architecture", "--json"], { env, cwd: stateRoot, encoding: "utf8" } ); const payload = JSON.parse(stdout); @@ -570,7 +573,7 @@ console.error("unsupported"); process.exit(2); assert.equal(payload.claudeSessionId, "bg123"); assert.match(payload.output, /Foreground Claude timed out/); - const status = execFileSync(process.execPath, [companion.pathname, "status", payload.jobId, "--json"], { + const status = execFileSync(process.execPath, [companion, "status", payload.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -589,7 +592,7 @@ console.error("unsupported"); process.exit(2); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const result = spawnSync( process.execPath, - [companion.pathname, "advise", "--timeout-ms", "50", "--no-background-fallback", "slow"], + [companion, "advise", "--timeout-ms", "50", "--no-background-fallback", "slow"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, @@ -608,7 +611,7 @@ if (args.includes("-p")) { console.log("human answer"); process.exit(0); } console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const stdout = execFileSync(process.execPath, [companion.pathname, "advise", "check architecture"], { + const stdout = execFileSync(process.execPath, [companion, "advise", "check architecture"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" @@ -633,7 +636,7 @@ console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; - const stdout = execFileSync(process.execPath, [companion.pathname, "rescue", "--write", "fix the failing test", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "rescue", "--write", "fix the failing test", "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -644,7 +647,7 @@ console.error("unsupported"); process.exit(2); assert.equal(payload.status, "completed"); assert.equal(payload.output, "rescued"); - const result = execFileSync(process.execPath, [companion.pathname, "result", payload.jobId, "--json"], { + const result = execFileSync(process.execPath, [companion, "result", payload.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -668,7 +671,7 @@ console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; - const stdout = execFileSync(process.execPath, [companion.pathname, "rescue", "diagnose the failure", "--json"], { + const stdout = execFileSync(process.execPath, [companion, "rescue", "diagnose the failure", "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -701,7 +704,7 @@ console.error("unsupported"); process.exit(2); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; const stdout = execFileSync( process.execPath, - [companion.pathname, "do", "--write", "--model", "sonnet", "implement the prepared task", "--json"], + [companion, "do", "--write", "--model", "sonnet", "implement the prepared task", "--json"], { env, cwd: stateRoot, encoding: "utf8" } ); const payload = JSON.parse(stdout); @@ -716,7 +719,7 @@ test("foreground timeout fails the job without hanging", () => { setTimeout(() => {}, 5000); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); - const result = spawnSync(process.execPath, [companion.pathname, "advise", "--timeout-ms", "50", "--no-background-fallback", "slow"], { + const result = spawnSync(process.execPath, [companion, "advise", "--timeout-ms", "50", "--no-background-fallback", "slow"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, encoding: "utf8" diff --git a/tests/e2e-codex-skill.mjs b/tests/e2e-codex-skill.mjs index 83b0417..ca68d5e 100644 --- a/tests/e2e-codex-skill.mjs +++ b/tests/e2e-codex-skill.mjs @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; -const repoRoot = new URL("..", import.meta.url).pathname; +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const skillMarker = "claude-code-advisor:claude"; const advisePrompt = [ "Use $claude advise --model sonnet --max-turns 1 --timeout-ms 120000 to ask Claude Code to reply with exactly PASS.", From b118300cf5ef3e11ce722af33036218865713838 Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Tue, 30 Jun 2026 16:13:45 +1000 Subject: [PATCH 02/11] Avoid trailing slash in e2e repo root Use path.dirname(fileURLToPath(import.meta.url)) for the E2E repo root instead of converting the parent directory URL directly. This keeps the path-with-spaces fix while avoiding a trailing slash in the path passed as cwd and to codex exec --cd. --- tests/e2e-codex-skill.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e-codex-skill.mjs b/tests/e2e-codex-skill.mjs index ca68d5e..47e31d7 100644 --- a/tests/e2e-codex-skill.mjs +++ b/tests/e2e-codex-skill.mjs @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import path from "node:path"; import { fileURLToPath } from "node:url"; -const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const repoRoot = path.dirname(fileURLToPath(import.meta.url)); const skillMarker = "claude-code-advisor:claude"; const advisePrompt = [ "Use $claude advise --model sonnet --max-turns 1 --timeout-ms 120000 to ask Claude Code to reply with exactly PASS.", From f7024f816531d2f2138c29df91997a4e0b82fa26 Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Tue, 30 Jun 2026 16:06:51 +1000 Subject: [PATCH 03/11] Harden review parsing and monitor agents JSON Use claude agents --json when monitoring background jobs so non-TTY Codex sessions can read lifecycle state and mark completed sessions done. Also tolerate Claude JSON envelopes whose result string begins with Claude tool-call markup before the review JSON. Direct review payload validation remains strict so arbitrary leading prose is still rejected. Tests cover agents JSON completion and tool-call-prefixed review output. --- .../scripts/claude-companion.mjs | 38 +++++++++++++++-- .../scripts/lib/runtime.mjs | 16 ++++++- tests/commands.test.mjs | 42 +++++++++++++++++++ tests/runtime.test.mjs | 32 ++++++++++++++ 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/plugins/claude-code-advisor/scripts/claude-companion.mjs b/plugins/claude-code-advisor/scripts/claude-companion.mjs index 490c951..5b2b05e 100755 --- a/plugins/claude-code-advisor/scripts/claude-companion.mjs +++ b/plugins/claude-code-advisor/scripts/claude-companion.mjs @@ -569,6 +569,34 @@ function findJob(ctx, reference) { return ctx.state.jobs.find((job) => job.id === reference || job.claudeSessionId === reference) || null; } +function parseAgentsJson(output, claudeSessionId) { + const text = String(output || "").trim(); + if (!text) { + return { output: "" }; + } + try { + const parsed = JSON.parse(text); + const sessions = Array.isArray(parsed) ? parsed : parsed.sessions || parsed.agents || []; + const match = sessions.find((session) => { + return ( + session?.id === claudeSessionId || + session?.sessionId === claudeSessionId || + session?.session_id === claudeSessionId + ); + }); + const lifecycle = `${match?.status || ""} ${match?.state || ""}`.toLowerCase(); + return { + output: text, + sessions, + match: match || null, + active: /\b(active|running|busy|working)\b/.test(lifecycle), + completed: /\b(done|completed|complete|stopped|exited|finished)\b/.test(lifecycle) + }; + } catch { + return { output: text }; + } +} + function readLiveStatus(job, options = {}) { if (!job?.claudeSessionId) { return { @@ -581,16 +609,17 @@ function readLiveStatus(job, options = {}) { } const timeoutMs = Number(options["timeout-ms"] || 10000); const logs = runClaude(["logs", job.claudeSessionId], { timeoutMs }); - const agents = runClaude(["agents"], { timeoutMs }); + const agents = runClaude(["agents", "--json"], { timeoutMs }); const agentsOutput = stripTerminalControl(`${agents.stdout || ""}${agents.stderr || ""}`); const logsOutput = stripTerminalControl(`${logs.stdout || ""}${logs.stderr || ""}`); + const agentStatus = agents.status === 0 ? parseAgentsJson(agentsOutput, job.claudeSessionId) : { output: agentsOutput }; const meaningfulLogLines = extractMeaningfulLogLines(logsOutput); - const completed = isCompletedLogOutput(logsOutput); + const completed = isCompletedLogOutput(logsOutput) || Boolean(agentStatus.completed); return { checkedAt: new Date().toISOString(), jobId: job.id, claudeSessionId: job.claudeSessionId, - active: logs.status === 0 && !completed, + active: !completed && (logs.status === 0 || Boolean(agentStatus.active)), completed, available: logs.status === 0 || agents.status === 0, logs: { @@ -600,7 +629,8 @@ function readLiveStatus(job, options = {}) { }, agents: { available: agents.status === 0, - output: agentsOutput.trim() + output: agentStatus.output || agentsOutput.trim(), + match: agentStatus.match || null } }; } diff --git a/plugins/claude-code-advisor/scripts/lib/runtime.mjs b/plugins/claude-code-advisor/scripts/lib/runtime.mjs index daf9be4..8462693 100644 --- a/plugins/claude-code-advisor/scripts/lib/runtime.mjs +++ b/plugins/claude-code-advisor/scripts/lib/runtime.mjs @@ -267,8 +267,8 @@ export function parseClaudeJsonResult(raw) { } const envelope = JSON.parse(text); const sessionId = envelope.session_id || envelope.sessionId || null; - const contentRaw = typeof envelope.result === "string" ? envelope.result : text; - const content = typeof envelope.result === "string" ? JSON.parse(envelope.result) : envelope; + const contentRaw = typeof envelope.result === "string" ? normalizeClaudeResult(envelope.result) : text; + const content = typeof envelope.result === "string" ? JSON.parse(contentRaw) : envelope; return { envelope, content, @@ -277,6 +277,18 @@ export function parseClaudeJsonResult(raw) { }; } +function normalizeClaudeResult(raw) { + const trimmed = String(raw || "").trim(); + if (trimmed.startsWith("{")) { + return trimmed; + } + const toolCalls = trimmed.match(/^[\s\S]*?<\/function_calls>\s*/); + if (!toolCalls) { + return trimmed; + } + return trimmed.slice(toolCalls[0].length).trim(); +} + export function buildReviewPrompt({ kind, targetLabel, gitContext, focus = "" }) { const reviewKind = kind === "adversarial-review" ? "adversarial reviewer" : "code reviewer"; const focusLine = focus ? `Focus: ${focus}\n` : ""; diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index f232b13..727e8df 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -200,6 +200,48 @@ console.error("unsupported"); process.exit(2); assert.equal(stored.job.lastMonitorSnapshot.summary.lastMeaningfulLine, "progress: still working"); }); +test("monitor uses agents json state to mark a background job completed", () => { + const fake = makeFakeClaude(` +const args = process.argv.slice(2); +if (args.includes("--version")) { console.log("2.1.132 (Claude Code)"); process.exit(0); } +if (args[0] === "--bg") { console.log("backgrounded · bg123 (idle - send a prompt to start)"); process.exit(0); } +if (args[0] === "logs") { console.log("final answer available"); process.exit(0); } +if (args[0] === "agents" && args[1] === "--json") { + console.log(JSON.stringify([{ id: "bg123", status: "idle", state: "done" }])); + process.exit(0); +} +if (args[0] === "agents") { console.error("agents must be read with --json"); process.exit(2); } +console.error("unsupported"); process.exit(2); +`); + const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); + const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; + const launched = execFileSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + env, + cwd: stateRoot, + encoding: "utf8" + }); + const job = JSON.parse(launched); + const watched = execFileSync(process.execPath, [companion.pathname, "monitor", job.jobId, "--json"], { + env, + cwd: stateRoot, + encoding: "utf8" + }); + const snapshot = JSON.parse(watched.trim()); + + assert.equal(snapshot.active, false); + assert.equal(snapshot.completed, true); + assert.equal(snapshot.agents.match.state, "done"); + + const result = execFileSync(process.execPath, [companion.pathname, "result", job.jobId, "--json"], { + env, + cwd: stateRoot, + encoding: "utf8" + }); + const stored = JSON.parse(result); + assert.equal(stored.job.status, "completed"); + assert.equal(stored.result, "final answer available"); +}); + test("monitor summarizes meaningful progress and stale repeated logs", () => { const fake = makeFakeClaude(` const args = process.argv.slice(2); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 45e6b8f..c053c09 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -213,6 +213,38 @@ test("parseClaudeJsonResult unwraps Claude CLI json envelope", () => { assert.equal(parsed.content.findings[0].severity, "MINOR"); }); +test("parseClaudeJsonResult tolerates Claude tool-call markup before review JSON", () => { + const payload = { + findings: [ + { + severity: "MINOR", + title: "Markup", + fact: "Claude prefixed the JSON with tool-call markup", + recommendation: "Strip the tool-call block before review validation" + } + ] + }; + const raw = JSON.stringify({ + type: "result", + subtype: "success", + result: [ + "", + '', + 'git log main...HEAD --oneline', + "", + "", + "", + JSON.stringify(payload) + ].join("\n"), + session_id: "session-456" + }); + + const parsed = parseClaudeJsonResult(raw); + assert.equal(parsed.sessionId, "session-456"); + assert.deepEqual(validateReviewPayload(parsed.contentRaw), payload); + assert.equal(parsed.content.findings[0].title, "Markup"); +}); + test("buildReviewPrompt includes git context and JSON-only contract", () => { const prompt = buildReviewPrompt({ kind: "adversarial-review", From 929b4481b69683eba820511027a4dc4e86e2d21c Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Tue, 30 Jun 2026 17:23:15 +1000 Subject: [PATCH 04/11] Extract review JSON after Claude prose Handle Claude JSON envelopes whose result begins with status prose before the requested review JSON. The direct review payload validator remains strict; this recovery is limited to Claude CLI envelope results. Adds a regression test for a result beginning with 'Now I have enough context to review.' --- .../scripts/lib/runtime.mjs | 46 +++++++++++++++++-- tests/runtime.test.mjs | 24 ++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/plugins/claude-code-advisor/scripts/lib/runtime.mjs b/plugins/claude-code-advisor/scripts/lib/runtime.mjs index 8462693..84e2bac 100644 --- a/plugins/claude-code-advisor/scripts/lib/runtime.mjs +++ b/plugins/claude-code-advisor/scripts/lib/runtime.mjs @@ -283,10 +283,50 @@ function normalizeClaudeResult(raw) { return trimmed; } const toolCalls = trimmed.match(/^[\s\S]*?<\/function_calls>\s*/); - if (!toolCalls) { - return trimmed; + const withoutToolCalls = toolCalls ? trimmed.slice(toolCalls[0].length).trim() : trimmed; + if (withoutToolCalls.startsWith("{")) { + return withoutToolCalls; + } + return extractFirstJsonObject(withoutToolCalls) || withoutToolCalls; +} + +function extractFirstJsonObject(value) { + const text = String(value || ""); + const start = text.indexOf("{"); + if (start === -1) { + return null; + } + + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < text.length; index += 1) { + const char = text[index]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + return text.slice(start, index + 1).trim(); + } + } } - return trimmed.slice(toolCalls[0].length).trim(); + return null; } export function buildReviewPrompt({ kind, targetLabel, gitContext, focus = "" }) { diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index c053c09..b025bb1 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -245,6 +245,30 @@ test("parseClaudeJsonResult tolerates Claude tool-call markup before review JSON assert.equal(parsed.content.findings[0].title, "Markup"); }); +test("parseClaudeJsonResult tolerates Claude prose before review JSON", () => { + const payload = { + findings: [ + { + severity: "MINOR", + title: "Prose", + fact: "Claude prefixed the JSON with a status sentence", + recommendation: "Extract the first complete JSON object from the envelope result" + } + ] + }; + const raw = JSON.stringify({ + type: "result", + subtype: "success", + result: `Now I have enough context to review.\n\n${JSON.stringify(payload)}\n\nDone.`, + session_id: "session-789" + }); + + const parsed = parseClaudeJsonResult(raw); + assert.equal(parsed.sessionId, "session-789"); + assert.deepEqual(validateReviewPayload(parsed.contentRaw), payload); + assert.equal(parsed.content.findings[0].title, "Prose"); +}); + test("buildReviewPrompt includes git context and JSON-only contract", () => { const prompt = buildReviewPrompt({ kind: "adversarial-review", From d0092657e7dd04358469017cfeefabbaede5c691 Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Tue, 30 Jun 2026 16:09:16 +1000 Subject: [PATCH 05/11] Document companion state root override Add README guidance for CLAUDE_COMPANION_STATE_ROOT so sandboxed Codex environments can point companion state at a writable private directory. Also note that the state root can contain prompts, Claude output, workspace paths, and review results, so it should stay local and out of version control. --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 9d8402d..5c328db 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,26 @@ so `$claude status`, `$claude result`, and `$claude cancel` can work across turns. It does not intentionally collect analytics, phone home, or send data to the repository owner. +## State Storage + +By default the companion stores state under: + +```text +~/.codex/claude-plugin-codex +``` + +Set `CLAUDE_COMPANION_STATE_ROOT` to use another local directory: + +```bash +CLAUDE_COMPANION_STATE_ROOT=/path/to/writable/state \ + node plugins/claude-code-advisor/scripts/claude-companion.mjs setup --json +``` + +This is useful in sandboxed Codex environments where the default Codex home +path is readable but not writable. The state root should be local, private, and +excluded from version control because it can contain job prompts, Claude output, +workspace paths, and review results. + ## Terms This project is provided under the MIT License. You are responsible for how you @@ -323,6 +343,11 @@ or start a new thread. Codex may still point at an older cached skill path after a plugin version bump. If the error remains, remove and reinstall the `claude-plugin-codex` marketplace. +If `$claude setup` or a companion command fails with a write permission error +under `~/.codex/claude-plugin-codex`, rerun it with `CLAUDE_COMPANION_STATE_ROOT` +pointing at a writable private directory. Do not point it at the project +repository unless you also ignore that path in Git. + ## License MIT. From da0d8c735c078a2e01d2e61552f0bb401d33d627 Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Mon, 17 Aug 2026 23:05:19 +1000 Subject: [PATCH 06/11] Prepare v0.1.13 release candidate Consolidate the path portability, review parser, monitor and state-root work from upstream PRs 2-4. Harden review diff collection, ambiguous JSON rejection, background cancellation and private state permissions. Make timeout tests deterministic, exercise the companion lifecycle in smoke tests, and update CI to current actions across Node 20, 22 and 24. --- .github/workflows/ci.yml | 15 +- .node-version | 1 + README.md | 24 ++- package.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .../scripts/claude-companion.mjs | 38 ++++- .../scripts/lib/runtime.mjs | 43 +++-- tests/commands.test.mjs | 155 +++++++++++++++++- tests/e2e-codex-skill.mjs | 4 +- tests/runtime.test.mjs | 66 ++++++++ tests/smoke-installed-tools.mjs | 57 ++++++- 11 files changed, 354 insertions(+), 53 deletions(-) create mode 100644 .node-version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 585b3f5..a929370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,22 @@ on: branches: - main +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest + name: Node ${{ matrix.node }} + strategy: + fail-fast: false + matrix: + node: [20, 22, 24] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: - node-version: "20" + node-version: ${{ matrix.node }} + package-manager-cache: false - run: npm test - run: node tests/validate-plugin.mjs diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/README.md b/README.md index 5c328db..832c0c3 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,9 @@ The companion owns: ## Development +Use Node.js 24 for development. The repository includes `.node-version` for +compatible version managers, and CI also checks Node.js 20 and 22 compatibility. + ```bash npm test npm run validate @@ -329,12 +332,17 @@ through the installed skill. Sonnet is used only for this small routing test. - Foreground prepared task routes use a larger default turn budget than structured review. If Claude reports that it hit the max-turn limit, rerun with `--max-turns ` or narrow the task. +- Working-tree structured reviews stop when untracked files exist because their + contents are absent from a Git diff and review mode cannot read the workspace. + Stage the intended files before rerunning the review. - `$claude monitor` checks a background job every 30 seconds by default. It - reads `claude logs` and `claude agents`, filters routine terminal noise, and - marks repeated output as stale after two minutes. -- Structured review depends on Claude returning valid JSON inside the - `--output-format json` result envelope. The companion validates and retries - once before failing. + reads `claude logs` and `claude agents --json --all`, filters routine terminal + noise, and marks repeated output as stale after two minutes. +- Structured review extracts a single complete JSON object from Claude's + `--output-format json` result envelope, tolerating leading status prose or + tool-call markup while rejecting ambiguous multiple objects. The extracted + review payload is still validated strictly, and the companion retries once + before failing. ## Troubleshooting @@ -345,8 +353,10 @@ a plugin version bump. If the error remains, remove and reinstall the If `$claude setup` or a companion command fails with a write permission error under `~/.codex/claude-plugin-codex`, rerun it with `CLAUDE_COMPANION_STATE_ROOT` -pointing at a writable private directory. Do not point it at the project -repository unless you also ignore that path in Git. +pointing at a writable directory. Within that root, the companion restricts its +workspace and thread directories to mode `0700` and state/pointer files to mode +`0600`. Do not point it at the project repository unless you also ignore that +path in Git. ## License diff --git a/package.json b/package.json index ee34508..7bd4b4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-plugin-codex", - "version": "0.1.12", + "version": "0.1.13", "description": "Bring local Claude Code into Codex for reviews, prepared tasks, advice, and rescue work.", "type": "module", "private": true, diff --git a/plugins/claude-code-advisor/.codex-plugin/plugin.json b/plugins/claude-code-advisor/.codex-plugin/plugin.json index 71b93e9..27d2d5f 100644 --- a/plugins/claude-code-advisor/.codex-plugin/plugin.json +++ b/plugins/claude-code-advisor/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "claude-code-advisor", - "version": "0.1.12", + "version": "0.1.13", "description": "Bring local Claude Code into Codex for reviews, prepared tasks, advice, and rescue work.", "author": { "name": "Yanchuk" diff --git a/plugins/claude-code-advisor/scripts/claude-companion.mjs b/plugins/claude-code-advisor/scripts/claude-companion.mjs index 5b2b05e..120acf7 100755 --- a/plugins/claude-code-advisor/scripts/claude-companion.mjs +++ b/plugins/claude-code-advisor/scripts/claude-companion.mjs @@ -317,19 +317,36 @@ function assertBackgroundMcpSafe(ctx, options = {}) { function persistContext(ctx, state) { const saved = saveState(ctx.stateDir, state); - fs.mkdirSync(ctx.indexDir, { recursive: true }); - fs.writeFileSync(path.join(ctx.indexDir, "latest-state-dir"), `${ctx.stateDir}\n`, "utf8"); + fs.mkdirSync(ctx.indexDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(ctx.indexDir, 0o700); + const latestStateFile = path.join(ctx.indexDir, "latest-state-dir"); + fs.writeFileSync(latestStateFile, `${ctx.stateDir}\n`, { encoding: "utf8", mode: 0o600 }); + fs.chmodSync(latestStateFile, 0o600); return saved; } function gitContext(cwd, options = {}) { const target = options.base ? `${options.base}...HEAD` : null; + if (!target) { + const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd, encoding: "utf8" }); + const paths = untracked.status === 0 ? untracked.stdout.trim().split(/\r?\n/).filter(Boolean) : []; + if (paths.length) { + const shown = paths.slice(0, 20).map((file) => `- ${file}`).join("\n"); + const remaining = paths.length > 20 ? `\n- ...and ${paths.length - 20} more` : ""; + throw new Error( + `Working-tree review cannot safely include untracked file contents. Stage the intended files first:\n${shown}${remaining}` + ); + } + } const args = target ? ["diff", "--stat", target] : ["status", "--short", "--untracked-files=all"]; const result = spawnSync("git", args, { cwd, encoding: "utf8" }); const first = result.status === 0 ? result.stdout : result.stderr; - const diffArgs = target ? ["diff", "--", target] : ["diff", "--"]; - const diff = spawnSync("git", diffArgs, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 }); - return [first, diff.status === 0 ? diff.stdout : ""].join("\n").trim(); + const diffArgs = target ? ["diff", target, "--"] : ["diff", "HEAD", "--"]; + let diff = spawnSync("git", diffArgs, { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 }); + if (!target && diff.status !== 0) { + diff = spawnSync("git", ["diff", "--"], { cwd, encoding: "utf8", maxBuffer: 1024 * 1024 }); + } + return [first, diff.status === 0 ? diff.stdout : diff.stderr].join("\n").trim(); } function detectCapabilities() { @@ -609,7 +626,7 @@ function readLiveStatus(job, options = {}) { } const timeoutMs = Number(options["timeout-ms"] || 10000); const logs = runClaude(["logs", job.claudeSessionId], { timeoutMs }); - const agents = runClaude(["agents", "--json"], { timeoutMs }); + const agents = runClaude(["agents", "--json", "--all"], { timeoutMs }); const agentsOutput = stripTerminalControl(`${agents.stdout || ""}${agents.stderr || ""}`); const logsOutput = stripTerminalControl(`${logs.stdout || ""}${logs.stderr || ""}`); const agentStatus = agents.status === 0 ? parseAgentsJson(agentsOutput, job.claudeSessionId) : { output: agentsOutput }; @@ -801,10 +818,17 @@ function handleCancel(argv) { try { const live = summarizeLiveStatus(readLiveStatus(job, options), {}, options); latest = persistMonitorSnapshot(ctx, job, live); + if (live.completed) { + output({ jobId: latest.id, status: "completed" }, options.json); + return; + } } catch { latest = job; } - runClaude(["stop", job.claudeSessionId], { timeoutMs: Number(options["timeout-ms"] || 10000) }); + const stopped = runClaude(["stop", job.claudeSessionId], { timeoutMs: Number(options["timeout-ms"] || 10000) }); + if (stopped.status !== 0) { + throw new Error(stopped.stderr || stopped.stdout || `Claude failed to stop session ${job.claudeSessionId}.`); + } } const cancelled = completeJob(ctx, latest, { status: "cancelled" }); output({ jobId: cancelled.id, status: "cancelled" }, options.json); diff --git a/plugins/claude-code-advisor/scripts/lib/runtime.mjs b/plugins/claude-code-advisor/scripts/lib/runtime.mjs index 84e2bac..3d58b92 100644 --- a/plugins/claude-code-advisor/scripts/lib/runtime.mjs +++ b/plugins/claude-code-advisor/scripts/lib/runtime.mjs @@ -83,13 +83,16 @@ export function loadState(stateDir) { } export function saveState(stateDir, state) { - fs.mkdirSync(stateDir, { recursive: true }); + fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(stateDir, 0o700); const next = { ...emptyState(), ...state, jobs: [...(state.jobs || [])].sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || ""))) }; - fs.writeFileSync(path.join(stateDir, "state.json"), `${JSON.stringify(next, null, 2)}\n`, "utf8"); + const stateFile = path.join(stateDir, "state.json"); + fs.writeFileSync(stateFile, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + fs.chmodSync(stateFile, 0o600); return next; } @@ -279,34 +282,34 @@ export function parseClaudeJsonResult(raw) { function normalizeClaudeResult(raw) { const trimmed = String(raw || "").trim(); - if (trimmed.startsWith("{")) { - return trimmed; - } const toolCalls = trimmed.match(/^[\s\S]*?<\/function_calls>\s*/); const withoutToolCalls = toolCalls ? trimmed.slice(toolCalls[0].length).trim() : trimmed; - if (withoutToolCalls.startsWith("{")) { + try { + JSON.parse(withoutToolCalls); return withoutToolCalls; + } catch { + const candidates = extractJsonObjects(withoutToolCalls); + if (candidates.length > 1) { + throw new Error("Ambiguous JSON Claude result: multiple complete objects were returned."); + } + return candidates[0] || withoutToolCalls; } - return extractFirstJsonObject(withoutToolCalls) || withoutToolCalls; } -function extractFirstJsonObject(value) { +function extractJsonObjects(value) { const text = String(value || ""); - const start = text.indexOf("{"); - if (start === -1) { - return null; - } - + const objects = []; + let start = -1; let depth = 0; let inString = false; let escaped = false; - for (let index = start; index < text.length; index += 1) { + for (let index = 0; index < text.length; index += 1) { const char = text[index]; if (escaped) { escaped = false; continue; } - if (char === "\\") { + if (inString && char === "\\") { escaped = true; continue; } @@ -318,15 +321,19 @@ function extractFirstJsonObject(value) { continue; } if (char === "{") { + if (depth === 0) { + start = index; + } depth += 1; - } else if (char === "}") { + } else if (char === "}" && depth > 0) { depth -= 1; if (depth === 0) { - return text.slice(start, index + 1).trim(); + objects.push(text.slice(start, index + 1).trim()); + start = -1; } } } - return null; + return objects; } export function buildReviewPrompt({ kind, targetLabel, gitContext, focus = "" }) { diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 727e8df..a345e39 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -47,6 +47,7 @@ if (args.includes("-p")) { console.log("{}"); process.exit(0); } console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); + fs.chmodSync(stateRoot, 0o755); const stdout = execFileSync(process.execPath, [companion, "setup", "--json"], { env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, cwd: stateRoot, @@ -57,6 +58,16 @@ console.error("unsupported"); process.exit(2); assert.equal(payload.ready, true); assert.equal(payload.capabilities.print, true); assert.equal(payload.capabilities.background, false); + assert.equal(fs.statSync(stateRoot).mode & 0o777, 0o755); + const workspaceIndex = fs.readdirSync(stateRoot).find((entry) => entry.startsWith("claude-state-")); + assert.ok(workspaceIndex); + const indexDir = path.join(stateRoot, workspaceIndex); + const latestStateFile = path.join(indexDir, "latest-state-dir"); + const stateDir = fs.readFileSync(latestStateFile, "utf8").trim(); + assert.equal(fs.statSync(indexDir).mode & 0o777, 0o700); + assert.equal(fs.statSync(stateDir).mode & 0o777, 0o700); + assert.equal(fs.statSync(path.join(stateDir, "state.json")).mode & 0o777, 0o600); + assert.equal(fs.statSync(latestStateFile).mode & 0o777, 0o600); }); test("review returns validated JSON and stores result", () => { @@ -94,6 +105,98 @@ console.error("unsupported"); process.exit(2); assert.equal(JSON.parse(result).job.id, payload.jobId); }); +test("review base range includes the patch in the Claude prompt", () => { + const promptLog = path.join(os.tmpdir(), `fake-review-base-${Date.now()}.log`); + const fake = makeFakeClaude(` +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (args.includes("-p")) { + fs.writeFileSync(${JSON.stringify(promptLog)}, args[args.indexOf("-p") + 1]); + console.log(JSON.stringify({findings:[]})); + process.exit(0); +} +console.error("unsupported"); process.exit(2); +`); + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "claude-review-base-")); + execFileSync("git", ["init", "-q"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repo }); + execFileSync("git", ["config", "user.email", "test@example.invalid"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "sample.txt"), "before\n", "utf8"); + execFileSync("git", ["add", "sample.txt"], { cwd: repo }); + execFileSync("git", ["commit", "-qm", "base"], { cwd: repo }); + const base = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8" }).trim(); + fs.writeFileSync(path.join(repo, "sample.txt"), "after-base-range\n", "utf8"); + execFileSync("git", ["add", "sample.txt"], { cwd: repo }); + execFileSync("git", ["commit", "-qm", "change"], { cwd: repo }); + const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); + + execFileSync(process.execPath, [companion, "review", "--base", base, "--json"], { + env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, + cwd: repo, + encoding: "utf8" + }); + + assert.match(fs.readFileSync(promptLog, "utf8"), /after-base-range/); +}); + +test("working-tree review includes staged patch content", () => { + const promptLog = path.join(os.tmpdir(), `fake-review-staged-${Date.now()}.log`); + const fake = makeFakeClaude(` +const fs = require("node:fs"); +const args = process.argv.slice(2); +if (args.includes("-p")) { + fs.writeFileSync(${JSON.stringify(promptLog)}, args[args.indexOf("-p") + 1]); + console.log(JSON.stringify({findings:[]})); + process.exit(0); +} +console.error("unsupported"); process.exit(2); +`); + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "claude-review-staged-")); + execFileSync("git", ["init", "-q"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repo }); + execFileSync("git", ["config", "user.email", "test@example.invalid"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "sample.txt"), "before\n", "utf8"); + execFileSync("git", ["add", "sample.txt"], { cwd: repo }); + execFileSync("git", ["commit", "-qm", "base"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "sample.txt"), "after-staging\n", "utf8"); + execFileSync("git", ["add", "sample.txt"], { cwd: repo }); + const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); + + execFileSync(process.execPath, [companion, "review", "--json"], { + env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, + cwd: repo, + encoding: "utf8" + }); + + assert.match(fs.readFileSync(promptLog, "utf8"), /after-staging/); +}); + +test("working-tree review refuses untracked files whose contents would be omitted", () => { + const fake = makeFakeClaude(` +console.error("Claude should not run when untracked files are present"); +process.exit(2); +`); + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "claude-review-untracked-")); + execFileSync("git", ["init", "-q"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: repo }); + execFileSync("git", ["config", "user.email", "test@example.invalid"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "tracked.txt"), "tracked\n", "utf8"); + execFileSync("git", ["add", "tracked.txt"], { cwd: repo }); + execFileSync("git", ["commit", "-qm", "base"], { cwd: repo }); + fs.writeFileSync(path.join(repo, "new-source.mjs"), "export const value = 1;\n", "utf8"); + const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); + + const reviewed = spawnSync(process.execPath, [companion, "review", "--json"], { + env: { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }, + cwd: repo, + encoding: "utf8" + }); + + assert.notEqual(reviewed.status, 0); + assert.match(reviewed.stderr, /Stage the intended files first/); + assert.match(reviewed.stderr, /new-source\.mjs/); +}); + test("background advise stores Claude session id and cancel calls claude stop", () => { const stopLog = path.join(os.tmpdir(), `fake-stop-${Date.now()}.log`); const fake = makeFakeClaude(` @@ -137,6 +240,40 @@ console.error("unsupported"); process.exit(2); assert.equal(stored.job.lastMeaningfulOutput, "latest output"); }); +test("cancel fails without persisting cancellation when Claude stop fails", () => { + const fake = makeFakeClaude(` +const args = process.argv.slice(2); +if (args.includes("--version")) { console.log("2.1.132 (Claude Code)"); process.exit(0); } +if (args[0] === "--bg") { console.log("backgrounded · bg-stop-fails (idle - send a prompt to start)"); process.exit(0); } +if (args[0] === "logs") { console.log("still working"); process.exit(0); } +if (args[0] === "agents") { console.log(JSON.stringify([{ id: "bg-stop-fails", status: "running" }])); process.exit(0); } +if (args[0] === "stop") { console.error("stop failed"); process.exit(2); } +console.error("unsupported"); process.exit(2); +`); + const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); + const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; + const launched = JSON.parse(execFileSync( + process.execPath, + [companion, "advise", "--background", "check architecture", "--json"], + { env, cwd: stateRoot, encoding: "utf8" } + )); + + const cancelled = spawnSync( + process.execPath, + [companion, "cancel", launched.jobId, "--json"], + { env, cwd: stateRoot, encoding: "utf8" } + ); + + assert.notEqual(cancelled.status, 0); + assert.match(cancelled.stderr, /stop failed/); + const stored = JSON.parse(execFileSync( + process.execPath, + [companion, "result", launched.jobId, "--json"], + { env, cwd: stateRoot, encoding: "utf8" } + )); + assert.equal(stored.job.status, "running"); +}); + test("review defaults to a single Claude turn", () => { const fake = makeFakeClaude(` const args = process.argv.slice(2); @@ -206,7 +343,7 @@ const args = process.argv.slice(2); if (args.includes("--version")) { console.log("2.1.132 (Claude Code)"); process.exit(0); } if (args[0] === "--bg") { console.log("backgrounded · bg123 (idle - send a prompt to start)"); process.exit(0); } if (args[0] === "logs") { console.log("final answer available"); process.exit(0); } -if (args[0] === "agents" && args[1] === "--json") { +if (JSON.stringify(args) === JSON.stringify(["agents", "--json", "--all"])) { console.log(JSON.stringify([{ id: "bg123", status: "idle", state: "done" }])); process.exit(0); } @@ -215,13 +352,13 @@ console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; - const launched = execFileSync(process.execPath, [companion.pathname, "advise", "--background", "check architecture", "--json"], { + const launched = execFileSync(process.execPath, [companion, "advise", "--background", "check architecture", "--json"], { env, cwd: stateRoot, encoding: "utf8" }); const job = JSON.parse(launched); - const watched = execFileSync(process.execPath, [companion.pathname, "monitor", job.jobId, "--json"], { + const watched = execFileSync(process.execPath, [companion, "monitor", job.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -232,7 +369,7 @@ console.error("unsupported"); process.exit(2); assert.equal(snapshot.completed, true); assert.equal(snapshot.agents.match.state, "done"); - const result = execFileSync(process.execPath, [companion.pathname, "result", job.jobId, "--json"], { + const result = execFileSync(process.execPath, [companion, "result", job.jobId, "--json"], { env, cwd: stateRoot, encoding: "utf8" @@ -598,9 +735,10 @@ if (args[0] === "--bg") { process.exit(0); } if (args.includes("-p")) { - setTimeout(() => {}, 5000); + setTimeout(() => process.exit(0), 5000); +} else { + console.error("unsupported"); process.exit(2); } -console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const env = { ...process.env, PATH: `${fake.dir}:${process.env.PATH}`, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; @@ -627,9 +765,10 @@ test("foreground advise can disable timeout background fallback", () => { const fake = makeFakeClaude(` const args = process.argv.slice(2); if (args.includes("-p")) { - setTimeout(() => {}, 5000); + setTimeout(() => process.exit(0), 5000); +} else { + console.error("unsupported"); process.exit(2); } -console.error("unsupported"); process.exit(2); `); const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-state-")); const result = spawnSync( diff --git a/tests/e2e-codex-skill.mjs b/tests/e2e-codex-skill.mjs index 47e31d7..33d516d 100644 --- a/tests/e2e-codex-skill.mjs +++ b/tests/e2e-codex-skill.mjs @@ -1,9 +1,11 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const repoRoot = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +assert.ok(fs.existsSync(path.join(repoRoot, "package.json")), "E2E repository root is invalid."); const skillMarker = "claude-code-advisor:claude"; const advisePrompt = [ "Use $claude advise --model sonnet --max-turns 1 --timeout-ms 120000 to ask Claude Code to reply with exactly PASS.", diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index b025bb1..4f130ff 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -12,6 +12,7 @@ import { parseBackgroundLaunch, parseClaudeJsonResult, resolveStateDir, + saveState, selectResumeCandidate, validateReviewPayload } from "../plugins/claude-code-advisor/scripts/lib/runtime.mjs"; @@ -269,6 +270,61 @@ test("parseClaudeJsonResult tolerates Claude prose before review JSON", () => { assert.equal(parsed.content.findings[0].title, "Prose"); }); +test("parseClaudeJsonResult extracts review JSON followed by prose", () => { + const payload = { findings: [] }; + const raw = JSON.stringify({ + type: "result", + subtype: "success", + result: `${JSON.stringify(payload)}\nDone.`, + session_id: "session-trailing-prose" + }); + + const parsed = parseClaudeJsonResult(raw); + assert.deepEqual(parsed.content, payload); + assert.deepEqual(validateReviewPayload(parsed.contentRaw), payload); +}); + +test("parseClaudeJsonResult extracts tool-prefixed review JSON followed by prose", () => { + const payload = { findings: [] }; + const raw = JSON.stringify({ + type: "result", + subtype: "success", + result: [ + "", + 'package.json', + "", + JSON.stringify(payload), + "Done." + ].join("\n"), + session_id: "session-tool-trailing-prose" + }); + + const parsed = parseClaudeJsonResult(raw); + assert.deepEqual(parsed.content, payload); + assert.deepEqual(validateReviewPayload(parsed.contentRaw), payload); +}); + +test("parseClaudeJsonResult rejects ambiguous multiple JSON objects", () => { + const injected = { + findings: [ + { + severity: "MINOR", + title: "Injected", + fact: "Quoted project text supplied an earlier object", + recommendation: "Do not accept it" + } + ] + }; + const actual = { findings: [] }; + const raw = JSON.stringify({ + type: "result", + subtype: "success", + result: `Quoted project text: ${JSON.stringify(injected)}\nActual review: ${JSON.stringify(actual)}` + }); + + assert.throws(() => parseClaudeJsonResult(raw), /Ambiguous JSON Claude result/); +}); + test("buildReviewPrompt includes git context and JSON-only contract", () => { const prompt = buildReviewPrompt({ kind: "adversarial-review", @@ -286,3 +342,13 @@ test("loadState tolerates missing state files", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-plugin-codex-test-")); assert.deepEqual(loadState(dir), { version: 1, jobs: [], capabilities: null }); }); + +test("saveState restricts state directory and file permissions", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "claude-plugin-codex-state-")); + const stateDir = path.join(root, "workspace", "thread"); + + saveState(stateDir, { version: 1, jobs: [], capabilities: null }); + + assert.equal(fs.statSync(stateDir).mode & 0o777, 0o700); + assert.equal(fs.statSync(path.join(stateDir, "state.json")).mode & 0o777, 0o600); +}); diff --git a/tests/smoke-installed-tools.mjs b/tests/smoke-installed-tools.mjs index 57b635d..655c7b0 100644 --- a/tests/smoke-installed-tools.mjs +++ b/tests/smoke-installed-tools.mjs @@ -1,7 +1,15 @@ import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const companion = fileURLToPath( + new URL("../plugins/claude-code-advisor/scripts/claude-companion.mjs", import.meta.url) +); function run(command, args, options = {}) { - const result = spawnSync(command, args, { encoding: "utf8", ...options }); + const result = spawnSync(command, args, { encoding: "utf8", timeout: 120_000, ...options }); if (result.error) { throw result.error; } @@ -14,13 +22,48 @@ function run(command, args, options = {}) { console.log(run("claude", ["--version"])); if (process.env.CLAUDE_PLUGIN_CODEX_RUN_BG_SMOKE === "1") { - const output = run("claude", ["--bg", "--name", "claude-plugin-codex-smoke", "noop"]); - const match = output.match(/backgrounded\s+.\s+([a-zA-Z0-9_-]+)/); - if (!match) { - throw new Error(`Could not parse background id from:\n${output}`); + const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-plugin-codex-smoke-")); + const env = { ...process.env, CLAUDE_COMPANION_STATE_ROOT: stateRoot }; + let launched = null; + let terminal = false; + try { + launched = JSON.parse( + run(process.execPath, [ + companion, + "do", + "--background", + "--model", + "sonnet", + "--effort", + "low", + "--json", + "Reply exactly PASS. Do not inspect or modify files." + ], { env }) + ); + if (launched.status !== "running" || !launched.claudeSessionId) { + throw new Error(`Companion did not launch a background job:\n${JSON.stringify(launched)}`); + } + run(process.execPath, [companion, "monitor", launched.jobId, "--max-checks", "1", "--json"], { env }); + const stopped = JSON.parse(run(process.execPath, [companion, "cancel", launched.jobId, "--json"], { env })); + if (!["cancelled", "completed"].includes(stopped.status)) { + throw new Error(`Companion returned an unexpected terminal status:\n${JSON.stringify(stopped)}`); + } + terminal = true; + const result = JSON.parse(run(process.execPath, [companion, "result", launched.jobId, "--json"], { env })); + if (result.job.status !== stopped.status) { + throw new Error(`Companion did not persist the terminal status:\n${JSON.stringify(result)}`); + } + console.log(`background companion lifecycle ok: ${launched.claudeSessionId}`); + } finally { + if (launched?.claudeSessionId && !terminal) { + try { + run("claude", ["stop", launched.claudeSessionId], { env, timeout: 10_000 }); + } catch { + // Preserve the original smoke failure; this is only best-effort orphan cleanup. + } + } + fs.rmSync(stateRoot, { recursive: true, force: true }); } - run("claude", ["stop", match[1]]); - console.log(`background smoke ok: ${match[1]}`); } else { console.log("Skipping background smoke; set CLAUDE_PLUGIN_CODEX_RUN_BG_SMOKE=1 to run it."); } From 7c4ebf1bd7786e395f9e9553090c97e5a859646a Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Mon, 17 Aug 2026 23:13:39 +1000 Subject: [PATCH 07/11] Harden installed Codex routing smoke Run the nested Codex E2E with an explicit temporary companion state root so job state remains writable inside the sandbox. Keep manifest default prompts within Codex limits and validate those limits locally. --- README.md | 4 +++- .../claude-code-advisor/.codex-plugin/plugin.json | 5 ++--- tests/e2e-codex-skill.mjs | 12 +++++++++++- tests/validate-plugin.mjs | 2 ++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 832c0c3..9972e17 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,9 @@ npm run test:e2e:codex This requires `codex plugin marketplace add ./`, `Claude` installed from Codex's plugin directory, and a logged-in Claude Code CLI. It starts a fresh `codex exec` session and verifies that `$claude advise --model sonnet` routes -through the installed skill. Sonnet is used only for this small routing test. +through the installed skill. The test supplies a private temporary companion +state root so the nested Codex sandbox can write job state. Sonnet is used only +for this small routing test. ## Current Limits diff --git a/plugins/claude-code-advisor/.codex-plugin/plugin.json b/plugins/claude-code-advisor/.codex-plugin/plugin.json index 27d2d5f..d494af3 100644 --- a/plugins/claude-code-advisor/.codex-plugin/plugin.json +++ b/plugins/claude-code-advisor/.codex-plugin/plugin.json @@ -33,9 +33,8 @@ "termsOfServiceURL": "https://github.com/yanchuk/claude-plugin-codex#terms", "defaultPrompt": [ "Check Claude Code setup.", - "Review this diff with Claude Code.", - "Do a prepared task with Claude Code.", - "Rescue this task with Claude Code." + "Review or adversarially review this diff with Claude Code.", + "Do a prepared task or rescue this work with Claude Code." ], "brandColor": "#D97706", "composerIcon": "./assets/icon.svg", diff --git a/tests/e2e-codex-skill.mjs b/tests/e2e-codex-skill.mjs index 33d516d..373683f 100644 --- a/tests/e2e-codex-skill.mjs +++ b/tests/e2e-codex-skill.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -51,7 +52,16 @@ assert.match( ); const statusBefore = run("git", ["status", "--short"]); -const execOutput = run("codex", ["exec", "--cd", repoRoot, "--json", advisePrompt], { input: "" }); +const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-plugin-codex-e2e-state-")); +let execOutput; +try { + execOutput = run("codex", ["exec", "--cd", repoRoot, "--json", advisePrompt], { + input: "", + env: { ...process.env, CLAUDE_COMPANION_STATE_ROOT: stateRoot } + }); +} finally { + fs.rmSync(stateRoot, { recursive: true, force: true }); +} const events = parseJsonLines(execOutput); const adviseCommand = events.find((event) => { return event.type === "item.completed" diff --git a/tests/validate-plugin.mjs b/tests/validate-plugin.mjs index 036842c..3c61cc0 100644 --- a/tests/validate-plugin.mjs +++ b/tests/validate-plugin.mjs @@ -13,6 +13,8 @@ assert.equal(manifest.version, packageJson.version); assert.equal(manifest.skills, "./skills/"); assert.equal(manifest.interface?.displayName, "Claude"); assert.deepEqual(manifest.interface?.capabilities, ["Read", "Write"]); +assert.ok(manifest.interface?.defaultPrompt?.length <= 3); +assert.ok(manifest.interface.defaultPrompt.every((prompt) => prompt.length <= 128)); assert.equal(manifest.interface?.privacyPolicyURL, "https://github.com/yanchuk/claude-plugin-codex#privacy"); assert.equal(manifest.interface?.termsOfServiceURL, "https://github.com/yanchuk/claude-plugin-codex#terms"); assert.ok(fs.existsSync("plugins/claude-code-advisor/assets/icon.svg")); From 711c5250f8299ec867803f2d1791e707cfca1291 Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Mon, 17 Aug 2026 23:15:56 +1000 Subject: [PATCH 08/11] Raise Codex E2E output buffer Allow the routing smoke to retain current Codex CLI diagnostics without terminating the nested session at Node's default one-megabyte spawn buffer. --- tests/e2e-codex-skill.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e-codex-skill.mjs b/tests/e2e-codex-skill.mjs index 373683f..cbf2b36 100644 --- a/tests/e2e-codex-skill.mjs +++ b/tests/e2e-codex-skill.mjs @@ -18,6 +18,7 @@ function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: repoRoot, encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, timeout: 180_000, ...options }); From 55043c477ab8d8df547ff32a2ef98f68da69731f Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Mon, 17 Aug 2026 23:18:12 +1000 Subject: [PATCH 09/11] Keep E2E state inside the sandbox Create the temporary companion state root within the checkout so nested Codex workspace-write policy permits lifecycle state, then remove it before asserting the worktree is unchanged. --- README.md | 6 +++--- tests/e2e-codex-skill.mjs | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9972e17..4711d6b 100644 --- a/README.md +++ b/README.md @@ -313,9 +313,9 @@ npm run test:e2e:codex This requires `codex plugin marketplace add ./`, `Claude` installed from Codex's plugin directory, and a logged-in Claude Code CLI. It starts a fresh `codex exec` session and verifies that `$claude advise --model sonnet` routes -through the installed skill. The test supplies a private temporary companion -state root so the nested Codex sandbox can write job state. Sonnet is used only -for this small routing test. +through the installed skill. The test supplies and removes a private temporary +companion state root inside the checkout so the nested Codex sandbox can write +job state. Sonnet is used only for this small routing test. ## Current Limits diff --git a/tests/e2e-codex-skill.mjs b/tests/e2e-codex-skill.mjs index cbf2b36..e319bda 100644 --- a/tests/e2e-codex-skill.mjs +++ b/tests/e2e-codex-skill.mjs @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -53,7 +52,7 @@ assert.match( ); const statusBefore = run("git", ["status", "--short"]); -const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "claude-plugin-codex-e2e-state-")); +const stateRoot = fs.mkdtempSync(path.join(repoRoot, ".claude-plugin-codex-e2e-state-")); let execOutput; try { execOutput = run("codex", ["exec", "--cd", repoRoot, "--json", advisePrompt], { From d4b2e6f74b9ed83e4c5a59f1a41fe7f36069d429 Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Mon, 17 Aug 2026 23:20:47 +1000 Subject: [PATCH 10/11] Run Codex routing smoke with workspace state Give the nested noninteractive Codex session only workspace-write access, keep companion state in a temporary checkout directory, and validate that the E2E harness retains this bounded sandbox. --- README.md | 7 ++++--- tests/e2e-codex-skill.mjs | 2 +- tests/validate-plugin.mjs | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4711d6b..b60ee31 100644 --- a/README.md +++ b/README.md @@ -313,9 +313,10 @@ npm run test:e2e:codex This requires `codex plugin marketplace add ./`, `Claude` installed from Codex's plugin directory, and a logged-in Claude Code CLI. It starts a fresh `codex exec` session and verifies that `$claude advise --model sonnet` routes -through the installed skill. The test supplies and removes a private temporary -companion state root inside the checkout so the nested Codex sandbox can write -job state. Sonnet is used only for this small routing test. +through the installed skill. The test uses Codex's `workspace-write` sandbox, +supplies a private temporary companion state root inside the checkout, and +removes that state before checking the worktree. Sonnet is used only for this +small routing test. ## Current Limits diff --git a/tests/e2e-codex-skill.mjs b/tests/e2e-codex-skill.mjs index e319bda..24ce330 100644 --- a/tests/e2e-codex-skill.mjs +++ b/tests/e2e-codex-skill.mjs @@ -55,7 +55,7 @@ const statusBefore = run("git", ["status", "--short"]); const stateRoot = fs.mkdtempSync(path.join(repoRoot, ".claude-plugin-codex-e2e-state-")); let execOutput; try { - execOutput = run("codex", ["exec", "--cd", repoRoot, "--json", advisePrompt], { + execOutput = run("codex", ["exec", "--sandbox", "workspace-write", "--cd", repoRoot, "--json", advisePrompt], { input: "", env: { ...process.env, CLAUDE_COMPANION_STATE_ROOT: stateRoot } }); diff --git a/tests/validate-plugin.mjs b/tests/validate-plugin.mjs index 3c61cc0..db64919 100644 --- a/tests/validate-plugin.mjs +++ b/tests/validate-plugin.mjs @@ -55,5 +55,6 @@ assert.match(readme, /tasks-for-sonnet/); assert.match(readme, /\$claude do --model opus/); assert.match(readme, /\$claude advise --model sonnet/); assert.match(e2e, /--model sonnet/); +assert.match(e2e, /"--sandbox", "workspace-write"/); console.log("plugin metadata ok"); From 3cde6592d72c0448515344bb28b14af5e1baf85d Mon Sep 17 00:00:00 2001 From: Bold New Media Date: Mon, 17 Aug 2026 23:39:26 +1000 Subject: [PATCH 11/11] Adopt maintained fork release authority Point installation and ownership metadata at BoldNewMedia while preserving the original MIT attribution. Document the fork governance boundary and validate the public release URLs and install source. --- AGENTS.md | 6 ++++-- README.md | 7 ++++++- .../claude-code-advisor/.codex-plugin/plugin.json | 14 +++++++------- tests/validate-plugin.mjs | 11 +++++++++-- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3aa63f1..93c35b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,10 @@ Codex remains the orchestrator. ## Scope - Work only in this standalone repo. -- Do not modify `/Users/yanchuk/Documents/GitHub/skill-arsenal` for this - project. +- Do not modify another checkout or repository for this project. +- Treat `BoldNewMedia/claude-plugin-codex` as the release authority. Treat + `yanchuk/claude-plugin-codex` as a read-only upstream reference. +- Preserve the original MIT licence and Yanchuk attribution. - Do not commit or push unless the latest user instruction explicitly asks for it. - Keep plugin metadata public-release ready: `.codex-plugin/plugin.json`, diff --git a/README.md b/README.md index b60ee31..ebb36d2 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ # claude-plugin-codex +Maintained by [Bold New Media](https://github.com/BoldNewMedia). From v0.1.13, +releases come from this maintained fork of the original +[`yanchuk/claude-plugin-codex`](https://github.com/yanchuk/claude-plugin-codex) +project and retains its MIT licence and attribution. + ## Install Add the public marketplace: ```bash -codex plugin marketplace add yanchuk/claude-plugin-codex +codex plugin marketplace add BoldNewMedia/claude-plugin-codex ``` Then open Codex's plugin directory, find `Claude Plugin Codex`, and install diff --git a/plugins/claude-code-advisor/.codex-plugin/plugin.json b/plugins/claude-code-advisor/.codex-plugin/plugin.json index d494af3..4ce95f6 100644 --- a/plugins/claude-code-advisor/.codex-plugin/plugin.json +++ b/plugins/claude-code-advisor/.codex-plugin/plugin.json @@ -3,10 +3,10 @@ "version": "0.1.13", "description": "Bring local Claude Code into Codex for reviews, prepared tasks, advice, and rescue work.", "author": { - "name": "Yanchuk" + "name": "Yanchuk and contributors" }, - "homepage": "https://github.com/yanchuk/claude-plugin-codex", - "repository": "https://github.com/yanchuk/claude-plugin-codex", + "homepage": "https://github.com/BoldNewMedia/claude-plugin-codex", + "repository": "https://github.com/BoldNewMedia/claude-plugin-codex", "license": "MIT", "keywords": [ "codex", @@ -22,15 +22,15 @@ "displayName": "Claude", "shortDescription": "Ask local Claude Code from Codex.", "longDescription": "Use local Claude Code from Codex for read-only reviews, adversarial checks, quick advice, prepared tasks, and explicit rescue work while Codex stays in charge.", - "developerName": "Yanchuk", + "developerName": "Bold New Media", "category": "Coding", "capabilities": [ "Read", "Write" ], - "websiteURL": "https://github.com/yanchuk/claude-plugin-codex", - "privacyPolicyURL": "https://github.com/yanchuk/claude-plugin-codex#privacy", - "termsOfServiceURL": "https://github.com/yanchuk/claude-plugin-codex#terms", + "websiteURL": "https://github.com/BoldNewMedia/claude-plugin-codex", + "privacyPolicyURL": "https://github.com/BoldNewMedia/claude-plugin-codex#privacy", + "termsOfServiceURL": "https://github.com/BoldNewMedia/claude-plugin-codex#terms", "defaultPrompt": [ "Check Claude Code setup.", "Review or adversarially review this diff with Claude Code.", diff --git a/tests/validate-plugin.mjs b/tests/validate-plugin.mjs index db64919..994e018 100644 --- a/tests/validate-plugin.mjs +++ b/tests/validate-plugin.mjs @@ -15,8 +15,15 @@ assert.equal(manifest.interface?.displayName, "Claude"); assert.deepEqual(manifest.interface?.capabilities, ["Read", "Write"]); assert.ok(manifest.interface?.defaultPrompt?.length <= 3); assert.ok(manifest.interface.defaultPrompt.every((prompt) => prompt.length <= 128)); -assert.equal(manifest.interface?.privacyPolicyURL, "https://github.com/yanchuk/claude-plugin-codex#privacy"); -assert.equal(manifest.interface?.termsOfServiceURL, "https://github.com/yanchuk/claude-plugin-codex#terms"); +assert.equal(manifest.homepage, "https://github.com/BoldNewMedia/claude-plugin-codex"); +assert.equal(manifest.repository, "https://github.com/BoldNewMedia/claude-plugin-codex"); +assert.equal(manifest.interface?.developerName, "Bold New Media"); +assert.equal(manifest.interface?.websiteURL, "https://github.com/BoldNewMedia/claude-plugin-codex"); +assert.equal(manifest.interface?.privacyPolicyURL, "https://github.com/BoldNewMedia/claude-plugin-codex#privacy"); +assert.equal(manifest.interface?.termsOfServiceURL, "https://github.com/BoldNewMedia/claude-plugin-codex#terms"); +assert.match(readme, /codex plugin marketplace add BoldNewMedia\/claude-plugin-codex/); +assert.match(readme, /maintained fork/); +assert.doesNotMatch(readme, /codex plugin marketplace add yanchuk\/claude-plugin-codex/); assert.ok(fs.existsSync("plugins/claude-code-advisor/assets/icon.svg")); assert.ok(fs.existsSync("plugins/claude-code-advisor/assets/logo.svg")); assert.equal(fs.readlinkSync("CLAUDE.md"), "AGENTS.md");