#!/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. --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//... 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); }