← back to Approvals Viewer

freshness-guard.js

451 lines

'use strict';
// freshness-guard.js — READ-ONLY, ADVISORY approval-time freshness re-check.
//
// TK-11685 (governance): an approval was granted at 18:26Z for a delete list a
// sibling lane had already invalidated at 15:43Z the SAME day, because nothing
// joined a fresh enumeration against concurrent findings before the memo reached
// Steve. This module closes that gap.
//
// Given a gated memo that carries an ENUMERATED TARGET SET (Shopify variant/product
// ids, DW-SKUs, GMC offer-ids, /products/ handles), it cross-references EVERY newer
// concurrent finding — sibling & filed memos across the whole queue, the
// executed-reversible ledger (already-done), and the decision logs — and flags any
// target that a finding NEWER than the memo's own as-of has since touched
// (executed / superseded / re-classified). It answers ONE question:
//
//     "N of M targets are now stale — do NOT execute this delete list blind."
//
// HARD RAILS (never violated by this file):
//   * READ-ONLY. It only reads files. It NEVER writes, moves, edits, approves,
//     rejects, or executes any memo, delete, or target action.
//   * ADVISORY. It annotates/warns. The human still decides. A STALE verdict is a
//     "re-enumerate before you fire" nudge, not an auto-reject.
//   * No network, no shell-out, no eval. It does NOT run the memo's re-verify
//     command (that would be arbitrary code from a parsed file) — it SURFACES the
//     command for the human to run.
//
// Verdict vocabulary (fleet-health-rollup compatible): PASS / WARN / FAIL.
//   FRESH      -> PASS  (0 targets touched by newer findings)
//   STALE      -> WARN  (>=1 target touched by a newer finding — advisory, human decides)
//   NO_TARGETS -> PASS  (memo carries no enumerated target set; guard is N/A here)
//   NOT_MEASURED-> WARN (memo unreadable / no parseable as-of time — never a false PASS,
//                        per the "an unmeasured input is never PASS" rule)

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');

// Subdirs of the queue whose memos count as concurrent findings. A memo that has
// been FILED (moved out of pending-approval into one of the "resolved" dirs) is the
// strongest paper signal that its targets were dealt with.
const FILED_DIRS = ['_done', '_resolved', '_superseded', '_approved', '_rejected', '_held', '_parked'];

function defaultConfig() {
  return {
    // the memo under review lives here; siblings here are OPEN concurrent findings
    queueDir: QUEUE,
    // memos here are FILED concurrent findings (already dispositioned)
    filedDirs: FILED_DIRS.map((d) => path.join(QUEUE, d)),
    // the append-only ledger of already-executed reversible actions (already-done)
    ledgerPaths: [path.join(HOME, '.claude/yolo-queue/executed-reversible/ledger.jsonl')],
    // decision logs — a related memo already approved/rejected
    decisionLogs: [
      path.join(QUEUE, '_decisions.jsonl'),
      path.join(QUEUE, '_review-decisions.jsonl'),
    ],
    // recurse one level into filed dirs (they nest by date/batch sometimes)
    recurseDepth: 2,
  };
}

// ---------------------------------------------------------------------------
// Anchor extraction — the JOIN keys. High-precision identifiers only, so a shared
// anchor between a memo and a newer finding is meaningful, not a coincidence.
// ---------------------------------------------------------------------------
// Only UNAMBIGUOUS product-target identifiers count as anchors. Precision matters:
// a shared anchor between a memo and a newer finding must mean "same target", not a
// coincidental infra name. So we deliberately do NOT anchor on backticked handle-ish
// tokens (they catch skill/canary names — fleet-health-rollup, dw-backup-canary,
// git-filter-repo — and the store domain, which are prose, not delete targets).
const RE_LONG_ID = /\b\d{11,}\b/g; // Shopify variant/product ids, bare GMC offer ids
const RE_DWSKU = /\bDW[A-Z]{2,4}-\d{3,}\b/gi; // DW SKU tokens
const RE_OFFER = /\bshopify_[A-Za-z]{2}_\d+_\d+\b/g; // GMC structured offer ids
const RE_HANDLE_CTX = /(?:\/products\/|[?&]variant=|handle[=:]\s*)([a-z0-9][a-z0-9-]{3,})/g; // handle in a target context

// Handles that are infrastructure, not products — never a delete target.
const HANDLE_STOPWORDS = new Set([
  'designer-laboratory-sandbox',
  'fleet-health-rollup',
  'dw-backup-canary',
  'git-filter-repo',
]);

