← back to Answer Cockpit
lib/memo.js
160 lines
'use strict';
// memo.js — pending-approval reader + decide/undo, MIRRORING
// ~/Projects/approvals-viewer/server.js:6-11,61-70 semantics:
// approve → mv to _approved/ ; block(reject) → mv to _rejected/ ; revise → note only
// every decision appended to _decisions.jsonl {ts,file,decision,note}
// PLUS a reversible-ledger line in executed-reversible/ledger.jsonl with undo_cmd.
// HARD RULE: this module NEVER reads-to-execute or executes ```ungate-run``` blocks
// or `! ` paste lines. It only classifies text. Copy is the client's only verb.
const fs = require('fs');
const path = require('path');
const HOME = process.env.HOME || require('os').homedir();
const QUEUE = path.join(HOME, '.claude/yolo-queue/pending-approval');
const APPROVED = path.join(QUEUE, '_approved');
const REJECTED = path.join(QUEUE, '_rejected');
const DECISIONS = path.join(QUEUE, '_decisions.jsonl');
const LEDGER = path.join(HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl');
const FILE_RE = /^[\w.\-]+\.md$/;
const TK_RE = /TK-\d{2,6}/;
const REC_RE = /^\*\*(APPROVE|REVISE|BLOCK)\*\*/gm;
const PASTE_RE = /^!\s+.+$/gm;
function safeFile(file) {
// top-level, no `_` prefix, no traversal
return typeof file === 'string' && FILE_RE.test(file) && !file.startsWith('_') && !file.includes('/') && !file.includes('..');
}
function parse(file, body, st) {
const title = (body.match(/^#\s+(.+)$/m) || [, file.replace(/\.md$/, '')])[1].slice(0, 160);
let ticket = (file.match(TK_RE) || [])[0] || null;
if (!ticket) ticket = (body.match(TK_RE) || [])[0] || null;
let recommendation = null;
let m; REC_RE.lastIndex = 0;
while ((m = REC_RE.exec(body)) !== null) recommendation = m[1]; // last one wins
const pasteLines = (body.match(PASTE_RE) || []).map((l) => l.trim()).slice(0, 20);
const hasUngateRun = /```ungate-run/.test(body);
// createdAt: birthtime when available (macOS), else mtime
const created = st.birthtime && st.birthtime.getTime() > 0 ? st.birthtime : st.mtime;
return {
file,
path: path.join(QUEUE, file),
title,
ticket,
recommendation,
hasUngateRun,
pasteLines,
createdAt: created.toISOString(),
mtime: st.mtime.toISOString(),
size: st.size,
excerpt: body.slice(0, 1500),
};
}
function list() {
let names = [];
try { names = fs.readdirSync(QUEUE); } catch { return []; }
const out = [];
for (const f of names) {
if (!f.endsWith('.md') || f.startsWith('_')) continue;
const fp = path.join(QUEUE, f);
let st; try { st = fs.statSync(fp); } catch { continue; }
if (!st.isFile()) continue;
let body; try { body = fs.readFileSync(fp, 'utf8'); } catch { continue; }
out.push(parse(f, body, st));
}
out.sort((a, b) => new Date(b.mtime) - new Date(a.mtime));
return out;
}
function byTicket(memos) {
const map = new Map();
for (const m of memos) {
if (!m.ticket) continue;
if (!map.has(m.ticket)) map.set(m.ticket, []);
map.get(m.ticket).push(m);
}
return map;
}
function noClobber(dir, file) {
let dest = path.join(dir, file);
if (fs.existsSync(dest)) dest = path.join(dir, file.replace(/\.md$/, '') + '.' + Date.now() + '.md');
return dest;
}
function appendJsonl(fp, obj) {
try { fs.mkdirSync(path.dirname(fp), { recursive: true }); } catch {}
fs.appendFileSync(fp, JSON.stringify(obj) + '\n');
}
/**
* decide({file, decision, note}) → {ok, moved, dest, undo_cmd}
* decision ∈ approve | block | revise. `reject` accepted as alias of block.
*/
function decide({ file, decision, note }) {
if (!safeFile(file)) throw new Error('bad file');
if (decision === 'reject') decision = 'block';
if (!['approve', 'block', 'revise'].includes(decision)) throw new Error('bad decision');
const src = path.join(QUEUE, file);
if (!fs.existsSync(src)) throw new Error('gone');
const st = fs.statSync(src); if (!st.isFile()) throw new Error('not a file');
for (const d of [APPROVED, REJECTED]) { try { fs.mkdirSync(d, { recursive: true }); } catch {} }
const ticket = (file.match(TK_RE) || [])[0] || null;
const ts = new Date().toISOString();
let dest = null, undo_cmd = null;
if (decision === 'approve') dest = noClobber(APPROVED, file);
else if (decision === 'block') dest = noClobber(REJECTED, file);
if (dest) {
fs.renameSync(src, dest);
undo_cmd = `mv ${JSON.stringify(dest)} ${JSON.stringify(src)}`;
}
const cleanNote = note ? String(note).slice(0, 500) : '';
appendJsonl(DECISIONS, { ts, file, decision, note: cleanNote, via: 'answer-cockpit', dest: dest ? path.basename(dest) : null });
appendJsonl(LEDGER, {
ts, agent: 'answer-cockpit', ticket: ticket || 'TK-11793',
action: `memo ${decision}: ${file}` + (dest ? ` → ${path.relative(QUEUE, dest)}` : ' (note only)'),
blast_radius: 1,
undo_cmd: undo_cmd || `# revise is note-only; nothing to undo (see ${DECISIONS})`,
verify: dest ? `test -f ${JSON.stringify(dest)}` : `tail -1 ${DECISIONS}`,
note: cleanNote || undefined,
});
return { ok: true, decision, moved: !!dest, dest, undo_cmd, ticket };
}
/** undo({file, decision}) — mirror of :9795 /api/undo. decision ∈ approve|block (reject alias). */
function undo({ file, decision }) {
if (!safeFile(file)) throw new Error('bad file');
if (decision === 'reject') decision = 'block';
if (!['approve', 'block'].includes(decision)) throw new Error('bad decision');
const from = decision === 'approve' ? APPROVED : REJECTED;
// exact name first, else the newest no-clobber-suffixed sibling
let src = path.join(from, file);
if (!fs.existsSync(src)) {
const base = file.replace(/\.md$/, '');
let cands = [];
try { cands = fs.readdirSync(from).filter((n) => n.startsWith(base + '.') && n.endsWith('.md')); } catch {}
cands.sort().reverse();
if (cands.length) src = path.join(from, cands[0]);
}
const ts = new Date().toISOString();
let restored = false, dest = null;
if (fs.existsSync(src)) {
dest = noClobber(QUEUE, file);
fs.renameSync(src, dest);
restored = true;
}
appendJsonl(DECISIONS, { ts, file, decision: 'undo:' + decision, via: 'answer-cockpit', restored });
appendJsonl(LEDGER, {
ts, agent: 'answer-cockpit', ticket: (file.match(TK_RE) || [])[0] || 'TK-11793',
action: `memo undo:${decision}: ${file}` + (restored ? ' restored to queue' : ' (nothing to restore)'),
blast_radius: 1,
undo_cmd: restored ? `mv ${JSON.stringify(dest)} ${JSON.stringify(src)}` : '# no-op',
verify: restored ? `test -f ${JSON.stringify(dest)}` : '# no-op',
});
return { ok: true, restored, dest };
}
module.exports = { list, byTicket, decide, undo, safeFile, QUEUE, APPROVED, REJECTED, DECISIONS, LEDGER };