[object Object]

← back to Gated Morning Review

TK-11801: gate the 03:00 sweep on ticket status so open-ticket memos aren't auto-hidden

90c548f442a250c05882abf8db48d6c13ba60807 · 2026-09-16 10:34:18 -0700 · Steve Abrams

Root cause: lib.mjs disposition was (staleSig || age>25d) -> STALE -> _done/, a
prose regex on the memo body with NO ticket-status check, so a memo whose body
merely mentioned resolved/superseded/no-op/✅ for a still-[blocked]/[doing] ticket
got filed out of the only dir the approval viewers read (28 memos on 2026-09-15,
9 of them blocked incl. TK-11089/TK-11738).

Fix (approved memo 2026-09-15-TK-11801):
1. staleSig auto-close now requires the ticket to be NOT [blocked]/[doing]
   (openTicketStatuses() from tk list) OR an explicit whole-memo STATUS: DONE marker.
2. age>25d -> new HELD disposition -> _held/ (still visible to the canary + /ungate),
   not _done/. Holding is not approving.
3. Every auto-close logged to data/decisions.jsonl (file+ticket+status+reason) and
   listed in the morning digest (auto_closed_list/held_list) so a mis-close is visible.
4. Negative test test-tk11801.mjs (GMR_QDIR/GMR_TK_STATUS_JSON seams): open-ticket
   'resolved' memo stays DECIDE, done/marked memo closes. 5/5 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZZC8iXKjWYHEbypyf3j7f

Files touched

Diff

commit 90c548f442a250c05882abf8db48d6c13ba60807
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 16 10:34:18 2026 -0700

    TK-11801: gate the 03:00 sweep on ticket status so open-ticket memos aren't auto-hidden
    
    Root cause: lib.mjs disposition was (staleSig || age>25d) -> STALE -> _done/, a
    prose regex on the memo body with NO ticket-status check, so a memo whose body
    merely mentioned resolved/superseded/no-op/✅ for a still-[blocked]/[doing] ticket
    got filed out of the only dir the approval viewers read (28 memos on 2026-09-15,
    9 of them blocked incl. TK-11089/TK-11738).
    
    Fix (approved memo 2026-09-15-TK-11801):
    1. staleSig auto-close now requires the ticket to be NOT [blocked]/[doing]
       (openTicketStatuses() from tk list) OR an explicit whole-memo STATUS: DONE marker.
    2. age>25d -> new HELD disposition -> _held/ (still visible to the canary + /ungate),
       not _done/. Holding is not approving.
    3. Every auto-close logged to data/decisions.jsonl (file+ticket+status+reason) and
       listed in the morning digest (auto_closed_list/held_list) so a mis-close is visible.
    4. Negative test test-tk11801.mjs (GMR_QDIR/GMR_TK_STATUS_JSON seams): open-ticket
       'resolved' memo stays DECIDE, done/marked memo closes. 5/5 pass.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_016ZZC8iXKjWYHEbypyf3j7f
---
 lib.mjs          | 47 +++++++++++++++++++++++++++++++++++++++++++----
 sweep.mjs        | 27 +++++++++++++++++++++------
 test-tk11801.mjs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 112 insertions(+), 10 deletions(-)

diff --git a/lib.mjs b/lib.mjs
index 9786902..a5a2ed6 100644
--- a/lib.mjs
+++ b/lib.mjs
@@ -4,7 +4,10 @@ import path from 'path';
 import os from 'os';
 import { execFileSync } from 'child_process';
 
-export const QDIR = path.join(os.homedir(), '.claude/yolo-queue/pending-approval');
+// TESTABILITY SEAM (TK-11801, CLAUDE.md TK-11431 amendment 3): GMR_QDIR points the
+// scan at a throwaway fixture dir; GMR_TK_STATUS_JSON supplies ticket statuses instead
+// of shelling `tk`. The launchd plist MUST NEVER set these — production uses the reals.
+export const QDIR = process.env.GMR_QDIR || path.join(os.homedir(), '.claude/yolo-queue/pending-approval');
 export const STALE_DAYS = 25;
 export const AGING_DAYS = 10;
 
@@ -61,6 +64,33 @@ const CAT = {
     sug: "🤔 Just pick yes or no — it's a choice, not a chore." },
 };
 