function extractAnchors(text) {
  const anchors = new Map(); // normalizedAnchor -> type
  const add = (raw, type) => {
    if (!raw) return;
    const key = String(raw).toUpperCase();
    if (!anchors.has(key)) anchors.set(key, type);
  };
  let m;
  RE_LONG_ID.lastIndex = 0;
  while ((m = RE_LONG_ID.exec(text))) add(m[0], 'id');
  RE_DWSKU.lastIndex = 0;
  while ((m = RE_DWSKU.exec(text))) add(m[0], 'sku');
  RE_OFFER.lastIndex = 0;
  while ((m = RE_OFFER.exec(text))) add(m[0], 'offer');
  RE_HANDLE_CTX.lastIndex = 0;
  while ((m = RE_HANDLE_CTX.exec(text))) {
    if (!HANDLE_STOPWORDS.has(m[1].toLowerCase())) add(m[1], 'handle');
  }
  return anchors;
}

// ---------------------------------------------------------------------------
// Timestamps. A memo's "as-of" is the most recent moment its content was authored
// or re-verified (an addendum re-verify counts). A finding invalidates only if it
// is NEWER than that — findings the memo already accounted for do not fire.
// ---------------------------------------------------------------------------
const RE_ISO = /\b(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\b/g;
const RE_DATE_ONLY = /\b(\d{4}-\d{2}-\d{2})\b/g;

function parseIsoTimes(text) {
  const out = [];
  let m;
  RE_ISO.lastIndex = 0;
  while ((m = RE_ISO.exec(text))) {
    const d = new Date(m[1]);
    if (!isNaN(+d)) out.push(d);
  }
  return out;
}

const RE_DRAFTED = /(?:\*\*)?(?:Drafted|Written|Date|Authored|Created)(?:\*\*)?[:\s]+.*?(\d{4}-\d{2}-\d{2})/i;

// Returns { asOf: Date|null, precise: bool }. precise=false means we only had a
// date (no time) — the caller treats that as NOT fully measured.
//
// CLAMP TO now: a timestamp in the FUTURE is never an authoring/enumeration event —
// it is a scheduled/expiry reference (e.g. a GMC googleExpirationDate 2026-10-11).
// If we let a future date become the as-of, every real finding looks OLDER than the
// memo and the memo goes falsely FRESH. So we only consider timestamps <= now.
function memoAsOf(body, filenameDate, now) {
  now = now || new Date();
  const times = parseIsoTimes(body).filter((t) => t <= now);
  if (times.length) {
    return { asOf: new Date(Math.max(...times.map(Number))), precise: true };
  }
  // date-only: prefer the memo's own DRAFTED/WRITTEN date (its enumeration date),
  // then the filename date, then the earliest date mentioned. Using the authored
  // date (not the oldest context date) avoids flagging findings the memo already
  // accounted for; the filename/earliest fallbacks still err toward flagging.
  const dr = RE_DRAFTED.exec(body);
  if (dr) {
    const d = new Date(dr[1] + 'T00:00:00Z');
    if (!isNaN(+d) && d <= now) return { asOf: d, precise: false };
  }
  if (filenameDate) {
    const d = new Date(filenameDate + 'T00:00:00Z');
    if (!isNaN(+d) && d <= now) return { asOf: d, precise: false };
  }
  const dm = [];
  let m;
  RE_DATE_ONLY.lastIndex = 0;
  while ((m = RE_DATE_ONLY.exec(body))) {
    const d = new Date(m[1] + 'T00:00:00Z');
    if (!isNaN(+d) && d <= now) dm.push(d);
  }
  if (dm.length) return { asOf: new Date(Math.min(...dm.map(Number))), precise: false };
  return { asOf: null, precise: false };
}

function filenameDate(name) {
  const m = /(\d{4}-\d{2}-\d{2})/.exec(name);
  return m ? m[1] : null;
}

function ticketOf(name, body) {
  const m = /(TK-\d+)/.exec(name) || /(TK-\d+)/.exec(body || '');
  return m ? m[1] : null;
}

// ---------------------------------------------------------------------------
// Gather concurrent findings from every configured surface.
// ---------------------------------------------------------------------------
function walkMemoFiles(dir, depth) {
  const out = [];
  let ents;
  try {
    ents = fs.readdirSync(dir, { withFileTypes: true });
  } catch {
    return out;
  }
  for (const e of ents) {
    const fp = path.join(dir, e.name);
    if (e.isFile() && e.name.endsWith('.md')) out.push(fp);
    else if (e.isDirectory() && depth > 0 && !e.name.startsWith('.')) {
      out.push(...walkMemoFiles(fp, depth - 1));
    }
  }
  return out;
}

function gatherFindings(cfg, selfPath, now) {
  now = now || new Date();
  const findings = [];
  const selfReal = selfPath ? path.resolve(selfPath) : null;

  const addMemo = (fp, kind) => {
    if (selfReal && path.resolve(fp) === selfReal) return; // never self
    let body;
    try {
      body = fs.readFileSync(fp, 'utf8');
    } catch {
      return;
    }
    const base = path.basename(fp);
    const { asOf } = memoAsOf(body, filenameDate(base), now);
    if (!asOf) return;
    findings.push({
      source: base,
      path: fp,
      kind, // 'sibling' (open) | 'filed'
      ts: asOf,
      anchors: extractAnchors(body),
      ticket: ticketOf(base, body),
    });
  };

  // open siblings
  for (const fp of walkMemoFiles(cfg.queueDir, 0)) addMemo(fp, 'sibling');
  // filed memos
  for (const d of cfg.filedDirs) for (const fp of walkMemoFiles(d, cfg.recurseDepth)) addMemo(fp, 'filed');

  // executed-reversible ledger (already-done — strongest invalidation)
  for (const lp of cfg.ledgerPaths) {
    let raw;
    try {
      raw = fs.readFileSync(lp, 'utf8');
    } catch {
      continue;
    }
    for (const line of raw.split('\n')) {
      if (!line.trim()) continue;
      let row;
      try {
        row = JSON.parse(line);
      } catch {
        continue;
      }
      const ts = new Date(row.ts);
      if (isNaN(+ts) || ts > now) continue; // skip unparseable / future-dated
      const text = [row.action, row.verify, row.undo_cmd, row.ticket].filter(Boolean).join(' ');
      findings.push({
        source: 'executed-reversible/ledger.jsonl',
        path: lp,
        kind: 'executed',
        ts,
        anchors: extractAnchors(text),
        ticket: row.ticket || null,
        agent: row.agent || null,
        snippet: String(row.action || '').slice(0, 160),
      });
    }
  }

  // decision logs — a related memo already approved/rejected
  for (const dl of cfg.decisionLogs) {
    let raw;
    try {
      raw = fs.readFileSync(dl, 'utf8');
    } catch {
      continue;
    }
    for (const line of raw.split('\n')) {
      if (!line.trim()) continue;
      let row;
      try {
        row = JSON.parse(line);
      } catch {
        continue;
      }
      const ts = new Date(row.ts);
      if (isNaN(+ts) || ts > now || !row.file) continue;
      findings.push({
        source: path.basename(dl),
        path: dl,
        kind: 'decision',
        ts,
        anchors: extractAnchors(row.file),
        ticket: ticketOf(row.file, ''),
        snippet: `${row.decision} ${row.file}`.slice(0, 160),
      });
    }
  }

  return findings;
}

// ---------------------------------------------------------------------------
// The check. Read a memo, extract its target set + as-of, join against newer
// findings, and return an advisory report.
// ---------------------------------------------------------------------------
const KIND_SEVERITY = { executed: 'high', filed: 'high', sibling: 'medium', decision: 'medium' };
const KIND_REASON = {
  executed: 'ALREADY EXECUTED per executed-reversible ledger',
  filed: 'target memo since FILED (resolved/superseded/decided)',
  sibling: 'another OPEN memo also targets this',
  decision: 'a related memo was already decided',
};

function findReverifyHint(body) {
  // surface the memo's own re-enumeration/re-verify command as the recommended
  // manual fresh-enumeration step (we never run it ourselves).
  const lines = body.split('\n');
  for (let i = 0; i < lines.length; i++) {
    if (/re-?verify|re-?enumerate|re-?run the canary|check\.mjs/i.test(lines[i])) {
      // prefer a fenced command near the hint
      for (let j = i; j < Math.min(i + 4, lines.length); j++) {
        const cm = /`([^`]*\b(?:node|check\.mjs)[^`]*)`/.exec(lines[j]);
        if (cm) return cm[1].trim();
      }
      return lines[i].replace(/^[>#\s*-]+/, '').trim().slice(0, 200);
    }
  }
  return null;
}

// input: memo file path OR { file, body }. cfg optional (defaults to real queue).
function checkMemo(input, cfg) {
  cfg = Object.assign(defaultConfig(), cfg || {});
  const now = cfg.now ? new Date(cfg.now) : new Date();
  let file, body, fp;
  if (typeof input === 'string') {
    fp = input;
    file = path.basename(input);
    try {
      body = fs.readFileSync(input, 'utf8');
    } catch (e) {
      return { file, verdict: 'NOT_MEASURED', status: 'WARN', reason: 'memo unreadable: ' + e.message };
    }
  } else {
    file = input.file;
    body = input.body;
    fp = input.path || null;
  }

  const anchors = extractAnchors(body);
  const { asOf, precise } = memoAsOf(body, filenameDate(file), now);
  const ticket = ticketOf(file, body);

  if (anchors.size === 0) {
    return {
      file,
      ticket,
      verdict: 'NO_TARGETS',
      status: 'PASS',
      targetCount: 0,
      reason: 'memo carries no enumerated target set (no ids/skus/offer-ids/handles) — freshness join N/A',
    };
  }
  if (!asOf) {
    return {
      file,
      ticket,
      verdict: 'NOT_MEASURED',
      status: 'WARN',
      targetCount: anchors.size,
      reason: 'no parseable as-of timestamp in memo — cannot join against findings; re-enumerate manually before acting',
    };
  }

  const findings = gatherFindings(cfg, fp, now);

  // For each target anchor, collect newer findings that reference it.
  const stale = new Map(); // anchor -> [ {source, ticket, ts, kind, severity, reason, snippet} ]
  for (const f of findings) {
    if (f.ts > now) continue; // never trust a future-dated finding
    if (!(f.ts > asOf)) continue; // only findings NEWER than the memo's as-of
    if (f.ticket && ticket && f.ticket === ticket && f.kind !== 'executed') {
      // same ticket, non-ledger: this is the memo's own lane re-filing itself; skip
      // (a ledger row for the same ticket IS an execution and DOES count).
      continue;
    }
    for (const [anchor] of anchors) {
      if (f.anchors.has(anchor)) {
        if (!stale.has(anchor)) stale.set(anchor, []);
        stale.get(anchor).push({
          source: f.source,
          ticket: f.ticket,
          ts: f.ts.toISOString(),
          kind: f.kind,
          severity: KIND_SEVERITY[f.kind] || 'medium',
          reason: KIND_REASON[f.kind] || 'referenced by a newer finding',
          snippet: f.snippet || null,
        });
      }
    }
  }

  const M = anchors.size;
  const N = stale.size;
  const reverifyHint = findReverifyHint(body);

  if (N === 0) {
    return {
      file,
      ticket,
      verdict: 'FRESH',
      status: precise ? 'PASS' : 'WARN',
      targetCount: M,
      staleCount: 0,
      asOf: asOf.toISOString(),
      precise,
      reverifyHint,
      headline: `0 of ${M} targets touched by newer findings — no concurrent invalidation detected.` +
        (precise ? '' : ' (as-of was date-only — treat as advisory, re-verify recommended)'),
    };
  }

  const staleTargets = [...stale.entries()].map(([anchor, hits]) => ({
    target: anchor,
    type: anchors.get(anchor),
    hits: hits.sort((a, b) => (a.ts < b.ts ? 1 : -1)),
    worst: hits.some((h) => h.severity === 'high') ? 'high' : 'medium',
  }));
  const anyHigh = staleTargets.some((s) => s.worst === 'high');

  return {
    file,
    ticket,
    verdict: 'STALE',
    status: 'WARN', // advisory — never auto-FAIL a memo; the human decides
    targetCount: M,
    staleCount: N,
    asOf: asOf.toISOString(),
    precise,
    reverifyHint,
    highSeverity: anyHigh,
    headline: `${N} of ${M} targets are now STALE — a newer concurrent finding has touched them since this memo's as-of (${asOf.toISOString()}). DO NOT execute this list blind; re-enumerate first.`,
    staleTargets,
  };
}

// convenience: check every open memo in the queue
function checkQueue(cfg) {
  cfg = Object.assign(defaultConfig(), cfg || {});
  const files = walkMemoFiles(cfg.queueDir, 0).filter((f) => !path.basename(f).startsWith('_'));
  return files.map((fp) => checkMemo(fp, cfg));
}

module.exports = {
  checkMemo,
  checkQueue,
  extractAnchors,
  memoAsOf,
  gatherFindings,
  defaultConfig,
};