← 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
M .gitignoreM pyramid.htmlM server.js
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':''} <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 →