Files
my-calm-game/development/_reference/ai/scripts/run-agent-loop.mjs
T

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);
}