Files
pepa-pi-bot/scripts/lint-patch.js
T
mayatnikovandClaude Opus 4.7 4ae63dabe1 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>
2026-05-26 15:16:42 +03:00

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 };
}