← 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
M lib.mjsM sweep.mjsA test-tk11801.mjs
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)