← back to Approval Verifier
ledger.mjs
136 lines
#!/usr/bin/env node
// approvals ledger — the single canonical source of truth for "following approvals".
// DTD-A (2026-06-24, 3/3): one local approvals.json that every tool reads/writes;
// CNCP renders a board from it; the yolo loop reads status; verify.mjs writes
// landed/failed back. Collapses the 4 drifting state sources into one file.
//
// Usage:
// node ledger.mjs # build/refresh approvals.json, then print the board
// node ledger.mjs --build # build/refresh only
// node ledger.mjs --board # print board from existing approvals.json (no rebuild)
// node ledger.mjs --json # dump approvals.json to stdout
// node ledger.mjs --status drafted,stale # board filtered to statuses
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join, basename } from "node:path";
const HOME = homedir();
const QUEUE = join(HOME, ".claude/yolo-queue");
const LEDGER = join(QUEUE, "approvals.json");
const DAY = 86400000;
const STALE_DAYS = 21;
const args = process.argv.slice(2);
const flag = (n) => args.includes(n);
const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] ? args[i + 1] : d; };
// directory → base lifecycle status
const DIRS = {
"pending-approval": "drafted",
"tasks": "approved", // promoted into the run queue
"done": "executed", // processed — needs verify to become 'landed'
"failed": "failed",
};
// --- classification heuristics (title + body) ---
const CATS = [
["fleet", /\bfleet|registry|watchdog|pm2|launchd|reconcile|keepalive|keep-alive|zombie|dark\b/i],
["secrets", /\bsecret|credential|\bcred\b|rotate|token|password|\.env\b/i],
["shopify", /\bshopify|theme|liquid|metafield|\bsku\b|product|variant|cadence|storefront\b/i],
["dns", /\bdns|domain|cloudflare|nameserver|\bns\b|certbot|ssl\b/i],
["email", /\bemail|purelymail|gmail|george|\bmx\b|dkim|dmarc|mailbox\b/i],
["catalog", /\bcatalog|scrape|vendor|import|dedup|enrich\b/i],
["cncp", /\bcncp|approval|dashboard\b/i],
];
// Explicit safety markers the memos use in their TITLES — strong low-risk signal.
const SAFE = /\bREAD-?ONLY|DRAFTS? ONLY|propose|proposal|\baudit\b|dry.?run|report only|never (send|push|publish|delete)|SAMPLE-GATE|recommend\b/i;
const HIGH = /\b(delete|deleting|\brm\b|\bdrop\b|rotate|\bprod\b|dw_unified|sudo|publish|push to (live|main)|send to|mailer|blast|nameserver|customer-facing|irreversible|\bforce\b)\b/i;
const MED = /\b(install|launchctl|bootstrap|bootout|restart|pm2 (start|stop|delete|restart)|activate|enable|migrate|\bwire\b|stop\b|archive|retire)\b/i;
const REVERSIBLE_BAD = /\b(delete|deleting|\brm\b|\bdrop\b|rotate|publish|push to (live|main)|send to|mailer|blast)\b/i;
// classify on the TITLE (curated + concise) — body is too noisy and over-flags.
function classify(title, body) {
const category = (CATS.find(([, re]) => re.test(title)) || ["other"])[0];
const safe = SAFE.test(title);
const risk = safe ? "low" : HIGH.test(title) ? "high" : MED.test(title) ? "med" : "low";
const reversible = safe || !REVERSIBLE_BAD.test(title);
const verifyM = body.match(/<!--\s*VERIFY:\s*([^]*?)-->/) || body.match(/```verify\s*([^]*?)```/);
const verify = verifyM ? verifyM[1].trim().split("\n")[0].trim() : null;
return { category, risk, reversible, verify };
}
function scanDir(dir, baseStatus) {
const p = join(QUEUE, dir);
if (!existsSync(p)) return [];
return readdirSync(p).filter(f => f.endsWith(".md")).map(f => {
const fp = join(p, f);
const body = readFileSync(fp, "utf8");
const title = (body.match(/^#[^#].*/m) || [f])[0].replace(/^#+\s*/, "").slice(0, 100);
const mtime = statSync(fp).mtime;
const age_days = Math.floor((Date.now() - mtime.getTime()) / DAY);
const { category, risk, reversible, verify } = classify(title, body);
let status = baseStatus;
if (status === "drafted" && age_days > STALE_DAYS) status = "stale";
return {
id: basename(f, ".md"), title, category, risk, reversible,
created: mtime.toISOString(), age_days, verify,
status, location: dir, last_checked: null, evidence: [],
};
});
}
function build() {
// preserve prior verify results (last_checked/evidence/landed) when refreshing
let prior = {};
if (existsSync(LEDGER)) { try { for (const e of JSON.parse(readFileSync(LEDGER, "utf8")).entries) prior[e.id] = e; } catch {} }
const entries = [];
for (const [dir, st] of Object.entries(DIRS)) {
for (const e of scanDir(dir, st)) {
const p = prior[e.id];
if (p && p.last_checked) { e.last_checked = p.last_checked; e.evidence = p.evidence;
if (["landed", "failed", "no-op"].includes(p.status) && e.status === "executed") e.status = p.status; }
entries.push(e);
}
}
const ledger = { generated: new Date().toISOString(), source: QUEUE.replace(HOME, "~"),
counts: entries.reduce((a, e) => (a[e.status] = (a[e.status] || 0) + 1, a), {}), entries };
writeFileSync(LEDGER, JSON.stringify(ledger, null, 2));
return ledger;
}
function board(ledger, statuses) {
const C = { drafted: "\x1b[37m", stale: "\x1b[90m", approved: "\x1b[36m", executed: "\x1b[36m",
landed: "\x1b[32m", failed: "\x1b[31m", "no-op": "\x1b[33m" };
const R = { high: "\x1b[31mHIGH\x1b[0m", med: "\x1b[33mmed \x1b[0m", low: "\x1b[32mlow \x1b[0m" };
const NC = "\x1b[0m";
// default view = the ACTIONABLE backlog (awaiting Steve), not the done/ history
const DEFAULT = ["drafted", "stale"];
const want = statuses?.length ? statuses : DEFAULT;
let rows = ledger.entries.filter(e => want.includes(e.status));
const order = { high: 0, med: 1, low: 2 };
rows.sort((a, b) => order[a.risk] - order[b.risk] || b.age_days - a.age_days);
console.log(`\n📋 approvals board — ${ledger.entries.length} items (${ledger.generated.slice(0,16)})\n`);
console.log(` ${"status".padEnd(9)} ${"risk".padEnd(4)} rev vfy ${"cat".padEnd(8)} age title`);
console.log(" " + "─".repeat(86));
for (const e of rows.slice(0, 60)) {
console.log(` ${C[e.status]||""}${e.status.padEnd(9)}${NC} ${R[e.risk]} ${e.reversible?" ✓ ":" ✗ "} ${e.verify?"✓":"·"} ${e.category.padEnd(8)} ${String(e.age_days).padStart(3)}d ${e.title.slice(0,46)}`);
}
console.log("\n── by status ──");
for (const [k, v] of Object.entries(ledger.counts).sort()) console.log(` ${C[k]||""}${k.padEnd(9)}${NC} ${v}`);
// the safe-to-batch bucket
const safe = ledger.entries.filter(e => ["drafted"].includes(e.status) && e.risk === "low" && e.reversible);
const verifiable = ledger.entries.filter(e => e.verify).length;
console.log(`\n ✅ safe-batch (drafted · low-risk · reversible): ${safe.length}`);
console.log(` 🔍 machine-verifiable (has VERIFY line): ${verifiable}/${ledger.entries.length} ← the rest need a VERIFY line or manual check`);
console.log(`\n rev=reversible vfy=has VERIFY line. Approve the safe-batch bucket first; never "approve all".`);
}
// --- run ---
let ledger;
if (flag("--board") && existsSync(LEDGER)) ledger = JSON.parse(readFileSync(LEDGER, "utf8"));
else ledger = build();
if (flag("--json")) { console.log(JSON.stringify(ledger, null, 2)); process.exit(0); }
if (flag("--build")) { console.log(`built ${LEDGER.replace(HOME, "~")} — ${ledger.entries.length} entries`); process.exit(0); }
board(ledger, (opt("--status", "") || "").split(",").map(s => s.trim()).filter(Boolean));