[object Object]

← back to Ai Workforce

ai-workforce v0.1 — live fleet-as-workforce dashboard (pm2+launchd+cabinet+CNCP+cost), basic-auth, sort+density, hired date/time

ad7b400599f3c979fdf2397c15e2fe3edebdd468 · 2026-07-13 15:29:30 -0700 · Steve

Files touched

Diff

commit ad7b400599f3c979fdf2397c15e2fe3edebdd468
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 13 15:29:30 2026 -0700

    ai-workforce v0.1 — live fleet-as-workforce dashboard (pm2+launchd+cabinet+CNCP+cost), basic-auth, sort+density, hired date/time
---
 .deploy.conf      |   6 ++
 README.md         |  36 +++++++++-
 lib/collect.js    | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json      |   9 +++
 public/app.js     | 140 +++++++++++++++++++++++++++++++++++++++
 public/index.html |  80 +++++++++++++++++++++-
 public/styles.css |  94 ++++++++++++++++++++++++++
 server.js         |  60 ++++++++++++++---
 8 files changed, 608 insertions(+), 10 deletions(-)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..a48de4a
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,6 @@
+# ai-workforce deploy config — scaffolded, NOT yet used.
+# Deploy is gated: only Steve runs /deploy. This file just makes it one command.
+PROJECT_NAME=ai-workforce
+DEPLOY_PATH=/root/public-projects/ai-workforce
+HEALTH_URL=http://127.0.0.1:9791/
+PORT=9791
diff --git a/README.md b/README.md
index c321bef..4742e60 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,35 @@
-Starter templates copied into new projects by scripts/new-project.sh.
+# AI Workforce
+
+A live dashboard that presents Steve's real fleet as an **AI workforce** —
+machines are offices, agents/services/scheduled jobs are full-time employees.
+Seeded by the @ScottyBeamIO tweet: *"a Mac Mini farm, each running its own AI
+agent like a full-time employee… an entire workforce."*
+
+Built via the Claude Web-Dev Accelerator. See `BRIEF.md` for the brief.
+
+## Run
+
+```bash
+PORT=9791 npm start      # → http://localhost:9791  (basic auth admin / DW2024!)
+```
+
+Override auth with `BASIC_AUTH_USER` / `BASIC_AUTH_PASS`. `PORT=0` picks a free port.
+
+## What it shows (all live, local, read-only)
+- **Ribbon** — total staff, working now, idle/on-call, needs-attention, cost today.
+- **Offices** — Mac2 (live cores/mem/load), Mac1 + Kamatera (declared, not yet probed).
+- **Today's output** — CNCP wins for today.
+- **Payroll today** — paid-API spend from the cost ledger, by app.
+- **Staff roster** — every pm2 service, `com.steve.*` launchd job, and cabinet
+  officer/agent as an employee card with role, status, metrics, and (house rule)
+  the **created/"hired" date + time**. Grid has **sort + a density slider**,
+  both persisted to `localStorage`.
+
+## Data sources
+pm2 (`pm2 jlist`) · launchd (`launchctl list | grep com.steve`) ·
+`~/Projects/agent-cabinet/cabinet.yaml` · CNCP `:3333/api/wins` ·
+`~/.claude/cost-ledger.jsonl` · Mac2 `sysctl`.
+
+## Status
+Local prototype. **Deploy / DNS / public exposure are gated and NOT taken** —
+`.deploy.conf` is scaffolded so it is one command from live, on Steve's go only.
diff --git a/lib/collect.js b/lib/collect.js
new file mode 100644
index 0000000..d6b7989
--- /dev/null
+++ b/lib/collect.js
@@ -0,0 +1,193 @@
+'use strict';
+// AI Workforce — live snapshot collector. All sources are LOCAL + READ-ONLY.
+// Each source is wrapped so a single failure degrades gracefully (never blanks
+// the whole snapshot). Nothing here writes, deploys, or touches the network
+// except a localhost GET to CNCP.
+const { execSync } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const HOME = os.homedir();
+const sh = (cmd, ms = 6000) => execSync(cmd, { timeout: ms, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
+const safe = (fn, fallback) => { try { return fn(); } catch { return fallback; } };
+const todayISO = () => new Date().toISOString().slice(0, 10);
+
+// ---- Machines (offices) ---------------------------------------------------
+function machines(pm2Count) {
+  const list = [];
+  // Mac2 — the local box, fully probeable.
+  const cores = safe(() => parseInt(sh('sysctl -n hw.ncpu').trim(), 10), null);
+  const memBytes = safe(() => parseInt(sh('sysctl -n hw.memsize').trim(), 10), null);
+  const load = safe(() => {
+    const m = sh('sysctl -n vm.loadavg').match(/[\d.]+/g);
+    return m ? { one: +m[0], five: +m[1], fifteen: +m[2] } : null;
+  }, null);
+  list.push({
+    id: 'mac2', name: 'Mac2', label: 'Primary workstation', probed: true,
+    cores, memGB: memBytes ? Math.round(memBytes / 1e9) : null, load,
+    loadPct: load && cores ? Math.min(100, Math.round((load.one / cores) * 100)) : null,
+    working: pm2Count.working, staff: pm2Count.total,
+  });
+  // Mac1 / Kamatera — known offices, but not locally probeable from here.
+  // Declared honestly rather than faked.
+  list.push({ id: 'mac1', name: 'Mac1', label: 'Secondary workstation', probed: false, note: 'remote — live probe not wired in this prototype' });
+  list.push({ id: 'kamatera', name: 'Kamatera', label: 'Production host (45.61.58.125)', probed: false, note: 'remote — live probe not wired in this prototype' });
+  return list;
+}
+
+// ---- pm2 services (live employees) ----------------------------------------
+function pm2Employees() {
+  const raw = safe(() => sh('pm2 jlist', 8000), '[]');
+  let arr = [];
+  try { arr = JSON.parse(raw); } catch { arr = []; }
+  return arr.map((p) => {
+    const env = p.pm2_env || {};
+    const status = env.status === 'online' ? 'working' : 'stopped';
+    return {
+      id: 'pm2:' + p.name,
+      name: p.name,
+      dept: 'Service',
+      role: 'Live process (pm2)',
+      machine: 'Mac2',
+      status,
+      metrics: {
+        cpu: p.monit ? p.monit.cpu : null,
+        memMB: p.monit && p.monit.memory ? Math.round(p.monit.memory / 1e6) : null,
+        restarts: env.restart_time,
+        uptimeMs: env.pm_uptime ? Date.now() - env.pm_uptime : null,
+      },
+      hired: env.created_at || env.pm_uptime || null,
+      lastActive: env.pm_uptime || null,
+    };
+  });
+}
+
+// ---- launchd com.steve.* jobs (shift workers) -----------------------------
+function launchdEmployees() {
+  const raw = safe(() => sh("launchctl list | grep com.steve", 6000), '');
+  const rows = raw.split('\n').map((l) => l.trim()).filter(Boolean);
+  const agentsDir = path.join(HOME, 'Library', 'LaunchAgents');
+  return rows.map((line) => {
+    // format: <PID|-> \t <last exit status> \t <label>
+    const parts = line.split(/\s+/);
+    const pid = parts[0];
+    const exit = parseInt(parts[1], 10);
+    const label = parts.slice(2).join(' ');
+    let status;
+    if (pid && pid !== '-') status = 'working';       // currently running
+    else if (exit === 0) status = 'idle';             // scheduled, last run clean
+    else status = 'error';                            // last run failed
+    const short = label.replace(/^com\.steve\./, '');
+    const plist = path.join(agentsDir, label + '.plist');
+    const hired = safe(() => fs.statSync(plist).mtimeMs, null);
+    return {
+      id: 'launchd:' + label,
+      name: short,
+      dept: 'Scheduled',
+      role: 'launchd job',
+      machine: 'Mac2',
+      status,
+      metrics: { pid: pid !== '-' ? pid : null, lastExit: isNaN(exit) ? null : exit },
+      hired,
+      lastActive: hired,
+    };
+  });
+}
+
+// ---- cabinet.yaml (management + named agents) -----------------------------
+function cabinetEmployees() {
+  const file = path.join(HOME, 'Projects', 'agent-cabinet', 'cabinet.yaml');
+  const txt = safe(() => fs.readFileSync(file, 'utf8'), '');
+  if (!txt) return [];
+  const hired = safe(() => fs.statSync(file).mtimeMs, null);
+  const lines = txt.split('\n');
+  const out = [];
+  for (let i = 0; i < lines.length; i++) {
+    const vp = lines[i].match(/^\s*-\s*vp:\s*(\S+)/);
+    if (vp) {
+      let domain = '';
+      for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
+        const d = lines[j].match(/^\s*domain:\s*(.+)/);
+        if (d) { domain = d[1].trim(); break; }
+      }
+      out.push({ id: 'cabinet:' + vp[1], name: vp[1], dept: 'Officer', role: domain || 'VP', machine: '—', status: 'roster', metrics: {}, hired, lastActive: hired });
+      continue;
+    }
+    const sub = lines[i].match(/^\s*-\s*subagent:\s*(\S+)/);
+    if (sub) {
+      out.push({ id: 'cabinet:' + sub[1], name: sub[1], dept: 'Agent', role: 'Specialist subagent', machine: '—', status: 'roster', metrics: {}, hired, lastActive: hired });
+    }
+  }
+  // de-dup names (a subagent can appear under several VPs)
+  const seen = new Set();
+  return out.filter((e) => (seen.has(e.id) ? false : (seen.add(e.id), true)));
+}
+
+// ---- CNCP wins (today's output) -------------------------------------------
+function winsToday() {
+  return new Promise((resolve) => {
+    const req = http.get('http://127.0.0.1:3333/api/wins', { timeout: 3500 }, (res) => {
+      let body = '';
+      res.on('data', (c) => (body += c));
+      res.on('end', () => {
+        let arr = [];
+        try { arr = JSON.parse(body); } catch { arr = []; }
+        const t = todayISO();
+        const today = (Array.isArray(arr) ? arr : []).filter((w) => (w.date || '').slice(0, 10) === t);
+        resolve({ today, total: Array.isArray(arr) ? arr.length : 0 });
+      });
+    });
+    req.on('error', () => resolve({ today: [], total: 0, unavailable: true }));
+    req.on('timeout', () => { req.destroy(); resolve({ today: [], total: 0, unavailable: true }); });
+  });
+}
+
+// ---- cost ledger (payroll / spend today) ----------------------------------
+function costToday() {
+  const file = path.join(HOME, '.claude', 'cost-ledger.jsonl');
+  const txt = safe(() => fs.readFileSync(file, 'utf8'), '');
+  if (!txt) return { totalUSD: 0, byApp: [], calls: 0 };
+  const t = todayISO();
+  const byApp = {};
+  let total = 0, calls = 0;
+  for (const line of txt.split('\n')) {
+    if (!line.includes('"ts":"' + t)) continue;
+    let row; try { row = JSON.parse(line); } catch { continue; }
+    const c = +row.cost_usd || 0;
+    total += c; calls++;
+    const key = row.app || row.api || 'other';
+    byApp[key] = (byApp[key] || 0) + c;
+  }
+  const list = Object.entries(byApp).map(([app, usd]) => ({ app, usd })).sort((a, b) => b.usd - a.usd).slice(0, 8);
+  return { totalUSD: +total.toFixed(4), byApp: list, calls };
+}
+
+async function snapshot() {
+  const pm2 = pm2Employees();
+  const launchd = launchdEmployees();
+  const cabinet = cabinetEmployees();
+  const pm2Count = { total: pm2.length, working: pm2.filter((e) => e.status === 'working').length };
+  const employees = [...pm2, ...launchd, ...cabinet];
+  const byStatus = employees.reduce((a, e) => ((a[e.status] = (a[e.status] || 0) + 1), a), {});
+  const wins = await winsToday();
+  return {
+    generatedAt: Date.now(),
+    machines: machines(pm2Count),
+    employees,
+    counts: {
+      total: employees.length,
+      working: byStatus.working || 0,
+      idle: byStatus.idle || 0,
+      error: byStatus.error || 0,
+      stopped: byStatus.stopped || 0,
+      roster: byStatus.roster || 0,
+      byDept: employees.reduce((a, e) => ((a[e.dept] = (a[e.dept] || 0) + 1), a), {}),
+    },
+    wins,
+    cost: costToday(),
+  };
+}
+
+module.exports = { snapshot };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9d32566
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "ai-workforce",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Your fleet as an AI workforce — machines are offices, agents are full-time employees.",
+  "scripts": {
+    "start": "node server.js"
+  }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..b777a95
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,140 @@
+'use strict';
+const $ = (s) => document.querySelector(s);
+const LS = {
+  get: (k, d) => { try { const v = localStorage.getItem('aiwf.' + k); return v === null ? d : v; } catch { return d; } },
+  set: (k, v) => { try { localStorage.setItem('aiwf.' + k, v); } catch {} },
+};
+let SNAP = null;
+
+// house rule: admin cards show created date AND time, local tz
+function fmtWhen(ms) {
+  if (!ms) return null;
+  const d = new Date(ms);
+  return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+}
+function dur(ms) { // format an elapsed duration (ms) as compact d/h/m
+  if (!ms || ms < 0) return '';
+  const s = Math.floor(ms / 1000);
+  if (s < 60) return s + 's';
+  if (s < 3600) return Math.floor(s / 60) + 'm';
+  if (s < 86400) return Math.floor(s / 3600) + 'h';
+  return Math.floor(s / 86400) + 'd';
+}
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+
+function ribbon(c, cost) {
+  $('#ribbon').innerHTML = [
+    ['total', c.total, 'Total staff'],
+    ['working', c.working, 'Working now'],
+    ['idle', (c.idle || 0) + (c.roster || 0), 'Idle / on-call'],
+    ['err', (c.error || 0) + (c.stopped || 0), 'Needs attention'],
+    ['cost', '$' + (cost.totalUSD || 0).toFixed(2), 'Cost today'],
+  ].map(([cls, n, l]) => `<div class="stat ${cls}"><div class="n">${esc(n)}</div><div class="l">${esc(l)}</div></div>`).join('');
+}
+
+function offices(list) {
+  $('#offices').innerHTML = list.map((m) => {
+    if (!m.probed) {
+      return `<div class="office dim"><h3>${esc(m.name)}</h3><div class="sub">${esc(m.label)}</div>
+        <span class="badge">${esc(m.note || 'remote')}</span></div>`;
+    }
+    const pct = m.staff ? Math.round((m.working / m.staff) * 100) : 0;
+    return `<div class="office"><h3>${esc(m.name)}</h3><div class="sub">${esc(m.label)}</div>
+      <div class="row"><span>Staff on this box</span><b>${m.working}/${m.staff} working</b></div>
+      <div class="bar"><i style="width:${pct}%"></i></div>
+      <div class="row" style="margin-top:10px"><span>Cores</span><b>${esc(m.cores ?? '—')}</b></div>
+      <div class="row"><span>Memory</span><b>${m.memGB ? m.memGB + ' GB' : '—'}</b></div>
+      <div class="row"><span>Load (1m)</span><b>${m.load ? m.load.one.toFixed(2) + (m.loadPct != null ? ' · ' + m.loadPct + '%' : '') : '—'}</b></div>
+    </div>`;
+  }).join('');
+}
+
+function wins(w) {
+  $('#wins-src').textContent = w.unavailable ? '(CNCP offline)' : '(' + w.today.length + ' today · ' + w.total + ' all-time)';
+  if (!w.today.length) { $('#wins').innerHTML = `<div class="empty">${w.unavailable ? 'CNCP not reachable on :3333.' : 'No wins logged yet today.'}</div>`; return; }
+  $('#wins').innerHTML = w.today.map((x) => `<div class="win">
+    <div class="p">${esc(x.project || '')}</div>
+    <div class="t">${esc(x.title || '')}</div>
+    ${x.summary ? `<div class="m">${esc(x.summary).slice(0, 180)}</div>` : ''}
+  </div>`).join('');
+}
+
+function payroll(cost) {
+  const rows = (cost.byApp || []).map((r) => `<div class="prow"><span class="app">${esc(r.app)}</span><span class="usd">$${r.usd.toFixed(4)}</span></div>`).join('');
+  $('#payroll').innerHTML = (rows || `<div class="empty">No paid-API spend today.</div>`) +
+    `<div class="tot">$${(cost.totalUSD || 0).toFixed(4)} <span class="muted">· ${cost.calls || 0} calls</span></div>`;
+}
+
+function statusRank(s) { return { working: 0, error: 1, stopped: 2, idle: 3, roster: 4 }[s] ?? 9; }
+
+function renderGrid() {
+  if (!SNAP) return;
+  const q = $('#search').value.trim().toLowerCase();
+  const dept = $('#dept').value, st = $('#status').value, sort = $('#sort').value;
+  let list = SNAP.employees.filter((e) =>
+    (!dept || e.dept === dept) && (!st || e.status === st) &&
+    (!q || (e.name + ' ' + e.role + ' ' + e.dept).toLowerCase().includes(q)));
+  list.sort((a, b) => {
+    if (sort === 'name') return a.name.localeCompare(b.name);
+    if (sort === 'dept') return a.dept.localeCompare(b.dept) || a.name.localeCompare(b.name);
+    if (sort === 'hired-new') return (b.hired || 0) - (a.hired || 0);
+    if (sort === 'hired-old') return (a.hired || Infinity) - (b.hired || Infinity);
+    return statusRank(a.status) - statusRank(b.status) || a.name.localeCompare(b.name);
+  });
+  $('#roster-count').textContent = list.length + ' shown';
+  $('#grid').innerHTML = list.map((e) => {
+    const m = e.metrics || {};
+    const chips = [];
+    if (m.cpu != null) chips.push('CPU ' + m.cpu + '%');
+    if (m.memMB != null) chips.push(m.memMB + ' MB');
+    if (m.restarts != null) chips.push('↻ ' + m.restarts);
+    if (m.uptimeMs) chips.push('up ' + dur(m.uptimeMs));
+    if (m.pid) chips.push('pid ' + m.pid);
+    if (m.lastExit != null && m.lastExit !== 0) chips.push('exit ' + m.lastExit);
+    if (e.machine && e.machine !== '—') chips.push('🖥 ' + e.machine);
+    const when = fmtWhen(e.hired);
+    const iso = e.hired ? new Date(e.hired).toISOString() : '';
+    return `<div class="card">
+      <div class="top">
+        <div><div class="dept">${esc(e.dept)}</div><div class="nm">${esc(e.name)}</div></div>
+        <span class="pill ${esc(e.status)}">${esc(e.status)}</span>
+      </div>
+      <div class="role">${esc(e.role)}</div>
+      ${chips.length ? `<div class="chips">${chips.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}</div>` : ''}
+      <div class="when" title="${esc(iso)}">${when ? '🕓 hired <b>' + esc(when) + '</b>' : '🕓 hired —'}</div>
+    </div>`;
+  }).join('');
+}
+
+function fillDepts() {
+  const depts = [...new Set(SNAP.employees.map((e) => e.dept))].sort();
+  const cur = $('#dept').value;
+  $('#dept').innerHTML = '<option value="">All departments</option>' +
+    depts.map((d) => `<option ${d === cur ? 'selected' : ''} value="${esc(d)}">${esc(d)} (${SNAP.counts.byDept[d] || 0})</option>`).join('');
+}
+
+async function load() {
+  $('#stamp').textContent = 'refreshing…';
+  try {
+    const r = await fetch('/api/snapshot', { cache: 'no-store' });
+    SNAP = await r.json();
+  } catch (e) { $('#stamp').textContent = 'snapshot failed'; return; }
+  ribbon(SNAP.counts, SNAP.cost);
+  offices(SNAP.machines);
+  wins(SNAP.wins);
+  payroll(SNAP.cost);
+  fillDepts();
+  renderGrid();
+  $('#stamp').textContent = 'updated ' + new Date(SNAP.generatedAt).toLocaleTimeString();
+}
+
+// wire controls + restore persisted prefs
+function applyDensity(v) { document.documentElement.style.setProperty('--cols', v); }
+$('#density').value = LS.get('density', '4'); applyDensity($('#density').value);
+$('#sort').value = LS.get('sort', 'status');
+$('#density').addEventListener('input', (e) => { applyDensity(e.target.value); LS.set('density', e.target.value); });
+$('#sort').addEventListener('change', (e) => { LS.set('sort', e.target.value); renderGrid(); });
+['#search', '#dept', '#status'].forEach((s) => $(s).addEventListener('input', renderGrid));
+$('#refresh').addEventListener('click', load);
+load();
+setInterval(load, 20000);
diff --git a/public/index.html b/public/index.html
index f4266d2..ebb0e41 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1 +1,79 @@
-<!doctype html><meta charset=utf-8><title>ai-workforce</title><h1>ai-workforce — scaffolded</h1><p>Wire the grid (sort + density) and content here.</p>
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>AI Workforce</title>
+<link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+<header class="topbar">
+  <div class="brand">
+    <span class="dot"></span>
+    <div>
+      <h1>AI&nbsp;WORKFORCE</h1>
+      <p class="tag">Your fleet as full-time employees — machines are offices, agents are staff.</p>
+    </div>
+  </div>
+  <div class="topbar-right">
+    <span id="stamp" class="stamp">loading…</span>
+    <button id="refresh" class="btn">↻ Refresh</button>
+  </div>
+</header>
+
+<section class="ribbon" id="ribbon"></section>
+
+<section class="panel">
+  <h2 class="ph">Offices</h2>
+  <div class="offices" id="offices"></div>
+</section>
+
+<div class="two-col">
+  <section class="panel">
+    <h2 class="ph">Today's output <span class="muted" id="wins-src"></span></h2>
+    <div class="wins" id="wins"></div>
+  </section>
+  <section class="panel">
+    <h2 class="ph">Payroll today <span class="muted">(paid-API spend)</span></h2>
+    <div class="payroll" id="payroll"></div>
+  </section>
+</div>
+
+<section class="panel">
+  <div class="roster-head">
+    <h2 class="ph">Staff roster <span class="muted" id="roster-count"></span></h2>
+    <div class="controls">
+      <input id="search" class="inp" type="search" placeholder="Search staff…" autocomplete="off">
+      <select id="dept" class="inp"></select>
+      <select id="status" class="inp">
+        <option value="">All status</option>
+        <option value="working">Working</option>
+        <option value="idle">Idle / scheduled</option>
+        <option value="error">Error</option>
+        <option value="stopped">Stopped</option>
+        <option value="roster">On roster</option>
+      </select>
+      <select id="sort" class="inp">
+        <option value="status">Sort: Status</option>
+        <option value="name">Sort: Name A→Z</option>
+        <option value="dept">Sort: Department</option>
+        <option value="hired-new">Sort: Newest hired</option>
+        <option value="hired-old">Sort: Oldest hired</option>
+      </select>
+      <label class="density" title="Card density">
+        <span>▦</span>
+        <input id="density" type="range" min="2" max="8" value="4">
+      </label>
+    </div>
+  </div>
+  <div class="grid" id="grid"></div>
+</section>
+
+<footer class="foot">
+  <span>Local prototype · read-only · <code>$0 (local)</code></span>
+  <span class="muted">Deploy / DNS / public exposure are gated — not taken here.</span>
+</footer>
+
+<script src="/app.js"></script>
+</body>
+</html>
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..89d6f6f
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,94 @@
+:root{
+  --bg:#0b0e14; --panel:#121722; --panel2:#0f141d; --line:#1e2633;
+  --ink:#e6ecf5; --muted:#8695ab; --accent:#4cc2ff; --accent2:#7c5cff;
+  --ok:#37d67a; --idle:#4cc2ff; --err:#ff5a6a; --stop:#f5a623; --roster:#8a7cff;
+  --cols:4;
+}
+*{box-sizing:border-box}
+body{margin:0;background:var(--bg);color:var(--ink);
+  font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
+h1,h2{margin:0}
+code{background:#0008;padding:1px 5px;border-radius:4px;color:#cfe}
+.muted{color:var(--muted);font-weight:400;font-size:12px}
+
+.topbar{display:flex;justify-content:space-between;align-items:center;gap:16px;
+  padding:16px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;
+  background:linear-gradient(180deg,#0d111a,#0b0e14);z-index:5}
+.brand{display:flex;align-items:center;gap:14px}
+.brand .dot{width:12px;height:12px;border-radius:50%;background:var(--ok);
+  box-shadow:0 0 0 4px #37d67a22,0 0 14px #37d67a88}
+.brand h1{font-size:19px;letter-spacing:.14em;font-weight:800}
+.tag{margin:2px 0 0;color:var(--muted);font-size:12px}
+.topbar-right{display:flex;align-items:center;gap:12px}
+.stamp{color:var(--muted);font-size:12px}
+.btn{background:#182234;color:var(--ink);border:1px solid var(--line);
+  padding:7px 12px;border-radius:8px;cursor:pointer;font-size:13px}
+.btn:hover{border-color:var(--accent)}
+
+.ribbon{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;padding:18px 22px}
+.stat{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
+.stat .n{font-size:26px;font-weight:800;letter-spacing:.02em}
+.stat .l{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin-top:2px}
+.stat.working .n{color:var(--ok)} .stat.idle .n{color:var(--idle)}
+.stat.err .n{color:var(--err)} .stat.cost .n{color:var(--accent2)}
+
+.panel{background:var(--panel2);border:1px solid var(--line);border-radius:14px;
+  margin:0 22px 18px;padding:16px 18px}
+.ph{font-size:13px;text-transform:uppercase;letter-spacing:.1em;color:#cdd8e8;margin-bottom:12px}
+.two-col{display:grid;grid-template-columns:1fr 1fr;gap:0 0}
+.two-col .panel:first-child{margin-right:9px}
+.two-col .panel:last-child{margin-left:9px}
+
+.offices{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
+.office{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px}
+.office h3{margin:0 0 2px;font-size:15px}
+.office .sub{color:var(--muted);font-size:12px;margin-bottom:10px}
+.office .row{display:flex;justify-content:space-between;font-size:12px;color:#b9c6d8;margin:3px 0}
+.bar{height:8px;border-radius:6px;background:#1b2434;overflow:hidden;margin-top:8px}
+.bar > i{display:block;height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2))}
+.office.dim{opacity:.62}
+.office .badge{display:inline-block;font-size:11px;color:var(--muted);border:1px solid var(--line);
+  border-radius:20px;padding:2px 9px;margin-top:8px}
+
+.wins{display:flex;flex-direction:column;gap:10px;max-height:280px;overflow:auto}
+.win{border-left:3px solid var(--ok);background:#0e1420;border-radius:0 8px 8px 0;padding:9px 12px}
+.win .t{font-weight:600}
+.win .m{color:var(--muted);font-size:12px;margin-top:2px}
+.win .p{color:var(--accent);font-size:11px;text-transform:uppercase;letter-spacing:.06em}
+.empty{color:var(--muted);font-size:13px;padding:8px 0}
+
+.payroll .prow{display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid #131a26}
+.payroll .prow:last-child{border-bottom:0}
+.payroll .app{color:#c8d4e6}
+.payroll .usd{font-variant-numeric:tabular-nums;color:var(--accent2);font-weight:700}
+.payroll .tot{margin-top:10px;font-size:22px;font-weight:800;color:var(--accent2)}
+
+.roster-head{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:12px}
+.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
+.inp{background:#0e1420;border:1px solid var(--line);color:var(--ink);border-radius:8px;
+  padding:7px 10px;font-size:13px;outline:none}
+.inp:focus{border-color:var(--accent)}
+.density{display:flex;align-items:center;gap:6px;color:var(--muted)}
+.density input{accent-color:var(--accent)}
+
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:12px}
+.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 13px;
+  display:flex;flex-direction:column;gap:7px;position:relative}
+.card:hover{border-color:#2c3a4f}
+.card .top{display:flex;justify-content:space-between;align-items:flex-start;gap:8px}
+.card .nm{font-weight:700;font-size:14px;word-break:break-word;line-height:1.25}
+.card .role{color:var(--muted);font-size:12px}
+.pill{font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px;white-space:nowrap;text-transform:uppercase;letter-spacing:.04em}
+.pill.working{background:#0f2c1c;color:var(--ok);border:1px solid #1c5236}
+.pill.idle{background:#0e2333;color:var(--idle);border:1px solid #1d4459}
+.pill.error{background:#2c1116;color:var(--err);border:1px solid #58212a}
+.pill.stopped{background:#2c2410;color:var(--stop);border:1px solid #55471b}
+.pill.roster{background:#1c1836;color:var(--roster);border:1px solid #362d63}
+.chips{display:flex;flex-wrap:wrap;gap:5px}
+.chip{font-size:11px;color:#aab8cc;background:#0e1420;border:1px solid var(--line);border-radius:6px;padding:1px 7px}
+.dept{font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:#93a2b8}
+.when{font-size:11px;color:var(--muted);margin-top:auto;padding-top:4px;border-top:1px solid #131a26}
+.when b{color:#b9c6d8;font-weight:600}
+.foot{display:flex;justify-content:space-between;padding:14px 24px 30px;color:#7c8aa0;font-size:12px;flex-wrap:wrap;gap:8px}
+
+@media(max-width:1000px){.ribbon{grid-template-columns:repeat(2,1fr)}.offices,.two-col{grid-template-columns:1fr}.two-col .panel{margin:0 22px 18px !important}}
diff --git a/server.js b/server.js
index 0040814..333b784 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,52 @@
-// minimal static server; PORT=0 picks a free port. Replace with the real build.
-const http=require('http'),fs=require('fs'),path=require('path');
-const PORT=parseInt(process.env.PORT||'0',10);
-http.createServer((req,res)=>{const f=req.url==='/'?'index.html':req.url.split('?')[0].replace(/^\//,'');
-  const fp=path.join(__dirname,'public',path.basename(f));
-  if(fs.existsSync(fp)){res.writeHead(200,{'Content-Type':f.endsWith('.html')?'text/html':'text/plain'});return res.end(fs.readFileSync(fp));}
-  res.writeHead(404);res.end('not found');
-}).listen(PORT,function(){console.log('['+require('path').basename(__dirname)+'] http://localhost:'+this.address().port);});
+'use strict';
+// ai-workforce — local prototype server. Basic-auth gated. PORT=0 → free port.
+// Endpoints: GET /api/snapshot (live fleet), static files from public/.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { snapshot } = require('./lib/collect');
+
+const PORT = parseInt(process.env.PORT || '0', 10);
+const USER = process.env.BASIC_AUTH_USER || 'admin';
+const PASS = process.env.BASIC_AUTH_PASS || 'DW2024!';
+const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };
+
+function authed(req) {
+  const h = req.headers.authorization || '';
+  if (!h.startsWith('Basic ')) return false;
+  const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
+  return u === USER && p === PASS;
+}
+
+const server = http.createServer(async (req, res) => {
+  if (!authed(req)) {
+    res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="ai-workforce"' });
+    return res.end('auth required');
+  }
+  const url = req.url.split('?')[0];
+
+  if (url === '/api/snapshot') {
+    try {
+      const snap = await snapshot();
+      res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+      return res.end(JSON.stringify(snap));
+    } catch (e) {
+      res.writeHead(500, { 'Content-Type': 'application/json' });
+      return res.end(JSON.stringify({ error: String(e && e.message || e) }));
+    }
+  }
+
+  // static
+  const rel = url === '/' ? 'index.html' : url.replace(/^\/+/, '');
+  const fp = path.join(__dirname, 'public', path.normalize(rel).replace(/^(\.\.[\\/])+/, ''));
+  if (fp.startsWith(path.join(__dirname, 'public')) && fs.existsSync(fp) && fs.statSync(fp).isFile()) {
+    res.writeHead(200, { 'Content-Type': MIME[path.extname(fp)] || 'application/octet-stream' });
+    return res.end(fs.readFileSync(fp));
+  }
+  res.writeHead(404); res.end('not found');
+});
+
+server.listen(PORT, function () {
+  const p = this.address().port;
+  console.log('[ai-workforce] http://localhost:' + p + '  (basic auth ' + USER + ' / ' + PASS + ')');
+});

← a010095 auto-save: 2026-07-13T15:25:47 (1 files) — BRIEF.md  ·  back to Ai Workforce  ·  silence favicon 404 (data-URI icon + /favicon.ico 204) — /3x f891d5f →