← 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 };