feat(runtime): v0.1.0 — adopt Voyager critic + Mindcraft modes/library/lint
Five concrete patterns from Voyager and Mindcraft, applied in our shape
without abandoning the git-as-evolution-substrate that makes pepa
distinct. Plus a first multi-agent surface so two bots from the same
repo can share intent.
1. runtime/critic.js (Voyager critic.txt)
- Spawns `pi -p` with a JSON-only critic prompt before a proposal is
written. {reasoning, success, critique}.
- success=true short-circuits the proposal (bot recovered between
detector tripping and now), saving Pi tokens on false positives.
- critique is spliced into the proposal body via attachCritique() so
the downstream auto-patcher has a sharp spec.
- Graceful: pi missing / timeout / unparseable JSON → proposal still
filed without the critic block.
2. scripts/lint-patch.js (Mindcraft coder._lintCode)
- Pre-flight gate between Pi commit and npm test: node --check, dynamic
import (catches missing named exports), regex extraction of
runSkill("id") calls cross-checked against the live registry.
- Cheaper than npm test, fails fast with a clear reason.
3. runtime/stuck-incident.renderActionTemplate (Voyager action_template.txt)
- All proposal bodies now follow the same fixed-section layout: Task /
Last result / Execution error / State / Metrics / Journal /
Scenarios / Critique / Fix / Edit scope / Forbidden.
4. runtime/skill-library.js (Mindcraft skill_library.getRelevantSkillDocs)
- Word-overlap ranking (Mindcraft's offline fallback) — zero deps,
deterministic. auto-patch.js injects top-3 similar skills into the
Pi prompt as "look at these patterns".
5. runtime/modes.js (Mindcraft modes.js)
- Declarative {name, interrupts, on, active, update(ctx)} chain that
runs BEFORE the curriculum each tick.
- Ships self_preservation (low HP → eat/flee), hunger (food<14 → eat),
night_shelter (night + bed in hand → sleep). Cleaner than ad-hoc
lastFleeAttempt cooldowns in reflex.js.
6. runtime/social/conversation.js + cmd:conv-say/conv-recent/conv-list
- File-JSONL topic channel so two bots from the same repo (different
usernames, different host dirs under state/) can append turns and
read peers. Skeleton — multi-agent collaboration on top later.
Differentiator preserved: every Pi-written skill still lands on main via
auto-patch.js (real git branch + smoke gate + cherry-pick). Voyager
keeps skills in a Chroma JSON, Mindcraft keeps them in RAM — pepa keeps
them as versioned source code reviewable in `git log`.
package.json: 0.0.1 → 0.1.0. 174/174 tests pass. README + AGENTS updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+31
-1
@@ -22,6 +22,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { parseEditScope, validateChangedFiles, effectiveScope } from "./edit-scope.js";
|
||||
import { lintPatch } from "./lint-patch.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -121,6 +122,18 @@ if (checkout.status !== 0) exit(2, `cannot create branch ${branch}: ${checkout.s
|
||||
acquireLock();
|
||||
log("info", `acquired ${LOCK_FILE}`);
|
||||
|
||||
// Pick top-k similar existing skills so Pi can crib patterns instead of
|
||||
// reinventing them (Mindcraft skill_library.getRelevantSkillDocs). Lazy
|
||||
// import — skill registry pulls in mineflayer transitively which is
|
||||
// expensive, and we don't need it on early-exit paths.
|
||||
let relevantDocsBlock = "_(skill library unavailable)_";
|
||||
try {
|
||||
const { renderRelevantDocs } = await import("../runtime/skill-library.js");
|
||||
relevantDocsBlock = renderRelevantDocs(proposalText, { k: 3 });
|
||||
} catch (e) {
|
||||
log("warn", `skill-library render failed: ${e.message}`);
|
||||
}
|
||||
|
||||
const scopeBullet = scope.map((p) => ` - \`${p}\``).join("\n");
|
||||
const prompt = [
|
||||
"You are patching the pepa-pi-bot repo to address an automatically-detected failure.",
|
||||
@@ -131,6 +144,10 @@ const prompt = [
|
||||
"",
|
||||
proposalText,
|
||||
"",
|
||||
"## Relevant existing skills (top-3 by word overlap — use these as patterns)",
|
||||
"",
|
||||
relevantDocsBlock,
|
||||
"",
|
||||
"## Hard rules (non-negotiable)",
|
||||
"",
|
||||
"1. Touch ONLY files matching the edit scope below. Any other path will be rejected after you commit and the patch will be discarded:",
|
||||
@@ -167,7 +184,7 @@ const timer = setTimeout(() => {
|
||||
pi.kill("SIGTERM");
|
||||
}, PI_TIMEOUT_MS);
|
||||
|
||||
pi.on("exit", (code) => {
|
||||
pi.on("exit", async (code) => {
|
||||
clearTimeout(timer);
|
||||
log("info", `pi exited code=${code}; stdout=${piStdout.length}B stderr=${piStderr.length}B`);
|
||||
|
||||
@@ -200,6 +217,19 @@ pi.on("exit", (code) => {
|
||||
exit(2, "patch touched off-limits files");
|
||||
}
|
||||
|
||||
// Pre-flight lint gate (Mindcraft coder._lintCode pattern, scripts/lint-patch.js).
|
||||
// Cheaper than npm test — catches parse errors, missing named imports,
|
||||
// and runSkill(id) where id isn't in the registry. Seconds, not 30s.
|
||||
log("info", "running lint pre-flight gate");
|
||||
const lint = await lintPatch({ repoRoot: REPO_ROOT, changedFiles: filesChanged });
|
||||
if (!lint.ok) {
|
||||
log("error", `lint FAILED — discarding:\n${lint.errors.join("\n")}`);
|
||||
git(["checkout", "main"]);
|
||||
git(["branch", "-D", branch]);
|
||||
exit(2, "patch failed lint");
|
||||
}
|
||||
log("info", "lint gate passed");
|
||||
|
||||
// Smoke gate: run `npm test` on the patched branch BEFORE cherry-picking.
|
||||
// Anything that turns the suite red gets thrown away — even if Pi thinks
|
||||
// the change is correct.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Pre-flight lint for auto-patch — adapted from Mindcraft's coder._lintCode.
|
||||
//
|
||||
// Runs AFTER Pi commits to the auto/* branch but BEFORE `npm test`. Cheap
|
||||
// checks that catch the most common "Pi hallucinated an API" failures:
|
||||
//
|
||||
// 1. node --check each changed runtime/*.js — parse errors caught
|
||||
// without spinning up the supervisor.
|
||||
// 2. dynamic import — surfaces "Named export X not found" before tests
|
||||
// that don't directly import the file would have caught it.
|
||||
// 3. runSkill("X.y", ...) calls — the id must exist in the live skill
|
||||
// registry. Pi sometimes invents skill ids that look plausible.
|
||||
//
|
||||
// Returns { ok: true } or { ok: false, errors: string[] }. The auto-patch
|
||||
// caller decides whether to discard the patch. We deliberately exit with
|
||||
// a list (not fail-fast) so a single discard reason is enough for Pi to
|
||||
// understand on the next attempt.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export function parseCheck(absPath) {
|
||||
const res = spawnSync(process.execPath, ["--check", absPath], { encoding: "utf8" });
|
||||
return res.status === 0
|
||||
? { ok: true }
|
||||
: { ok: false, error: `parse: ${res.stderr.split("\n").slice(0, 2).join(" ")}` };
|
||||
}
|
||||
|
||||
export function importCheck(absPath) {
|
||||
const code = `import("${absPath.replace(/"/g, '\\"')}").then(()=>process.exit(0)).catch(e=>{console.error(e.message);process.exit(1)})`;
|
||||
const res = spawnSync(process.execPath, ["--input-type=module", "-e", code], { encoding: "utf8", timeout: 15_000 });
|
||||
return res.status === 0
|
||||
? { ok: true }
|
||||
: { ok: false, error: `import: ${(res.stderr || res.stdout || "").split("\n")[0].slice(0, 200)}` };
|
||||
}
|
||||
|
||||
// Extract runSkill("...") / getSkill("...") string-literal arguments.
|
||||
// Multi-line tolerated; backticks tolerated; templating not (Pi must
|
||||
// pass a literal id at lint time, otherwise we can't verify).
|
||||
const SKILL_CALL_RE = /(?:runSkill|getSkill)\s*\(\s*["'`]([a-zA-Z0-9_.-]+)["'`]/g;
|
||||
|
||||
export function extractSkillCalls(code) {
|
||||
const seen = new Set();
|
||||
let m;
|
||||
SKILL_CALL_RE.lastIndex = 0;
|
||||
while ((m = SKILL_CALL_RE.exec(code)) !== null) seen.add(m[1]);
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
export async function loadRegisteredSkillIds(repoRoot) {
|
||||
const skillsIndex = path.join(repoRoot, "runtime", "skills", "index.js");
|
||||
const mod = await import(skillsIndex);
|
||||
if (typeof mod.listSkills === "function") return new Set(mod.listSkills().map((s) => s.id));
|
||||
return new Set();
|
||||
}
|
||||
|
||||
export async function lintPatch({ repoRoot, changedFiles }) {
|
||||
const errors = [];
|
||||
const runtimeFiles = (changedFiles || []).filter((f) => /^runtime\/.*\.js$/.test(f) && !f.endsWith(".test.js"));
|
||||
for (const rel of runtimeFiles) {
|
||||
const abs = path.join(repoRoot, rel);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
const pc = parseCheck(abs);
|
||||
if (!pc.ok) errors.push(`${rel}: ${pc.error}`);
|
||||
}
|
||||
// import-check only after parse-check is clean so we report the first
|
||||
// failure clearly. import-check spins a fresh node, so we limit it to
|
||||
// the actually-touched runtime files.
|
||||
if (errors.length === 0) {
|
||||
for (const rel of runtimeFiles) {
|
||||
const abs = path.join(repoRoot, rel);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
const ic = importCheck(abs);
|
||||
if (!ic.ok) errors.push(`${rel}: ${ic.error}`);
|
||||
}
|
||||
}
|
||||
// runSkill id check — only meaningful if imports work.
|
||||
if (errors.length === 0) {
|
||||
let known = new Set();
|
||||
try { known = await loadRegisteredSkillIds(repoRoot); }
|
||||
catch (e) { return { ok: false, errors: [`skills index load failed: ${e.message}`] }; }
|
||||
for (const rel of runtimeFiles) {
|
||||
const abs = path.join(repoRoot, rel);
|
||||
if (!fs.existsSync(abs)) continue;
|
||||
const code = fs.readFileSync(abs, "utf8");
|
||||
for (const id of extractSkillCalls(code)) {
|
||||
if (!known.has(id) && !id.startsWith("diag.") && !id.startsWith("test.")) {
|
||||
errors.push(`${rel}: references unknown skill id "${id}" — not in runtime/skills/index.js`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { extractSkillCalls } from "./lint-patch.js";
|
||||
|
||||
test("extractSkillCalls: runSkill double-quoted", () => {
|
||||
const code = `await runSkill("gather.logs", ctx, args);`;
|
||||
assert.deepEqual(extractSkillCalls(code), ["gather.logs"]);
|
||||
});
|
||||
|
||||
test("extractSkillCalls: getSkill backtick", () => {
|
||||
const code = "const s = getSkill(`village.deposit-surplus`);";
|
||||
assert.deepEqual(extractSkillCalls(code), ["village.deposit-surplus"]);
|
||||
});
|
||||
|
||||
test("extractSkillCalls: multiple unique ids dedupe", () => {
|
||||
const code = `
|
||||
await runSkill("gather.logs", ctx);
|
||||
const s = getSkill('gather.stone');
|
||||
await runSkill("gather.logs", ctx); // duplicate
|
||||
`;
|
||||
assert.deepEqual(extractSkillCalls(code).sort(), ["gather.logs", "gather.stone"]);
|
||||
});
|
||||
|
||||
test("extractSkillCalls: ignores template literals it can't verify", () => {
|
||||
const code = "await runSkill(`${dynamicId}`, ctx);";
|
||||
// Pattern requires literal — dynamic ids are not extracted (and not lint-checked).
|
||||
const out = extractSkillCalls(code);
|
||||
assert.equal(out.length, 0);
|
||||
});
|
||||
|
||||
test("extractSkillCalls: tolerates whitespace + newlines", () => {
|
||||
const code = `await runSkill(
|
||||
"explore.far",
|
||||
ctx,
|
||||
args,
|
||||
);`;
|
||||
assert.deepEqual(extractSkillCalls(code), ["explore.far"]);
|
||||
});
|
||||
Reference in New Issue
Block a user