Files
ozone-tech_owl_prime/agent/cli.mjs
Даня Архипов 985f7c327d feat: maximize sorter demo realism and add safe agent MVP
Wire live classifyItem into continuous playback, add jam/E-stop cases,
presenter controls (seek/speed/hotkeys), quality modes, demo scripts,
audit docs, and a verify-only autonomous agent CLI with hard safety limits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 16:53:35 +00:00

316 lines
8.2 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Autonomous improvement agent MVP — Orchestrator shell.
*
* Modes: dry-run | run-once | status | stop | resume | pause | report
* NEVER merges to main or deploys production.
*/
import { spawnSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
appendFileSync,
unlinkSync,
} from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
const STATE_DIR = join(ROOT, 'agent', 'state');
const REPORTS_DIR = join(ROOT, 'agent', 'reports');
const KILL_SWITCH = join(STATE_DIR, 'KILL');
const STATUS_FILE = join(STATE_DIR, 'status.json');
const AUDIT_LOG = join(STATE_DIR, 'audit.jsonl');
const LIMITS = {
maxCycleMinutes: 25,
maxChangedFiles: 12,
maxDiffLines: 800,
maxParallelWorkers: 1,
dailyLlmBudgetUsd: 5,
forbidMergeToMain: true,
forbidProductionDeploy: true,
forbidSecretAccess: true,
requireTestsPass: true,
requireBuildPass: true,
minScoreDelta: 0,
};
function ensureDirs() {
mkdirSync(STATE_DIR, { recursive: true });
mkdirSync(REPORTS_DIR, { recursive: true });
}
function nowIso() {
return new Date().toISOString();
}
function audit(event, payload = {}) {
appendFileSync(AUDIT_LOG, `${JSON.stringify({ ts: nowIso(), event, ...payload })}\n`);
}
function writeStatus(status) {
writeFileSync(STATUS_FILE, JSON.stringify({ ...status, updatedAt: nowIso() }, null, 2));
}
function readStatus() {
if (!existsSync(STATUS_FILE)) return { state: 'idle' };
return JSON.parse(readFileSync(STATUS_FILE, 'utf8'));
}
function isKilled() {
return existsSync(KILL_SWITCH);
}
function run(cmd, args) {
const result = spawnSync(cmd, args, {
cwd: ROOT,
encoding: 'utf8',
timeout: 15 * 60 * 1000,
env: { ...process.env, AGENT_MODE: '1' },
});
return {
code: result.status ?? 1,
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
};
}
function collectBaseline() {
const tests = run('npm', ['test']);
const build = run('npm', ['run', 'build']);
return {
testsPassed: tests.code === 0,
buildPassed: build.code === 0,
testOutputTail: tests.stdout.split('\n').slice(-20).join('\n'),
buildOutputTail: build.stdout.split('\n').slice(-20).join('\n'),
};
}
function proposeTasks(baseline) {
const backlog = [
{
id: 'unify-classifier-proof',
title: 'Keep classifyItem as single decision source on main demo',
impact: 9,
risk: 2,
},
{
id: 'safety-playlist',
title: 'Jam / E-stop visible on main playlist',
impact: 8,
risk: 3,
},
{
id: 'demo-hotkeys',
title: 'Presenter seek/speed/presentation hotkeys',
impact: 7,
risk: 2,
},
{
id: 'perf-quality-modes',
title: 'Adaptive quality modes for demo FPS',
impact: 6,
risk: 2,
},
];
if (!baseline.testsPassed || !baseline.buildPassed) {
return {
id: 'stabilize-baseline',
title: 'Restore green tests/build before further changes',
impact: 10,
risk: 1,
};
}
return backlog.sort((a, b) => b.impact - a.impact)[0];
}
function scoreCategories(baseline, notes) {
const categories = {
visualRealism: 72,
physicsFidelity: 68,
demoClarity: 80,
technicalStability: baseline.testsPassed && baseline.buildPassed ? 88 : 40,
performance: 78,
faultTolerance: 75,
architecture: 76,
testCoverage: 82,
launchConvenience: 84,
logging: 74,
security: 90,
hardwareFit: 86,
};
const values = Object.values(categories);
const total = Math.round(values.reduce((a, b) => a + b, 0) / values.length);
return { categories, total, notes };
}
function writeReport(runId, report) {
const path = join(REPORTS_DIR, `${runId}.json`);
writeFileSync(path, JSON.stringify(report, null, 2));
writeFileSync(join(REPORTS_DIR, 'latest.json'), JSON.stringify(report, null, 2));
return path;
}
function cmdDryRun() {
ensureDirs();
if (isKilled()) {
console.error('Kill switch active. Remove agent/state/KILL to continue.');
process.exit(2);
}
const runId = `dry-${Date.now()}`;
writeStatus({ state: 'dry-run', runId });
audit('dry_run_start', { runId });
console.log(`[agent] dry-run ${runId}`);
console.log('[agent] collecting baseline (tests + build)...');
const baseline = collectBaseline();
const task = proposeTasks(baseline);
const score = scoreCategories(baseline, 'dry-run — no code changes');
const report = {
runId,
mode: 'dry-run',
hypothesis: `If we execute "${task.title}", demo jury clarity improves without regressing FPS.`,
task,
limits: LIMITS,
baseline,
score,
decision: 'PLAN_ONLY',
reason: 'dry-run does not apply patches',
safety: {
wouldTouchProduction: false,
wouldMergeMain: false,
killSwitch: false,
},
};
const path = writeReport(runId, report);
writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'dry-run' });
audit('dry_run_complete', { runId, path, taskId: task.id });
console.log(`[agent] planned task: ${task.id} — ${task.title}`);
console.log(`[agent] score: ${score.total}/100`);
console.log(`[agent] report: ${path}`);
}
function cmdRunOnce() {
ensureDirs();
if (isKilled()) {
console.error('Kill switch active.');
process.exit(2);
}
const runId = `once-${Date.now()}`;
writeStatus({ state: 'run-once', runId });
audit('run_once_start', { runId });
console.log(`[agent] run-once ${runId}`);
console.log('[agent] MVP policy: verify baseline only — no automatic code mutation.');
const baseline = collectBaseline();
const task = proposeTasks(baseline);
const score = scoreCategories(baseline, 'run-once verification');
const decision =
baseline.testsPassed && baseline.buildPassed ? 'ACCEPT_BASELINE' : 'REJECT_BASELINE';
const report = {
runId,
mode: 'run-once',
task,
limits: LIMITS,
baseline,
score,
decision,
reason:
decision === 'ACCEPT_BASELINE'
? 'Tests and build green; agent will not auto-patch in MVP (requires human Implementer).'
: 'Baseline red — agent refuses further patches until fixed.',
nextSafeActions: [
'Keep working on feature/maximum-demo-realism',
'Never merge to main automatically',
'Use agent dry-run before long autonomous sessions',
],
};
const path = writeReport(runId, report);
writeStatus({ state: 'idle', lastRunId: runId, lastMode: 'run-once', decision });
audit('run_once_complete', { runId, decision });
console.log(`[agent] decision: ${decision}`);
console.log(`[agent] report: ${path}`);
process.exit(decision === 'ACCEPT_BASELINE' ? 0 : 1);
}
function cmdStatus() {
ensureDirs();
console.log(JSON.stringify({ ...readStatus(), killSwitch: isKilled(), limits: LIMITS }, null, 2));
}
function cmdStop() {
ensureDirs();
writeFileSync(KILL_SWITCH, `stopped at ${nowIso()}\n`);
writeStatus({ state: 'stopped' });
audit('kill_switch_on');
console.log('[agent] stop requested — kill switch written');
}
function cmdResume() {
ensureDirs();
if (existsSync(KILL_SWITCH)) unlinkSync(KILL_SWITCH);
writeStatus({ state: 'idle' });
audit('kill_switch_off');
console.log('[agent] resumed');
}
function cmdPause() {
writeStatus({ ...readStatus(), state: 'paused' });
audit('paused');
console.log('[agent] paused');
}
function cmdReport() {
ensureDirs();
const latest = join(REPORTS_DIR, 'latest.json');
if (!existsSync(latest)) {
console.error('No report yet. Run: node agent/cli.mjs dry-run');
process.exit(1);
}
console.log(readFileSync(latest, 'utf8'));
}
const cmd = process.argv[2] ?? 'status';
switch (cmd) {
case 'dry-run':
cmdDryRun();
break;
case 'run-once':
cmdRunOnce();
break;
case 'status':
cmdStatus();
break;
case 'stop':
cmdStop();
break;
case 'resume':
cmdResume();
break;
case 'pause':
cmdPause();
break;
case 'report':
cmdReport();
break;
case 'start':
console.log('[agent] continuous start disabled in MVP. Use: dry-run | run-once');
process.exit(1);
break;
default:
console.log('Usage: node agent/cli.mjs <dry-run|run-once|status|stop|resume|pause|report>');
process.exit(1);
}