Files
ECC/skills/council-multi-model/scripts/review-with-codex.js
5a2453e167 feat: add council-multi-model skill (heterogeneous Codex review) (#2281)
* feat: add council-multi-model skill (heterogeneous Codex review)

Rebased onto latest main to resolve the merge conflict (the branch had gone
DIRTY as main advanced). Trimmed to just the skill files (no top-level
README/AGENTS edits), mirroring the merged #2381. Previously reviewed
favorably by greptile/coderabbit/daltino.

* feat: add Entry B (independent parallel propose + aggregate, MoA-style) alongside Entry A (review)

Splits the skill into two entries depending on what already exists:
Entry A (unchanged) reviews an existing draft. New Entry B has every
voice (Claude x3 + Codex if available) answer the same question fully
independently and in parallel, then aggregates without collapsing
disagreement or blending incompatible approaches into one hybrid.
For the heaviest decisions the two chain: B first, then A's review
step on the aggregation -- with an explicit honesty caveat when Codex
already proposed in B and so cannot independently judge the result.

* feat: prefer Codex MCP tool over the SDK script when available

mcp__codex__codex is now the primary path for both Entry A's
heterogeneous review and Entry B's independent proposal -- zero relay,
talks directly to OpenAI's backend, no temp file or shell escaping
needed. The openai-codex SDK script becomes the fallback for sessions
without that MCP tool configured; behavior and guardrails (read-only,
verbatim quoting, explicit 'absent' labeling) are unchanged.

* fix: register council-multi-model install path

* docs: sync skill catalog count

* fix: publish council-multi-model skill

* fix: harden council multi-model fallback

* docs: sync remaining skill count

* fix: narrow multi-model council to bounded review

* fix: address council adapter review feedback

* fix(council-multi-model): enforce tool-less Codex review

* fix(council-multi-model): close Codex tool boundary

---------

Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
2026-08-11 16:30:04 -04:00

306 lines
9.3 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const DEFAULT_TIMEOUT_MS = 60_000;
const MAX_TIMEOUT_MS = 120_000;
const MAX_PROMPT_BYTES = 64 * 1024;
const SUPPORTED_CODEX_VERSION = '0.146.0';
const HOST_PROVIDERS = new Set(['anthropic', 'openai', 'unknown']);
const REQUIRED_TOOLLESS_FEATURES = Object.freeze([
'apps',
'auth_elicitation',
'browser_use',
'browser_use_external',
'browser_use_full_cdp_access',
'computer_use',
'code_mode_host',
'goals',
'hooks',
'image_generation',
'in_app_browser',
'multi_agent',
'plugin_sharing',
'plugins',
'remote_plugin',
'shell_snapshot',
'shell_tool',
'skill_search',
'skill_mcp_dependency_install',
'tool_call_mcp_elicitation',
'tool_suggest',
'unified_exec',
'workspace_dependencies',
]);
function usage() {
return [
'Usage: review-with-codex.js --consent-to-openai --host-provider <anthropic|openai|unknown>',
' [--timeout-seconds <10-120>]',
'',
'Reads one compact review packet from stdin and prints the labeled Codex critique.',
].join('\n');
}
function parseArgs(argv) {
const options = {
consent: false,
hostProvider: null,
timeoutMs: DEFAULT_TIMEOUT_MS,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--consent-to-openai') {
options.consent = true;
} else if (arg === '--host-provider') {
options.hostProvider = argv[index + 1];
index += 1;
} else if (arg === '--timeout-seconds') {
const seconds = Number(argv[index + 1]);
if (!Number.isInteger(seconds) || seconds < 10 || seconds > 120) {
throw new Error('--timeout-seconds must be an integer from 10 to 120');
}
options.timeoutMs = seconds * 1000;
index += 1;
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
if (options.help) return options;
if (!options.consent) {
throw new Error('explicit --consent-to-openai is required');
}
if (!HOST_PROVIDERS.has(options.hostProvider)) {
throw new Error('--host-provider must be anthropic, openai, or unknown');
}
return options;
}
function providerLabel(hostProvider) {
if (hostProvider === 'anthropic') return 'cross-provider external critique';
if (hostProvider === 'openai') return 'same-provider external critique';
return 'provider relationship unverified';
}
function buildCodexArgs(tempDir, outputFile) {
return [
'--ask-for-approval', 'never',
...REQUIRED_TOOLLESS_FEATURES.flatMap((feature) => ['--disable', feature]),
'exec',
'--ephemeral',
'--ignore-user-config',
'--ignore-rules',
'--strict-config',
'--skip-git-repo-check',
'--sandbox', 'read-only',
'--cd', tempDir,
'--color', 'never',
'--config', 'shell_environment_policy.inherit="none"',
'--config', 'skills.include_instructions=false',
'--config', 'web_search="disabled"',
'--config', 'mcp_servers={}',
'--output-last-message', outputFile,
'-',
];
}
function probeCodex(spawn, args, options, label) {
const result = spawn('codex', args, options);
if (result.error) {
if (result.error.code === 'ENOENT') throw new Error('Codex CLI is not installed');
throw new Error(`Codex ${label} probe failed: ${result.error.message}`);
}
if (result.status !== 0) {
const detail = (result.stderr || '').trim().split('\n').slice(-1)[0];
throw new Error(`Codex ${label} probe failed${detail ? `: ${detail}` : ''}`);
}
return (result.stdout || '').trim();
}
function verifyToollessSupport(dependencies = {}) {
const spawn = dependencies.spawnSync || spawnSync;
const options = {
cwd: os.tmpdir(),
env: buildEnvironment(dependencies.env || process.env),
encoding: 'utf8',
timeout: 5_000,
maxBuffer: 256 * 1024,
windowsHide: true,
};
const versionText = probeCodex(spawn, ['--version'], options, 'version');
const versionMatch = versionText.match(/^codex-cli\s+([^\s]+)$/m);
if (!versionMatch) {
throw new Error('Codex version could not be verified for tool-less review');
}
if (versionMatch[1] !== SUPPORTED_CODEX_VERSION) {
throw new Error(
`unsupported Codex version ${versionMatch[1]}; `
+ `tool-less review requires exactly ${SUPPORTED_CODEX_VERSION}`
);
}
const featuresText = probeCodex(spawn, ['features', 'list'], options, 'feature');
const stages = new Map();
for (const line of featuresText.split('\n')) {
const match = line.trim().match(
/^(\S+)\s+(stable|under development|experimental|deprecated|removed)\s+(true|false)$/
);
if (match) stages.set(match[1], match[2]);
}
const unavailable = REQUIRED_TOOLLESS_FEATURES.filter(
(feature) => stages.get(feature) !== 'stable'
);
if (unavailable.length > 0) {
throw new Error(
`Codex ${versionMatch[1]} cannot guarantee tool-less review; `
+ `required stable feature toggles unavailable: ${unavailable.join(', ')}`
);
}
return versionMatch[1];
}
function buildEnvironment(sourceEnv = process.env) {
const allowed = [
'PATH', 'HOME', 'USERPROFILE', 'CODEX_HOME',
'TMPDIR', 'TMP', 'TEMP', 'SystemRoot', 'ComSpec', 'PATHEXT',
];
return Object.fromEntries(
allowed.filter((name) => sourceEnv[name]).map((name) => [name, sourceEnv[name]])
);
}
function runReview(prompt, options, dependencies = {}) {
if (!prompt.trim()) throw new Error('review packet is empty');
if (Buffer.byteLength(prompt, 'utf8') > MAX_PROMPT_BYTES) {
throw new Error(`review packet exceeds ${MAX_PROMPT_BYTES} bytes`);
}
if (!options.consent) throw new Error('OpenAI transfer consent is required');
if (options.timeoutMs < 10_000 || options.timeoutMs > MAX_TIMEOUT_MS) {
throw new Error('timeout is outside the 10-120 second safety range');
}
const spawn = dependencies.spawnSync || spawnSync;
const environment = buildEnvironment(dependencies.env || process.env);
const verifySupport = dependencies.verifyToollessSupport || verifyToollessSupport;
verifySupport({ spawnSync: spawn, env: environment });
const makeTemp = dependencies.mkdtempSync || fs.mkdtempSync;
const readFile = dependencies.readFileSync || fs.readFileSync;
const remove = dependencies.rmSync || fs.rmSync;
const tempDir = makeTemp(path.join(os.tmpdir(), 'ecc-council-review-'));
const outputFile = path.join(tempDir, 'last-message.txt');
try {
const result = spawn('codex', buildCodexArgs(tempDir, outputFile), {
cwd: tempDir,
env: environment,
input: prompt,
encoding: 'utf8',
timeout: options.timeoutMs,
maxBuffer: 1024 * 1024,
windowsHide: true,
});
if (result.error) {
if (result.error.code === 'ETIMEDOUT') throw new Error('Codex review timed out');
if (result.error.code === 'ENOENT') throw new Error('Codex CLI is not installed');
throw new Error(`Codex invocation failed: ${result.error.message}`);
}
if (result.status !== 0) {
const detail = (result.stderr || '').trim().split('\n').slice(-1)[0];
throw new Error(`Codex review failed${detail ? `: ${detail}` : ''}`);
}
let text;
try {
text = readFile(outputFile, 'utf8').trim();
} catch (error) {
throw new Error(`Codex returned no final response: ${error.message}`);
}
if (!text) throw new Error('Codex returned an empty final response');
return `${providerLabel(options.hostProvider)}\n${text}`;
} finally {
remove(tempDir, { recursive: true, force: true });
}
}
function runStdinReview(options, dependencies = {}) {
const stdin = dependencies.stdin || process.stdin;
const stdout = dependencies.stdout || process.stdout;
const stderr = dependencies.stderr || process.stderr;
const review = dependencies.runReview || runReview;
const setExitCode = dependencies.setExitCode || ((code) => { process.exitCode = code; });
const chunks = [];
let promptBytes = 0;
let promptOverflow = false;
stdin.setEncoding('utf8');
stdin.on('data', (chunk) => {
if (promptOverflow) return;
promptBytes += Buffer.byteLength(chunk, 'utf8');
if (promptBytes > MAX_PROMPT_BYTES) {
promptOverflow = true;
chunks.length = 0;
return;
}
chunks.push(chunk);
});
stdin.on('end', () => {
if (promptOverflow) {
stderr.write(
`external review absent: review packet exceeds ${MAX_PROMPT_BYTES} bytes\n`
);
setExitCode(1);
return;
}
try {
stdout.write(`${review(chunks.join(''), options)}\n`);
} catch (error) {
stderr.write(`external review absent: ${error.message}\n`);
setExitCode(1);
}
});
return 0;
}
function main() {
let options;
try {
options = parseArgs(process.argv.slice(2));
} catch (error) {
process.stderr.write(`${error.message}\n${usage()}\n`);
return 2;
}
if (options.help) {
process.stdout.write(`${usage()}\n`);
return 0;
}
return runStdinReview(options);
}
if (require.main === module) {
process.exitCode = main();
}
module.exports = {
MAX_PROMPT_BYTES,
REQUIRED_TOOLLESS_FEATURES,
SUPPORTED_CODEX_VERSION,
buildCodexArgs,
buildEnvironment,
parseArgs,
providerLabel,
runStdinReview,
runReview,
verifyToollessSupport,
};