[object Object]

← back to Gated Morning Review

morning-review: dedupe queue — hide tasks that duplicate a gated memo, badge sibling memos as 'step N of M'

e3a8b5e665534ce568095c4fcc8e5b8470ed0026 · 2026-08-20 09:36:15 -0700 · Steve Abrams

- scanTasks(limit, excludeTkNums): drop any tk task whose TK# already has a gated memo card (they were the same work shown twice — 28 phantom dups)
- scanQueue: tag multi-file tickets with siblingIndex/siblingCount so related memos read as intentional steps
- server: wire gatedTicketNums() into the render + /api/items; add 'step N of M' badge

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

Files touched

Diff

commit e3a8b5e665534ce568095c4fcc8e5b8470ed0026
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 20 09:36:15 2026 -0700

    morning-review: dedupe queue — hide tasks that duplicate a gated memo, badge sibling memos as 'step N of M'
    
    - scanTasks(limit, excludeTkNums): drop any tk task whose TK# already has a gated memo card (they were the same work shown twice — 28 phantom dups)
    - scanQueue: tag multi-file tickets with siblingIndex/siblingCount so related memos read as intentional steps
    - server: wire gatedTicketNums() into the render + /api/items; add 'step N of M' badge
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib.mjs    | 23 +++++++++++++++++++++--
 server.mjs |  9 +++++----
 2 files changed, 26 insertions(+), 6 deletions(-)

