[object Object]

← back to Ticket System

Add unified ticket agent and skill viewer

438a987464a8dcc800a9d6d78b092c359cad7932 · 2026-09-02 13:55:56 -0700 · Steve Abrams

Files touched

Diff

commit 438a987464a8dcc800a9d6d78b092c359cad7932
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 2 13:55:56 2026 -0700

    Add unified ticket agent and skill viewer
---
 board.html                                       | 49 +++++++++++++++++++++---
 server.js                                        | 47 +++++++++++++++++++++++
 verification/TK-viewer-navigation-e2e-proof.json | 19 +++++++++
 3 files changed, 110 insertions(+), 5 deletions(-)

diff --git a/board.html b/board.html
index 4496df92..434a2673 100644
--- a/board.html
+++ b/board.html
@@ -22,6 +22,11 @@
   .search{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:9px;padding:7px 11px;font-size:13px;min-width:200px}
   .btn{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 10px;font-size:12px;cursor:pointer}
   .btn:hover{border-color:var(--acc)} .btn:disabled{opacity:.4;cursor:default}
+  .globalnav{display:flex;align-items:center;gap:4px;padding:4px;background:var(--card);border:1px solid var(--line);border-radius:10px}
+  .globalnav button{border:0;background:transparent;color:var(--mut);padding:6px 10px;border-radius:7px;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.08em;text-transform:uppercase;cursor:pointer}
+  .globalnav button:hover,.globalnav button.on{background:var(--ink);color:var(--bg)}
+  .layoutnav{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+  .layoutnav button{border:0;border-right:1px solid var(--line);background:var(--card);color:var(--mut);padding:6px 9px;cursor:pointer}.layoutnav button:last-child{border:0}.layoutnav button.on{color:var(--gold);background:var(--thbg)}
   select.gsel{background:var(--card);border:1px solid var(--line);color:var(--ink);border-radius:8px;padding:6px 8px;font-size:12px}
   .grow{flex:1 1 auto}
   #dens{width:96px;accent-color:var(--acc)}
@@ -91,9 +96,17 @@
   .convo{background:var(--card);border:1px solid var(--line);border-left:3px solid #a06ef2;border-radius:8px;padding:8px 10px;font-size:12px}
   .convo .chd{font-size:11px;color:#c9a4f2;font-weight:600;margin-bottom:5px}
   .dm{margin:5px 0;padding-top:5px;border-top:1px dashed #2c3140}.dm:first-of-type{border-top:0}.dm .arw{color:var(--mut);margin:0 3px}.dm .dtx{color:#c6ccd8;margin-top:2px}
+  #catalog{display:none;padding:16px;--catalog-cols:4}
+  #catalog.show{display:block}.catalog-head{display:flex;align-items:center;gap:10px;margin-bottom:14px}.catalog-head h2{margin:0;font:800 20px/1.1 Georgia,serif}.catalog-head small{color:var(--mut)}
+  .catalog-grid{display:grid;grid-template-columns:repeat(var(--catalog-cols),minmax(220px,1fr));gap:10px}
+  .catalog-card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px;min-width:0}.catalog-card:hover{border-color:var(--acc)}
+  .catalog-card h3{margin:0 0 7px;font:700 13px/1.25 ui-monospace,Menlo,monospace;overflow-wrap:anywhere}.catalog-card p{color:var(--mut);font-size:12px;line-height:1.45;margin:7px 0}
+  .kvrow{display:flex;gap:12px;align-items:baseline;border-bottom:1px solid var(--rowline);padding:10px 4px}.kvrow h3{width:230px;flex:0 0 230px;margin:0;font:700 12px ui-monospace,Menlo,monospace}.kvmeta{display:flex;gap:14px;flex-wrap:wrap;min-width:0}.kv{font-size:12px}.kv b{color:var(--faint);font-size:9px;text-transform:uppercase;letter-spacing:.08em;margin-right:4px}.stamp{display:block;color:var(--faint);font-size:10px;margin-top:8px}
+  @media(max-width:900px){.catalog-grid{grid-template-columns:repeat(2,minmax(180px,1fr))}.globalnav{order:10;width:100%}.kvrow{align-items:flex-start;flex-direction:column}.kvrow h3{width:auto;flex:auto}}
 </style></head><body>
 <header>
   <h1>FLEET TICKETS</h1>
+  <nav class="globalnav" aria-label="Fleet viewer"><button data-section="all">All</button><button data-section="blocked">Blocked</button><button data-section="agents">Agents</button><button data-section="skills">Skills</button></nav>
   <select class="gsel" id="view" title="Separate actionable work from recurring registries">
     <option value="task">Task Queue</option><option value="continuous_loop">Continuous Loops</option><option value="scheduled_job">Scheduled Jobs</option><option value="all">All</option>
   </select>
@@ -104,6 +117,7 @@
   </label>
   <span id="dtdchip" title="last DTD run-now sweep">DTD: —</span>
   <span class="grow"></span>
+  <span class="layoutnav" title="Grid or compact KV list"><button data-layout="grid">▦ Grid</button><button data-layout="kv">☷ KV</button></span>
   <input type="range" id="dens" min="4" max="14" step="1" title="row density">
   <button class="btn" id="colsBtn">▦ Columns</button>
   <button class="btn" id="pillsBtn" title="Toggle stat pills">◍ Pills</button>
@@ -131,6 +145,7 @@
 <div id="pillmenu"></div>
 <div id="detmenu"></div>
 <div class="tblwrap"><table class="g"><thead><tr id="thead"></tr></thead><tbody id="tbody"></tbody></table></div>
+<section id="catalog"></section>
 <div id="dmwrap"></div>
 <script>
 const $=s=>document.querySelector(s), esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
@@ -188,7 +203,11 @@ function lastTs(t){let ms=+new Date(t.updated_at||t.created_at||0);for(const a o
 function lastText(t){const acts=(t.actions||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));if(acts[0])return acts[0].text;const cs=(t.comments||[]).slice().sort((a,b)=>+new Date(b.ts)-+new Date(a.ts));return cs[0]?cs[0].text:'';}
 const CK=COLS.map(c=>c.k);
 const LS=(k,d)=>{try{return JSON.parse(localStorage.getItem(k))??d}catch(e){return d}}, save=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}};
-let DATA=[], q='';
+let DATA=[], AGENTS=[], SKILLS=[], q='';
+const URLSTATE=new URLSearchParams(location.search);
+let SECTION=URLSTATE.get('section')||LS('tkSection','blocked'), LAYOUT=URLSTATE.get('layout')||LS('tkLayout','grid');
+if(!['all','blocked','agents','skills'].includes(SECTION))SECTION='blocked';if(!['grid','kv'].includes(LAYOUT))LAYOUT='grid';
+let CATALOG_SORT=LS('tkCatalogSort','name'), CATALOG_DIR=LS('tkCatalogDir',1);
 let sortKey=LS('tkSortKey','created_at'), sortDir=LS('tkSortDir',-1); if(!CK.includes(sortKey))sortKey='created_at';
 let VIEW=LS('tkView','task');
 let VIS=LS('tkCols',null)||Object.fromEntries(COLS.map(c=>[c.k,!!c.def]));
@@ -237,13 +256,15 @@ function renderHead(){
   }
   $('#selall').onclick=e=>{const on=e.target.checked;const vis=DATA.filter(match);if(on)for(const t of vis)SEL.add(t.id);else for(const t of vis)SEL.delete(t.id);render();syncBar();};
 }
-function match(t){if(VIEW!=='all'&&(t.kind||'task')!==VIEW)return false;if(!q)return true;
+function match(t){if(VIEW!=='all'&&(t.kind||'task')!==VIEW)return false;if(SECTION==='blocked'&&t.status!=='blocked')return false;if(!q)return true;
   // search the FULL thread — every comment (note/win/challenge/cody incl.) + every action — not just lastText
   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==='blocker'?(BLOCKER_FACE[v]||v||'Unclassified'):k==='assignee'?(v||'unassigned'):(v||'—');
 function render(){
+  if(SECTION==='agents'||SECTION==='skills'){renderCatalog();return;}
+  $('#catalog').classList.remove('show');$('.tblwrap').style.display='';$('#actionbar').classList.remove('show');
   renderHead();
   const cols=ordered(); const span=cols.length+1;
   let rows=DATA.filter(match);
@@ -286,6 +307,24 @@ function render(){
   const vis=DATA.filter(match); const sa=$('#selall'); if(sa)sa.checked=vis.length>0&&vis.every(t=>SEL.has(t.id));
   renderPills(); syncBar();
 }
+function catalogRows(){return SECTION==='agents'?AGENTS:SKILLS;}
+function catalogFields(){return SECTION==='agents'?[['name','Agent'],['tickets','Tickets'],['assigned','Assigned'],['actions','Actions'],['comments','Comments'],['updated_at','Updated']]:[['name','Skill'],['root','Root'],['slug','Slug'],['description','Description'],['updated_at','Updated']];}
+function renderCatalog(){
+  $('.tblwrap').style.display='none';$('#catalog').classList.add('show');$('#actionbar').classList.remove('show');
+  let rows=catalogRows().filter(r=>!q||Object.values(r).join(' ').toLowerCase().includes(q.toLowerCase()));
+  rows.sort((a,b)=>{const x=a[CATALOG_SORT]??'',y=b[CATALOG_SORT]??'';return (typeof x==='number'&&typeof y==='number'?(x-y):String(x).localeCompare(String(y),undefined,{numeric:true}))*CATALOG_DIR;});
+  const fields=catalogFields(), opts=fields.map(([k,l])=>`<option value="${k}" ${CATALOG_SORT===k?'selected':''}>${l}</option>`).join('');
+  const meta=r=>fields.slice(1).map(([k,l])=>r[k]==null||r[k]===''?'':`<span class="kv"><b>${esc(l)}</b>${k.endsWith('_at')?dt(r[k]):esc(r[k])}</span>`).join('');
+  const stamp=r=>`<span class="stamp" title="${esc(r.created_at||'')}">🕓 ${r.created_at?new Date(r.created_at).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'unknown'}</span>`;
+  const body=LAYOUT==='kv'?rows.map(r=>`<article class="kvrow"><h3>${esc(r.name)}</h3><div class="kvmeta">${meta(r)}${stamp(r)}</div></article>`).join(''): `<div class="catalog-grid">${rows.map(r=>`<article class="catalog-card"><h3>${esc(r.name)}</h3>${r.description?`<p>${esc(r.description)}</p>`:''}<div class="kvmeta">${meta(r)}</div>${stamp(r)}</article>`).join('')}</div>`;
+  $('#catalog').innerHTML=`<div class="catalog-head"><h2>${SECTION==='agents'?'Agent activity':'Installed skills'}</h2><small>${rows.length} shown</small><span class="grow"></span><select class="gsel" id="catalogSort">${opts}</select><button class="btn" id="catalogDir">${CATALOG_DIR>0?'▲ Asc':'▼ Desc'}</button></div>${body||'<div class="c none">No matching records.</div>'}`;
+  $('#catalogSort').onchange=e=>{CATALOG_SORT=e.target.value;save('tkCatalogSort',CATALOG_SORT);renderCatalog();};$('#catalogDir').onclick=()=>{CATALOG_DIR*=-1;save('tkCatalogDir',CATALOG_DIR);renderCatalog();};
+}
+function syncUrl(){const u=new URL(location.href);u.searchParams.set('section',SECTION);u.searchParams.set('layout',LAYOUT);history.replaceState(null,'',u);}
+function setSection(section){SECTION=section;save('tkSection',SECTION);syncUrl();q='';$('#q').value='';document.querySelectorAll('[data-section]').forEach(b=>b.classList.toggle('on',b.dataset.section===SECTION));render();}
+function setLayout(layout){LAYOUT=layout;save('tkLayout',LAYOUT);syncUrl();document.querySelectorAll('[data-layout]').forEach(b=>b.classList.toggle('on',b.dataset.layout===LAYOUT));if(SECTION==='agents'||SECTION==='skills')renderCatalog();else{const agent=window.NavAgent&&window.NavAgent.instances&&window.NavAgent.instances[0];if(agent&&agent.setView)agent.setView(LAYOUT==='kv'?'list':'grid');}}
+document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>setSection(b.dataset.section));document.querySelectorAll('[data-layout]').forEach(b=>b.onclick=()=>setLayout(b.dataset.layout));
+document.querySelectorAll('[data-section]').forEach(b=>b.classList.toggle('on',b.dataset.section===SECTION));document.querySelectorAll('[data-layout]').forEach(b=>b.classList.toggle('on',b.dataset.layout===LAYOUT));
 // ── selection action bar ──
 function syncBar(){const n=SEL.size;$('#seln').textContent=n;$('#actionbar').classList.toggle('show',n>0);}
 function selIds(){return [...SEL];}
@@ -364,8 +403,8 @@ function dmPanel(mm){
 async function load(){
   try{
     const base=location.origin; // never carries userinfo — fixes "URL includes credentials" fetch error when opened via admin:pass@host
-    const [t,m]=await Promise.all([fetch(base+'/api/tickets').then(r=>r.json()),fetch(base+'/api/messages').then(r=>r.json())]);
-    DATA=t; LASTMSG=m;
+    const [t,m,a,s]=await Promise.all([fetch(base+'/api/tickets').then(r=>r.json()),fetch(base+'/api/messages').then(r=>r.json()),fetch(base+'/api/agents').then(r=>r.json()),fetch(base+'/api/skills').then(r=>r.json())]);
+    DATA=t; LASTMSG=m; AGENTS=a; SKILLS=s;
     // drop any selected ids that no longer exist
     const live=new Set(DATA.map(x=>x.id));for(const id of [...SEL])if(!live.has(id))SEL.delete(id);
     // Deep-link support: a URL like /#q=TK-10454 (from the morning digest email)
@@ -376,7 +415,7 @@ async function load(){
   }catch(e){$('#tbody').innerHTML=`<tr><td>load failed: ${esc(e.message)}</td></tr>`;}
 }
 window.addEventListener('hashchange',()=>{const hq=decodeURIComponent((location.hash.match(/[#&]q=([^&]+)/)||[])[1]||'');setQ(hq);});
-renderColMenu(); renderPillMenu(); renderDetMenu(); $('#dmBtn').classList.toggle('off',!DM_ON); load(); loadDTD().then(()=>{ if(DTD.running) pollDTD(); }); setInterval(load,30000);
+renderColMenu(); renderPillMenu(); renderDetMenu(); $('#dmBtn').classList.toggle('off',!DM_ON); load(); loadDTD().then(()=>{ if(DTD.running) pollDTD(); }); setInterval(load,30000);setTimeout(()=>setLayout(LAYOUT),300);
 // ── day / night theme toggle (persisted; head-script already applied it — this wires the button) ──
 (function(){const b=$('#themeBtn'); if(!b)return; const paint=()=>{b.textContent=document.documentElement.dataset.theme==='light'?'☀ Day':'🌙 Night';}; paint();
   b.onclick=()=>{const light=document.documentElement.dataset.theme==='light'; document.documentElement.dataset.theme=light?'dark':'light'; save('tkTheme',light?'dark':'light'); paint();};})();
diff --git a/server.js b/server.js
index fe21a133..fdcd1fe6 100644
--- a/server.js
+++ b/server.js
@@ -65,6 +65,51 @@ function getRunning(cb) {
 
 const OFFICE_HTML = path.join(__dirname, 'office.html');
 const BOARD_HTML = path.join(__dirname, 'board.html');
+const SKILL_ROOTS = [
+  path.join(os.homedir(), '.agents', 'skills'),
+  path.join(os.homedir(), '.codex', 'skills'),
+];
+
+function installedSkills() {
+  const found = new Map();
+  for (const root of SKILL_ROOTS) {
+    let names = []; try { names = fs.readdirSync(root); } catch { continue; }
+    for (const dir of names) {
+      const file = path.join(root, dir, 'SKILL.md');
+      let body, st; try { body = fs.readFileSync(file, 'utf8'); st = fs.statSync(file); } catch { continue; }
+      const fm = body.match(/^---\s*\n([\s\S]*?)\n---/);
+      const meta = fm ? fm[1] : '';
+      const name = (meta.match(/^name:\s*["']?(.+?)["']?\s*$/m) || [])[1] || dir;
+      const rawDesc = (meta.match(/^description:\s*[>|-]?\s*["']?(.+?)["']?\s*$/m) || [])[1] || '';
+      const key = String(name).trim().toLowerCase();
+      if (!found.has(key)) found.set(key, {
+        name: String(name).trim(), slug: dir, description: String(rawDesc).trim(),
+        root: root.includes('.agents') ? 'agents' : 'codex', path: file,
+        created_at: (st.birthtime || st.mtime).toISOString(), updated_at: st.mtime.toISOString(),
+      });
+    }
+  }
+  return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
+}
+
+function ticketAgents() {
+  const map = new Map();
+  const touch = (name, ts, role, ticket) => {
+    if (!name) return;
+    let a = map.get(name); if (!a) a = { name, assigned: 0, actions: 0, comments: 0, tickets: new Set(), first_at: ts, last_at: ts };
+    a.tickets.add(ticket.id); if (role === 'assigned') a.assigned++; else a[role]++;
+    if (ts && (!a.first_at || ts < a.first_at)) a.first_at = ts;
+    if (ts && (!a.last_at || ts > a.last_at)) a.last_at = ts;
+    map.set(name, a);
+  };
+  for (const t of tickets().values()) {
+    touch(t.assignee, t.updated_at || t.created_at, 'assigned', t);
+    for (const a of (t.actions || [])) touch(a.agent, a.ts, 'actions', t);
+    for (const c of (t.comments || [])) touch(c.agent, c.ts, 'comments', t);
+  }
+  return [...map.values()].map(a => ({ ...a, tickets: a.tickets.size, created_at: a.first_at, updated_at: a.last_at }))
+    .sort((a, b) => b.tickets - a.tickets || a.name.localeCompare(b.name));
+}
 
 const PORT = process.env.PORT || 9794;
 const AUTH = 'Basic ' + Buffer.from(process.env.TK_AUTH || 'admin:DW2024!').toString('base64');
@@ -393,6 +438,8 @@ http.createServer((req, res) => {
   }
   if (req.url === '/api/running') { return getRunning(d => { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(d)); }); }
   if (req.url === '/api/tickets') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(withRanking([...tickets().values()]))); }
+  if (req.url === '/api/agents') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(ticketAgents())); }
+  if (req.url === '/api/skills') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify(installedSkills())); }
   if (req.url === '/api/messages') { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); return res.end(JSON.stringify([...messages().values()])); }
   if (req.url === '/kanban') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); return res.end(page()); }
   // default (/) = the adjustable-columns TABLE view (Steve's list-builds rule, 2026-08-10)
diff --git a/verification/TK-viewer-navigation-e2e-proof.json b/verification/TK-viewer-navigation-e2e-proof.json
new file mode 100644
index 00000000..855f8ad0
--- /dev/null
+++ b/verification/TK-viewer-navigation-e2e-proof.json
@@ -0,0 +1,19 @@
+{
+  "intent": "Expose blocked tickets in the advanced ticket viewer with unified All/Blocked/Agents/Skills navigation and Grid/KV layouts.",
+  "risk_tier": "R2 local admin UI",
+  "timestamp": "2026-09-02T20:55:00Z",
+  "environment": "temporary localhost server on 127.0.0.1:19794; installed headless Chrome",
+  "checks": [
+    {"boundary":"syntax","verdict":"PASS","evidence":"server.js node --check and both board inline scripts parsed with vm.Script"},
+    {"boundary":"unit/regression","verdict":"PASS","evidence":"npm test: 27/27"},
+    {"boundary":"ticket API","verdict":"PASS","evidence":"1264 tickets; 53 current blocked records"},
+    {"boundary":"agent API","verdict":"PASS","evidence":"954+ durable activity identities returned; live render later showed 956 as the ledger advanced"},
+    {"boundary":"skill API","verdict":"PASS","evidence":"572 installed SKILL.md records returned"},
+    {"boundary":"blocked browser route","verdict":"PASS","evidence":"/tmp/tk-blocked-viewer.png; Blocked active, rows and controls rendered"},
+    {"boundary":"agents grid route","verdict":"PASS","evidence":"/tmp/tk-agents-grid.png; cards, timestamps, counts, sort rendered"},
+    {"boundary":"skills KV route","verdict":"PASS","evidence":"/tmp/tk-skills-kv.png; compact labeled rows, descriptions, timestamps, sort rendered"},
+    {"boundary":"negative routing","verdict":"PASS","evidence":"unknown section/layout values fall back to blocked/grid"}
+  ],
+  "side_effects": "Read-only catalog endpoints and local UI assets only; no ticket status mutation or external action.",
+  "overall_verdict": "PASS"
+}

← 481c7122 auto-data-snapshot: 2026-09-01T12:55:30 (1 data files) — pac  ·  back to Ticket System  ·  Add five-word viewer summaries 811c80f2 →