455 lines
15 KiB
JavaScript
Executable File
455 lines
15 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
import { basename, join, resolve } from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
|
|
const DONE_STATUS = 'WORKER_DONE_PENDING_CODEX_GATE';
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
const booleans = new Set(['dry-run', 'gate-only', 'skip-worker', 'help']);
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (!arg.startsWith('--')) {
|
|
out._ = out._ || [];
|
|
out._.push(arg);
|
|
continue;
|
|
}
|
|
|
|
const key = arg.slice(2);
|
|
if (booleans.has(key)) {
|
|
out[key] = true;
|
|
continue;
|
|
}
|
|
|
|
const value = argv[i + 1];
|
|
if (!value || value.startsWith('--')) {
|
|
throw new Error(`Missing value for --${key}`);
|
|
}
|
|
out[key] = value;
|
|
i += 1;
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function usage() {
|
|
return `Usage:
|
|
npm run agent:loop -- --task "Add a calm end-game reset flow and verify the Svelte app."
|
|
|
|
Options:
|
|
--version Development version. Defaults to v<root package.json version>.
|
|
--name Optional run label. Defaults to a slug from --task.
|
|
--task Worker assignment text. Required unless --prompt-file is used.
|
|
--prompt-file Optional file with a longer Codex-authored assignment.
|
|
--run-dir Existing .codex/logs/agent-runs/<version>/... directory for --gate-only.
|
|
--runs-dir Optional base directory for run artifacts. Defaults to .codex/logs/agent-runs.
|
|
--run-checks none, auto, or full. Defaults to auto.
|
|
auto runs git gate and existing npm check/build scripts for app changes.
|
|
full is currently the same as auto unless more project checks are added.
|
|
--model Optional Pi model. Forwarded to run-pi-worker.mjs.
|
|
--thinking Optional Pi thinking level. Forwarded to run-pi-worker.mjs.
|
|
--pi Optional pi binary path. Forwarded to run-pi-worker.mjs.
|
|
--session-id Optional Pi session id. Forwarded to run-pi-worker.mjs.
|
|
--event-idle-timeout-ms
|
|
Optional Pi idle watchdog. Forwarded to run-pi-worker.mjs.
|
|
--dry-run Generate the Pi prompt/artifacts only; skip the Codex gate.
|
|
--gate-only Do not launch Pi; run the Codex gate for --run-dir or latest run.
|
|
`;
|
|
}
|
|
|
|
function readJson(path) {
|
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
}
|
|
|
|
function rootVersion(cwd) {
|
|
const pkg = readJson(resolve(cwd, 'package.json'));
|
|
if (!pkg.version) throw new Error('Root package.json has no version');
|
|
return pkg.version;
|
|
}
|
|
|
|
function normalizeVersion(value) {
|
|
const raw = String(value || '').trim();
|
|
if (!raw) return raw;
|
|
return raw.startsWith('v') ? raw : `v${raw}`;
|
|
}
|
|
|
|
function slugify(value) {
|
|
return String(value || 'worker')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80) || 'worker';
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
const text = String(value);
|
|
if (/^[a-zA-Z0-9_./:=@+-]+$/.test(text)) return text;
|
|
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function formatCommand(command, args) {
|
|
return [command, ...args].map(shellQuote).join(' ');
|
|
}
|
|
|
|
function printHeading(title) {
|
|
console.log(`\n== ${title} ==`);
|
|
}
|
|
|
|
function capture(command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
cwd: options.cwd || process.cwd(),
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
if (result.error) {
|
|
return {
|
|
command: formatCommand(command, args),
|
|
cwd: options.cwd || process.cwd(),
|
|
code: 1,
|
|
signal: null,
|
|
stdout: result.stdout || '',
|
|
stderr: `${result.stderr || ''}${result.error.message || result.error}\n`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: formatCommand(command, args),
|
|
cwd: options.cwd || process.cwd(),
|
|
code: result.status ?? (result.signal ? 1 : 0),
|
|
signal: result.signal,
|
|
stdout: result.stdout || '',
|
|
stderr: result.stderr || '',
|
|
};
|
|
}
|
|
|
|
function runCheck(label, command, args, options = {}) {
|
|
console.log(`\n$ ${formatCommand(command, args)}`);
|
|
const result = capture(command, args, options);
|
|
if (result.stdout.trim()) process.stdout.write(result.stdout);
|
|
if (result.stderr.trim()) process.stderr.write(result.stderr);
|
|
if (!result.stdout.trim() && !result.stderr.trim()) console.log('(no output)');
|
|
console.log(`[exit ${result.code}] ${label}`);
|
|
return { label, ...result };
|
|
}
|
|
|
|
function lines(text) {
|
|
return String(text || '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function uniqueSorted(values) {
|
|
return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
function getChangedFiles(cwd, pathspecs = []) {
|
|
const tracked = capture('git', ['diff', '--name-only', 'HEAD', '--', ...pathspecs], { cwd });
|
|
const untracked = capture('git', ['ls-files', '--others', '--exclude-standard', '--', ...pathspecs], { cwd });
|
|
|
|
return uniqueSorted([
|
|
...(tracked.code === 0 ? lines(tracked.stdout) : []),
|
|
...(untracked.code === 0 ? lines(untracked.stdout) : []),
|
|
]);
|
|
}
|
|
|
|
function defaultRunsDir(cwd, args) {
|
|
return resolve(cwd, args['runs-dir'] || '.codex/logs/agent-runs');
|
|
}
|
|
|
|
function newestRunDir(cwd, args, version, name) {
|
|
const runsDir = resolve(defaultRunsDir(cwd, args), version);
|
|
if (!existsSync(runsDir)) return null;
|
|
|
|
const wantedSuffix = name ? `-pi-worker-${slugify(name)}` : null;
|
|
const dirs = readdirSync(runsDir)
|
|
.map((name) => resolve(runsDir, name))
|
|
.filter((path) => {
|
|
if (!existsSync(path) || !statSync(path).isDirectory()) return false;
|
|
if (wantedSuffix && !basename(path).endsWith(wantedSuffix)) return false;
|
|
return existsSync(join(path, 'summary.json'));
|
|
})
|
|
.map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
|
|
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
|
|
return dirs[0]?.path || null;
|
|
}
|
|
|
|
function resolveRunDir(cwd, args, version, name) {
|
|
if (args['run-dir']) {
|
|
const runDir = resolve(cwd, args['run-dir']);
|
|
if (!existsSync(runDir)) throw new Error(`Run directory does not exist: ${runDir}`);
|
|
return runDir;
|
|
}
|
|
|
|
const runDir = newestRunDir(cwd, args, version, name);
|
|
if (!runDir) {
|
|
throw new Error(`Could not find an agent run for ${version}${name ? ` / ${name}` : ''}`);
|
|
}
|
|
return runDir;
|
|
}
|
|
|
|
function summaryVersionFromRunDir(cwd, args) {
|
|
if (!args['run-dir']) return null;
|
|
const summaryPath = resolve(cwd, args['run-dir'], 'summary.json');
|
|
if (!existsSync(summaryPath)) return null;
|
|
const summary = readJson(summaryPath);
|
|
return summary.version ? normalizeVersion(summary.version) : null;
|
|
}
|
|
|
|
function runWorker(cwd, args, version, name) {
|
|
const workerScript = resolve(cwd, 'development/_reference/ai/scripts/run-pi-worker.mjs');
|
|
const workerArgs = [
|
|
workerScript,
|
|
'--version',
|
|
version,
|
|
];
|
|
|
|
if (name) workerArgs.push('--name', name);
|
|
if (args.task) workerArgs.push('--task', args.task);
|
|
if (args['prompt-file']) workerArgs.push('--prompt-file', args['prompt-file']);
|
|
if (args['runs-dir']) workerArgs.push('--runs-dir', args['runs-dir']);
|
|
if (args.model) workerArgs.push('--model', args.model);
|
|
if (args.thinking) workerArgs.push('--thinking', args.thinking);
|
|
if (args.pi) workerArgs.push('--pi', args.pi);
|
|
if (args['session-id']) workerArgs.push('--session-id', args['session-id']);
|
|
if (args['event-idle-timeout-ms']) {
|
|
workerArgs.push('--event-idle-timeout-ms', args['event-idle-timeout-ms']);
|
|
}
|
|
if (args['dry-run']) workerArgs.push('--dry-run');
|
|
|
|
printHeading('Pi Worker');
|
|
console.log(`$ ${formatCommand(process.execPath, workerArgs)}`);
|
|
const result = spawnSync(process.execPath, workerArgs, {
|
|
cwd,
|
|
env: process.env,
|
|
stdio: 'inherit',
|
|
});
|
|
|
|
if (result.error) {
|
|
console.error(result.error.message || result.error);
|
|
}
|
|
|
|
return {
|
|
code: result.error ? 1 : (result.status ?? (result.signal ? 1 : 0)),
|
|
signal: result.signal,
|
|
};
|
|
}
|
|
|
|
function forbiddenPathspecs(version) {
|
|
return [
|
|
'.pi',
|
|
'.agents',
|
|
'.codex',
|
|
'AGENTS.md',
|
|
'development/_reference/ai',
|
|
];
|
|
}
|
|
|
|
function packageScripts(cwd) {
|
|
try {
|
|
return readJson(resolve(cwd, 'package.json')).scripts || {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function hasScript(cwd, name) {
|
|
return Object.prototype.hasOwnProperty.call(packageScripts(cwd), name);
|
|
}
|
|
|
|
function selectAutoChecks(cwd, changedFiles, mode) {
|
|
if (mode === 'none') return [];
|
|
|
|
const checks = [];
|
|
const appChanged = changedFiles.some((file) => (
|
|
file.startsWith('src/') ||
|
|
file.startsWith('static/') ||
|
|
[
|
|
'package.json',
|
|
'package-lock.json',
|
|
'svelte.config.js',
|
|
'tsconfig.json',
|
|
'vite.config.ts',
|
|
'vite.config.js',
|
|
].includes(file)
|
|
));
|
|
|
|
if (appChanged) {
|
|
if (hasScript(cwd, 'check')) checks.push({ label: 'Svelte/type check', command: 'npm', args: ['run', 'check'], cwd });
|
|
if (hasScript(cwd, 'build')) checks.push({ label: 'Production build', command: 'npm', args: ['run', 'build'], cwd });
|
|
}
|
|
|
|
return checks;
|
|
}
|
|
|
|
function hasUiBehaviorRisk(changedFiles) {
|
|
return changedFiles.some((file) => {
|
|
if (!file.startsWith('src/') && !file.startsWith('static/')) return false;
|
|
return ['.svelte', '.ts', '.js', '.html', '.css', '.svg'].some((suffix) => file.endsWith(suffix));
|
|
});
|
|
}
|
|
|
|
function writeGateArtifact(runDir, payload) {
|
|
const path = join(runDir, 'codex-gate.json');
|
|
writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`);
|
|
return path;
|
|
}
|
|
|
|
function runGate({ cwd, args, version, name, preForbiddenFiles, workerResult }) {
|
|
const runChecks = args['run-checks'] || 'auto';
|
|
if (!['none', 'auto', 'full'].includes(runChecks)) {
|
|
throw new Error(`Unsupported --run-checks ${runChecks}. Expected none, auto, or full.`);
|
|
}
|
|
|
|
const runDir = resolveRunDir(cwd, args, version, name);
|
|
const summaryPath = join(runDir, 'summary.json');
|
|
if (!existsSync(summaryPath)) throw new Error(`Missing summary.json in ${runDir}`);
|
|
|
|
const summary = readJson(summaryPath);
|
|
const checks = [];
|
|
const failures = [];
|
|
|
|
printHeading('Worker Summary');
|
|
console.log(`Run directory: ${runDir}`);
|
|
console.log(`Final status: ${summary.finalStatus || 'null'}`);
|
|
console.log(`Orchestrator status: ${summary.orchestratorStatus || 'null'}`);
|
|
console.log(`Last message: ${summary.lastAssistantTextPath || join(runDir, 'last-message.txt')}`);
|
|
if (workerResult?.code) {
|
|
console.log(`Worker process exit: ${workerResult.code}${workerResult.signal ? ` (${workerResult.signal})` : ''}`);
|
|
}
|
|
|
|
printHeading('Codex Gate');
|
|
checks.push(runCheck('git status', 'git', ['status', '--short'], { cwd }));
|
|
checks.push(runCheck('git diff whitespace check', 'git', ['diff', '--check'], { cwd }));
|
|
|
|
const forbidden = forbiddenPathspecs(version);
|
|
const forbiddenStatusCheck = runCheck(
|
|
'forbidden orchestration path status',
|
|
'git',
|
|
['status', '--short', '--', ...forbidden],
|
|
{ cwd },
|
|
);
|
|
checks.push(forbiddenStatusCheck);
|
|
|
|
const currentForbiddenFiles = getChangedFiles(cwd, forbidden);
|
|
const preForbidden = new Set(preForbiddenFiles || []);
|
|
const newForbiddenFiles = currentForbiddenFiles.filter((file) => !preForbidden.has(file));
|
|
|
|
if (summary.orchestratorStatus !== DONE_STATUS) {
|
|
failures.push(`worker status is ${summary.orchestratorStatus || 'missing'}, expected ${DONE_STATUS}`);
|
|
}
|
|
for (const check of checks) {
|
|
if (check.code !== 0) failures.push(`${check.label} exited ${check.code}`);
|
|
}
|
|
if (newForbiddenFiles.length > 0) {
|
|
failures.push(`forbidden paths changed during/after worker: ${newForbiddenFiles.join(', ')}`);
|
|
}
|
|
|
|
const changedFiles = getChangedFiles(cwd);
|
|
const autoChecks = [];
|
|
if (failures.length === 0) {
|
|
const selectedChecks = selectAutoChecks(cwd, changedFiles, runChecks);
|
|
if (selectedChecks.length > 0) printHeading('Auto Checks');
|
|
for (const check of selectedChecks) {
|
|
const result = runCheck(check.label, check.command, check.args, { cwd: check.cwd });
|
|
autoChecks.push(result);
|
|
if (result.code !== 0) failures.push(`${check.label} exited ${result.code}`);
|
|
}
|
|
} else {
|
|
console.log('\nSkipping auto checks because the basic gate did not pass.');
|
|
}
|
|
|
|
const uiSmokeRequired = hasUiBehaviorRisk(changedFiles);
|
|
const status = failures.length === 0 ? 'PASSED_PENDING_REVIEW' : 'FAILED';
|
|
const gatePath = writeGateArtifact(runDir, {
|
|
status,
|
|
generatedAt: new Date().toISOString(),
|
|
runDir,
|
|
version,
|
|
name,
|
|
runChecks,
|
|
workerResult: workerResult || null,
|
|
workerSummary: {
|
|
finalStatus: summary.finalStatus || null,
|
|
orchestratorStatus: summary.orchestratorStatus || null,
|
|
lastAssistantTextPath: summary.lastAssistantTextPath || null,
|
|
gitStatusAfter: summary.gitStatusAfter || null,
|
|
},
|
|
changedFiles,
|
|
forbiddenFiles: currentForbiddenFiles,
|
|
newForbiddenFiles,
|
|
checks,
|
|
autoChecks,
|
|
uiSmokeRequired,
|
|
failures,
|
|
});
|
|
|
|
printHeading('Gate Result');
|
|
console.log(`Gate artifact: ${gatePath}`);
|
|
console.log(`Changed files: ${changedFiles.length ? changedFiles.join(', ') : '(none)'}`);
|
|
if (uiSmokeRequired) {
|
|
console.log('UI smoke: required if this changed visible behavior; use the local stand and a real browser before commit.');
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.log('Verdict: FAILED');
|
|
for (const failure of failures) console.log(`- ${failure}`);
|
|
return 1;
|
|
}
|
|
|
|
console.log('Verdict: PASSED_PENDING_REVIEW');
|
|
console.log('Next: Codex reviews the diff, then stages only expected files and commits if approved.');
|
|
return 0;
|
|
}
|
|
|
|
function main() {
|
|
const cwd = process.cwd();
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
if (args.help) {
|
|
console.log(usage());
|
|
return;
|
|
}
|
|
|
|
if (!existsSync(resolve(cwd, 'AGENTS.md')) || !existsSync(resolve(cwd, 'development/_reference/ai/scripts/run-pi-worker.mjs'))) {
|
|
throw new Error('Run this command from the project repository root.');
|
|
}
|
|
|
|
const gateOnly = Boolean(args['gate-only'] || args['skip-worker']);
|
|
const runDirVersion = gateOnly ? summaryVersionFromRunDir(cwd, args) : null;
|
|
const version = normalizeVersion(args.version || runDirVersion || rootVersion(cwd));
|
|
if (args.version && runDirVersion && normalizeVersion(args.version) !== runDirVersion) {
|
|
throw new Error(`--version ${normalizeVersion(args.version)} does not match run summary version ${runDirVersion}`);
|
|
}
|
|
const name = args.name || (gateOnly ? '' : slugify(args.task || args['prompt-file'] || 'worker'));
|
|
const hasTask = Boolean(args.task || args['prompt-file']);
|
|
|
|
if (!gateOnly && !hasTask) throw new Error(`Missing --task or --prompt-file\n\n${usage()}`);
|
|
|
|
const runsDir = resolve(defaultRunsDir(cwd, args), version);
|
|
mkdirSync(runsDir, { recursive: true });
|
|
|
|
const preForbiddenFiles = gateOnly ? [] : getChangedFiles(cwd, forbiddenPathspecs(version));
|
|
const workerResult = gateOnly ? null : runWorker(cwd, args, version, name);
|
|
|
|
if (args['dry-run']) {
|
|
console.log('\nDry run complete. Pi was not launched and the Codex gate was skipped.');
|
|
process.exit(workerResult.code || 0);
|
|
}
|
|
|
|
const exitCode = runGate({ cwd, args, version, name, preForbiddenFiles, workerResult });
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error.message || error);
|
|
process.exit(1);
|
|
}
|