Initialize calm game project
This commit is contained in:
+454
@@ -0,0 +1,454 @@
|
||||
#!/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);
|
||||
}
|
||||
+579
@@ -0,0 +1,579 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {};
|
||||
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 (key === 'dry-run') {
|
||||
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:
|
||||
node development/_reference/ai/scripts/run-pi-worker.mjs \\
|
||||
--version v0.1.0 \\
|
||||
--name "Add calm reset flow" \\
|
||||
--task "Implement only the concrete worker task described by Codex."
|
||||
|
||||
Options:
|
||||
--version Required development version, for example v0.6.47.
|
||||
--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.
|
||||
--session-id Optional Pi session id. Defaults to a stable version/name id.
|
||||
--model Optional Pi model. Defaults to zai/glm-5.2.
|
||||
--thinking Optional Pi thinking level. Defaults to xhigh, which Z.ai maps to reasoning_effort=max.
|
||||
--pi Optional pi binary path. Defaults to PI_BIN or pi.
|
||||
--pi-mode Optional Pi mode: json or text. Defaults to json.
|
||||
--runs-dir Optional base directory for run artifacts. Defaults to .codex/logs/agent-runs.
|
||||
--event-idle-timeout-ms
|
||||
Optional watchdog timeout without worker output. Defaults to 600000 (10 minutes). Use 0 to disable.
|
||||
--dry-run Write prompt/meta, print command, but do not launch Pi.
|
||||
`;
|
||||
}
|
||||
|
||||
function requireArg(args, name) {
|
||||
if (!args[name]) throw new Error(`Missing required --${name}\n\n${usage()}`);
|
||||
return args[name];
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value || 'worker')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'worker';
|
||||
}
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
function readOptionalFile(path) {
|
||||
if (!path) return '';
|
||||
return readFileSync(resolve(path), 'utf8').trim();
|
||||
}
|
||||
|
||||
function parseNonNegativeInt(value, fallback) {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`Expected a non-negative integer, got: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getGitStatus(cwd) {
|
||||
try {
|
||||
return execFileSync('git', ['status', '--short'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
} catch (error) {
|
||||
return `GIT_STATUS_ERROR: ${error.message || error}`;
|
||||
}
|
||||
}
|
||||
|
||||
function textFromContent(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (Array.isArray(value)) return value.map(textFromContent).filter(Boolean).join('');
|
||||
if (typeof value !== 'object') return '';
|
||||
|
||||
if (typeof value.text === 'string') return value.text;
|
||||
if (typeof value.delta === 'string') return value.delta;
|
||||
if (typeof value.content === 'string') return value.content;
|
||||
if (Array.isArray(value.content)) return textFromContent(value.content);
|
||||
if (value.type === 'text' && typeof value.value === 'string') return value.value;
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function textFromMessage(message) {
|
||||
if (!message || message.role !== 'assistant') return '';
|
||||
return textFromContent(message.content).trim();
|
||||
}
|
||||
|
||||
function extractTextDelta(event) {
|
||||
const update = event?.assistantMessageEvent;
|
||||
if (!update || typeof update !== 'object') return '';
|
||||
if (typeof update.delta === 'string') return update.delta;
|
||||
if (typeof update.text === 'string') return update.text;
|
||||
return '';
|
||||
}
|
||||
|
||||
function findLastAssistantTextFromMessages(messages) {
|
||||
if (!Array.isArray(messages)) return '';
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
const text = textFromMessage(messages[i]);
|
||||
if (text) return text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function findFinalStatus(text) {
|
||||
const matches = [...String(text || '').matchAll(/(?:^|\n)\s*WORKER_STATUS:\s*(WORKER_DONE|BLOCKED|NEEDS_DISCUSSION)\b/g)];
|
||||
return matches.at(-1)?.[1] || null;
|
||||
}
|
||||
|
||||
function summarizeOrchestratorStatus({ finalStatus, exitCode, signal, killedByIdle, gitStatusAfter }) {
|
||||
if (killedByIdle) {
|
||||
return gitStatusAfter ? 'WORKER_STALLED_WITH_DIFF' : 'WORKER_STALLED_NO_DIFF';
|
||||
}
|
||||
if (signal) return `PROCESS_SIGNAL_${signal}`;
|
||||
if (exitCode !== null && exitCode !== undefined && exitCode !== 0) return 'PROCESS_FAILED';
|
||||
if (finalStatus === 'WORKER_DONE') return 'WORKER_DONE_PENDING_CODEX_GATE';
|
||||
if (finalStatus === 'BLOCKED') return 'WORKER_BLOCKED';
|
||||
if (finalStatus === 'NEEDS_DISCUSSION') return 'WORKER_NEEDS_DISCUSSION';
|
||||
if (exitCode === null || exitCode === undefined) return 'RUNNING_AWAITING_STATUS';
|
||||
return 'NO_STRUCTURED_STATUS';
|
||||
}
|
||||
|
||||
function renderEventForConsole(event) {
|
||||
if (!event || typeof event !== 'object') return null;
|
||||
if (event.type === 'message_update') return extractTextDelta(event) || null;
|
||||
if (event.type === 'tool_execution_start') return `\n[pi] tool start: ${event.toolName || 'unknown'}\n`;
|
||||
if (event.type === 'tool_execution_end') {
|
||||
const status = event.isError ? 'error' : 'ok';
|
||||
return `\n[pi] tool end: ${event.toolName || 'unknown'} (${status})\n`;
|
||||
}
|
||||
if (
|
||||
event.type === 'agent_start' ||
|
||||
event.type === 'agent_end' ||
|
||||
event.type === 'turn_start' ||
|
||||
event.type === 'turn_end' ||
|
||||
event.type === 'compaction_start' ||
|
||||
event.type === 'compaction_end' ||
|
||||
event.type === 'auto_retry_start' ||
|
||||
event.type === 'auto_retry_end' ||
|
||||
event.type === 'extension_error'
|
||||
) {
|
||||
return `\n[pi] ${event.type}\n`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assertRequiredFiles(cwd, version) {
|
||||
const required = [
|
||||
'AGENTS.md',
|
||||
'development/_reference/ai/autonomous-agent-harness.md',
|
||||
];
|
||||
|
||||
const missing = required.filter((file) => !existsSync(resolve(cwd, file)));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required orchestration files:\n${missing.map((file) => `- ${file}`).join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function existingContextFiles(cwd, version) {
|
||||
return [
|
||||
'README.md',
|
||||
`development/${version}/prd.md`,
|
||||
`development/${version}/plan.md`,
|
||||
`development/${version}/execution-log.md`,
|
||||
'development/_reference/ai/autonomous-agent-harness.md',
|
||||
].filter((file) => existsSync(resolve(cwd, file)));
|
||||
}
|
||||
|
||||
function buildPrompt({ contextFiles, name, taskText, version }) {
|
||||
const contextLine = contextFiles.length > 0
|
||||
? `Read these context files when relevant: ${contextFiles.join(', ')}.`
|
||||
: 'No version planning files were found; use the direct Codex assignment as the scoped contract.';
|
||||
|
||||
return `You are the Pi worker launched by Codex-Orchestrator for this project.
|
||||
|
||||
Codex remains the orchestrator, verifier, reviewer, and committer. You are only the bounded implementation worker.
|
||||
|
||||
Task label:
|
||||
${name}
|
||||
|
||||
Worker assignment:
|
||||
${taskText}
|
||||
|
||||
Repository contract:
|
||||
- Read AGENTS.md before editing.
|
||||
- ${contextLine}
|
||||
- The direct Codex assignment is the binding scope. Do not broaden it into adjacent cleanup, dependent tasks, or a full release pass.
|
||||
- If the assignment is too large to finish safely, implement a coherent safe subset and report the recommended next worker task.
|
||||
- Do not edit planning or reference docs unless the assignment explicitly asks.
|
||||
- Do not create or update execution logs unless the assignment explicitly asks. Durable progress is captured by Codex through reviewed git checkpoint commits.
|
||||
- Do not commit, push, merge, reset, clean, or rewrite git history.
|
||||
- Do not deploy, publish, or touch external services unless the assignment explicitly says so.
|
||||
- Do not run destructive cleanup or wipe commands.
|
||||
- Do not edit .pi/**, .agents/**, AGENTS.md, or development/_reference/ai/**.
|
||||
- If unrelated uncommitted changes exist, work around them. Stop with NEEDS_DISCUSSION if they block the task.
|
||||
- If credentials, external access, clean local stand setup, or human business choice is required, stop with BLOCKED instead of asking a question.
|
||||
- The ask_question tool is disabled. Do not request interactive confirmation.
|
||||
|
||||
Implementation rules:
|
||||
- Prefer existing repo patterns and narrow diffs.
|
||||
- This is a SvelteKit + TypeScript app. Keep UI behavior calm, readable, and consistent with the existing design.
|
||||
- Keep game data, game state, and UI components separated according to the current structure.
|
||||
- Keep user-facing copy consistent with the app language and tone already present in the touched files.
|
||||
- Prefer existing npm scripts from package.json. Do not invent scripts.
|
||||
- For UI behavior changes, describe the local browser smoke path Codex should verify.
|
||||
|
||||
Worker output contract:
|
||||
- Run only relevant local checks that exist in package.json / repo docs.
|
||||
- If UI behavior changed and local browser smoke is required, use only a local dev server.
|
||||
- Finish with one of: WORKER_DONE, BLOCKED, or NEEDS_DISCUSSION.
|
||||
- End your final response with a standalone final status line in this exact shape:
|
||||
WORKER_STATUS: WORKER_DONE
|
||||
or WORKER_STATUS: BLOCKED
|
||||
or WORKER_STATUS: NEEDS_DISCUSSION
|
||||
- List changed files and observed command exit codes.
|
||||
- If there is meaningful follow-up work, propose the next concrete worker task.
|
||||
- Do not claim review approval or human acceptance. Codex will verify, review, and commit if appropriate.
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cwd = process.cwd();
|
||||
const version = requireArg(args, 'version');
|
||||
const model = args.model || 'zai/glm-5.2';
|
||||
const thinking = args.thinking || 'xhigh';
|
||||
const piBin = args.pi || process.env.PI_BIN || 'pi';
|
||||
const piMode = args['pi-mode'] || 'json';
|
||||
const eventIdleTimeoutMs = parseNonNegativeInt(args['event-idle-timeout-ms'], 600000);
|
||||
const taskText = [args.task || '', readOptionalFile(args['prompt-file'])]
|
||||
.filter(Boolean)
|
||||
.join('\n\nAdditional Codex assignment details:\n')
|
||||
.trim();
|
||||
|
||||
if (!taskText) throw new Error(`Missing --task or --prompt-file\n\n${usage()}`);
|
||||
if (!['json', 'text'].includes(piMode)) {
|
||||
throw new Error(`Unsupported --pi-mode ${piMode}. Expected json or text.`);
|
||||
}
|
||||
|
||||
assertRequiredFiles(cwd, version);
|
||||
const name = args.name || slugify(taskText);
|
||||
const contextFiles = existingContextFiles(cwd, version);
|
||||
const runsBaseDir = resolve(cwd, args['runs-dir'] || '.codex/logs/agent-runs');
|
||||
|
||||
const runSlug = slugify(`${name}`);
|
||||
const runDir = resolve(runsBaseDir, version, `${timestamp()}-pi-worker-${runSlug}`);
|
||||
mkdirSync(runDir, { recursive: true });
|
||||
|
||||
const prompt = buildPrompt({ contextFiles, name, taskText, version });
|
||||
const promptPath = join(runDir, 'prompt.txt');
|
||||
const stdoutPath = join(runDir, 'stdout.txt');
|
||||
const stderrPath = join(runDir, 'stderr.txt');
|
||||
const metaPath = join(runDir, 'meta.json');
|
||||
const eventsPath = join(runDir, 'events.jsonl');
|
||||
const summaryPath = join(runDir, 'summary.json');
|
||||
const lastMessagePath = join(runDir, 'last-message.txt');
|
||||
|
||||
const sessionId = args['session-id'] || `calm-${version.replace(/[^a-zA-Z0-9]+/g, '-')}-${runSlug}`;
|
||||
const piArgs = [
|
||||
...(piMode === 'json' ? ['--mode', 'json'] : ['-p']),
|
||||
'--model',
|
||||
model,
|
||||
'--thinking',
|
||||
thinking,
|
||||
'--approve',
|
||||
'--exclude-tools',
|
||||
'ask_question',
|
||||
'--session-id',
|
||||
sessionId,
|
||||
prompt,
|
||||
];
|
||||
|
||||
writeFileSync(promptPath, prompt);
|
||||
const startedAt = new Date().toISOString();
|
||||
const baseMeta = {
|
||||
tool: 'pi',
|
||||
piBin,
|
||||
args: piArgs.slice(0, -1).concat(['<prompt>']),
|
||||
cwd,
|
||||
version,
|
||||
name,
|
||||
model,
|
||||
thinking,
|
||||
piMode,
|
||||
eventIdleTimeoutMs,
|
||||
sessionId,
|
||||
runDir,
|
||||
promptPath,
|
||||
stdoutPath,
|
||||
stderrPath,
|
||||
eventsPath,
|
||||
summaryPath,
|
||||
lastMessagePath,
|
||||
startedAt,
|
||||
dryRun: Boolean(args['dry-run']),
|
||||
};
|
||||
|
||||
writeFileSync(metaPath, `${JSON.stringify(baseMeta, null, 2)}\n`);
|
||||
writeFileSync(summaryPath, `${JSON.stringify({
|
||||
...baseMeta,
|
||||
status: 'NOT_STARTED',
|
||||
finalStatus: null,
|
||||
orchestratorStatus: 'NOT_STARTED',
|
||||
eventCounts: {},
|
||||
eventCount: 0,
|
||||
parseErrors: [],
|
||||
gitStatusAfter: null,
|
||||
}, null, 2)}\n`);
|
||||
|
||||
console.log(`Run directory: ${runDir}`);
|
||||
console.log(`Prompt: ${promptPath}`);
|
||||
console.log(`Events: ${eventsPath}`);
|
||||
console.log(`Summary: ${summaryPath}`);
|
||||
console.log(`Command: ${piBin} ${piArgs.slice(0, -1).join(' ')} <prompt>`);
|
||||
|
||||
if (args['dry-run']) {
|
||||
console.log('Dry run: Pi was not launched.');
|
||||
return;
|
||||
}
|
||||
|
||||
const stdout = createWriteStream(stdoutPath);
|
||||
const stderr = createWriteStream(stderrPath);
|
||||
const events = createWriteStream(eventsPath);
|
||||
let stdoutRemainder = '';
|
||||
let stdoutTail = '';
|
||||
let lastAssistantText = '';
|
||||
let lastEventAt = null;
|
||||
let lastEventType = null;
|
||||
let parentSignal = null;
|
||||
let killedByIdle = false;
|
||||
let idleTimer = null;
|
||||
const parseErrors = [];
|
||||
const eventCounts = {};
|
||||
let eventCount = 0;
|
||||
|
||||
const child = spawn(piBin, piArgs, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
function writeRunningSummary(extra = {}) {
|
||||
const gitStatusAfter = extra.gitStatusAfter ?? null;
|
||||
const finalStatus = findFinalStatus(lastAssistantText);
|
||||
const orchestratorStatus = summarizeOrchestratorStatus({
|
||||
finalStatus,
|
||||
exitCode: extra.exitCode ?? null,
|
||||
signal: extra.signal ?? null,
|
||||
killedByIdle,
|
||||
gitStatusAfter,
|
||||
});
|
||||
|
||||
writeFileSync(summaryPath, `${JSON.stringify({
|
||||
...baseMeta,
|
||||
status: extra.status || 'RUNNING',
|
||||
finalStatus,
|
||||
orchestratorStatus,
|
||||
eventCounts,
|
||||
eventCount,
|
||||
parseErrors,
|
||||
lastEventAt,
|
||||
lastEventType,
|
||||
parentSignal,
|
||||
killedByIdle,
|
||||
gitStatusAfter,
|
||||
stdoutTail,
|
||||
lastAssistantTextPath: lastMessagePath,
|
||||
...extra,
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function resetIdleTimer() {
|
||||
if (eventIdleTimeoutMs === 0) return;
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
killedByIdle = true;
|
||||
console.error(`\nPi worker produced no output for ${eventIdleTimeoutMs}ms; sending SIGTERM.`);
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
}, 10000).unref();
|
||||
}, eventIdleTimeoutMs);
|
||||
idleTimer.unref();
|
||||
}
|
||||
|
||||
function handleEvent(event) {
|
||||
eventCount += 1;
|
||||
eventCounts[event.type || 'unknown'] = (eventCounts[event.type || 'unknown'] || 0) + 1;
|
||||
lastEventAt = new Date().toISOString();
|
||||
lastEventType = event.type || 'unknown';
|
||||
events.write(`${JSON.stringify(event)}\n`);
|
||||
|
||||
if (event.type === 'message_end') {
|
||||
const text = textFromMessage(event.message);
|
||||
if (text) lastAssistantText = text;
|
||||
} else if (event.type === 'agent_end') {
|
||||
const text = findLastAssistantTextFromMessages(event.messages);
|
||||
if (text) lastAssistantText = text;
|
||||
}
|
||||
|
||||
if (lastAssistantText) writeFileSync(lastMessagePath, `${lastAssistantText}\n`);
|
||||
|
||||
const rendered = renderEventForConsole(event);
|
||||
if (rendered) process.stdout.write(rendered);
|
||||
}
|
||||
|
||||
function handleStdoutChunk(chunk) {
|
||||
const text = chunk.toString('utf8');
|
||||
stdoutTail = `${stdoutTail}${text}`.slice(-12000);
|
||||
stdout.write(chunk);
|
||||
resetIdleTimer();
|
||||
|
||||
if (piMode !== 'json') {
|
||||
process.stdout.write(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
stdoutRemainder += text;
|
||||
const lines = stdoutRemainder.split('\n');
|
||||
stdoutRemainder = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
handleEvent(JSON.parse(trimmed));
|
||||
} catch (error) {
|
||||
parseErrors.push({
|
||||
at: new Date().toISOString(),
|
||||
error: error.message || String(error),
|
||||
line: trimmed.slice(0, 500),
|
||||
});
|
||||
process.stdout.write(`${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
writeRunningSummary();
|
||||
}
|
||||
|
||||
function handleParentSignal(signal) {
|
||||
parentSignal = signal;
|
||||
console.error(`\nReceived ${signal}; forwarding to Pi worker.`);
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
process.once('SIGINT', handleParentSignal);
|
||||
process.once('SIGTERM', handleParentSignal);
|
||||
|
||||
child.on('error', (error) => {
|
||||
parseErrors.push({
|
||||
at: new Date().toISOString(),
|
||||
error: error.message || String(error),
|
||||
line: 'spawn',
|
||||
});
|
||||
});
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
handleStdoutChunk(chunk);
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
resetIdleTimer();
|
||||
process.stderr.write(chunk);
|
||||
stderr.write(chunk);
|
||||
});
|
||||
|
||||
resetIdleTimer();
|
||||
writeRunningSummary();
|
||||
|
||||
const { exitCode, signal } = await new Promise((resolveExit) => {
|
||||
child.on('close', (code, closeSignal) => resolveExit({ exitCode: code, signal: closeSignal }));
|
||||
});
|
||||
clearTimeout(idleTimer);
|
||||
|
||||
if (piMode === 'json' && stdoutRemainder.trim()) {
|
||||
try {
|
||||
handleEvent(JSON.parse(stdoutRemainder.trim()));
|
||||
} catch (error) {
|
||||
parseErrors.push({
|
||||
at: new Date().toISOString(),
|
||||
error: error.message || String(error),
|
||||
line: stdoutRemainder.trim().slice(0, 500),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stdout.end();
|
||||
stderr.end();
|
||||
events.end();
|
||||
|
||||
const finishedAt = new Date().toISOString();
|
||||
const gitStatusAfter = getGitStatus(cwd);
|
||||
const finalStatus = piMode === 'json' ? findFinalStatus(lastAssistantText) : findFinalStatus(stdoutTail);
|
||||
const orchestratorStatus = summarizeOrchestratorStatus({
|
||||
finalStatus,
|
||||
exitCode,
|
||||
signal,
|
||||
killedByIdle,
|
||||
gitStatusAfter,
|
||||
});
|
||||
|
||||
writeFileSync(
|
||||
metaPath,
|
||||
`${JSON.stringify({ ...baseMeta, finishedAt, exitCode, signal }, null, 2)}\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
summaryPath,
|
||||
`${JSON.stringify({
|
||||
...baseMeta,
|
||||
status: 'FINISHED',
|
||||
finalStatus,
|
||||
orchestratorStatus,
|
||||
eventCounts,
|
||||
eventCount,
|
||||
parseErrors,
|
||||
lastEventAt,
|
||||
lastEventType,
|
||||
parentSignal,
|
||||
killedByIdle,
|
||||
gitStatusAfter,
|
||||
stdoutTail,
|
||||
lastAssistantTextPath: lastMessagePath,
|
||||
finishedAt,
|
||||
exitCode,
|
||||
signal,
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
|
||||
console.log(`\nPi worker finished with ${orchestratorStatus}. See ${runDir}`);
|
||||
|
||||
if (parentSignal === 'SIGINT') process.exit(130);
|
||||
if (parentSignal === 'SIGTERM') process.exit(143);
|
||||
if (killedByIdle) process.exit(124);
|
||||
if (exitCode !== 0 || signal) {
|
||||
console.error(`Pi worker exited with code ${exitCode ?? 'null'} signal ${signal ?? 'null'}. See ${runDir}`);
|
||||
process.exit(exitCode || 1);
|
||||
}
|
||||
if (!finalStatus) {
|
||||
console.error(`Pi worker exited without WORKER_STATUS marker. See ${summaryPath}`);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
console.log(`Pi worker completed. Structured summary: ${summaryPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user