Read the script
// Estado del Lore de un árbol, por contenido — Lore Plugin.
//
// El único piso común que tienen los dos hosts y todos los usuarios es el sistema
// de archivos. No `git` (no es requisito del kit, y hay árboles de Lore sin él),
// no el transcript (invisible a las escrituras por script), no lo que el agente
// diga haber corrido (una frase no es un hecho).
//
// Se usa desde la guardia de Codex y desde los subcomandos locales de `lore-plugin mycelium`.
import { createHash, randomUUID } from "node:crypto";
import {
chmodSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
// Un archivo de Lore: un `.md` dentro de un `lore/`, o uno de los nombres
// distintivos de criterio en cualquier nivel. FASES/PHASES es estado y nunca entra.
const LORE_DIR = /(^|[/\\])lore[/\\][^/\\]+\.md$/i;
const LORE_FILE = /(^|[/\\])(principios|principles|identidad|identity|enrutamiento|routing)\.md$/i;
const PHASE_FILE = /(^|[/\\])(FASES|PHASES)\.md$/i;
// Directorios universales de dependencias y artefactos, más las dos formas en que
// un árbol contiene Lore que no es suyo: **fixtures** (dato de prueba) y **backups**
// (una copia de otro árbol). El principio detrás de las dos es el mismo — *una copia
// de un Lore no es el Lore del árbol que la contiene* — y la frontera es honesta: el
// recorrido no puede distinguir una copia cualquiera de un original, así que se apoya
// en convenciones de nombre. Un árbol que guarde copias con otro nombre las verá
// contadas, y su digest se moverá cuando esas copias se regeneren.
//
// No se listan nombres de carpeta propios de ningún ecosistema: generalizar desde un
// solo caso es cómo una forma se lleva puesto lo que era propio de ese caso.
const SKIP = new Set([
"node_modules", ".git", ".venv", "venv", "dist", "build", "coverage",
".next", "__pycache__",
"fixtures", "__fixtures__", "test-fixtures",
"_backup", "backup", "backups", ".backup",
]);
const MAX_DEPTH = 6;
const MAX_BYTES = 2_000_000;
export const RECEIPT = ".lore-mycelium";
// El receipt distingue entre escaneo estructural (bodies/federated) y escaneo completo
// (cada pista con su paso). El cierre de MYCELIUM no se da por hecho tras correr solo
// los escaneos estructurales: el receipt registra qué tipo de escaneo se hizo.
export const SWEEP_TYPES = ["structural", "full"];
export const SWEEP_TYPE_DEFAULT = "structural";
export function loreFiles(root, { maxDepth = MAX_DEPTH } = {}) {
const found = [];
const walk = (dir, depth) => {
if (depth > maxDepth) return;
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP.has(e.name)) walk(full, depth + 1);
} else if (e.isFile() && !PHASE_FILE.test(full)
&& (LORE_DIR.test(full) || LORE_FILE.test(full))) {
found.push(full);
}
}
};
walk(root, 0);
return found.sort();
}
function normalizedBody(file) {
return readFileSync(file, "utf8").replace(/\r\n/g, "\n");
}
function loreBodies(files) {
const bodies = new Map();
for (const file of files) {
try {
if (statSync(file).size <= MAX_BYTES) bodies.set(file, normalizedBody(file));
} catch {}
}
return bodies;
}
function digestBodies(root, files, bodies) {
const h = createHash("sha256");
for (const file of files) {
const body = bodies.get(file);
if (body === undefined) continue;
h.update(relative(root, file).split(sep).join("/"));
h.update("\0");
h.update(createHash("sha256").update(body).digest("hex"));
h.update("\n");
}
return h.digest("hex");
}
// Digest por CONTENIDO, no por mtime: tocar un archivo sin cambiarlo no cuenta,
// y el mismo árbol da el mismo digest en otra máquina.
export function digest(root) {
const files = loreFiles(root);
return digestBodies(root, files, loreBodies(files));
}
const CONTRACTS = ["CLAUDE.md", "AGENTS.md"];
const BLOCK = /<!--\s*lore:always-on\s*-->([\s\S]*?)<!--\s*\/lore:always-on\s*-->/;
function alwaysOnFiles(root) {
const contract = CONTRACTS.find((name) => existsSync(join(root, name)));
if (!contract) return [];
let scope;
try {
scope = BLOCK.exec(readFileSync(join(root, contract), "utf8"))?.[1];
} catch {
return [];
}
if (!scope) return [];
const found = new Set();
for (const match of scope.matchAll(/`([^`]+)`/g)) {
const pointer = match[1].trim();
const segments = pointer.split(/[/\\]+/);
if (isAbsolute(pointer)
|| !pointer.toLowerCase().endsWith(".md")
|| !segments.some((part) => /^(lore|canon)$/i.test(part))
|| /^(FASES|PHASES)\.md$/i.test(basename(pointer))) continue;
const file = resolve(root, pointer);
try {
if (statSync(file).isFile()) found.add(file);
} catch {}
}
return [...found];
}
export function snapshot(root) {
const files = loreFiles(root);
const bodies = loreBodies(files);
return {
digest: digestBodies(root, files, bodies),
fileCount: files.length,
alwaysOnBytes: alwaysOnFiles(root).reduce((sum, file) =>
sum + Buffer.byteLength(bodies.get(file) ?? normalizedBody(file)), 0),
};
}
export function readReceipt(root) {
try {
const raw = readFileSync(join(root, RECEIPT), "utf8").trim();
if (/^[0-9a-f]{64}$/.test(raw)) {
return { version: 1, digest: raw, alwaysOnBytes: null };
}
const receipt = JSON.parse(raw);
if (receipt?.version !== 2
|| !/^[0-9a-f]{64}$/.test(receipt.digest)
|| !Number.isInteger(receipt.alwaysOnBytes)
|| receipt.alwaysOnBytes < 0) return null;
const state = {
version: 2,
digest: receipt.digest,
alwaysOnBytes: receipt.alwaysOnBytes,
sweepType: SWEEP_TYPES.includes