[object Object]

← back to Ticket System

tk: evidence guard on done (open gated memo / no logged work) + tests (TK-11903)

dee8eacda913a5b7e0d15e621c4bfa09135b8250 · 2026-09-18 08:14:15 -0700 · Steve Abrams

- tk done / tk status <id> done refused (exit 2) when an OPEN memo naming the
  ticket sits in pending-approval/ (top level) or the ticket has zero tk log
  actions from any agent in the last 24h. Identity-independent.
- --force "<reason>" overrides and records a FORCED DONE comment first.
- TK_DONE_GUARD=0 bypass set in the two sanctioned auto-closers (reaper.js,
  ticket-autodone.js) which leave their own audit comment.
- TK_APPROVAL_DIR env override; tests/tk-done-guard.sh runs 10 cases against a
  temp store (TICKET_DATA_DIR) — negative + positive directions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YT3be6iadPzjKJDiaTasEE

Files touched

Diff

commit dee8eacda913a5b7e0d15e621c4bfa09135b8250
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 18 08:14:15 2026 -0700

    tk: evidence guard on done (open gated memo / no logged work) + tests (TK-11903)
    
    - tk done / tk status <id> done refused (exit 2) when an OPEN memo naming the
      ticket sits in pending-approval/ (top level) or the ticket has zero tk log
      actions from any agent in the last 24h. Identity-independent.
    - --force "<reason>" overrides and records a FORCED DONE comment first.
    - TK_DONE_GUARD=0 bypass set in the two sanctioned auto-closers (reaper.js,
      ticket-autodone.js) which leave their own audit comment.
    - TK_APPROVAL_DIR env override; tests/tk-done-guard.sh runs 10 cases against a
      temp store (TICKET_DATA_DIR) — negative + positive directions.
    
    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01YT3be6iadPzjKJDiaTasEE
---
 reaper.js              |  6 +++-
 tests/tk-done-guard.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++
 ticket-autodone.js     |  6 +++-
 tk                     | 63 +++++++++++++++++++++++++++++++++++++++--
 4 files changed, 146 insertions(+), 5 deletions(-)

