Patches each link of the closed-loop chain (world-truth -> invariant -> dispatch -> worldDelta -> milestone) so the contract can no longer advance on a lie or stall on an unreachable milestone, and surfaces the result on the monitor. - Unblock M6_lighting: add gather.coal producer (mirrors gather.stone, strict expectGain), register it, add the skill-graph node, and make curriculum.shelter.torch two-step (gather coal -> craft torch). craft.torch needed coal but no skill produced it, so the contract pinned on M6 forever and starved M7/M8/M9. One curriculum edit fixes both rails. - M8 no longer falsely satisfied: build-shelter records locations.shelter only on real progress (setLocation moved below the no_progress guard). - M2_bed made monotonic: sleepInBed writes locations.bed when the bot places its own bed, so M2 stays met after the carried bed item is consumed. - Observability: new ink-free tui/format.js helper renders per-tick state / firing rail / active skill / blocked prerequisite / stall reason in the monitor header (fields bot.js already computes but never showed). - Harden the valid-skill-id guardrail: skillRegistryPrompt preserves the "NEVER invent" footer when truncating (a growing registry was dropping it), and fix the stale "tech-tree > autonomous" reflex chain in the escalation prompt. Tests: 494/494 passing; new build-shelter / invariants / format suites plus skill-graph, curriculum, skill-registry cases. lint-patch clean. Deferred audit findings recorded in plans/v0.4.0-vnext.md (local). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
77 lines
2.9 KiB
JavaScript
77 lines
2.9 KiB
JavaScript
// Single source of truth for "what skill ids are real" — exported separately
|
|
// from skills/index.js so coach/advice.js, coach/postmortem.js, coach/reflect.js,
|
|
// and coach/fast-advisor.js can all consult the SAME live registry without
|
|
// circular imports through runSkill.
|
|
//
|
|
// Why this exists: in v0.2.x Pi (the LLM coach) routinely fabricated
|
|
// skill ids that never existed — "relocate.surface", "choose.safe.surface",
|
|
// "survive.shelter", "gather.visible_log". Of 47 Pi-extracted lessons,
|
|
// 0 were ever applied because normalisePreferSkill() couldn't map them
|
|
// to anything real. The fix is two-pronged: (a) hand Pi the real registry
|
|
// in its system prompt so it doesn't have to guess; (b) reject anything
|
|
// not in the registry at the consult() boundary.
|
|
|
|
import { listSkills } from "./skills/index.js";
|
|
|
|
let _cache = null;
|
|
|
|
function rebuild() {
|
|
const all = listSkills();
|
|
const byId = new Map();
|
|
const byNamespace = new Map();
|
|
for (const s of all) {
|
|
byId.set(s.id, s);
|
|
const ns = s.id.split(".")[0] || "misc";
|
|
if (!byNamespace.has(ns)) byNamespace.set(ns, []);
|
|
byNamespace.get(ns).push(s);
|
|
}
|
|
_cache = { all, byId, byNamespace };
|
|
return _cache;
|
|
}
|
|
|
|
function get() { return _cache ?? rebuild(); }
|
|
|
|
export function listSkillIds() {
|
|
return Array.from(get().byId.keys());
|
|
}
|
|
|
|
export function isRegistered(id) {
|
|
if (!id || typeof id !== "string") return false;
|
|
return get().byId.has(id);
|
|
}
|
|
|
|
export function describeSkill(id) {
|
|
return get().byId.get(id) ?? null;
|
|
}
|
|
|
|
// Human-readable block to drop into LLM system prompts. Groups by
|
|
// namespace, lists "id — title". Capped at ~2KB to stay well within the
|
|
// model's instruction window. When the list overflows the cap we truncate
|
|
// the LIST, never the footer — the footer carries the load-bearing
|
|
// guardrail ("NEVER invent new ids"), the whole reason this prompt exists.
|
|
// A naive tail-slice (the pre-v0.4.1 behaviour) would silently drop that
|
|
// guardrail once the registry grew past the cap, re-opening the exact v0.2
|
|
// skill-id hallucination this module was built to close.
|
|
export function skillRegistryPrompt({ limit = 3000 } = {}) {
|
|
const { byNamespace } = get();
|
|
const namespaces = Array.from(byNamespace.keys()).sort();
|
|
const lines = ["Valid skill ids (USE ONLY THESE for avoid_skill / prefer_skill):"];
|
|
for (const ns of namespaces) {
|
|
const skills = byNamespace.get(ns).sort((a, b) => a.id.localeCompare(b.id));
|
|
lines.push(` ${ns}:`);
|
|
for (const s of skills) {
|
|
lines.push(` - ${s.id} — ${s.title ?? s.id}`);
|
|
}
|
|
}
|
|
const body = lines.join("\n");
|
|
const footer = "If no listed skill fits, set the field to null. NEVER invent new ids.";
|
|
const full = `${body}\n\n${footer}`;
|
|
if (full.length <= limit) return full;
|
|
const sep = "\n...\n\n";
|
|
const room = Math.max(0, limit - footer.length - sep.length);
|
|
return `${body.slice(0, room)}${sep}${footer}`;
|
|
}
|
|
|
|
// For tests / hot-reload scenarios.
|
|
export function _resetForTest() { _cache = null; }
|