[object Object]

← back to Ticket System

Backfill blocked ticket classifications

94c9fbd3c6b791d32c77f1d5212fbe710ad39d53 · 2026-08-31 10:25:04 -0700 · Steve Abrams

Files touched

Diff

commit 94c9fbd3c6b791d32c77f1d5212fbe710ad39d53
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 10:25:04 2026 -0700

    Backfill blocked ticket classifications
---
 board.html                    |  4 ++--
 scripts/backfill-blockers.mjs | 46 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 48 insertions(+), 2 deletions(-)

diff --git a/board.html b/board.html
index 2a172c55..4cb6c7ff 100644
--- a/board.html
+++ b/board.html
@@ -225,7 +225,7 @@ function match(t){if(VIEW!=='all'&&(t.kind||'task')!==VIEW)return false;if(!q)re
   const thread=[...(t.comments||[]).map(c=>c.text),...(t.actions||[]).map(a=>a.text)].join(' ');
   const hay=(t.id+' '+t.title+' '+(t.project||'')+' '+(t.assignee||'')+' '+t.status+' '+(t.kind||'task')+' '+JSON.stringify(t.schedule||{})+' '+thread).toLowerCase();
   return q.toLowerCase().split(/\s+/).every(w=>hay.includes(w));}
-const groupLabel=(k,v)=>k==='status'?(v==='stopped'?'TicketStopped':v):k==='assignee'?(v||'unassigned'):(v||'—');
+const groupLabel=(k,v)=>k==='status'?(v==='stopped'?'TicketStopped':v):k==='blocker'?(BLOCKER_FACE[v]||v||'Unclassified'):k==='assignee'?(v||'unassigned'):(v||'—');
 function render(){
   renderHead();
   const cols=ordered(); const span=cols.length+1;
@@ -251,7 +251,7 @@ function render(){
     for(const t of rows)rowTr(t);
   }else{
     const groups=new Map();
-    for(const t of rows){const gv=GROUP==='status'?t.status:GROUP==='project'?(t.project||''):(t.assignee||'');(groups.get(gv)||groups.set(gv,[]).get(gv)).push(t);}
+    for(const t of rows){const gv=GROUP==='status'?t.status:GROUP==='blocker'?(t.blocker?.type||''):GROUP==='project'?(t.project||''):(t.assignee||'');(groups.get(gv)||groups.set(gv,[]).get(gv)).push(t);}
     // order groups: for status use canonical order, else alpha
     const order=GROUP==='status'?['open','doing','blocked','done','stopped']:[...groups.keys()].sort((a,b)=>String(a).localeCompare(String(b)));
     for(const gv of order){const list=groups.get(gv);if(!list)continue;
diff --git a/scripts/backfill-blockers.mjs b/scripts/backfill-blockers.mjs
new file mode 100644
index 00000000..87ffc3e3
--- /dev/null
+++ b/scripts/backfill-blockers.mjs
@@ -0,0 +1,46 @@
+#!/usr/bin/env node
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const tk = require('../lib.js');
+
+const apply = process.argv.includes('--apply');
+const force = process.argv.includes('--force');
+const now = new Date();
+const isoAfter = days => new Date(now.getTime() + days * 86400000).toISOString();
+const allText = t => [t.title, ...(t.comments || []).map(x => x.text), ...(t.actions || []).map(x => x.text)].join(' ');
+const latestText = t => [...(t.comments || []), ...(t.actions || [])].sort((a,b) => a.ts.localeCompare(b.ts)).at(-1)?.text || t.title;
+
+function classify(t) {
+  const text = allText(t);
+  if (/standing directive|kill[- ]switch|purpose is to keep .* blocked|intentional (fail|guardrail)|do not (run|send|publish)/i.test(text)) return 'intentional_guardrail';
+  if (t.project === 'cron-fire-canary' || /^Cron issue:/i.test(t.title)) return 'technical_dependency';
+  if (/send-to-list|destructive|identity|console-only|login|2FA|KYC|spend|real-money|sudo|delete|purge|history rewrite/i.test(text)) return 'steve_action';
+  if (/awaiting .*vendor|vendor .*cost list|cost list .*arriv|upstream .*unavailable|platform review|app review|trademark gate|legal gate|external .*wait/i.test(text)) return 'external_wait';
+  if (/\bSteve('|’)?s?\b|approval|customer-facing|production write|prod write|live Shopify|publish authorization/i.test(text)) return 'steve_action';
+  return 'technical_dependency';
+}
+
+function nextAction(type) {
+  if (type === 'steve_action') return 'Surface in the next ranked Steve decision batch; execute only after explicit approval or identity action.';
+  if (type === 'external_wait') return 'Recheck the named external dependency on schedule; attach fresh evidence when it changes.';
+  if (type === 'intentional_guardrail') return 'Keep the guardrail armed; recheck only after an explicit Steve directive changes it.';
+  return 'Owner reproduces the dependency, records exact evidence, and advances only reversible non-gated remediation.';
+}
+
+const rows = [...tk.tickets().values()].filter(t => t.status === 'blocked' && (force || !t.blocker));
+const summary = {};
+for (const t of rows) {
+  const type = classify(t); summary[type] = (summary[type] || 0) + 1;
+  const blocker = {
+    type,
+    condition: latestText(t).replace(/\s+/g, ' ').slice(0, 500),
+    next_action: nextAction(type),
+    owner: type === 'steve_action' ? 'steve' : (t.assignee || 'unassigned'),
+    evidence_at: t.updated_at,
+    recheck_at: type === 'technical_dependency' ? isoAfter(1) : type === 'external_wait' ? isoAfter(7) : undefined,
+    steve_one_action: type === 'steve_action',
+  };
+  if (apply) tk.ticketBlocker(t.id, blocker, { agent:'codex' });
+  console.log(`${apply ? 'APPLY' : 'DRY'}\t${t.id}\t${type}\t${blocker.owner}`);
+}
+console.error(JSON.stringify({ apply, tickets:rows.length, lanes:summary }));

← 2d6d9cea Add actionable blocker lanes  ·  back to Ticket System  ·  Record blocker lanes E2E proof 219bfbe0 →