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>
110 lines
4.1 KiB
JavaScript
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);
|
|
});
|