← 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 →