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>
95 lines
3.9 KiB
JavaScript
95 lines
3.9 KiB
JavaScript
// 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 };
|
|
}
|