[object Object]

← back to Stack Map Viewer

viewer: click any skill/canary → description + who/what/where/why/when 5W card; auto-open one on load

18c85c09ef924166c94796d41fd1a2d405d33150 · 2026-09-22 12:39:57 -0700 · Steve Abrams

Files touched

Diff

commit 18c85c09ef924166c94796d41fd1a2d405d33150
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 22 12:39:57 2026 -0700

    viewer: click any skill/canary → description + who/what/where/why/when 5W card; auto-open one on load
---
 index.html | 30 +++++++++++++++++++++--
 server.js  | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 109 insertions(+), 4 deletions(-)

diff --git a/index.html b/index.html
index b67637e..e8cbabd 100644
--- a/index.html
+++ b/index.html
@@ -53,6 +53,13 @@
   #crumbs .sep{color:#3a4761;margin:0 6px}
   #drawer h2{font-size:16px;margin:0 0 4px;word-break:break-all}
   #drawer .cnt{color:var(--dim);font-size:12px;margin-bottom:12px}
+  /* 5W info card */
+  #dinfo{margin:0 0 14px}
+  .icard{background:#0e1420;border:1px solid #2a3247;border-radius:10px;padding:12px 12px 8px}
+  .idesc{font-size:12.5px;line-height:1.55;color:#c7d2e6;margin-bottom:10px;border-bottom:1px solid #222a3a;padding-bottom:10px}
+  .iw{display:flex;gap:10px;padding:5px 0;align-items:baseline}
+  .iw .ik{flex:0 0 52px;font-size:10px;font-weight:700;letter-spacing:.6px;text-align:right;padding-top:1px}
+  .iw .iv{flex:1;font-size:12px;line-height:1.5;color:#dde3f0;word-break:break-word}
   #drawer ul{list-style:none;margin:0;padding:0;font-size:13px}
   #drawer li{padding:6px 8px;border-radius:6px;cursor:pointer;display:flex;gap:8px;align-items:baseline}
   #drawer li:nth-child(odd){background:#181c28}
@@ -91,7 +98,7 @@
 <div class="foot" id="foot"></div>
 <div id="drawer"><span id="close">✕</span>
   <div id="crumbs"></div>
-  <h2 id="dtitle"></h2><div class="cnt" id="dcnt"></div><ul id="dlist"></ul></div>
+  <h2 id="dtitle"></h2><div class="cnt" id="dcnt"></div><div id="dinfo"></div><ul id="dlist"></ul></div>
 <script>
 const N=(v)=>v==null?'…':v.toLocaleString();
 let DATA=null;
@@ -273,14 +280,30 @@ async function fetchDrill(type,id){
 }
 function startDrill(type,id,label){CRUMBS=[];openDrill(type,id,label);}
 function openDrill(type,id,label){CRUMBS.push({type,id,label:label||id});renderDrill();}
+function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[m]));}
+// ── 5W info card (description + what/where/who/why/when) ──────────────────────
+function renderInfo(info){
+  const el=document.getElementById('dinfo');
+  if(!info){el.innerHTML='';return;}
+  const row=(k,v,c)=>v?'<div class="iw"><span class="ik" style="color:'+c+'">'+k+'</span><span class="iv">'+esc(v)+'</span></div>':'';
+  el.innerHTML='<div class="icard">'
+    +(info.description?'<div class="idesc">'+esc(info.description)+'</div>':'')
+    +row('WHAT',info.what,'#8fe3b4')
+    +row('WHY',info.why,'#ffd27a')
+    +row('WHERE',info.where,'#9fc4ff')
+    +row('WHO',info.who,'#d6b8ff')
+    +row('WHEN',info.when,'#f3b6b6')
+    +'</div>';
+}
 async function renderDrill(){
   const c=CRUMBS[CRUMBS.length-1];
   const dw=document.getElementById('drawer');dw.classList.add('open');
-  document.getElementById('dtitle').textContent='…';document.getElementById('dlist').innerHTML='';document.getElementById('dcnt').textContent='';
+  document.getElementById('dtitle').textContent='…';document.getElementById('dlist').innerHTML='';document.getElementById('dcnt').textContent='';document.getElementById('dinfo').innerHTML='';
   renderCrumbs();
   const j=await fetchDrill(c.type,c.id);
   if(CRUMBS[CRUMBS.length-1]!==c)return; // superseded by a faster click
   document.getElementById('dtitle').textContent=j.title||c.label;
+  renderInfo(j.info);
   document.getElementById('dcnt').textContent=((j.children||[]).length)+' items'+(j.error?(' · error: '+j.error):'');
   const html=(j.children||[]).map((ch,i)=>'<li data-type="'+ch.type+'" data-id="'+encodeURIComponent(ch.id)+'"><span class="li-l">'+(ch.label||ch.id)+'</span>'+(ch.meta?'<span class="li-m" title="'+String(ch.meta).replace(/"/g,'&quot;')+'">'+ch.meta+'</span>':'')+'<span class="li-t">'+ch.type+'</span></li>').join('');
   document.getElementById('dlist').innerHTML=html||'<li>(no deeper detail)</li>';
@@ -389,8 +412,11 @@ function renderFolders(folders){
 }
 
 // ── Data / refresh ───────────────────────────────────────────────────────────
+let bootDrilled=false;
 function applyData(d){
   DATA=d;
+  // on load, auto-open one skill's 5W card so the description/who-what-where-why-when panel is visible to test
+  if(!bootDrilled){bootDrilled=true;setTimeout(()=>startDrill('skill','greenland-scraper-manager','greenland-scraper-manager'),450);}
   document.getElementById('sub').textContent=N(d.skills)+' skills · '+N(d.agents)+' agents · '+N(d.projects)+' projects · '+N(d.cronLoaded)+' cron · '+N(d.parked)+' parked';
   const vb=document.getElementById('verdict');vb.textContent='fleet: '+d.verdict;vb.className='badge '+d.verdict;
   document.getElementById('foot').textContent='Live from ~/.claude · ~/Library/LaunchAgents · yolo-queue — auto-refresh 30s — last scan '+new Date(d.ts).toLocaleTimeString();
diff --git a/server.js b/server.js
index 02a0457..15f3c3e 100644
--- a/server.js
+++ b/server.js
@@ -173,6 +173,85 @@ function heartbeatKids(dir) {
   return out;
 }
 
+// ── skill "5W" info block: description + what/why/where/who/when ─────────────
+// Robust to BOTH SKILL.md shapes: YAML frontmatter (name:/description:) AND the
+// bare "# Heading + prose" style. Always returns a filled card — never blank.
+const firstSentence = (s) => { const m = String(s || '').match(/^[\s\S]*?[.!?](\s|$)/); return (m ? m[0] : String(s || '')).trim(); };
+const sentences = (s) => String(s || '').split(/(?<=[.!?])\s+/).map(x => x.trim()).filter(Boolean);
+
+function parseSkillMd(id) {
+  const dir = path.join(SKILLS, id);
+  const md = readText(path.join(dir, 'SKILL.md'));
+  let name = id, desc = '', body = md;
+  const fm = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
+  if (fm) {
+    body = fm[2] || '';
+    const nm = fm[1].match(/^name:\s*(.+)$/m); if (nm) name = nm[1].trim();
+    const dm = fm[1].match(/^description:\s*([\s\S]*?)(?:\n[a-zA-Z_-]+:\s|\n*$)/m); if (dm) desc = dm[1].replace(/\s+/g, ' ').trim();
+  }
+  if (!desc) { // no frontmatter description — take the first real prose paragraph, stopping at any structural line
+    const h = body.match(/^#\s+(.+)$/m); if (h) name = h[1].replace(/[*#]/g, '').trim();
+    const isStruct = (l) => { const t = l.trim(); return t === '' || /^[#>`|]/.test(t) || /^\*\*[^*]+\*\*\s*:/.test(t) || /^(?:CREATE|SELECT|INSERT|UPDATE|DELETE|ALTER|\{|\}|<|\d+[.)])/i.test(t); };
+    const buf = []; let started = false;
+    for (const l of body.split('\n')) {
+      if (!started) { if (isStruct(l) || l.trim().length < 25) continue; started = true; buf.push(l.trim()); }
+      else { if (isStruct(l)) break; buf.push(l.trim()); if (buf.length >= 4) break; }
+    }
+    desc = buf.join(' ').replace(/\*\*/g, '').replace(/\s+/g, ' ').trim();
+  }
+  return { dir, name, desc, body };
+}
+
+function skillInfo(id) {
+  const { dir, name, desc, body } = parseSkillMd(id);
+  const hay = desc + ' \n' + body.slice(0, 4000);
+  // WHERE — vendor domains, staging tables, SKU prefix, target systems, dir
+  const domains = [...new Set([...hay.matchAll(/\b([a-z0-9][a-z0-9-]*\.(?:com|co\.uk|fr|net|org|io|us|design))\b/gi)].map(m => m[1].toLowerCase()))]
+    .filter(d => !/\.(?:js|md|json|sh|mjs|py)$/.test(d) && !/^com\.steve/.test(d)).slice(0, 4);
+  const tables = [...new Set([...hay.matchAll(/\b([a-z][a-z0-9_]*_(?:catalog|colorways|pricing|registry))\b/g)].map(m => m[1]))].slice(0, 4);
+  const prefix = (hay.match(/\bDW[A-Z]{2,4}-/) || [])[0] || '';
+  const systems = ['Shopify', 'FileMaker', 'dw_unified', 'Merchant Center', 'Postgres', 'Browserbase'].filter(s => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(hay));
+  let scripts = [];
+  try { scripts = fs.readdirSync(dir).filter(f => /\.(?:mjs|js|py|sh)$/.test(f)).slice(0, 6); } catch {}
+  const whereBits = [];
+  if (domains.length) whereBits.push('source ' + domains.join(', '));
+  if (tables.length) whereBits.push('tables ' + tables.join(', '));
+  if (prefix) whereBits.push('SKU ' + prefix);
+  if (systems.length) whereBits.push('writes ' + systems.join(' · '));
+  if (scripts.length) whereBits.push('scripts ' + scripts.join(', '));
+  whereBits.push('~/.claude/skills/' + id);
+  // WHO — referenced-by (cron plists · agents · sibling skills)
+  const refP = lines(sh(`grep -lF ${shq(id)} ${LA}/com.steve.*.plist 2>/dev/null`)).map(base);
+  const refA = lines(sh(`grep -lF ${shq(id)} ${AGENTS}/*.md 2>/dev/null`)).map(f => base(f).replace(/\.md$/, ''));
+  const refS = lines(sh(`grep -rlF ${shq(id)} ${SKILLS}/*/SKILL.md 2>/dev/null`)).map(f => base(path.dirname(f))).filter(n => n !== id);
+  const whoBits = [];
+  if (refA.length) whoBits.push(refA.length + ' agent' + (refA.length > 1 ? 's' : '') + ' (' + refA.slice(0, 4).join(', ') + (refA.length > 4 ? '…' : '') + ')');
+  if (refP.length) whoBits.push(refP.length + ' cron plist' + (refP.length > 1 ? 's' : ''));
+  if (refS.length) whoBits.push(refS.length + ' skill' + (refS.length > 1 ? 's' : ''));
+  // WHEN — cadence from a driving plist, else on-demand
+  let when = 'on-demand (invoked via Skill tool)';
+  if (refP.length) {
+    const xml = readText(path.join(LA, refP[0]));
+    const iv = xml.match(/<key>StartInterval<\/key>\s*<integer>(\d+)/);
+    const cal = /StartCalendarInterval/.test(xml);
+    if (iv) { const s = +iv[1]; when = s >= 3600 ? 'every ' + (s / 3600) + 'h (cron)' : s >= 60 ? 'every ' + (s / 60) + 'm (cron)' : 'every ' + s + 's (cron)'; }
+    else if (cal) { const hrs = [...xml.matchAll(/<key>Hour<\/key>\s*<integer>(\d+)/g)].map(x => x[1]); when = 'daily' + (hrs.length ? ' @ ' + hrs.join(',') + 'h' : '') + ' (cron)'; }
+    else when = 'cron (' + refP[0].replace(/\.plist$/, '') + ')';
+  }
+  // WHY — pull rationale/trigger sentences from the description
+  const why = sentences(desc).filter(s => /\b(use when|use proactively|born|catch|guard|because|so that|must never|never customer-facing|enforce|ensure|prevent|keeps?|refuses?|private label)\b/i.test(s)).slice(0, 2).join(' ')
+    || sentences(desc).slice(1, 3).find(s => s.length > 25) || firstSentence(desc) || '—';
+  // WHAT — one-line summary
+  const what = firstSentence(desc) || desc || '—';
+  return {
+    description: desc || '(no description found in SKILL.md)',
+    what, why,
+    who: whoBits.length ? whoBits.join(' · ') : 'nothing references it (pure on-demand)',
+    where: whereBits.join('  ·  '),
+    when, name,
+  };
+}
+
 function catChildType(kind) {
   if (kind === 'canaries' || kind === 'sk_canary') return 'canary';
   if (kind === 'officers' || kind === 'ag_officers') return 'officer';
@@ -205,7 +284,7 @@ const DRILLERS = {
       const abs = path.join(dir, e);
       children.push({ type: 'file', id: abs, label: e + (isDirP(abs) ? '/' : ''), meta: isDirP(abs) ? 'dir' : fmtSize(sizeP(abs)) });
     }
-    return { title: 'skill · ' + id, breadcrumb: id, children };
+    return { title: 'skill · ' + id, breadcrumb: id, info: skillInfo(id), children };
   },
   refby(id) {
     const children = [];
@@ -295,7 +374,7 @@ const DRILLERS = {
     const gp = sh(`grep -lF ${shq(id)} ${LA}/com.steve.*.plist 2>/dev/null`);
     for (const f of lines(gp)) children.push({ type: 'plist', id: base(f), label: '⏰ ' + base(f), meta: 'cron driver' });
     if (!children.length) children.push({ type: 'kv', id, label: '(no heartbeat / no cron found)', meta: '' });
-    return { title: 'canary · ' + id, breadcrumb: 'canary/' + id, children };
+    return { title: 'canary · ' + id, breadcrumb: 'canary/' + id, info: skillInfo(id), children };
   },
   project(id) {
     const dir = path.join(PROJECTS, id);

← a676017 viewer: draggable nodes + recursive drill + view switcher  ·  back to Stack Map Viewer  ·  viewer: add LAST RUN to scraper cards (newest scrape timesta 4c7440f →