← 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
M reaper.jsA tests/tk-done-guard.shM ticket-autodone.jsM tk
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 →