[object Object]

← back to Agent Cabinet

pyramid: add Gaps dotted-line overlay (stand up officers / adopt orphans)

372ea93f5cd5490eb74fe39399e85b095666435b · 2026-06-18 18:32:44 -0700 · Steve Abrams

Surfaces the org-structure gaps as red dotted lines on the cabinet pyramid,
reusing the approve→write-to-cabinet.yaml mechanism from cross-officer
suggestions. new-vp gaps (vp-security, vp-special-projects) render as ghost
officer columns hanging off the President; adopt-orphan gaps render as dashed
chips under their target officer. Clicking a line/+ handle opens a drawer;
Approve inserts the VP block or director into cabinet.yaml and auto-commits.

Commits are serialized in-process (a click never races another click for the
git index) and retried with backoff against external committers' index.lock,
so an approval never silently leaves an uncommitted change. /api/gaps + /api/gap,
GAPS arrays mirrored server/client, gap-state file gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 372ea93f5cd5490eb74fe39399e85b095666435b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 18 18:32:44 2026 -0700

    pyramid: add Gaps dotted-line overlay (stand up officers / adopt orphans)
    
    Surfaces the org-structure gaps as red dotted lines on the cabinet pyramid,
    reusing the approve→write-to-cabinet.yaml mechanism from cross-officer
    suggestions. new-vp gaps (vp-security, vp-special-projects) render as ghost
    officer columns hanging off the President; adopt-orphan gaps render as dashed
    chips under their target officer. Clicking a line/+ handle opens a drawer;
    Approve inserts the VP block or director into cabinet.yaml and auto-commits.
    
    Commits are serialized in-process (a click never races another click for the
    git index) and retried with backoff against external committers' index.lock,
    so an approval never silently leaves an uncommitted change. /api/gaps + /api/gap,
    GAPS arrays mirrored server/client, gap-state file gitignored.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore   |   1 +
 pyramid.html | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 server.js    | 159 ++++++++++++++++++++++++++++++++++++++++++++++++----
 3 files changed, 325 insertions(+), 13 deletions(-)

diff --git a/.gitignore b/.gitignore
index b55c8d3..1b76856 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ build/
 .next/
 *.bak
 pyramid-suggest-state.json
