← back to Approval Verifier
verify.mjs
205 lines
#!/usr/bin/env node
// approval-verifier — did an approved yolo-queue item actually LAND end-to-end?
//
// The yolo-queue tracks that a task was *processed* (done/ vs failed/), but NOT
// that its real-world EFFECT landed (a memo can be prose-only, self-gate, or
// land "cancelled"). This tool reads memo .md files, derives machine-checkable
// assertions, runs them, and reports per item:
//
// LANDED explicit VERIFY checks all passed (high conf)
// FAILED explicit VERIFY checks present, some failed (high conf)
// LIKELY-LANDED auto-observed resource state matches the memo's action verb
// LIKELY-NOOP auto-observed state contradicts the action verb (didn't take)
// OBSERVED resources found, direction ambiguous — evidence printed
// UNVERIFIABLE no machine-checkable signal — surfaced, never silently "done"
//
// Verification signal priority:
// 1. Explicit <!-- VERIFY: <shell test> --> lines, or a ```verify fenced block.
// Each line is a shell command; exit 0 = that check passed. AUTHORITATIVE.
// 2. Auto-inference (best-effort, no false "done"): launchd labels (com.steve.*),
// pm2 process names, and explicitly-claimed file paths — checked against live
// state, with the memo's nearby action verb used to infer expected direction.
// 3. none → UNVERIFIABLE.
//
// Usage:
// node verify.mjs [--dir <path>] [--memo <file>] [--json] [--status LANDED,FAILED]
// default --dir = ~/.claude/yolo-queue/pending-approval/done (approved+processed)
// --pending scan ~/.claude/yolo-queue/pending-approval (the live drafts)
// --done also scan ~/.claude/yolo-queue/done
// Writes a JSONL ledger to ~/.claude/yolo-queue/.approval-verify-ledger.jsonl
import { readFileSync, writeFileSync, readdirSync, existsSync, appendFileSync, statSync } from "node:fs";
import { execSync } from "node:child_process";
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, ".approval-verify-ledger.jsonl");
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; };
const sh = (cmd, timeout = 5000) => {
try { return { ok: true, out: execSync(cmd, { timeout, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() }; }
catch (e) { return { ok: false, out: (e.stdout || "").toString().trim() }; }
};
// --- live state gathered once ---
const launchdLoaded = (() => {
const r = sh("launchctl list", 4000);
const set = new Set();
if (r.ok) for (const line of r.out.split("\n")) { const c = line.split(/\s+/); if (c[2]) set.add(c[2]); }
return set; // labels currently loaded
})();
const pm2 = (() => {
const r = sh("pm2 jlist 2>/dev/null", 6000);
const map = new Map();
if (r.ok && r.out) { try { for (const p of JSON.parse(r.out)) map.set(p.name, p.pm2_env?.status || "?"); } catch {} }
return map; // name -> status (online/stopped/...)
})();
// --- per-memo signal extraction ---
const ACTION_UP = /\b(install|installed|load|loaded|activate|activated|start|started|restart|enable|enabled|create|created|wrote|written|add|added|publish|published)\b/i;
const ACTION_DOWN = /\b(stop|stopped|bootout|boot out|retire|retired|remove|removed|delete|deleted|disable|disabled|archive|archived|decommission|kill|killed|unload|unloaded)\b/i;
function nearbyVerb(text, idx) {
const win = text.slice(Math.max(0, idx - 60), idx + 20);
if (ACTION_DOWN.test(win)) return "down";
if (ACTION_UP.test(win)) return "up";
return null;
}
function extractExplicit(text) {
const checks = [];
for (const m of text.matchAll(/<!--\s*VERIFY:\s*([^]*?)-->/g)) checks.push(m[1].trim());
for (const m of text.matchAll(/```verify\s*([^]*?)```/g))
for (const line of m[1].split("\n").map(s => s.trim()).filter(Boolean)) checks.push(line);
return checks.filter(Boolean);
}
function extractResources(text) {
const res = [];
for (const m of text.matchAll(/\bcom\.steve\.[a-z0-9._-]+/gi)) {
const label = m[0].replace(/\.plist$/i, ""); const dir = nearbyVerb(text, m.index);
res.push({ kind: "launchd", id: label, dir, observed: launchdLoaded.has(label) ? "loaded" : "not-loaded" });
}
for (const [name, status] of pm2) {
const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
const idx = text.search(re);
if (idx >= 0) res.push({ kind: "pm2", id: name, dir: nearbyVerb(text, idx), observed: status });
}
// explicitly-claimed created/written file paths only (avoid every referenced path)
for (const m of text.matchAll(/\b(?:wrote|created|written|saved|added)\b[^\n]*?[`"]?((?:~|\/Users\/stevestudio2)\/[^\s`"')]+)/gi)) {
let p = m[1].replace(/^~/, HOME);
res.push({ kind: "file", id: m[1], dir: "up", observed: existsSync(p) ? "exists" : "absent" });
}
// dedup by kind+id
const seen = new Set();
return res.filter(r => { const k = r.kind + r.id; if (seen.has(k)) return false; seen.add(k); return true; });
}
function classifyResource(r) {
const up = r.observed === "loaded" || r.observed === "online" || r.observed === "exists";
const down = r.observed === "not-loaded" || r.observed === "stopped" || r.observed === "absent";
if (!r.dir) return "ambiguous";
if (r.dir === "up") return up ? "match" : "contradict";
if (r.dir === "down") return down ? "match" : "contradict";
return "ambiguous";
}
function verifyMemo(file) {
const text = readFileSync(file, "utf8");
const explicit = extractExplicit(text);
const evidence = [];
if (explicit.length) {
let pass = 0;
for (const cmd of explicit) {
const r = sh(cmd);
evidence.push(`${r.ok ? "✓" : "✗"} ${cmd}`);
if (r.ok) pass++;
}
return { status: pass === explicit.length ? "LANDED" : (pass === 0 ? "NO-OP" : "FAILED"),
conf: "high", evidence };
}
const res = extractResources(text);
if (!res.length) return { status: "UNVERIFIABLE", conf: "none", evidence: ["no VERIFY block, no checkable resource"] };
const verdicts = res.map(r => { const c = classifyResource(r); evidence.push(`${r.kind}:${r.id} → ${r.observed}${r.dir ? ` (expected ${r.dir})` : ""} [${c}]`); return c; });
if (verdicts.every(v => v === "match")) return { status: "LIKELY-LANDED", conf: "med", evidence };
if (verdicts.some(v => v === "contradict")) return { status: "LIKELY-NOOP", conf: "med", evidence };
return { status: "OBSERVED", conf: "low", evidence };
}
// --- run ---
let dir = opt("--dir", null);
if (!dir) dir = flag("--pending") ? join(QUEUE, "pending-approval")
: flag("--done") ? join(QUEUE, "done")
: join(QUEUE, "pending-approval/done");
const single = opt("--memo", null);
const wantJson = flag("--json");
const statusFilter = (opt("--status", "") || "").split(",").map(s => s.trim()).filter(Boolean);
let files = [];
if (single) files = [single];
else if (existsSync(dir)) files = readdirSync(dir).filter(f => f.endsWith(".md")).map(f => join(dir, f)).sort();
else { console.error(`dir not found: ${dir}`); process.exit(1); }
const COLOR = { LANDED: "\x1b[32m", "LIKELY-LANDED": "\x1b[92m", OBSERVED: "\x1b[36m",
UNVERIFIABLE: "\x1b[90m", "LIKELY-NOOP": "\x1b[33m", "NO-OP": "\x1b[33m", FAILED: "\x1b[31m" };
const NC = "\x1b[0m";
const tally = {};
const rows = [];
for (const f of files) {
const r = verifyMemo(f);
if (statusFilter.length && !statusFilter.includes(r.status)) continue;
tally[r.status] = (tally[r.status] || 0) + 1;
const name = basename(f).replace(/\.md$/, "");
rows.push({ name, ...r, file: f });
}
if (wantJson) {
console.log(JSON.stringify({ dir, scanned: files.length, tally, rows }, null, 2));
} else {
console.log(`\napproval-verifier — ${rows.length} item(s) in ${dir.replace(HOME, "~")}\n`);
for (const r of rows) {
console.log(`${COLOR[r.status] || ""}${r.status.padEnd(13)}${NC} ${r.conf.padEnd(4)} ${r.name.slice(0, 60)}`);
for (const e of r.evidence.slice(0, 4)) console.log(` ${e}`);
}
console.log("\n── summary ──");
for (const [k, v] of Object.entries(tally).sort()) console.log(` ${COLOR[k] || ""}${k.padEnd(13)}${NC} ${v}`);
console.log(`\nLANDED/LIKELY-LANDED = effect confirmed. NO-OP/LIKELY-NOOP/FAILED = did NOT land.`);
console.log(`UNVERIFIABLE/OBSERVED = needs a VERIFY block or manual check (add <!-- VERIFY: <shell test> --> to the memo).`);
}
// append run ledger (skip in single/json ad-hoc mode)
if (!single && !wantJson) {
const ts = new Date().toISOString();
try { appendFileSync(LEDGER, rows.map(r => JSON.stringify({ ts, name: r.name, status: r.status, conf: r.conf })).join("\n") + "\n"); } catch {}
}
// --write-ledger: merge verify results back into the canonical approvals.json (closes the loop).
// Only flips POST-approval entries (executed/approved) — never a 'drafted' item.
if (flag("--write-ledger")) {
const APPROVALS = join(QUEUE, "approvals.json");
const MAP = { LANDED: "landed", "LIKELY-LANDED": "landed", FAILED: "failed", "NO-OP": "no-op", "LIKELY-NOOP": "no-op" };
if (existsSync(APPROVALS)) {
try {
const L = JSON.parse(readFileSync(APPROVALS, "utf8"));
const byId = new Map(rows.map(r => [r.name, r]));
let n = 0;
for (const e of L.entries) {
const r = byId.get(e.id); if (!r) continue;
e.last_checked = new Date().toISOString(); e.evidence = r.evidence;
if (MAP[r.status] && ["executed", "approved"].includes(e.status)) { e.status = MAP[r.status]; n++; }
}
L.counts = L.entries.reduce((a, e) => (a[e.status] = (a[e.status] || 0) + 1, a), {});
writeFileSync(APPROVALS, JSON.stringify(L, null, 2));
console.log(`\n↪ wrote verify results into approvals.json (${n} status transitions)`);
} catch (e) { console.error("ledger write failed:", e.message); }
} else console.error("approvals.json not found — run `node ledger.mjs --build` first");
}