[object Object]

← back to Agent Cabinet

Pyramid: clickable suggestion lines — click a dotted arrow to see the relationship + Approve (writes into cabinet.yaml under target VP, idempotent/git-reversible) / Remove / Reset; state persisted server-side; per-row controls in the analysis list too

d87e58a8039c151d90a5755330a3cc0cb34e0a1f · 2026-06-16 15:48:01 -0700 · SteveStudio2

Files touched

Diff

commit d87e58a8039c151d90a5755330a3cc0cb34e0a1f
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue Jun 16 15:48:01 2026 -0700

    Pyramid: clickable suggestion lines — click a dotted arrow to see the relationship + Approve (writes into cabinet.yaml under target VP, idempotent/git-reversible) / Remove / Reset; state persisted server-side; per-row controls in the analysis list too
---
 .gitignore   |  1 +
 pyramid.html | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 server.js    | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 117 insertions(+), 4 deletions(-)

diff --git a/.gitignore b/.gitignore
index a084fe3..b55c8d3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ dist/
 build/
 .next/
 *.bak
+pyramid-suggest-state.json
diff --git a/pyramid.html b/pyramid.html
index 6bfe4be..29889dd 100644
--- a/pyramid.html
+++ b/pyramid.html
@@ -117,6 +117,12 @@
   .scr-n{font:11.5px ui-monospace,monospace;color:#cfe;word-break:break-all}
   .mini{background:#161d2c;border:1px solid #2b3650;color:#bcd0f0;border-radius:6px;padding:3px 8px;font-size:11px;cursor:pointer;margin-left:5px}
   .mini:hover{background:#1b2435}
+  .sug-hit{cursor:pointer}
+  .rel-flow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:12px;margin-bottom:4px}
+  .rel-end{background:#161d2c;border:1px solid #2b3650;border-radius:6px;padding:3px 9px;font-weight:700}
+  .rel-to{border-color:#fbbf24;color:#fde68a}
+  .rel-mid{color:#fbbf24;font:11px ui-monospace,monospace}
+  #dr-body code{background:#101725;border:1px solid var(--line);border-radius:4px;padding:1px 5px;font-size:11px}
   #toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(20px);background:#0a0e16;border:1px solid #2b3650;color:#e7ecf3;
     padding:10px 16px;border-radius:9px;font-size:12.5px;opacity:0;transition:.2s;z-index:120;box-shadow:0 8px 30px rgba(0,0,0,.5);pointer-events:none}
   #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
@@ -234,6 +240,8 @@ const JOBS = [
 const childRefs = []; // {childEl, parentEl, kind}
 const allNodes = [];  // {el, kind, id, officer}
 let SHARED = [];
+let SUG_STATE = {};   // key -> 'approved' | 'removed'
+const sKey = (g) => g.kind+':'+g.id+':'+g.from+':'+g.to;
 
 // Curated cross-officer SUGGESTIONS — a task that should ALSO live under another
 // officer (drawn as amber arrows). Distinct from "shared now" which is computed
@@ -425,13 +433,21 @@ function drawCross(){
     pts.forEach(p=>{ sh+=`<circle cx="${p.x}" cy="${p.y}" r="2.6" fill="hsl(${hue} 82% 66%)"/>`; });
   });
   SUGGEST.forEach(g=>{
+    const key=sKey(g), dec=SUG_STATE[key];
+    if(dec==='removed') return;
     const src=allNodes.find(n=>n.id===g.id && n.officer===g.from);
     const dst=OFF.find(o=>o.id===g.to);
     if(!src||!dst||!dst._node) return;
     const a=midC(src.el,root), b=center(dst._node,root), bx=b.x, by=b.y, my=(a.y+by)/2;
-    sg+=`<path d="M${a.x},${a.y} C${a.x},${my} ${bx},${my} ${bx},${by}" fill="none" stroke="#fbbf24" stroke-width="1.3" stroke-opacity=".8" stroke-dasharray="1.5 4.5" marker-end="url(#arr)"/>`;
+    const d=`M${a.x},${a.y} C${a.x},${my} ${bx},${my} ${bx},${by}`;
+    const ap=dec==='approved';
+    sg+=`<path d="${d}" fill="none" stroke="${ap?'#34d399':'#fbbf24'}" stroke-width="${ap?1.9:1.3}" stroke-opacity="${ap?.95:.82}" ${ap?'':'stroke-dasharray="1.5 4.5"'} marker-end="url(#${ap?'arrG':'arr'})"/>`;
+    sg+=`<path class="sug-hit" data-key="${key}" d="${d}" fill="none" stroke="transparent" stroke-width="14" pointer-events="stroke" style="cursor:pointer"/>`;
   });
-  svg.innerHTML=`<defs><marker id="arr" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#fbbf24"/></marker></defs><g id="g-shared">${sh}</g><g id="g-suggest">${sg}</g>`;
+  svg.innerHTML=`<defs>`+
+    `<marker id="arr" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#fbbf24"/></marker>`+
+    `<marker id="arrG" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6.5" markerHeight="6.5" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#34d399"/></marker>`+
+    `</defs><g id="g-shared">${sh}</g><g id="g-suggest">${sg}</g>`;
   applyToggles();
 }
 function applyToggles(){
@@ -459,15 +475,51 @@ function showAnalysis(){
   let h=`<div class="dr-note"><b style="color:#cbd5e1">Shared now (${SHARED.length})</b> — already wired to ≥2 officers (dashed colored lines). A task serving many officers.</div>`;
   h+=SHARED.map(s=>`<div class="scr-row"><span class="scr-n" style="color:hsl(${hashHue(s.id)} 82% 70%)">${s.id}</span><span style="font-size:10.5px;color:#8a94a6;text-align:right">${s.officers.map(offName).join(' · ')}</span></div>`).join('');
   h+=`<div class="dr-note" style="margin-top:16px"><b style="color:#fbbf24">Suggested moves (${SUGGEST.length})</b> — should also belong to another officer (amber arrows).</div>`;
-  h+=SUGGEST.map(g=>`<div class="scr-row" style="display:block"><div><span class="scr-n">${g.kind==='skill'?'/':'@'}${g.id}</span> <span style="color:#fbbf24;font-size:10.5px">${offName(g.from)} → ${offName(g.to)}</span></div><div style="font-size:10.5px;color:#8a94a6;margin-top:3px;line-height:1.4">${g.reason}</div></div>`).join('');
+  h+=SUGGEST.map(g=>{ const key=sKey(g), dec=SUG_STATE[key], sc=dec==='approved'?'#86efac':dec==='removed'?'#fca5a5':'#fbbf24';
+    return `<div class="scr-row" style="display:block"><div><span class="scr-n">${g.kind==='skill'?'/':'@'}${g.id}</span> <span style="color:#fbbf24;font-size:10.5px">${offName(g.from)} → ${offName(g.to)}</span>${dec?` <span style="color:${sc};font-size:10px">● ${dec}</span>`:''}</div><div style="font-size:10.5px;color:#8a94a6;margin:3px 0;line-height:1.4">${g.reason}</div><div><button class="mini" onclick="decide('${key}','approve')">✓ Approve</button><button class="mini" onclick="decide('${key}','remove')">✕ Remove</button><button class="mini" onclick="decide('${key}','reset')">↺</button></div></div>`; }).join('');
   document.getElementById('dr-body').innerHTML=h;
 }
 
+// ── Click a dotted suggestion line → relationship + Approve / Remove ──
+async function loadSugState(){ try{ const r=await(await fetch('/api/suggestions')).json(); if(r&&r.decisions) SUG_STATE=r.decisions; }catch(e){} }
+function openSuggestion(key){
+  const g=SUGGEST.find(x=>sKey(x)===key); if(!g) return;
+  const dec=SUG_STATE[key];
+  document.getElementById('drawer').classList.add('open');
+  document.getElementById('dr-title').textContent=(g.kind==='skill'?'/':'@')+g.id;
+  const k=document.getElementById('dr-kind'); k.textContent=dec?dec.toUpperCase():'SUGGESTED';
+  k.className='dr-kind'; k.style.cssText=dec==='approved'?'background:#0f2a1c;color:#86efac;border:1px solid #16a34a':'background:#33280a;color:#fbbf24;border:1px solid #fbbf24';
+  const run=document.getElementById('dr-run'), open=document.getElementById('dr-open');
+  run.style.display=''; open.style.display='';
+  run.className='dr-btn run'; run.textContent=dec==='approved'?'✓ Approved':'✓ Approve';
+  open.className='dr-btn'; open.style.cssText='flex:1;display:flex;align-items:center;justify-content:center;gap:7px;padding:10px;border-radius:9px;font-size:13px;font-weight:700;cursor:pointer;background:#2a1414;color:#fca5a5;border:1px solid #b91c1c';
+  open.textContent=dec==='removed'?'✕ Removed':'✕ Remove';
+  run.onclick=()=>decide(key,'approve'); open.onclick=()=>decide(key,'remove');
+  document.getElementById('dr-body').innerHTML=
+    `<div class="rel-flow"><span class="rel-end">${offName(g.from)}</span><span class="rel-mid">${g.kind==='skill'?'/':'@'}${g.id} should also serve →</span><span class="rel-end rel-to">${offName(g.to)}</span></div>`+
+    `<div class="dr-note" style="margin-top:8px">${g.reason}</div>`+
+    (dec?`<div style="margin-top:10px;font-size:12px;color:${dec==='approved'?'#86efac':'#fca5a5'}">Status: <b>${dec}</b>${dec==='approved'?' — added under '+offName(g.to)+' in cabinet.yaml':''} &nbsp;<button class="mini" onclick="decide('${key}','reset')">↺ Reset</button></div>`:'')+
+    `<div class="dr-note" style="margin-top:14px;font-size:11px">Approve appends <code>- ${g.kind}: ${g.id}</code> under <b>${g.to}</b>'s directors in cabinet.yaml (idempotent, git-reversible). Remove just dismisses it.</div>`;
+}
+async function decide(key,action){
+  try{
+    const r=await(await fetch('/api/suggest?action='+action+'&key='+encodeURIComponent(key))).json();
+    if(!r.ok){ toast('failed: '+(r.error||'?')); return; }
+    SUG_STATE=r.decisions||SUG_STATE; drawCross();
+    const g=SUGGEST.find(x=>sKey(x)===key)||{};
+    toast(action==='approve'?(r.cabinet&&r.cabinet.already?'already in cabinet.yaml':'✓ approved — added under '+offName(g.to)+' in cabinet.yaml'):action==='remove'?'✕ suggestion removed':'↺ decision reset');
+    if(document.getElementById('dr-title').textContent==='Cross-officer analysis') showAnalysis();
+    else if(action==='remove') closePanel();
+    else openSuggestion(key);
+  }catch(e){ toast('server unreachable — open via http://localhost:9766/pyramid'); }
+}
+
 legend(); build(); timeline(); buildToolbar();
 document.getElementById('pyramid').addEventListener('click', onNodeClick);
+document.getElementById('xlines').addEventListener('click', e=>{ const t=e.target.closest('.sug-hit'); if(t) openSuggestion(t.dataset.key); });
 document.addEventListener('keydown', e=>{ if(e.key==='Escape') closePanel(); });
 function redraw(){ drawLines(); drawCross(); }
-requestAnimationFrame(()=>requestAnimationFrame(redraw));
+loadSugState().then(()=>requestAnimationFrame(()=>requestAnimationFrame(redraw)));
 window.addEventListener('resize', ()=>{ requestAnimationFrame(redraw); });
 </script>
 </body>
diff --git a/server.js b/server.js
index 3ed5c96..d484df7 100644
--- a/server.js
+++ b/server.js
@@ -51,6 +51,50 @@ function spawnClaude(kind, name, cb) {
   execFile('osascript', ['-e', script], { timeout: 15000 }, (err) => cb(err, cmd));
 }
 
+// Cross-officer SUGGESTIONS (must mirror pyramid.html). Each is a proposal that a
+// skill/agent under `from` should ALSO live under `to`. Approve writes it into
+// cabinet.yaml under the target VP; Remove just dismisses it. State persists.
+const SUGGESTIONS = [
+  { id:'dw-legal-compliance', kind:'skill',    from:'vp-dw-commerce',      to:'vp-compliance-policy', reason:'Settlement / vendor-contract review is also a compliance gate' },
+  { id:'security-auditor',    kind:'subagent', from:'vp-engineering',      to:'vp-compliance-policy', reason:'Security posture (OWASP/auth) overlaps policy & compliance' },
+  { id:'best-practices',      kind:'skill',    from:'vp-compliance-policy',to:'vp-engineering',       reason:'Standing-rules pre-flight before any code / prod op' },
+  { id:'la-research-agent',   kind:'subagent', from:'vp-directories',      to:'vp-research-content',  reason:'LA public-records work IS a core research capability' },
+  { id:'analytics-agent',     kind:'subagent', from:'vp-operations',       to:'vp-research-content',  reason:'GA4 traffic data feeds site audits & competitor analysis' },
+  { id:'comms-compliance',    kind:'subagent', from:'vp-compliance-policy',to:'vp-dw-marketing',      reason:'Every mailer / social send must clear CAN-SPAM first' },
+  { id:'dw-vendor-landing',   kind:'skill',    from:'vp-dw-marketing',     to:'vp-dw-commerce',       reason:'Vendor landings are storefront surfaces commerce owns' },
+  { id:'collection-creator',  kind:'skill',    from:'vp-dw-marketing',     to:'vp-dw-commerce',       reason:'Collection/merchandising lives in the Shopify catalog' },
+  { id:'new-arrivals-rotator',kind:'skill',    from:'vp-dw-marketing',     to:'vp-dw-commerce',       reason:'Auto-rotating Shopify collections are catalog ops' },
+  { id:'pairs-well-with',     kind:'skill',    from:'vp-dw-marketing',     to:'vp-dw-commerce',       reason:'Per-SKU recommendations are a product-page feature' },
+  { id:'exa-agent',           kind:'subagent', from:'vp-research-content', to:'vp-directories',       reason:'Web research enriches directory listings' },
+  { id:'logo-agent',          kind:'skill',    from:'vp-dw-marketing',     to:'vp-research-content',  reason:'Tournament brand-mark picker is a design/research tool' },
+];
+const SUGSTATE_FILE = path.join(ROOT, 'pyramid-suggest-state.json');
+const sugKey = (s) => s.kind + ':' + s.id + ':' + s.from + ':' + s.to;
+function loadSugState() { try { return JSON.parse(fs.readFileSync(SUGSTATE_FILE, 'utf8')); } catch (e) { return { decisions: {} }; } }
+function saveSugState(s) { try { fs.writeFileSync(SUGSTATE_FILE, JSON.stringify(s, null, 2)); } catch (e) {} }
+
+// Approve = idempotently add "- <kind>: <id>" under the target VP's directors block.
+function applyToCabinet(s) {
+  const lines = fs.readFileSync(CABINET, 'utf8').split('\n');
+  const vpIdx = lines.findIndex(l => new RegExp('^  - vp:\\s*' + s.to + '\\s*$').test(l));
+  if (vpIdx < 0) return { added: false, error: 'target VP not found' };
+  let dirIdx = -1, end = lines.length;
+  for (let i = vpIdx + 1; i < lines.length; i++) {
+    if (/^  - vp:/.test(lines[i])) { end = i; break; }
+    if (dirIdx < 0 && /^    directors:/.test(lines[i])) dirIdx = i;
+  }
+  if (dirIdx < 0) return { added: false, error: 'directors block not found' };
+  const esc = s.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  for (let i = dirIdx + 1; i < end; i++) {
+    if (new RegExp('^\\s+- ' + s.kind + ':\\s*' + esc + '\\s*$').test(lines[i])) return { added: false, already: true };
+  }
+  lines.splice(dirIdx + 1, 0,
+    '      - ' + s.kind + ': ' + s.id,
+    '        owns: [cross-officer — approved via pyramid (primary: ' + s.from + ')]');
+  fs.writeFileSync(CABINET, lines.join('\n'));
+  return { added: true };
+}
+
 function parseYaml(text) {
   // Tiny YAML subset parser — good enough for cabinet.yaml's shape.
   // Produces {president, cabinet:[{vp, domain, triggers:[], directors:[{skill?,subagent?,owns?}]}]}
@@ -539,6 +583,22 @@ const server = http.createServer((req, res) => {
     spawnClaude(q.get('kind'), q.get('name'), (err, cmd) => J({ ok: !err, cmd, error: err ? String(err) : undefined }));
     return;
   }
+  if (u.pathname === '/api/suggestions') {
+    return J({ ok: true, list: SUGGESTIONS, decisions: (loadSugState().decisions) || {} });
+  }
+  if (u.pathname === '/api/suggest') {
+    const key = q.get('key'), action = q.get('action');
+    const s = SUGGESTIONS.find(x => sugKey(x) === key);
+    if (!s) return J({ ok: false, error: 'unknown suggestion' });
+    if (!['approve', 'remove', 'reset'].includes(action)) return J({ ok: false, error: 'bad action' });
+    const st = loadSugState(); st.decisions = st.decisions || {};
+    let cab;
+    if (action === 'approve') { st.decisions[key] = 'approved'; cab = applyToCabinet(s); }
+    else if (action === 'remove') { st.decisions[key] = 'removed'; }
+    else { delete st.decisions[key]; }
+    saveSugState(st);
+    return J({ ok: true, action, decisions: st.decisions, cabinet: cab });
+  }
 
   if (req.url === '/cabinet.yaml') {
     try {

← eb43caf Pyramid: cross-officer lines — 18 'shared now' (computed fro  ·  back to Agent Cabinet  ·  Approve auto-commits cabinet.yaml (path-limited, local, Stev 2f295b3 →