Files
cd14bbf89a feat(runtime): state persistence + proposals + supervisor hot-restart (#6)
Closes the self-improvement loop end-to-end:

  reflex fails 3× → proposal file → operator approves in TUI →
  `npm run propose:apply <file>` spawns Pi on a feature branch →
  Pi commits the patch → supervisor watches runtime/*.js and
  restarts the child on change.

runtime/state-store.js — atomic current-task.json writes, daily diary
  append, proposals/ + proposals/approved/ helpers.

runtime/bot.js:
  - ctx.dispatch writes current-task.json on start and updates it on
    completion / failure / throw.
  - failure tracker: 3 consecutive same-label failures → writeProposal()
    with the snapshot, labels, and a suggested-next-step section.
    30-min cooldown prevents proposal spam.
  - on startup, surfaces resume info (previous task + pending proposal
    count); on death, clears current-task.json + writes diary line.
  - new IPC commands: PROPOSAL_LATEST returns the newest pending
    proposal body; PROPOSAL_APPROVE moves it to proposals/approved/.

tui/tui.tsx — status bar shows `[proposals N, press y]` badge when
  bot.pendingProposals > 0. Hotkey 'y' opens the proposal panel; 'y'
  approves, 'n'/Esc closes.

scripts/propose-apply.js — given an approved proposal filename, creates
  a `feat/proposal-<slug>` branch and spawns `pi -p` with the proposal
  + repo-conventions prompt. Refuses on dirty tree. No auto-push, no
  auto-merge — operator reviews the diff and decides.

runtime/supervisor.js — forks bot.js as a child, watches runtime/*.js,
  restarts on file change or on child exit code 42. Rate-limited at 5
  restarts/minute. SIGINT/SIGTERM forward cleanly. `npm run bot` now
  goes through the supervisor; `npm run bot:bare` skips it.

Smoke-tested: supervisor spawned, bot connected to MC, spawned at
expected coords, diary line written, state cleanup on SIGTERM correct.

Co-authored-by: Yuriy Mayatnikov <mayatnikov@me.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:20:09 +03:00

110 lines
4.1 KiB
JavaScript

#!/usr/bin/env node
// propose:apply <filename>
//
// Take an approved proposal from state/<host>/proposals/approved/, build a
// task prompt for Pi headless, hand it the proposal + relevant repo context,
// and let Pi write the patch on a new feature branch. The script:
//
// 1. Verifies the proposal exists in proposals/approved/.
// 2. Refuses to run on a dirty working tree.
// 3. Creates a new branch `feat/proposal-<slug>` off the current HEAD.
// 4. Spawns `pi -p "<prompt>"` and streams its stdout to the terminal.
// 5. After Pi exits, prints the resulting `git status` so the operator
// can inspect what changed before pushing/merging.
//
// We do NOT push or merge automatically — the operator reviews `git diff`,
// runs the smoke test, and decides.
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, "..");
const filenameArg = process.argv[2];
if (!filenameArg) {
console.error("usage: npm run propose:apply <filename>");
process.exit(2);
}
function findApproved(filename) {
const stateRoot = path.join(REPO_ROOT, "state");
if (!fs.existsSync(stateRoot)) return null;
for (const host of fs.readdirSync(stateRoot)) {
const candidate = path.join(stateRoot, host, "proposals", "approved", filename);
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
const proposalPath = findApproved(filenameArg);
if (!proposalPath) {
console.error(`proposal not found in any state/<host>/proposals/approved/: ${filenameArg}`);
process.exit(3);
}
const dirty = spawnSync("git", ["status", "--porcelain"], { cwd: REPO_ROOT })
.stdout.toString()
.trim();
if (dirty) {
console.error("working tree is dirty — commit or stash first");
console.error(dirty);
process.exit(4);
}
const slug = filenameArg
.replace(/\.md$/, "")
.replace(/[^a-zA-Z0-9-]+/g, "-")
.slice(0, 60);
const branch = `feat/proposal-${slug}`;
const branchRes = spawnSync("git", ["checkout", "-b", branch], { cwd: REPO_ROOT, stdio: "inherit" });
if (branchRes.status !== 0) {
console.error(`could not create branch ${branch}`);
process.exit(5);
}
const proposalText = fs.readFileSync(proposalPath, "utf8");
const prompt = [
"You are about to patch the pepa-pi-bot repo to address an approved proposal.",
"",
"## The proposal",
"",
proposalText,
"",
"## Your task",
"",
"Read the proposal carefully. Make a *minimal* patch to address the underlying problem.",
"",
"Rules:",
"1. Touch as few files as possible. Prefer fixing the smallest thing that addresses the root cause.",
"2. The fix should land under `runtime/` (the hybrid runtime) — NOT under `extensions/` (Pi-only legacy).",
"3. Honor the existing patterns:",
" - Reflexes live in `runtime/reflex.js` and follow the `(ctx) => { action, ... }` shape.",
" - Actions live in `runtime/actions.js` and return `{ ok, detail }` with a hard timeout.",
" - State on disk goes through `runtime/state-store.js`.",
"4. Add or update a short comment explaining WHY only if the change is non-obvious.",
"5. Do not introduce new npm dependencies without a strong reason.",
"6. Do not push, do not open a PR — just commit on the current branch.",
"7. Use a conventional commit message (feat/fix/refactor scope).",
"",
"When you're done, commit and stop. The operator will review.",
].join("\n");
console.log("\n=== spawning pi -p (this may take a few minutes) ===\n");
const child = spawn("pi", ["-p", prompt], { cwd: REPO_ROOT, stdio: "inherit" });
child.on("exit", (code) => {
console.log(`\n=== pi exited code=${code} ===\n`);
console.log("git status:");
spawnSync("git", ["status"], { cwd: REPO_ROOT, stdio: "inherit" });
console.log("\nDiff summary:");
spawnSync("git", ["diff", "--stat", "HEAD~1..HEAD"], { cwd: REPO_ROOT, stdio: "inherit" });
console.log(`\nBranch: ${branch}`);
console.log(`Next steps: review the diff, smoke-test with 'npm run bot', then 'git push -u origin ${branch}' if good.`);
process.exit(code ?? 0);
});