diff --git a/lib.mjs b/lib.mjs
index 4580f04..9786902 100644
--- a/lib.mjs
+++ b/lib.mjs
@@ -80,7 +80,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();
-  return files.map(f => {
+  const out = files.map(f => {
     const full = path.join(QDIR, f);
     let st; try { st = fs.statSync(full); } catch { return null; }
     const age = Math.floor((now - st.mtimeMs) / 86400000);
@@ -99,10 +99,28 @@ export function scanQueue() {
       ageWord: age <= 0 ? 'today' : age === 1 ? '1 day old' : `${age} days old`,
       ...c, bad, suggest, aging, disposition: (c.staleSig || age > STALE_DAYS) ? 'STALE' : 'DECIDE' };
   }).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.
+  const tkCount = {};
+  for (const it of out) if (it.ticket) tkCount[it.ticket] = (tkCount[it.ticket] || 0) + 1;
+  const tkSeen = {};
+  for (const it of out) if (it.ticket && tkCount[it.ticket] > 1) {
+    it.siblingCount = tkCount[it.ticket];
+    it.siblingIndex = (tkSeen[it.ticket] = (tkSeen[it.ticket] || 0) + 1);
+  }
+  return out;
+}
+
+// The numeric TK ids (e.g. "TK-10488") that already have a gated memo file in the queue.
+// A tk task whose number is here is the SAME work as its memo card — dup, hide it.
+export function gatedTicketNums() {
+  return new Set(scanQueue().map(x => x.ticket).filter(Boolean));
 }
 
 // tk task backlog — parsed from `tk list`.
-export function scanTasks(limit = 60) {
+// excludeTkNums: numeric TK ids that already have a gated memo card — those tasks are
+// the same work shown twice, so we drop them from this viewer (they stay on the tk board).
+export function scanTasks(limit = 60, excludeTkNums = null) {
   let out = '';
   try { out = execFileSync('tk', ['list'], { encoding: 'utf8', timeout: 8000, env: { ...process.env, TK_AGENT: 'morning-viewer' } }); }
   catch { return []; }
@@ -111,6 +129,7 @@ export function scanTasks(limit = 60) {
     const m = line.match(/^(TK-[\w-]+)\s+\[(\w+)\]\s+\(([^)]*)\)\s+\{([^}]*)\}\s+(.*)$/);
     if (!m) continue;
     const [, id, status, owner, project, title] = m;
+    if (excludeTkNums) { const n = (id.match(/TK-\d+/) || [])[0]; if (n && excludeTkNums.has(n)) continue; }
     const c = classify(title, '', id);
     const suggest = status === 'blocked' ? "⏳ Stuck waiting on something else — can't run it yet."
       : status === 'doing' ? "⚙️ A robot's already working on it — leave it alone."
diff --git a/server.mjs b/server.mjs
index e7e465d..06edc87 100644
--- a/server.mjs
+++ b/server.mjs
@@ -6,7 +6,7 @@ import path from 'path';
 import os from 'os';
 import { execFile } from 'child_process';
 import { fileURLToPath } from 'url';
-import { scanQueue, scanTasks, QDIR } from './lib.mjs';
+import { scanQueue, scanTasks, gatedTicketNums, QDIR } from './lib.mjs';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const PORT = process.env.PORT || 9440;
@@ -59,7 +59,7 @@ const card = (it) => {
     ? `<span class="age ${it.aging ? 'old' : ''}">${it.aging ? '🔴 ' : '🕒 '}${it.ageWord}</span>`
     : `<span class="age ${it.blocked ? 'blk' : it.doing ? 'go' : ''}">${it.blocked ? '⛔ blocked' : it.doing ? '⚙️ in progress' : '○ open'}</span>`;
   return `<div class=card>
-    <div class=top>${ageBadge}${it.ticket || (it.kind==='task'?it.id:'') ? `<span class=tk>${it.ticket||it.id}</span>` : ''}${it.kind==='task'?`<span class=proj>${it.project||''}</span>`:''}</div>
+    <div class=top>${ageBadge}${it.ticket || (it.kind==='task'?it.id:'') ? `<span class=tk>${it.ticket||it.id}</span>` : ''}${it.siblingCount ? `<span class=sib>· step ${it.siblingIndex} of ${it.siblingCount}</span>` : ''}${it.kind==='task'?`<span class=proj>${it.project||''}</span>`:''}</div>
     <div class=big>${it.big}</div>
     <div class=cols>
       <div class=col><div class=lbl>❓ Why it's needed</div><div class=txt>${it.why}</div>
@@ -87,6 +87,7 @@ h1{font-size:38px;margin:6px 0}.sub{font-size:20px;color:#6b6257;margin-bottom:1
 .age{font-size:15px;font-weight:700;padding:3px 11px;border-radius:99px;background:#efe9df;color:#5b5346}
 .age.old{background:#fde7e5;color:var(--old)}.age.blk{background:#f3e8ff;color:#7c3aed}.age.go{background:#e6f4ea;color:#1f8f4e}
 .tk{font-size:14px;font-weight:700;color:#8a6d3b;background:#fbf3e2;padding:2px 9px;border-radius:6px}
+.sib{font-size:13px;font-weight:700;color:#8a6d3b}
 .proj{font-size:13px;color:#9a8f80}
 .big{font-size:26px;font-weight:700;margin:2px 0 12px}
 .cols{display:flex;gap:18px;flex-wrap:wrap}.col{flex:1;min-width:260px}
@@ -125,7 +126,7 @@ async function close_(id,kind,el){el.closest('.card').classList.add('done');
 http.createServer((req, res) => {
   if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Morning Review"' }); return res.end('auth'); }
   if (req.url === '/healthz') { res.writeHead(200); return res.end('ok'); }
-  if (req.url === '/api/items') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ gated: scanQueue(), tasks: scanTasks() })); }
+  if (req.url === '/api/items') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ gated: scanQueue(), tasks: scanTasks(60, gatedTicketNums()) })); }
   if ((req.url === '/api/run' || req.url === '/api/decide') && req.method === 'POST') {
     let b = ''; req.on('data', c => b += c); req.on('end', async () => {
       try {
@@ -140,6 +141,6 @@ http.createServer((req, res) => {
     }); return;
   }
   const gated = scanQueue().filter(i => i.disposition === 'DECIDE');
-  const tasks = scanTasks();
+  const tasks = scanTasks(60, gatedTicketNums());
   res.writeHead(200, { 'content-type': 'text/html' }); res.end(PAGE(gated, tasks));
 }).listen(PORT, () => console.log(`[morning-review] http://127.0.0.1:${PORT} (admin/DW2024!)`));

← 4708dcb viewer v3: add 'What I'd do' suggested-action column (12yr p  ·  back to Gated Morning Review  ·  fix: Tasks-tab Close now marks the tk ticket done (was a no- 8bf121c →