+pyramid-gap-state.json
diff --git a/pyramid.html b/pyramid.html
index 91905e7..bbb0676 100644
--- a/pyramid.html
+++ b/pyramid.html
@@ -184,6 +184,32 @@
   .bar-row .nm{width:170px;color:#cfd8e6;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
   .bar-row .bar{height:9px;border-radius:5px;background:linear-gradient(90deg,#6d34f4,#a78bfa)}
   .bar-row .v{color:#fde68a;font:11px ui-monospace,monospace;min-width:52px;text-align:right}
+
+  /* ── Gaps overlay (proposed officers + orphan adoptions) ── */
+  svg#glines{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible;z-index:1}
+  #gap-handles{position:absolute;inset:0;pointer-events:none;z-index:6}
+  .gap-col{opacity:.95}
+  .officer-node.gap-node{border-style:dashed;border-color:#f87171;
+    background:repeating-linear-gradient(135deg,#16131a,#16131a 9px,#1a1014 9px,#1a1014 18px)}
+  .officer-node.gap-node::before{background:#f87171}
+  .officer-node.gap-node.gap-approved{border-style:solid;border-color:#34d399}
+  .officer-node.gap-node.gap-approved::before{background:#34d399}
+  .gap-tag{position:absolute;top:7px;right:8px;font-size:8px;font-weight:800;letter-spacing:.1em;
+    background:#b91c1c;color:#fff;padding:2px 6px;border-radius:4px}
+  .gap-node.gap-approved .gap-tag{background:#16a34a}
+  .gap-chip{border-style:dashed !important;opacity:.85}
+  .gap-orphans{margin-top:9px;border-top:1px dashed #3a2530;padding-top:7px}
+  .gap-orphans::before{content:"orphans · click + to adopt";display:block;width:100%;font-size:8.5px;
+    letter-spacing:.08em;text-transform:uppercase;color:#9a7480;opacity:.8;margin-bottom:4px}
+  .orphan-chip{background:#1a1410;border:1px dashed #b91c1c;color:#fca5a5;cursor:pointer}
+  .orphan-chip:hover{filter:brightness(1.25);border-color:#f87171}
+  .orphan-chip.gap-approved{background:#0f2a1c;border-color:#34d399;color:#86efac}
+  .gap-hit{cursor:pointer}
+  .gap-h{position:absolute;transform:translate(-50%,-50%);pointer-events:auto;cursor:pointer;width:22px;height:22px;border-radius:50%;
+    border:2px solid #0b0e14;background:#f87171;color:#3a0a0a;font-size:13px;font-weight:900;line-height:1;display:flex;align-items:center;justify-content:center;
+    box-shadow:0 0 0 2px rgba(248,113,113,.35),0 3px 9px rgba(0,0,0,.55);transition:transform .12s}
+  .gap-h:hover{transform:translate(-50%,-50%) scale(1.4);z-index:7}
+  .gap-h.approved{background:#34d399;color:#062a1c;box-shadow:0 0 0 2px rgba(52,211,153,.4),0 3px 9px rgba(0,0,0,.55)}
 </style>
 </head>
 <body>
@@ -197,7 +223,9 @@
   <div id="pyramid">
     <svg id="lines"></svg>
     <svg id="xlines"></svg>
+    <svg id="glines"></svg>
     <div id="sug-handles"></div>
+    <div id="gap-handles"></div>
     <div class="tier president"><div class="tier-label" style="top:14px">President</div>
       <div class="pres-node" id="pres"><div class="t">Steve</div><div class="s">Chief Executive · steve@designerwallcoverings.com</div></div>
     </div>
@@ -322,6 +350,40 @@ const SUGGEST = [
   { id:'logo-agent',          kind:'skill',    from:'vp-dw-marketing',     to:'vp-research-content',  reason:'Tournament brand-mark picker is a design/research tool' },
 ];
 
+// ── Org GAPS (mirror server.js GAPS) — the structural gaps Steve flagged, drawn
+// as red dotted lines you click to APPROVE. new-vp = stand up a whole officer;
+// adopt-orphan = file an unregistered agent under an existing officer. Approving
+// writes it into cabinet.yaml + auto-commits (git-reversible).
+const GAPS = [
+  { id:'vp-security', type:'new-vp', label:'VP Security', col:'#ef4444',
+    domain:'Security · incident response · secrets · firewalls · monitoring',
+    reason:'No single owner for security today — it is split across vp-engineering (security-auditor), vp-operations (secrets-manager, open-firewalls) and the orphaned security-dashboard (:9889) + Kamatera security-monitor.sh. This stands up one incident commander.',
+    directors:[
+      {kind:'subagent', name:'security-auditor', note:'cross-officer (primary: vp-engineering)'},
+      {kind:'subagent', name:'secrets-manager', note:'cross-officer (primary: vp-operations)'},
+      {kind:'subagent', name:'security-dashboard', note:'monitoring fleet :9889'},
+      {kind:'skill', name:'open-firewalls', note:'cross-officer (primary: vp-operations)'},
+      {kind:'skill', name:'secrets', note:'cross-officer (primary: vp-operations)'},
+      {kind:'subagent', name:'comms-compliance', note:'cross-officer (primary: vp-compliance-policy)'},
+    ] },
+  { id:'vp-special-projects', type:'new-vp', label:'VP Special Projects', col:'#14b8a6',
+    domain:'Non-DW / side / product-builder projects',
+    reason:'wallco.ai, apartmentwallpaper, site-factory, small-business-builder and the Abrams portfolio have no officer — and their agents (wallco-agent, apartment-wallpaper-agent, seam-debug-agent) are not even in the cabinet.',
+    directors:[
+      {kind:'subagent', name:'wallco-agent', note:'wallco.ai storefront + gen pipeline'},
+      {kind:'subagent', name:'apartment-wallpaper-agent', note:'apartmentwallpaper.com'},
+      {kind:'subagent', name:'seam-debug-agent', note:'wallco seam/joint healer'},
+      {kind:'skill', name:'site-factory', note:'multi-site Next.js generator'},
+      {kind:'skill', name:'small-business-builder', note:'SDCC analysis + rebuild'},
+    ] },
+  { id:'agent-architect',  type:'adopt-orphan', kind:'subagent', to:'vp-operations',  reason:'It owns cabinet.yaml + this viewer — belongs under Operations.' },
+  { id:'abramstasks-agent', type:'adopt-orphan', kind:'subagent', to:'vp-operations',  reason:'Plan→parallel-Claudes orchestrator — an ops/orchestration utility.' },
+  { id:'context-manager',  type:'adopt-orphan', kind:'subagent', to:'vp-engineering', reason:'Multi-agent context management — an engineering utility.' },
+  { id:'dfm',              type:'adopt-orphan', kind:'subagent', to:'vp-operations',  reason:'Autonomous task executor — confirm purpose, file under Operations.' },
+];
+const gapKey = (g) => g.type==='new-vp' ? 'vp:'+g.id : g.kind+':'+g.id+':'+g.to;
+let GAP_STATE = {};   // key -> 'approved' | 'removed'
+
 function legend(){
   const L = document.getElementById('legend');
   OFF.forEach(o=>{
@@ -373,6 +435,36 @@ function build(){
   WF.forEach(w=>{ const e=document.createElement('div'); e.className='wf'; e.style.setProperty('--wc',w.col);
     e.dataset.kind='skill'; e.dataset.id=w.n; e.dataset.label=w.n;
     e.innerHTML=`<div class="wn">${w.n}</div><div class="wd">${w.d}</div><div class="wt">${w.t}</div>`; wfrow.appendChild(e); });
+
+  buildGaps();
+}
+
+// Ghost officer columns (new-vp) appended to the officers row, + dashed orphan
+// chips appended under their target officer. Both carry data-gap → openGap().
+function buildGaps(){
+  const row=document.getElementById('officers');
+  GAPS.filter(g=>g.type==='new-vp').forEach(g=>{
+    const col=document.createElement('div'); col.className='officer-col gap-col';
+    const node=document.createElement('div'); node.className='officer-node gap-node'; node.style.setProperty('--oc',g.col);
+    node.dataset.gap=gapKey(g);
+    node.innerHTML=`<div class="gap-tag">PROPOSED</div><div class="nm">${g.label}</div><div class="dm">${g.domain}</div><div class="ct">${g.directors.length} directors · GAP</div>`;
+    col.appendChild(node); g._node=node;
+    const aL=document.createElement('div'); aL.className='group-label'; aL.textContent='Proposed directors'; col.appendChild(aL);
+    const aWrap=document.createElement('div'); aWrap.className='agents';
+    g.directors.forEach(d=>{ const e=document.createElement('div'); e.className=(d.kind==='skill'?'skill':'agent')+' gap-chip';
+      e.style.setProperty('--oc',g.col); e.textContent=d.name; e.title=d.note||''; aWrap.appendChild(e); });
+    col.appendChild(aWrap);
+    row.appendChild(col);
+  });
+  GAPS.filter(g=>g.type==='adopt-orphan').forEach(g=>{
+    const target=OFF.find(o=>o.id===g.to); if(!target||!target._node) return;
+    const col=target._node.parentElement;
+    let host=col.querySelector('.gap-orphans');
+    if(!host){ host=document.createElement('div'); host.className='agents gap-orphans'; col.appendChild(host); }
+    const e=document.createElement('div'); e.className='agent gap-chip orphan-chip'; e.style.setProperty('--oc',target.col);
+    e.dataset.gap=gapKey(g); e.textContent=g.id; e.title='Orphan — click to adopt under '+target.nm;
+    host.appendChild(e); g._chip=e; g._target=target;
+  });
 }
 
 function center(el, root){
@@ -514,6 +606,41 @@ function drawCross(){
   const hc=document.getElementById('sug-handles'); if(hc) hc.innerHTML=hh;
   applyToggles();
 }
+// ── Gaps: red dotted lines (President→ghost officer, or orphan chip→officer) ──
+function drawGaps(){
+  const root=document.getElementById('pyramid'), svg=document.getElementById('glines');
+  svg.setAttribute('width',root.scrollWidth); svg.setAttribute('height',root.scrollHeight); svg.setAttribute('viewBox',`0 0 ${root.scrollWidth} ${root.scrollHeight}`);
+  const pres=document.getElementById('pres');
+  let gp='', hh='';
+  GAPS.forEach(g=>{
+    const key=gapKey(g), dec=GAP_STATE[key];
+    if(dec==='removed') return;
+    const ap=dec==='approved';
+    let a,b;
+    if(g.type==='new-vp'){
+      if(!g._node) return;
+      g._node.classList.toggle('gap-approved',ap);
+      const tag=g._node.querySelector('.gap-tag'); if(tag) tag.textContent=ap?'APPROVED':'PROPOSED';
+      const p=center(pres,root), c=center(g._node,root); a={x:p.x,y:p.yb}; b={x:c.x,y:c.y};
+    } else {
+      if(!g._chip||!g._target||!g._target._node) return;
+      g._chip.classList.toggle('gap-approved',ap);
+      const s=center(g._chip,root), t=center(g._target._node,root); a={x:s.x,y:s.y+s.h/2}; b={x:t.x,y:t.yb};
+    }
+    const my=(a.y+b.y)/2, col=ap?'#34d399':'#f87171';
+    gp+=`<path d="M${a.x},${a.y} C${a.x},${my} ${b.x},${my} ${b.x},${b.y}" fill="none" stroke="${col}" stroke-width="${ap?1.9:1.4}" stroke-opacity="${ap?.95:.85}" ${ap?'':'stroke-dasharray="2 5"'} marker-end="url(#${ap?'gapArrG':'gapArr'})"/>`;
+    gp+=`<path class="gap-hit" data-key="${key}" d="M${a.x},${a.y} C${a.x},${my} ${b.x},${my} ${b.x},${b.y}" fill="none" stroke="transparent" stroke-width="16" pointer-events="stroke"/>`;
+    const hx=(a.x+b.x)/2, hy=(a.y+b.y)/2;
+    const ttl=g.type==='new-vp'?('Stand up '+g.label):('Adopt @'+g.id+' → '+g._target.nm);
+    hh+=`<button class="gap-h${ap?' approved':''}" data-key="${key}" style="left:${hx}px;top:${hy}px" title="${ttl}  (click to approve / dismiss)">${ap?'✓':'+'}</button>`;
+  });
+  svg.innerHTML=`<defs>`+
+    `<marker id="gapArr" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#f87171"/></marker>`+
+    `<marker id="gapArrG" viewBox="0 0 10 10" refX="8.5" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#34d399"/></marker>`+
+    `</defs><g id="g-gaps">${gp}</g>`;
+  const ghc=document.getElementById('gap-handles'); if(ghc) ghc.innerHTML=hh;
+  applyToggles();
+}
 function applyToggles(){
   const on=id=>{ const e=document.getElementById(id); return e?e.checked:true; };
   document.getElementById('lines').style.display = on('tg-hier')?'':'none';
@@ -521,6 +648,8 @@ function applyToggles(){
   if(gs) gs.style.display = on('tg-shared')?'':'none';
   if(gg) gg.style.display = on('tg-sug')?'':'none';
   const hc=document.getElementById('sug-handles'); if(hc) hc.style.display = on('tg-sug')?'':'none';
+  const gx=document.getElementById('g-gaps'); if(gx) gx.style.display = on('tg-gap')?'':'none';
+  const ghc=document.getElementById('gap-handles'); if(ghc) ghc.style.display = on('tg-gap')?'':'none';
 }
 function buildToolbar(){
   SHARED=computeShared();
@@ -528,10 +657,11 @@ function buildToolbar(){
     `<label><input type="checkbox" id="tg-hier" checked> Hierarchy</label>`+
     `<label><input type="checkbox" id="tg-shared" checked><span class="tog-line" style="border-top:2px dashed #cbd5e1"></span>Shared now (${SHARED.length})</label>`+
     `<label><input type="checkbox" id="tg-sug" checked><span class="tog-line" style="border-top:2px dotted #fbbf24"></span>Suggested moves (${SUGGEST.length})</label>`+
+    `<label><input type="checkbox" id="tg-gap" checked><span class="tog-line" style="border-top:2px dotted #f87171"></span>Gaps (${GAPS.length})</label>`+
     `<button class="tb-btn" onclick="openIdeasGallery()">💡 Ideas</button>`+
     `<button class="tb-btn" style="margin-left:0" onclick="location.href='/activity'">🕓 Activity</button>`+
     `<button class="tb-btn" style="margin-left:0" onclick="showAnalysis()">▤ List</button>`;
-  ['tg-hier','tg-shared','tg-sug'].forEach(id=>document.getElementById(id).addEventListener('change',applyToggles));
+  ['tg-hier','tg-shared','tg-sug','tg-gap'].forEach(id=>document.getElementById(id).addEventListener('change',applyToggles));
 }
 function offName(id){ const o=OFF.find(x=>x.id===id); return o?o.nm.replace('VP ',''):id; }
 function showAnalysis(){
@@ -581,6 +711,45 @@ async function decide(key,action){
   }catch(e){ toast('server unreachable — open via http://localhost:9766/pyramid'); }
 }
 
+// ── Click a red dotted gap line / + handle → drawer with Approve / Dismiss ──
+async function loadGapState(){ try{ const r=await(await fetch('/api/gaps')).json(); if(r&&r.decisions) GAP_STATE=r.decisions; }catch(e){} }
+function openGap(key){
+  const g=GAPS.find(x=>gapKey(x)===key); if(!g) return;
+  const dec=GAP_STATE[key];
+  document.getElementById('drawer').classList.add('open');
+  document.getElementById('dr-title').textContent = g.type==='new-vp'?g.label:('@'+g.id);
+  const k=document.getElementById('dr-kind'); k.textContent=dec?dec.toUpperCase():'GAP'; k.className='dr-kind';
+  k.style.cssText=dec==='approved'?'background:#0f2a1c;color:#86efac;border:1px solid #16a34a':'background:#2a1414;color:#fca5a5;border:1px solid #b91c1c';
+  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':(g.type==='new-vp'?'✓ Stand up officer':'✓ Adopt orphan');
+  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'?'✕ Dismissed':'✕ Dismiss';
+  run.onclick=()=>decideGap(key,'approve'); open.onclick=()=>decideGap(key,'remove');
+  let body;
+  if(g.type==='new-vp'){
+    body=`<div class="rel-flow"><span class="rel-end">Steve</span><span class="rel-mid">stand up new officer →</span><span class="rel-end rel-to">${g.label}</span></div>`+
+      `<div class="dr-note" style="margin-top:8px">${g.reason}</div>`+
+      `<div class="dr-note" style="margin-top:10px"><b style="color:#fca5a5">${g.directors.length} directors</b><br>${g.directors.map(d=>(d.kind==='skill'?'/':'@')+d.name+(d.note?' <span style="color:#6b7280">— '+d.note+'</span>':'')).join('<br>')}</div>`+
+      `<div class="dr-note" style="margin-top:12px;font-size:11px">Approve inserts a full <code>- vp: ${g.id}</code> block (domain, triggers, ${g.directors.length} directors) into cabinet.yaml before <code>architecture_principles</code> — idempotent, git-reversible. The Mermaid view at <code>/</code> then shows it as a full officer.</div>`;
+  } else {
+    body=`<div class="rel-flow"><span class="rel-end">orphan</span><span class="rel-mid">@${g.id} → adopt under</span><span class="rel-end rel-to">${offName(g.to)}</span></div>`+
+      `<div class="dr-note" style="margin-top:8px">${g.reason}</div>`+
+      `<div class="dr-note" style="margin-top:12px;font-size:11px">Approve appends <code>- ${g.kind}: ${g.id}</code> under <b>${g.to}</b>'s directors in cabinet.yaml — idempotent, git-reversible.</div>`;
+  }
+  if(dec) body+=`<div style="margin-top:10px;font-size:12px;color:${dec==='approved'?'#86efac':'#fca5a5'}">Status: <b>${dec}</b>${dec==='approved'?' — written to cabinet.yaml':''} <button class="mini" onclick="decideGap('${key}','reset')">↺ Reset</button></div>`;
+  document.getElementById('dr-body').innerHTML=body;
+}
+async function decideGap(key,action){
+  try{
+    const r=await(await fetch('/api/gap?action='+action+'&key='+encodeURIComponent(key))).json();
+    if(!r.ok){ toast('failed: '+(r.error||'?')); return; }
+    GAP_STATE=r.decisions||GAP_STATE; drawGaps();
+    toast(action==='approve'?(r.cabinet&&r.cabinet.already?'already in cabinet.yaml':('✓ approved — committed '+(r.commit||'(no hash)'))):action==='remove'?'✕ gap dismissed':'↺ decision reset');
+    if(action==='remove') closePanel(); else openGap(key);
+  }catch(e){ toast('server unreachable — open via http://localhost:9766/pyramid'); }
+}
+
 // ───────────────────── IDEAS MODALS ─────────────────────
 function openModal(html){ document.getElementById('modal-content').innerHTML=html; document.getElementById('modal').classList.add('open'); }
 function closeModal(){ document.getElementById('modal').classList.remove('open'); }
@@ -692,10 +861,13 @@ 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.getElementById('sug-handles').addEventListener('click', e=>{ const t=e.target.closest('.sug-h'); if(t) openSuggestion(t.dataset.key); });
+document.getElementById('glines').addEventListener('click', e=>{ const t=e.target.closest('.gap-hit'); if(t) openGap(t.dataset.key); });
+document.getElementById('gap-handles').addEventListener('click', e=>{ const t=e.target.closest('.gap-h'); if(t) openGap(t.dataset.key); });
+document.getElementById('officers').addEventListener('click', e=>{ const t=e.target.closest('[data-gap]'); if(t){ e.stopPropagation(); openGap(t.dataset.gap); } });
 document.getElementById('modal').addEventListener('click', e=>{ if(e.target.id==='modal') closeModal(); });
 document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closePanel(); closeModal(); } });
-function redraw(){ drawLines(); drawCross(); }
-loadSugState().then(()=>requestAnimationFrame(()=>requestAnimationFrame(redraw)));
+function redraw(){ drawLines(); drawCross(); drawGaps(); }
+Promise.all([loadSugState(), loadGapState()]).then(()=>requestAnimationFrame(()=>requestAnimationFrame(redraw)));
 window.addEventListener('resize', ()=>{ requestAnimationFrame(redraw); });
 </script>
 </body>
diff --git a/server.js b/server.js
index 3176484..634824b 100644
--- a/server.js
+++ b/server.js
@@ -97,17 +97,127 @@ function applyToCabinet(s) {
 
 // Auto-commit ONLY cabinet.yaml (path-limited, so other dirty files aren't swept
 // in). Local commit, no push. Author = Steve, with Claude co-author trailer.
-function gitCommitCabinet(s, cb) {
-  const msg = 'cabinet: approve cross-officer ' + s.kind + ' ' + s.id + ' under ' + s.to +
-    ' (from ' + s.from + ') via pyramid\n\nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>';
-  execFile('git', ['-C', ROOT, 'add', 'cabinet.yaml'], (e1) => {
-    if (e1) return cb(e1);
-    execFile('git', ['-C', ROOT, '-c', 'user.name=Steve Abrams', '-c', 'user.email=steve@designerwallcoverings.com',
-      'commit', '-m', msg, '--', 'cabinet.yaml'], { timeout: 15000 }, (e2) => {
-      if (e2) return cb(e2);
-      execFile('git', ['-C', ROOT, 'rev-parse', '--short', 'HEAD'], (e3, out) => cb(e3, (out || '').trim()));
+// Serialize cabinet read-modify-write-commit sections so concurrent approve
+// clicks never (a) race each other for the git index, or (b) let one commit
+// sweep another's just-written block (which would leave it commit-less). Each
+// critical section = apply-to-file THEN commit, run start-to-finish before the
+// next begins. The lock-retry inside commit then only absorbs collisions with
+// EXTERNAL committers (yolo-loop, council jobs).
+let _cabinetChain = Promise.resolve();
+function withCabinetLock(fn) {
+  const run = _cabinetChain.then(fn, fn);
+  _cabinetChain = run.then(() => {}, () => {});
+  return run;
+}
+function commitCabinetMsg(msg, cb) {
+  const full = msg + '\n\nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>';
+  // External committers (yolo-loop, council jobs) can still hold .git/index.lock —
+  // retry the add+commit a few times with backoff so a click never silently leaves
+  // an uncommitted (orphaned) change.
+  const MAX = 6;
+  const isLock = (e, se) => /index\.lock|unable to create|another git process|cannot lock ref/i.test((se || '') + ' ' + (e && e.message || ''));
+  const run = (args, opts, done) => execFile('git', ['-C', ROOT, ...args], opts, done);
+  const attempt = (n) => {
+    run(['add', 'cabinet.yaml'], {}, (e1, _o1, se1) => {
+      if (e1) { if (isLock(e1, se1) && n < MAX) return setTimeout(() => attempt(n + 1), 150 * n); return cb(e1); }
+      run(['-c', 'user.name=Steve Abrams', '-c', 'user.email=steve@designerwallcoverings.com',
+        'commit', '-m', full, '--', 'cabinet.yaml'], { timeout: 15000 }, (e2, _o2, se2) => {
+        if (e2) { if (isLock(e2, se2) && n < MAX) return setTimeout(() => attempt(n + 1), 150 * n); return cb(e2); }
+        run(['rev-parse', '--short', 'HEAD'], {}, (e3, out) => cb(e3, (out || '').trim()));
+      });
     });
-  });
+  };
+  attempt(1);
+}
+function gitCommitCabinet(s, cb) {
+  commitCabinetMsg('cabinet: approve cross-officer ' + s.kind + ' ' + s.id + ' under ' + s.to +
+    ' (from ' + s.from + ') via pyramid', cb);
+}
+
+// ── Org-GAPS overlay (must mirror pyramid.html GAPS) ──
+// Surfaces the structural gaps Steve flagged: missing officers + orphan agents
+// not yet in the cabinet. Approving a gap WRITES the change into cabinet.yaml
+// (new-vp = a whole VP block; adopt-orphan = a director under an existing VP)
+// and auto-commits. This is the dotted-line "approve to execute" mechanism,
+// extended from cross-officer SUGGESTIONS to "stand up an officer / adopt an orphan".
+const GAPS = [
+  { id: 'vp-security', type: 'new-vp',
+    domain: 'Security posture, incident response, secrets, firewalls, monitoring',
+    triggers: [
+      'security | breach | incident | intrusion | backdoor',
+      'secret | token | rotate | leak | exposed key',
+      'firewall | port | hardening | cve | dependency audit',
+      'security-dashboard | rkhunter | chkrootkit | security monitor',
+    ],
+    directors: [
+      { kind: 'subagent', name: 'security-auditor', note: 'cross-officer (primary: vp-engineering)' },
+      { kind: 'subagent', name: 'secrets-manager', note: 'cross-officer (primary: vp-operations)' },
+      { kind: 'subagent', name: 'security-dashboard', note: 'monitoring fleet — :9889 + Kamatera security-monitor.sh' },
+      { kind: 'skill', name: 'open-firewalls', note: 'cross-officer (primary: vp-operations)' },
+      { kind: 'skill', name: 'secrets', note: 'cross-officer (primary: vp-operations)' },
+      { kind: 'subagent', name: 'comms-compliance', note: 'cross-officer (primary: vp-compliance-policy)' },
+    ] },
+  { id: 'vp-special-projects', type: 'new-vp',
+    domain: 'Non-DW / side / product-builder projects (wallco, apartment, site-factory, abrams)',
+    triggers: [
+      'wallco | wallco.ai | mural | seam',
+      'apartment wallpaper | peel and stick',
+      'site-factory | small-business-builder | sdcc',
+      'agentabrams | 4square | abrams portfolio',
+      'special project | side project',
+    ],
+    directors: [
+      { kind: 'subagent', name: 'wallco-agent', note: 'wallco.ai storefront + gen pipeline' },
+      { kind: 'subagent', name: 'apartment-wallpaper-agent', note: 'apartmentwallpaper.com peel-and-stick' },
+      { kind: 'subagent', name: 'seam-debug-agent', note: 'wallco seam/joint healer' },
+      { kind: 'skill', name: 'site-factory', note: 'multi-site Next.js generator' },
+      { kind: 'skill', name: 'small-business-builder', note: 'SDCC analysis + rebuild' },
+    ] },
+  { id: 'agent-architect',  type: 'adopt-orphan', kind: 'subagent', to: 'vp-operations',  note: 'owns cabinet.yaml + this viewer' },
+  { id: 'abramstasks-agent', type: 'adopt-orphan', kind: 'subagent', to: 'vp-operations',  note: 'plan→parallel-Claudes orchestrator' },
+  { id: 'context-manager',  type: 'adopt-orphan', kind: 'subagent', to: 'vp-engineering', note: 'multi-agent context management' },
+  { id: 'dfm',              type: 'adopt-orphan', kind: 'subagent', to: 'vp-operations',  note: 'autonomous task executor — confirm purpose' },
+];
+const GAPSTATE_FILE = path.join(ROOT, 'pyramid-gap-state.json');
+const gapKey = (g) => g.type === 'new-vp' ? 'vp:' + g.id : g.kind + ':' + g.id + ':' + g.to;
+function loadGapState() { try { return JSON.parse(fs.readFileSync(GAPSTATE_FILE, 'utf8')); } catch (e) { return { decisions: {} }; } }
+function saveGapState(s) { try { fs.writeFileSync(GAPSTATE_FILE, JSON.stringify(s, null, 2)); } catch (e) {} }
+
+// new-vp → insert a full VP block before architecture_principles (idempotent).
+// adopt-orphan → append a director under the target VP's directors (idempotent).
+function applyGapToCabinet(g) {
+  const lines = fs.readFileSync(CABINET, 'utf8').split('\n');
+  if (g.type === 'new-vp') {
+    if (lines.some(l => new RegExp('^  - vp:\\s*' + g.id + '\\s*$').test(l))) return { added: false, already: true };
+    const apIdx = lines.findIndex(l => /^architecture_principles:/.test(l));
+    if (apIdx < 0) return { added: false, error: 'architecture_principles anchor not found' };
+    const block = ['  - vp: ' + g.id, '    domain: ' + g.domain, '    triggers:'];
+    g.triggers.forEach(t => block.push('      - ' + t));
+    block.push('    directors:');
+    g.directors.forEach(d => { block.push('      - ' + d.kind + ': ' + d.name); if (d.note) block.push('        owns: [' + d.note + ']'); });
+    block.push('');
+    lines.splice(apIdx, 0, ...block);
+    fs.writeFileSync(CABINET, lines.join('\n'));
+    return { added: true };
+  }
+  // adopt-orphan
+  const vpIdx = lines.findIndex(l => new RegExp('^  - vp:\\s*' + g.to + '\\s*$').test(l));
+  if (vpIdx < 0) return { added: false, error: 'target VP ' + g.to + ' not found (approve it first)' };
+  let dirIdx = -1, end = lines.length;
+  for (let i = vpIdx + 1; i < lines.length; i++) {
+    if (/^  - vp:/.test(lines[i]) || /^architecture_principles:/.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 = g.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  for (let i = dirIdx + 1; i < end; i++) {
+    if (new RegExp('^\\s+- ' + g.kind + ':\\s*' + esc + '\\s*$').test(lines[i])) return { added: false, already: true };
+  }
+  const ins = ['      - ' + g.kind + ': ' + g.id];
+  if (g.note) ins.push('        owns: [adopted orphan via pyramid gaps — ' + g.note + ']');
+  lines.splice(dirIdx + 1, 0, ...ins);
+  fs.writeFileSync(CABINET, lines.join('\n'));
+  return { added: true };
 }
 
 // ── Idea 4: Live Health Overlay — read DW canary verdicts, map to officers ──
@@ -702,6 +812,35 @@ const server = http.createServer((req, res) => {
     }
     return;
   }
+  if (u.pathname === '/api/gaps') {
+    return J({ ok: true, list: GAPS, decisions: (loadGapState().decisions) || {} });
+  }
+  if (u.pathname === '/api/gap') {
+    const key = q.get('key'), action = q.get('action');
+    const g = GAPS.find(x => gapKey(x) === key);
+    if (!g) return J({ ok: false, error: 'unknown gap' });
+    if (!['approve', 'remove', 'reset'].includes(action)) return J({ ok: false, error: 'bad action' });
+    const st = loadGapState(); st.decisions = st.decisions || {};
+    if (action === 'remove') { st.decisions[key] = 'removed'; saveGapState(st); return J({ ok: true, action, decisions: st.decisions }); }
+    if (action === 'reset')  { delete st.decisions[key];      saveGapState(st); return J({ ok: true, action, decisions: st.decisions }); }
+    // approve: run apply-to-file + commit as ONE serialized critical section, so
+    // parallel clicks neither race the index nor sweep each other's writes.
+    withCabinetLock(() => new Promise((resolve) => {
+      const cab = applyGapToCabinet(g);
+      if (cab && cab.error) { J({ ok: false, error: cab.error }); return resolve(); }
+      const st2 = loadGapState(); st2.decisions = st2.decisions || {};
+      st2.decisions[key] = 'approved'; saveGapState(st2);
+      if (cab && cab.added) {
+        const msg = g.type === 'new-vp'
+          ? 'cabinet: stand up ' + g.id + ' officer via pyramid gaps overlay'
+          : 'cabinet: adopt orphan ' + g.kind + ' ' + g.id + ' under ' + g.to + ' via pyramid gaps overlay';
+        commitCabinetMsg(msg, (gerr, hash) => { J({ ok: true, action, decisions: st2.decisions, cabinet: cab, commit: gerr ? null : hash, gitError: gerr ? String(gerr) : undefined }); resolve(); });
+      } else {
+        J({ ok: true, action, decisions: st2.decisions, cabinet: cab }); resolve();
+      }
+    }));
+    return;
+  }
   if (u.pathname === '/api/queue-idea') {
     const title = q.get('title') || 'Cabinet idea';
     const summary = q.get('summary') || '';

← 7ac1761 Add dw-yolo-loop skill to cabinet (vp-operations) + pyramid:  ·  back to Agent Cabinet  ·  pyramid: fix P0 UI/UX (a11y + drawer focus + double-write gu 281dc4a →