[object Object]

← back to Ticket System

ticket board: PARKED lane + reaper skips parked tickets (durable registry)

e8b78db94d5e465499cb3bf29219b951eb8e4b7f · 2026-09-20 11:29:57 -0700 · Steve

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

Files touched

Diff

commit e8b78db94d5e465499cb3bf29219b951eb8e4b7f
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Sep 20 11:29:57 2026 -0700

    ticket board: PARKED lane + reaper skips parked tickets (durable registry)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QZehHfR2cgtmFvznNavrn5
---
 reaper.js | 10 ++++++++++
 server.js | 42 ++++++++++++++++++++++++++++++++++++------
 2 files changed, 46 insertions(+), 6 deletions(-)

diff --git a/reaper.js b/reaper.js
index 8c459cba..96cc7cdb 100644
--- a/reaper.js
+++ b/reaper.js
@@ -45,6 +45,15 @@ for (const line of ps) {
 const tickets = [...loadTickets().values()];
 const shortId = id => (id.match(/^(TK-\d+)/) || [id, id])[1].toUpperCase();
 
+// Durable PARKED registry (TK-11946): a parked ticket is deliberately held out of the
+// active queue, so the reaper must NOT flag it a zombie (that would resurface it).
+const PARKED_MJS = require('path').join(require('os').homedir(), '.claude', 'skills', 'parked', 'parked.mjs');
+let parkedTickets = new Set();
+try {
+  parkedTickets = new Set(JSON.parse(sh(`${process.execPath} ${PARKED_MJS} list-parked --json`) || '[]')
+    .filter(e => e && e.kind === 'ticket').map(e => e.id));
+} catch { /* unreadable -> treat none parked (never over-suppress on a blind read) */ }
+
 // disposition heuristic from the most-recent action text
 function suggest(t, lastText) {
   const s = (lastText || '').toLowerCase();
@@ -56,6 +65,7 @@ function suggest(t, lastText) {
 const zombies = [];
 for (const t of tickets) {
   if (t.status !== 'doing') continue;
+  if (parkedTickets.has(shortId(t.id)) || parkedTickets.has(t.id)) continue; // parked → held, never reaped
   const acts = (t.actions || []).map(a => ({ ts: +new Date(a.ts), text: a.text })).sort((a, b) => b.ts - a.ts);
   const last = acts[0] || { ts: +new Date(t.updated_at || t.created_at || now), text: '' };
   const idleH = (now - last.ts) / 3600000;
diff --git a/server.js b/server.js
index e6cb44b1..8a81aebe 100644
--- a/server.js
+++ b/server.js
@@ -3,7 +3,7 @@ const http = require('http');
 const fs = require('fs');
 const path = require('path');
 const os = require('os');
-const { exec, execFile, spawn } = require('child_process');
+const { exec, execFile, execFileSync, spawn } = require('child_process');
 const zlib = require('zlib');
 const { tickets, STATUSES, messages, resolveId, append, withLock, IDRE, REFRE, resolveList, EVENTS } = require('./lib.js');
 
@@ -429,13 +429,36 @@ function scoreTicket(t) {
   const tier = priority >= 26 ? 'high' : priority >= 18 ? 'med' : 'low';
   return { value, urgency, ease, safety, priority, tier, money: money || 0, days };
 }
+// Durable PARKED registry (TK-11946) — a parked TICKET leaves the active/blocked
+// columns into a PARKED lane and is excluded from ranking, so it stops resurfacing
+// on the board. Read via the shared helper (the ONE implementation), cached 5s.
+// Call parked.mjs directly with THIS node (process.execPath) — a bash->node wrapper
+// fails under a pm2/launchd bare PATH, which would silently empty the parked set.
+const PARKED_MJS = path.join(os.homedir(), '.claude', 'skills', 'parked', 'parked.mjs');
+// Tickets are stored with FULL ids (TK-11946-build-...); the registry stores the
+// SHORT id (TK-11946). Compare on the short form so a parked ticket matches.
+const shortTk = id => (String(id).match(/^(TK-\d+)/) || [id, id])[1];
+let _parkedCache = { at: 0, ids: new Set() };
+function parkedTicketIds() {
+  if (Date.now() - _parkedCache.at < 5000) return _parkedCache.ids;
+  let ids = new Set();
+  try {
+    const out = execFileSync(process.execPath, [PARKED_MJS, 'list-parked', '--json'], { encoding: 'utf8', timeout: 8000 });
+    ids = new Set(JSON.parse(out).filter(e => e && e.kind === 'ticket').map(e => e.id));
+  } catch { /* registry unreadable -> treat none parked (never hide a ticket on a blind read) */ }
+  _parkedCache = { at: Date.now(), ids };
+  return ids;
+}
+
 // Attach ranking/ratings to a flat ticket list. Only open/doing/blocked get ranked
 // (done/stopped are excluded from the ranking per the brief) — those get priority 0 / no rank.
+// A parked ticket is never ranked (it is durably held out of the active queue).
 function withRanking(list) {
   const RANKABLE = new Set(['open', 'doing', 'blocked']);
+  const parked = parkedTicketIds();
   const scored = [];
   for (const t of list) {
-    if ((t.kind || 'task') === 'task' && RANKABLE.has(t.status)) {
+    if ((t.kind || 'task') === 'task' && RANKABLE.has(t.status) && !parked.has(shortTk(t.id))) {
       const s = scoreTicket(t);
       t.priority = s.priority; t.tier = s.tier;
       t.ratings = { value: s.value, urgency: s.urgency, ease: s.ease, safety: s.safety };
@@ -453,9 +476,15 @@ function withRanking(list) {
 }
 
 function page() {
-  const cols = { open: [], doing: [], blocked: [], done: [], stopped: [] };
-  for (const t of cachedTickets().values()) (cols[t.status] || (cols[t.status] = [])).push(t);
+  const cols = { open: [], doing: [], blocked: [], done: [], stopped: [], parked: [] };
+  const parkedIds = parkedTicketIds();
+  for (const t of cachedTickets().values()) {
+    // A parked, still-active ticket peels out of its status column into the PARKED lane.
+    const key = (parkedIds.has(shortTk(t.id)) && ['open', 'doing', 'blocked'].includes(t.status)) ? 'parked' : t.status;
+    (cols[key] || (cols[key] = [])).push(t);
+  }
   for (const k of STATUSES) cols[k].sort((a, b) => a.updated_at < b.updated_at ? 1 : -1);
+  cols.parked.sort((a, b) => a.updated_at < b.updated_at ? 1 : -1);
   cols.done = cols.done.slice(0, 40);
   cols.stopped = cols.stopped.slice(0, 40);
   const card = t => `<div class="card" onclick="this.classList.toggle('x')">
@@ -484,7 +513,8 @@ function page() {
   body{margin:0;font:14px -apple-system,sans-serif;background:#0f1115;color:#e6e6e6}
   h1{font-size:16px;margin:0;padding:14px 18px;border-bottom:1px solid #262a33;letter-spacing:.06em}
   h1 small{color:#8a93a5;font-weight:400;margin-left:10px}
-  .board{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;padding:14px;align-items:start}
+  .board{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;padding:14px;align-items:start}
+  .col-parked .card{border-left:3px solid #ff69b4;opacity:.7}.col-parked h2{color:#ff9ecb}
   .col h2{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#8a93a5;margin:4px 2px 8px}
   .card{background:#171b22;border:1px solid #262a33;border-radius:8px;padding:10px 12px;margin-bottom:8px;cursor:pointer}
   .cid{font-weight:600;color:#6db3f2;font-size:12px}.proj{float:right;color:#8a93a5;font-weight:400}
@@ -513,7 +543,7 @@ function page() {
 </style>
 <h1>FLEET TICKETS<small>every agent action rides a ticket — tk new / comment / note / log / dm / inbox / reply / @mention / take / done</small><a href="/office" style="float:right;color:#c9a4f2;text-decoration:none;font-size:13px;border:1px solid #2c3140;padding:4px 10px;border-radius:6px">🏢 3D Office →</a></h1>
 ${dmPanel}
-<div class="board">${STATUSES.map(s => `<div class="col col-${s}"><h2>${s} (${cols[s].length})</h2>${cols[s].map(card).join('') || '<div class="c none">empty</div>'}</div>`).join('')}</div>`;
+<div class="board">${[...STATUSES, 'parked'].map(s => `<div class="col col-${s}"><h2>${s === 'parked' ? '🩷 parked' : s} (${(cols[s] || []).length})</h2>${(cols[s] || []).map(card).join('') || '<div class="c none">empty</div>'}</div>`).join('')}</div>`;
 }
 
 http.createServer((req, res) => {

← fbfd5fba tk new: intercept bare --help/-h so it prints usage instead  ·  back to Ticket System  ·  TK-11946: hold parked tickets out of the DEFAULT / board.htm 188325df →