+// TK-11801 fix: a whole-memo, explicit done-marker (the strict rule prune.py uses).
+// This is the ONLY prose that may auto-close a memo whose ticket is still open — a
+// deliberate STATUS line, not an incidental word in the body.
+export function hasExplicitDoneMarker(head) {
+  return /^\s*(?:>?\s*\*{0,2})?\s*STATUS:\s*(DONE|APPLIED|COMPLETE|COMPLETED|RESOLVED|EXECUTED)\b/im.test(head || '');
+}
+
+// TK-11801 fix: map of numeric TK id -> board status, parsed from `tk list`.
+// Tickets absent from the open board are treated as not-open (closed/unknown).
+export function openTicketStatuses() {
+  const map = new Map();
+  if (process.env.GMR_TK_STATUS_JSON) { // test seam: statuses injected, no `tk` shell-out
+    try { for (const [k, v] of Object.entries(JSON.parse(process.env.GMR_TK_STATUS_JSON))) map.set(k, v); } catch {}
+    return map;
+  }
+  let out = '';
+  try { out = execFileSync('tk', ['list'], { encoding: 'utf8', timeout: 8000, env: { ...process.env, TK_AGENT: 'morning-viewer' } }); }
+  catch { return map; }
+  for (const line of out.split('\n')) {
+    const m = line.match(/^(TK-[\w-]+)\s+\[(\w+)\]/);
+    if (!m) continue;
+    const n = (m[1].match(/TK-\d+/) || [])[0];
+    if (n && !map.has(n)) map.set(n, m[2]);
+  }
+  return map;
+}
+
 export function classify(title, head, file) {
   const s = (title + ' ' + head + ' ' + file).toLowerCase();
   const has = (re) => re.test(s);
@@ -80,6 +110,7 @@ export function scanQueue() {
   let files = [];
   try { files = fs.readdirSync(QDIR).filter(f => /\.(md|csv|json|patch)$/.test(f) && !f.startsWith('DONE-')); } catch {}
   const now = Date.now();
+  const tkStatus = openTicketStatuses(); // TK-11801: know each memo's ticket status
   const out = files.map(f => {
     const full = path.join(QDIR, f);
     let st; try { st = fs.statSync(full); } catch { return null; }
@@ -89,15 +120,23 @@ export function scanQueue() {
     const ticket = (head.match(/TK-\d+/) || [])[0] || '';
     const c = classify(title, head, f);
     const aging = age >= AGING_DAYS;
+    // TK-11801 fix: never auto-close on prose (staleSig) a memo whose ticket is
+    // still OPEN ([blocked]/[doing]) — unless it carries an explicit whole-memo
+    // STATUS: DONE marker. Age>25d no longer means _done/: it means HELD (→_held/,
+    // still visible to the approval-invisibility canary + /ungate). Holding != approving.
+    const tstat = ticket ? (tkStatus.get(ticket) || '') : '';
+    const openTicket = tstat === 'blocked' || tstat === 'doing';
+    const staleClose = c.staleSig && (hasExplicitDoneMarker(head) || !openTicket);
+    const disposition = staleClose ? 'STALE' : (age > STALE_DAYS ? 'HELD' : 'DECIDE');
     // aging items get an extra "stale-data" warning appended to the bad reason
     const bad = aging ? c.bad + " ⚠️ And it's OLD — the info inside may be out of date, so re-check before running." : c.bad;
     // my suggestion, in plain kid words — with an old/stale overlay
-    const suggest = c.staleSig ? "🗑️ Probably already done — safe to toss."
+    const suggest = staleClose ? "🗑️ Probably already done — safe to toss."
       : aging ? "⚠️ It's OLD — first make sure the info is still true, THEN " + c.sug.replace(/^..? /, "").toLowerCase()
       : c.sug;
-    return { kind: 'gated', id: f, file: f, title, ticket, age,
+    return { kind: 'gated', id: f, file: f, title, ticket, ticketStatus: tstat, openTicket, age,
       ageWord: age <= 0 ? 'today' : age === 1 ? '1 day old' : `${age} days old`,
-      ...c, bad, suggest, aging, disposition: (c.staleSig || age > STALE_DAYS) ? 'STALE' : 'DECIDE' };
+      ...c, bad, suggest, aging, disposition };
   }).filter(Boolean).sort((a, b) => b.age - a.age);
   // Sibling info: when several memo files share one ticket they are steps of ONE ticket,
   // not accidental dups — tag "N of M" so the viewer reads them as intentional.
diff --git a/sweep.mjs b/sweep.mjs
index 60adf28..e853193 100644
--- a/sweep.mjs
+++ b/sweep.mjs
@@ -13,35 +13,50 @@ const ts = new Date().toISOString();
 
 const items = scanQueue();
 const stale = items.filter(i => i.disposition === 'STALE');
+const held  = items.filter(i => i.disposition === 'HELD'); // TK-11801: age>25d, NOT approved
 const decide = items.filter(i => i.disposition === 'DECIDE');
 
 // AUTO-CLOSE the stale ones (reversible file moves — recoverable from _done/_never).
+// TK-11801: HELD (merely OLD) goes to _held/, which the approval-invisibility canary
+// and /ungate still read as unresolved — holding is not approving. Every move is
+// logged to data/decisions.jsonl with the ticket status so a mis-close is visible.
 const doneDir = path.join(QDIR, '_done'); const neverDir = path.join(QDIR, '_never');
-fs.mkdirSync(doneDir, { recursive: true }); fs.mkdirSync(neverDir, { recursive: true });
-let closed = 0;
+const heldDir = path.join(QDIR, '_held');
+fs.mkdirSync(doneDir, { recursive: true }); fs.mkdirSync(neverDir, { recursive: true }); fs.mkdirSync(heldDir, { recursive: true });
+const decisionsLog = path.join(DATA, 'decisions.jsonl');
+const logMove = (it, dest, reason) => {
+  try { fs.appendFileSync(decisionsLog, JSON.stringify({ ts, file: it.file, ticket: it.ticket, ticket_status: it.ticketStatus || '', disposition: it.disposition, dest: path.basename(dest), reason }) + '\n'); } catch {}
+};
+let closed = 0, heldCount = 0;
 for (const it of stale) {
   const declined = /declined|no action needed|blocked-kill|do not/i.test(it.title);
   const dest = declined ? neverDir : doneDir;
-  try { fs.renameSync(path.join(QDIR, it.file), path.join(dest, it.file)); closed++; } catch {}
+  try { fs.renameSync(path.join(QDIR, it.file), path.join(dest, it.file)); closed++; logMove(it, dest, declined ? 'declined' : 'staleSig + ticket not open'); } catch {}
+}
+for (const it of held) {
+  try { fs.renameSync(path.join(QDIR, it.file), path.join(heldDir, it.file)); heldCount++; logMove(it, heldDir, `age ${it.age}d > ${STALE_DAYS}d (held, not approved)`); } catch {}
 }
 
 const oldest = decide[0];
 const digest = {
   ts, viewer: VIEWER,
-  total_before: items.length, auto_closed: closed,
+  total_before: items.length, auto_closed: closed, held: heldCount,
   awaiting_decision: decide.length,
   aging_count: decide.filter(d => d.aging).length,
   oldest_days: oldest ? oldest.age : 0,
   by_category: decide.reduce((m, d) => (m[d.category] = (m[d.category] || 0) + 1, m), {}),
   items: decide.map(d => ({ file: d.file, ticket: d.ticket, age: d.age, big: d.big, title: d.title })),
+  // TK-11801: surface WHAT got auto-moved so a mis-close is visible the same morning
+  auto_closed_list: stale.map(d => ({ file: d.file, ticket: d.ticket, ticket_status: d.ticketStatus || '', reason: 'staleSig' })),
+  held_list: held.map(d => ({ file: d.file, ticket: d.ticket, ticket_status: d.ticketStatus || '', age: d.age })),
 };
 fs.writeFileSync(path.join(DATA, 'digest.json'), JSON.stringify(digest, null, 2));
 fs.writeFileSync(path.join(DATA, 'latest.json'), JSON.stringify({
   verdict: digest.aging_count > 0 ? 'WARN' : 'PASS', status: digest.aging_count > 0 ? 'WARN' : 'PASS',
-  ts, awaiting: decide.length, auto_closed: closed, aging: digest.aging_count, oldest_days: digest.oldest_days,
+  ts, awaiting: decide.length, auto_closed: closed, held: heldCount, aging: digest.aging_count, oldest_days: digest.oldest_days,
 }, null, 2));
 
-console.log(`[gated-morning-sweep] ${ts}: auto-closed ${closed} stale, ${decide.length} await decision (${digest.aging_count} aging >${AGING_DAYS}d, oldest ${digest.oldest_days}d).`);
+console.log(`[gated-morning-sweep] ${ts}: auto-closed ${closed} stale, held ${heldCount} old, ${decide.length} await decision (${digest.aging_count} aging >${AGING_DAYS}d, oldest ${digest.oldest_days}d).`);
 
 // Email Steve the morning link via the shared George helper — the SINGLE correct path
 // (George is behind Tailscale-HTTPS + Basic auth; a raw http://127.0.0.1:9850 POST 401s).
diff --git a/test-tk11801.mjs b/test-tk11801.mjs
new file mode 100644
index 0000000..65755e1
--- /dev/null
+++ b/test-tk11801.mjs
@@ -0,0 +1,48 @@
+#!/usr/bin/env node
+// TK-11801 negative test (CLAUDE.md TK-11431 amendment 3): prove the sweep does NOT
+// auto-close a memo whose ticket is still OPEN just because its prose says "resolved",
+// and that the fix goes RED if that guard is removed. Uses the GMR_QDIR / GMR_TK_STATUS_JSON
+// seams so it never touches the real queue or shells `tk`.
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gmr-tk11801-'));
+process.env.GMR_QDIR = tmp;
+process.env.GMR_TK_STATUS_JSON = JSON.stringify({
+  'TK-11089': 'blocked',   // OPEN — a genuine waiting-on-Steve ask
+  'TK-22222': 'doing',     // OPEN
+  'TK-33333': 'done',      // closed
+});
+
+const memo = (name, body) => fs.writeFileSync(path.join(tmp, name), body);
+// 1. OPEN ticket, header SAYS "resolved" — the exact founding bug. Must NOT be STALE.
+memo('2026-09-13-TK-11089-pj-resume-decision-GATED.md', '# GATED — TK-11089 resume?\n\n✅ DTD VERDICT: superseded reasoning, already resolved elsewhere.\n');
+// 2. OPEN [doing] ticket with staleSig prose. Must NOT be STALE.
+memo('TK-22222-thing.md', '# TK-22222 — no-op after the flip, nothing to do\n');
+// 3. DONE ticket with staleSig. SHOULD be STALE (safe to close).
+memo('TK-33333-old.md', '# TK-33333 — resolved and applied\n');
+// 4. OPEN ticket BUT explicit STATUS: DONE marker. SHOULD be STALE (marker wins).
+memo('TK-11089-b-explicit.md', '# TK-11089 phase 2\nSTATUS: DONE\nsuperseded\n');
+// 5. No staleSig, open ticket, fresh. Must be DECIDE (surfaced).
+memo('TK-22222-fresh.md', '# TK-22222 — please approve the deploy\n');
+
+const { scanQueue } = await import('./lib.mjs?' + Date.now()); // fresh import after env set
+const byFile = Object.fromEntries(scanQueue().map(i => [i.file, i.disposition]));
+
+let fails = 0;
+const expect = (file, want) => {
+  const got = byFile[file];
+  const ok = got === want;
+  console.log(`${ok ? 'PASS' : 'FAIL'}  ${file} -> ${got} (want ${want})`);
+  if (!ok) fails++;
+};
+expect('2026-09-13-TK-11089-pj-resume-decision-GATED.md', 'DECIDE'); // the founding bug: NOT closed
+expect('TK-22222-thing.md', 'DECIDE');                                // open [doing]: NOT closed
+expect('TK-33333-old.md', 'STALE');                                   // done: safe to close
+expect('TK-11089-b-explicit.md', 'STALE');                            // explicit marker wins
+expect('TK-22222-fresh.md', 'DECIDE');                                // surfaced
+
+fs.rmSync(tmp, { recursive: true, force: true });
+if (fails) { console.log(`\n${fails} FAILED`); process.exit(1); }
+console.log('\nall 5 passed — open-ticket memos survive the sweep; only done/marked ones close.');

← 03c1002 auto-data-snapshot: 2026-09-01T12:55:30 (1 data files) — pac  ·  back to Gated Morning Review  ·  (newest)