diff --git a/reaper.js b/reaper.js
index 80cf1d87..8c459cba 100644
--- a/reaper.js
+++ b/reaper.js
@@ -83,7 +83,11 @@ if (BLOCK) {
     // returns the ticket to the dispatch queue (Steve's call 2026-09-14). All
     // three are valid STATUSES and every set is reversible (tk status <id> doing).
     sh(`TK_AGENT=reaper node ${TK} comment ${z.fullId} "reaper: DOING but idle ${z.idleH}h with no live worker → auto-set ${z.suggest} (owning session ended without tk done/status). Reversible: tk status ${z.fullId} doing." 2>/dev/null`);
-    sh(`TK_AGENT=reaper node ${TK} status ${z.fullId} ${z.suggest} 2>/dev/null`);
+    // TK_DONE_GUARD=0: reaper is a sanctioned auto-closer — it acts only on 6h+ idle
+    // tickets with no live worker (which by construction have no tk log in 24h) and
+    // leaves its own audit comment above, so the tk done evidence guard (TK-11903)
+    // is bypassed HERE, in the caller, rather than weakened in tk.
+    sh(`TK_DONE_GUARD=0 TK_AGENT=reaper node ${TK} status ${z.fullId} ${z.suggest} 2>/dev/null`);
     console.log(`  ${icon[z.suggest] || '•'} ${z.id} → ${z.suggest}`);
   }
 } else {
diff --git a/tests/tk-done-guard.sh b/tests/tk-done-guard.sh
new file mode 100755
index 00000000..ccb49125
--- /dev/null
+++ b/tests/tk-done-guard.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+# tests/tk-done-guard.sh — proves the `tk done` evidence guard (TK-11903) goes RED
+# on the injected faults AND stays GREEN on legitimate closes. Runs against a TEMP
+# event store (TICKET_DATA_DIR) + a TEMP pending-approval dir (TK_APPROVAL_DIR), so
+# the real board is never touched.
+#   (a) bare ticket, no tk log            -> tk done refused, exit 2
+#   (b) ticket with an OPEN gated memo    -> tk done refused, exit 2
+#   (c) ticket with a tk log in <24h      -> tk done succeeds
+#   (d) case (a) + --force "reason"       -> succeeds, FORCED DONE comment present
+#   (e) --force with empty reason         -> exit 2
+#   (f) TK_DONE_GUARD=0 bypass            -> bare close succeeds (sanctioned closers)
+#   (g) tk list / tk show still work
+set -uo pipefail
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+TK="$DIR/tk"
+TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+export TICKET_DATA_DIR="$TMP/store"
+export TK_APPROVAL_DIR="$TMP/approval"
+mkdir -p "$TK_APPROVAL_DIR/_done"
+export TK_AGENT=guard-test
+unset TK_DONE_GUARD ITERM_SESSION_ID
+fail=0
+pass() { echo "PASS  $1"; }
+flunk() { echo "FAIL  $1"; fail=1; }
+
+A=$("$TK" new "guard case a bare" -p test)
+B=$("$TK" new "guard case b memo" -p test)
+C=$("$TK" new "guard case c logged" -p test)
+D=$("$TK" new "guard case d forced" -p test)
+F=$("$TK" new "guard case f bypass" -p test)
+[[ "$A" =~ ^TK-[0-9]+ ]] || { echo "FAIL  could not create tickets in temp store"; exit 1; }
+numB="${B#TK-}"; numB="${numB%%-*}"
+
+# (a) bare flip refused
+out=$("$TK" done "$A" 2>&1); rc=$?
+if [[ $rc -eq 2 && "$out" == *"NO logged work"* ]]; then pass "(a) bare ticket refused (exit 2)"; else flunk "(a) bare ticket: rc=$rc out=$out"; fi
+# also via `tk status <id> done`
+out=$("$TK" status "$A" done 2>&1); rc=$?
+if [[ $rc -eq 2 ]]; then pass "(a2) tk status <id> done refused too"; else flunk "(a2) status done: rc=$rc out=$out"; fi
+
+# (b) open gated memo refused even WITH a fresh tk log; memo in _done/ does not count
+"$TK" log "$B" "did real work" >/dev/null
+: > "$TK_APPROVAL_DIR/2026-09-18-TK-${numB}-something-GATED.md"
+out=$("$TK" done "$B" 2>&1); rc=$?
+if [[ $rc -eq 2 && "$out" == *"OPEN gated memo"* ]]; then pass "(b) open memo refused (exit 2)"; else flunk "(b) open memo: rc=$rc out=$out"; fi
+mv "$TK_APPROVAL_DIR/2026-09-18-TK-${numB}-something-GATED.md" "$TK_APPROVAL_DIR/_done/"
+out=$("$TK" done "$B" 2>&1); rc=$?
+if [[ $rc -eq 0 ]]; then pass "(b2) memo moved to _done/ -> close allowed"; else flunk "(b2) _done memo: rc=$rc out=$out"; fi
+
+# (c) logged ticket, no memo -> succeeds
+"$TK" log "$C" "did real work" >/dev/null
+out=$("$TK" done "$C" 2>&1); rc=$?
+if [[ $rc -eq 0 && "$("$TK" show "$C" | head -1)" == *"[done]"* ]]; then pass "(c) logged ticket closes normally"; else flunk "(c) logged: rc=$rc out=$out"; fi
+
+# (d) bare + --force -> succeeds with FORCED DONE comment
+out=$("$TK" done "$D" --force "test override" 2>&1); rc=$?
+shown=$("$TK" show "$D")
+if [[ $rc -eq 0 && "$shown" == *"FORCED DONE: test override"* && "$(echo "$shown" | head -1)" == *"[done]"* ]]; then pass "(d) --force closes + records FORCED DONE comment"; else flunk "(d) force: rc=$rc out=$out"; fi
+
+# (e) --force with no reason -> exit 2
+E=$("$TK" new "guard case e empty force" -p test)
+out=$("$TK" done "$E" --force 2>&1); rc=$?
+if [[ $rc -eq 2 ]]; then pass "(e) --force without reason refused (exit 2)"; else flunk "(e) empty force: rc=$rc out=$out"; fi
+
+# (f) TK_DONE_GUARD=0 bypass for sanctioned auto-closers
+out=$(TK_DONE_GUARD=0 "$TK" done "$F" 2>&1); rc=$?
+if [[ $rc -eq 0 ]]; then pass "(f) TK_DONE_GUARD=0 bypass works"; else flunk "(f) bypass: rc=$rc out=$out"; fi
+
+# (g) list/show smoke
+if "$TK" list --all | grep -q "guard case a bare" && "$TK" show "$A" | grep -q "guard case a bare"; then pass "(g) tk list / tk show still work"; else flunk "(g) list/show broken"; fi
+
+# store hygiene: nothing touched outside TMP
+[[ "$(wc -l < "$TICKET_DATA_DIR/events.jsonl")" -gt 5 ]] && pass "(h) temp store used ($TICKET_DATA_DIR)" || flunk "(h) temp store empty"
+
+if [[ $fail -eq 0 ]]; then echo "ALL PASS"; else echo "SOME FAILED"; fi
+exit $fail
diff --git a/ticket-autodone.js b/ticket-autodone.js
index 93e8647d..887453d1 100644
--- a/ticket-autodone.js
+++ b/ticket-autodone.js
@@ -98,7 +98,11 @@ 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`);
+    // TK_DONE_GUARD=0: autodone is a sanctioned auto-closer (positive-completion regex
+    // + 6h idle + no live worker, audit comment above); the idle requirement means the
+    // ticket may have no tk log inside 24h, so the tk done evidence guard (TK-11903) is
+    // bypassed in this caller, not weakened in tk.
+    sh(`TK_DONE_GUARD=0 TK_AGENT=autodone node ${TK} status ${p.fullId} done 2>/dev/null`);
     console.log(`  ✅ ${p.id} → done`);
   }
 } else {
diff --git a/tk b/tk
index 5ba824be..e3642d3f 100755
--- a/tk
+++ b/tk
@@ -39,6 +39,63 @@ const die = m => { console.error(m); process.exit(1); };
 // canonical stored id — legacy short ids and new TK-00024-slug ids both work.
 const norm = ref => { if (!ref) die('missing ticket id'); const id = resolveId(ref); if (!id) die('no such ticket ' + ref); return id; };
 const fmt = t => `${t.id} [${t.status}] <${t.kind || 'task'}> (${t.assignee || 'unassigned'})${t.project ? ' {' + t.project + '}' : ''} ${t.title}`;
+
+// ── `done` evidence guard (TK-11903, 2026-09-18) ─────────────────────────────
+// Overnight 2026-09-17→18, 92 tickets were flipped to done; 32 were bare flips
+// with zero logged work and 31 of those took a ticket from `blocked` (several
+// with a live gated memo in pending-approval) straight to `done`. A done event
+// is a self-attestation, so the guard is EVIDENCE-based and identity-independent
+// (setting TK_AGENT cannot bypass it). tk done / tk status <id> done is refused
+// (exit 2) when EITHER:
+//   1. an OPEN memo naming this ticket's number sits in pending-approval/ (top
+//      level only — _done/, _never/, subdirs are not open) → the ticket is
+//      blocked on Steve, not done;
+//   2. the ticket has zero `action` (tk log) events from ANY agent in the last
+//      24h → a bare flip with no logged work.
+// `--force "<reason>"` bypasses both and records `FORCED DONE: <reason>` as a
+// comment first, so a human close is never blocked silently. TK_DONE_GUARD=0
+// disables the guard for sanctioned auto-closers that already leave their own
+// audit comment (reaper.js, ticket-autodone.js). TK_APPROVAL_DIR overrides the
+// memo dir (tests). Cost: the memo check is one readdir; the 24h check reads
+// the ticket map the command already loaded.
+const APPROVAL_DIR = process.env.TK_APPROVAL_DIR || require('path').join(require('os').homedir(), '.claude', 'yolo-queue', 'pending-approval');
+const GUARD_WINDOW_MS = 24 * 60 * 60 * 1000;
+function openMemosFor(id) {
+  const m = String(id).match(/TK-0*(\d+)/); if (!m) return [];
+  const re = new RegExp('TK-0*' + m[1] + '(?!\\d)');
+  let names = [];
+  try { names = require('fs').readdirSync(APPROVAL_DIR, { withFileTypes: true }).filter(d => d.isFile()).map(d => d.name); } catch { return []; }
+  return names.filter(n => re.test(n));
+}
+function doneGuard(id, t, nowIso) {
+  if (process.env.TK_DONE_GUARD === '0') return;
+  const memos = openMemosFor(id);
+  if (memos.length) {
+    console.error(`tk done refused (exit 2): ${id} has an OPEN gated memo in pending-approval/ (${memos[0]}${memos.length > 1 ? ' +' + (memos.length - 1) : ''}) — a ticket waiting on Steve is \`tk status ${id} blocked\`, not done. Override: tk done ${id} --force "<reason>" (recorded as a comment). [TK-11903]`);
+    process.exit(2);
+  }
+  const since = Date.parse(nowIso) - GUARD_WINDOW_MS;
+  const recent = (t.actions || []).filter(a => { const x = Date.parse(a.ts); return Number.isFinite(x) && x >= since; });
+  if (!recent.length) {
+    console.error(`tk done refused (exit 2): ${id} has NO logged work (tk log) from any agent in the last 24h — a bare status flip is not evidence the work ran. Log what was done first (tk log ${id} "..."), or: tk done ${id} --force "<reason>". [TK-11903]`);
+    process.exit(2);
+  }
+}
+// Parse --force. Presence without a non-empty reason is itself an error (exit 2).
+function forceReason() {
+  const i = argv.indexOf('--force'); if (i === -1) return null;
+  const v = argv[i + 1]; argv.splice(i, (v !== undefined && !v.startsWith('-')) ? 2 : 1);
+  const reason = String(v || '').trim();
+  if (!reason || reason.startsWith('-')) { console.error('tk done --force requires a non-empty reason: --force "<why this close is legitimate>"'); process.exit(2); }
+  return reason;
+}
+function closeDone(id) {
+  const force = forceReason();
+  const t = tickets().get(id); if (!t) die('no such ticket ' + id);
+  if (force) append({ ts, type: 'comment', id, kind: 'comment', agent, text: 'FORCED DONE: ' + force });
+  else doneGuard(id, t, ts);
+  append({ ts, type: 'status', id, status: 'done', agent }); console.log(`${id} → done${force ? ' (forced)' : ''}`);
+}
 // Self-title the iTerm2 window with this ticket's short number (TK-<n>) when a session
 // binds to a ticket (take/new/doing) — so even a MANUALLY-opened Claude window labels
 // itself, not just run-ticket.sh-launched ones. Best-effort, non-blocking, no-op off iTerm
