← back to Ticket System

ticket-autodone.js

107 lines

#!/usr/bin/env node
// ticket auto-done — the "self-determine completed tasks" pass for TICKETS, the
// mirror of CNCP's approvals auto-complete sweep (Steve 2026-07-28). The hourly
// reaper.js deliberately NEVER marks done (it only auto-BLOCKS zombie DOING
// tickets); this pass is the deliberate, conservative complement Steve asked for:
// close tickets whose work is CLEARLY finished but were never `tk done`'d.
//
// A ticket auto-closes to done ONLY when ALL hold — kept tight so a real
// in-flight or gated ticket is never swept:
//   1. status ∈ {doing, blocked}         (open = never started; done = already closed)
//   2. no live `claude … Work ticket <id>` worker process
//   3. idle ≥ THRESHOLD_H (default 6h)    (never races an active session)
//   4. strong completion evidence — a `win` comment, OR the recent action/comment
//      text matches the POSITIVE completion regex …
//   5. … AND that text does NOT match the NEGATIVE ("not done / remaining / failed")
//      or GATED ("awaiting Steve / pending-approval / human review") guards — those
//      mean the ticket is unfinished or waiting on a person, not complete.
//
//   node ticket-autodone.js            # report-only (what it WOULD close + why)
//   node ticket-autodone.js --apply    # actually mark each → done (reversible)
//
// Reversible: a status is just an appended event — flip back with `tk status <id> doing`.
// Disable the nightly caller with TICKET_AUTODONE=0.
const { execSync } = require('child_process');
const path = require('path');
const THRESHOLD_H = Number(process.env.TICKET_AUTODONE_IDLE_H || process.env.REAPER_IDLE_H || 6);
const APPLY = process.argv.includes('--apply');
const BOARD = 'http://127.0.0.1:9794', AUTH = 'admin:DW2024!';
const TK = path.join(__dirname, 'tk');

const now = Date.now();
const sh = c => { try { return execSync(c, { encoding: 'utf8' }); } catch { return ''; } };

// live worker processes, keyed by TK id on their command line (same probe as reaper)
const liveIds = new Set();
for (const line of sh('ps -Ao command').split('\n')) {
  const m = line.match(/Work ticket (TK-[0-9]+)/i);
  if (m) liveIds.add(m[1].toUpperCase());
}

const tickets = JSON.parse(sh(`curl -s -u ${AUTH} ${BOARD}/api/tickets`) || '[]');
const shortId = id => (id.match(/^(TK-\d+)/) || [id, id])[1].toUpperCase();

// POS is deliberately a WHOLE-TICKET completion phrase, not any mention of "shipped"
// / "verified" — a ticket can bank a win or ship a SUB-task while the main work
// continues (proven live: TK-13 shipped greenland while its DW purge was still armed).
// Only the latest line declaring the TICKET itself finished counts as the trigger.
const POS   = /\b(all done|fully (done|complete|completed|shipped|deployed)|task complete|ticket complete|complete[d]? (and|&) verified|verified (and|&) (done|complete|closed)|shipped \+ verified|done \+ verified|closing (this )?(one )?out|marking (this )?(one )?done|ready to close|safe to close|nothing (left|remaining)|no (work )?remaining)\b/i;
const NEG   = /\b(not done|isn'?t done|not complete|incomplete|todo|to-do|remaining|still (need|to|armed|waiting)|next step|in progress|wip|partial|failed|failing|error|broken|regress|held|hold|aborted|will not|won'?t|declined|waiting for|wait for)\b/i;
const GATED = /\b(pending[- ]approval|awaiting steve|await(s|ing)? approval|needs steve|go-live gated|human review|needs review|review needed|manual review|not auto-executable|sign-?off needed|co-sign|gated|owning session|other (tab|session))\b/i;

// The signal = the SINGLE most-recent action/comment — the current state of the
// work. Using only the latest entry (not the whole history) is deliberate: an
// early "drafted to pending-approval" must NOT gate a ticket whose latest word is
// "shipped + verified live, closing out". If the latest word is itself gated /
// unfinished, the guards below still hold the ticket back.
function latestText(t) {
  const items = []
    .concat((t.actions || []).map(a => ({ ts: +new Date(a.ts), text: a.text || '' })))
    .concat((t.comments || []).map(c => ({ ts: +new Date(c.ts), text: c.text || '' })))
    .sort((a, b) => b.ts - a.ts);
  return items.length ? items[0].text : '';
}
function lastActivityTs(t) {
  const stamps = []
    .concat((t.actions || []).map(a => +new Date(a.ts)))
    .concat((t.comments || []).map(c => +new Date(c.ts)))
    .concat([+new Date(t.updated_at || t.created_at || now)])
    .filter(n => !isNaN(n));
  return stamps.length ? Math.max(...stamps) : now;
}

const picks = [];
for (const t of tickets) {
  if (t.status !== 'doing' && t.status !== 'blocked') continue;      // (1)
  const sid = shortId(t.id);
  if (liveIds.has(sid)) continue;                                    // (2)
  const idleH = (now - lastActivityTs(t)) / 3600000;
  if (idleH < THRESHOLD_H) continue;                                 // (3)
  const hasWin = (t.comments || []).some(c => c.kind === 'win');
  const txt = latestText(t);
  if (!POS.test(txt)) continue;                                      // (4) latest line must declare the TICKET complete
  if (NEG.test(txt) || GATED.test(txt)) continue;                    // (5) latest word still open/gated/handed-off → hold
  const reason = 'latest activity declares ticket complete' + (hasWin ? ' (+ win on record)' : '');
  picks.push({ id: sid, fullId: t.id, status: t.status, assignee: t.assignee,
    idleH: idleH.toFixed(1), reason, last: txt.slice(0, 90) });
}

if (!picks.length) {
  console.log(`✅ no tickets to auto-close (doing/blocked, idle ≥ ${THRESHOLD_H}h, clearly complete, no live worker).`);
  process.exit(0);
}
console.log(`${APPLY ? '✔' : '⚠'} ${picks.length} ticket(s) self-determined COMPLETE:\n`);
for (const p of picks)
  console.log(`  ${p.id.padEnd(11)} [${p.status.padEnd(7)}] idle ${String(p.idleH).padStart(6)}h  @${(p.assignee||'—').padEnd(20)} — ${p.reason}\n      «${p.last}»`);

if (APPLY) {
  console.log(`\n--apply: closing each → done (reversible; each gets an audit comment)…`);
  for (const p of picks) {
    sh(`TK_AGENT=autodone node ${TK} comment ${p.fullId} "auto-done 2026: self-determined COMPLETE — ${p.reason}; idle ${p.idleH}h, no live worker. Reversible via tk status ${p.id} doing." 2>/dev/null`);
    sh(`TK_AGENT=autodone node ${TK} status ${p.fullId} done 2>/dev/null`);
    console.log(`  ✅ ${p.id} → done`);
  }
} else {
  console.log(`\n(report-only — re-run with --apply to close them, or reconcile by hand.)`);
}