← 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
A .deploy.confM README.mdA lib/collect.jsA package.jsonA public/app.jsM public/index.htmlA public/styles.cssM server.js
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 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 →