@@ -117,11 +174,11 @@ if (cmd === 'new') {
   const id = norm(argv.shift()); const s = (argv.shift() || '').toLowerCase();
   if (!STATUSES.includes(s)) die('status must be one of: ' + STATUSES.join(' '));
   if (!tickets().has(id)) die('no such ticket ' + id);
-  append({ ts, type: 'status', id, status: s, agent }); console.log(`${id} → ${s}`);
+  if (s === 'done') { closeDone(id); }
+  else { append({ ts, type: 'status', id, status: s, agent }); console.log(`${id} → ${s}`); }
   if (s === 'doing') titleWindow(id);
 } else if (cmd === 'done') {
-  const id = norm(argv.shift()); if (!tickets().has(id)) die('no such ticket ' + id);
-  append({ ts, type: 'status', id, status: 'done', agent }); console.log(`${id} → done`);
+  closeDone(norm(argv.shift()));   // closeDone re-reads the map once for the existence + 24h-action check
 } else if (cmd === 'blocker') {
   const id = norm(argv.shift()); const type = String(argv.shift() || '').toLowerCase();
   const blocker = cleanBlocker({

← 018f10f5 auto-data-snapshot: 2026-09-17T17:25:36 (9 data files) — evi  ·  back to Ticket System  ·  audit-overnight-closes: evidence join for overnight done eve f231c870 →