[object Object]

← back to Ticket System

nightly board hygiene: ticket auto-done + approvals sweep at 4am

32d067a90f734a240375c36739f5f14d405f41db · 2026-07-28 07:50:09 -0700 · Steve Abrams

ticket-autodone.js — self-determines COMPLETE tickets (mirror of CNCP's
approvals auto-complete). Closes doing/blocked tickets whose LATEST activity
declares the whole ticket complete, idle >=6h, no live worker, not neg/gated.
Deliberately tight after a live false-positive check (TK-13 banked a sub-task
win while its main gated op was still armed — correctly NOT closed). Reversible
(status is an appended event); report-only without --apply.

nightly-cleanup.sh — 4am runner: POST /api/approvals/sweep then
ticket-autodone --apply. Installed as launchd com.steve.nightly-approvals-tickets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 32d067a90f734a240375c36739f5f14d405f41db
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 07:50:09 2026 -0700

    nightly board hygiene: ticket auto-done + approvals sweep at 4am
    
    ticket-autodone.js — self-determines COMPLETE tickets (mirror of CNCP's
    approvals auto-complete). Closes doing/blocked tickets whose LATEST activity
    declares the whole ticket complete, idle >=6h, no live worker, not neg/gated.
    Deliberately tight after a live false-positive check (TK-13 banked a sub-task
    win while its main gated op was still armed — correctly NOT closed). Reversible
    (status is an appended event); report-only without --apply.
    
    nightly-cleanup.sh — 4am runner: POST /api/approvals/sweep then
    ticket-autodone --apply. Installed as launchd com.steve.nightly-approvals-tickets.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 nightly-cleanup.sh |  47 ++++++++++++++++++++++++
 ticket-autodone.js | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 153 insertions(+)

diff --git a/nightly-cleanup.sh b/nightly-cleanup.sh
new file mode 100755
index 0000000..b0a3dd6
--- /dev/null
+++ b/nightly-cleanup.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# Nightly 4am board hygiene (Steve 2026-07-28) — the scheduled companion to the
+# on-demand self-completion we built this session. Two conservative, reversible
+# passes; neither ever EXECUTES a gated action or falsely claims work shipped:
+#
+#   1. CNCP approvals auto-complete sweep — flips approvals to done when a ticket
+#      they reference is done, the memo is marked complete, or an approve has
+#      settled (> APPROVAL_AUTODONE_DAYS). POST /api/approvals/sweep.
+#   2. Ticket auto-done — closes doing/blocked tickets whose LATEST line declares
+#      the whole ticket complete (idle ≥6h, no live worker, not neg/gated).
+#
+# Installed as launchd com.steve.nightly-approvals-tickets (04:00 daily). Skip a
+# pass with CNCP_APPROVAL_AUTODONE=0 / TICKET_AUTODONE=0.
+set -u
+ROOT="$HOME/Projects/ticket-system"
+LOG="$ROOT/nightly-cleanup.log"
+NODE="$(command -v node || echo /opt/homebrew/bin/node)"
+CNCP="http://localhost:3333"
+TS="$(date '+%F %T')"
+
+{
+  echo "=== nightly-cleanup $TS ==="
+
+  # 1) CNCP approvals self-completion sweep
+  if [ "${CNCP_APPROVAL_AUTODONE:-1}" != "0" ]; then
+    echo "[1/2] approvals sweep → POST $CNCP/api/approvals/sweep"
+    curl -s -m 30 -X POST "$CNCP/api/approvals/sweep" -H 'Content-Type: application/json' -d '{}' \
+      || echo "  (approvals sweep unreachable — CNCP down?)"
+    echo
+  else
+    echo "[1/2] approvals sweep SKIPPED (CNCP_APPROVAL_AUTODONE=0)"
+  fi
+
+  # 2) Ticket auto-done (conservative; reversible)
+  if [ "${TICKET_AUTODONE:-1}" != "0" ]; then
+    echo "[2/2] ticket auto-done → node ticket-autodone.js --apply"
+    ( cd "$ROOT" && "$NODE" ticket-autodone.js --apply )
+  else
+    echo "[2/2] ticket auto-done SKIPPED (TICKET_AUTODONE=0)"
+  fi
+
+  echo "=== done $TS ==="
+} >> "$LOG" 2>&1
+
+# keep the log bounded
+tail -1200 "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG"
+exit 0
diff --git a/ticket-autodone.js b/ticket-autodone.js
new file mode 100644
index 0000000..93e8647
--- /dev/null
+++ b/ticket-autodone.js
@@ -0,0 +1,106 @@
+#!/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.)`);
+}

← 3cea49e auto-save: 2026-07-27T20:24:23 (2 files) — gdrive-sync-launc  ·  back to Ticket System  ·  nightly-cleanup: add pass 3 — CNCP panel retire + cache-relo 1fffe67 →