Clarification answer path + pixel office v0

- POST /companies/{slug}/work-items/{id}/answer: shareholder answers a
  blocked item; answer is appended to the description and the existing
  kanban task is rerun (status=ready + updated body), or the item goes
  back to ready for fresh dispatch if it was never dispatched
- Dashboard: answer button on blocked work item rows (native prompt)
- /office: pixel-art 2D office view — one desk row per employee, avatar
  color by role, LED/typing animation driven only by real engine status,
  live event ticker (PRD 12: no fake activity)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
fez5587andClaude Fable 5 committed 2026-08-04 12:01:37 -05:00
1 parent fd3feb6a08
commit c2320c3dc8
3 files changed
+153 -2

No files matched your search

+45
View File
@@ -77,6 +77,11 @@ async def dashboard() -> FileResponse:
return FileResponse(Path(__file__).parent / "static" / "index.html")
@app.get("/office")
async def office() -> FileResponse:
return FileResponse(Path(__file__).parent / "static" / "office.html")
@app.get("/health")
async def health() -> dict:
row = await db.fetch_one("SELECT count(*) AS companies FROM company")
@@ -295,6 +300,46 @@ async def shareholder_directive(slug: str, body: DirectiveCreate) -> dict:
return result
class AnswerCreate(BaseModel):
answer: str = Field(min_length=1, max_length=4000)
@app.post("/companies/{slug}/work-items/{item_id}/answer")
async def answer_blocked_item(slug: str, item_id: UUID, body: AnswerCreate) -> dict:
"""Shareholder answers a blocked/clarification item; work resumes."""
item = await db.fetch_one(
"SELECT wi.* FROM work_item wi JOIN company c ON c.id = wi.company_id "
"WHERE wi.id = %s AND c.slug = %s", item_id, slug)
if not item:
raise HTTPException(404, "no such work item")
if item["status"] != "blocked":
raise HTTPException(409, f"work item is {item['status']}, not blocked")
new_desc = f"{item['description']}\n\n## Shareholder answer\n{body.answer}"
if item["hermes_task_id"]:
# rerun the existing kanban task with the answer appended
try:
await hermes.update_task(item["hermes_task_id"], status="ready", body=new_desc)
except HermesError as e:
raise HTTPException(502, f"hermes update failed: {e}")
new_status = "in_progress" # poll loop picks the rerun up
else:
new_status = "ready" # never dispatched (e.g. dispatch.failed); dispatch fresh
async with db.pool.connection() as conn:
async with conn.transaction():
await conn.execute(
"UPDATE work_item SET description=%s, status=%s, updated_at=now() WHERE id=%s",
(new_desc, new_status, item_id))
await conn.execute(
"UPDATE employee SET status='working' WHERE id=%s", (item["owner_id"],))
await db.append_event(
conn, actor="shareholder:philip", event_type="clarification.answered",
company_id=item["company_id"], work_item_id=item_id,
source="api", payload={"answer": body.answer[:500]})
return {"status": new_status}
@app.get("/companies/{slug}/work-items")
async def list_work_items(slug: str) -> list[dict]:
company = await db.fetch_one("SELECT id FROM company WHERE slug = %s", slug)
+15 -2
View File
@@ -37,6 +37,7 @@
</head>
<body>
<h1>🏠 FarmHouse Holdings
<a href="/office" style="color:var(--run); font-size:.8rem; text-decoration:none;">🕹 office view</a>
<span id="health" class="pill">…</span>
<button id="pausebtn" onclick="togglePause()">…</button>
</h1>
@@ -53,6 +54,15 @@ async function togglePause() {
refresh();
}
async function answerItem(slug, id) {
const answer = window.prompt('Answer / unblock instruction for this task:');
if (!answer || !answer.trim()) return;
const r = await fetch(`/companies/${slug}/work-items/${id}/answer`, {method:'POST',
headers:{'Content-Type':'application/json'}, body: JSON.stringify({answer: answer.trim()})});
if (!r.ok) alert(`answer failed: ${(await r.json()).detail ?? r.status}`);
refresh();
}
async function sendDirective(ev, slug) {
ev.preventDefault();
const el = document.getElementById('dir-' + slug);
@@ -86,8 +96,11 @@ async function refresh() {
j(`/companies/${c.slug}/events?limit=40`)]);
const emps = detail.employees.map(e =>
`<tr><td>${e.name}</td><td class="muted">${e.title}</td><td>${pill(e.status)}</td></tr>`).join('');
const rows = items.filter(w => w.type !== 'objective').map(w =>
`<tr><td>${w.title}</td><td class="muted">${w.owner_name ?? ''}</td><td>${pill(w.status)}</td></tr>`).join('');
const rows = items.filter(w => w.type !== 'objective').map(w => {
const answer = w.status === 'blocked'
? ` <button onclick="answerItem('${c.slug}','${w.id}')">answer</button>` : '';
return `<tr><td>${w.title}</td><td class="muted">${w.owner_name ?? ''}</td><td>${pill(w.status)}${answer}</td></tr>`;
}).join('');
const objs = items.filter(w => w.type === 'objective').map(w =>
`<tr><td>${w.title}</td><td>${pill(w.status)}</td></tr>`).join('');
const feed = events.map(e => {
+93
View File
@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FarmHouse Office</title>
<style>
body { background:#101418; color:#d8dee6; font:14px/1.5 system-ui, sans-serif;
margin:0; padding:1rem; text-align:center; }
h1 { font-size:1.1rem; } a { color:#5c9de0; }
canvas { image-rendering:pixelated; border:1px solid #2a323c; border-radius:8px;
background:#151b22; max-width:100%; }
#ticker { max-width:800px; margin:.5rem auto; color:#8a94a0; font-size:.8rem;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
</style>
</head>
<body>
<h1>🏠 FarmHouse Office <a href="/">← dashboard</a></h1>
<canvas id="c" width="400" height="220" style="width:800px"></canvas>
<div id="ticker">…</div>
<script>
// Pixel office v0: every drawn state is real engine state (PRD 12: no fake activity).
// desk row per employee; avatar color by role; LED + animation by live status.
const ctx = document.getElementById('c').getContext('2d');
const ROLE_COLORS = { manager:'#e0b354', engineer:'#5c9de0', qa:'#b48ce0' };
const STATUS_LED = { idle:'#4caf7d', working:'#5c9de0', blocked:'#e06c5c' };
let state = { companies: [] }, tick = 0;
async function j(u) { return (await fetch(u)).json(); }
async function poll() {
try {
const companies = (await j('/companies')).filter(c => c.lifecycle_state === 'active');
for (const c of companies) {
const [detail, items, events] = await Promise.all([
j(`/companies/${c.slug}`), j(`/companies/${c.slug}/work-items`),
j(`/companies/${c.slug}/events?limit=1`)]);
c.employees = detail.employees;
c.working = Object.fromEntries(items.filter(w => w.status === 'in_progress' && w.owner_name)
.map(w => [w.owner_name, w.title]));
c.lastEvent = events[0];
}
state.companies = companies;
const ev = companies[0]?.lastEvent;
if (ev) document.getElementById('ticker').textContent =
`${new Date(ev.created_at).toLocaleTimeString()} — ${ev.event_type}: ${JSON.stringify(ev.payload ?? {}).slice(0, 110)}`;
} catch (e) { document.getElementById('ticker').textContent = 'engine unreachable'; }
}
function px(x, y, w, h, color) { ctx.fillStyle = color; ctx.fillRect(x, y, w, h); }
function drawEmployee(x, y, emp, workingOn) {
const c = ROLE_COLORS[emp.role] ?? '#8a94a0';
const working = emp.status === 'working';
const bob = working ? (tick >> 3) % 2 : 0; // typing bob only when actually working
// desk
px(x - 4, y + 18, 28, 4, '#3a4653'); px(x - 2, y + 22, 3, 8, '#2a323c'); px(x + 19, y + 22, 3, 8, '#2a323c');
// monitor (lit only when working)
px(x + 12, y + 8 - 0, 10, 8, '#20262e'); px(x + 13, y + 9, 8, 6, working ? '#9fd0ff' : '#333c46');
// avatar: head, body, arms
px(x, y + bob, 8, 8, '#e8c39e'); // head
px(x - 1, y + 8 + bob, 10, 10, c); // body
px(x - 3, y + 10 + bob, 2, 6, c); px(x + 9, y + 10 + bob, 2, 6, c);
// status LED
px(x + 22, y, 4, 4, STATUS_LED[emp.status] ?? '#8a94a0');
ctx.fillStyle = '#d8dee6'; ctx.font = '6px monospace';
ctx.fillText(`${emp.name} · ${emp.title}`, x - 4, y + 38);
if (workingOn) { ctx.fillStyle = '#8a94a0'; ctx.fillText(workingOn.slice(0, 34), x - 4, y + 45); }
if (emp.status === 'blocked') { ctx.fillStyle = '#e06c5c'; ctx.fillText('!? needs answer', x - 4, y + 45); }
}
function draw() {
tick++;
px(0, 0, 400, 220, '#151b22');
px(0, 200, 400, 20, '#1a2027'); // floor
let y = 24;
for (const c of state.companies) {
ctx.fillStyle = '#d8dee6'; ctx.font = '8px monospace';
ctx.fillText(`▪ ${c.name}`, 12, y - 10);
let x = 30;
for (const emp of c.employees ?? []) {
drawEmployee(x, y, emp, c.working?.[emp.name]);
x += 120;
}
y += 80;
}
requestAnimationFrame(draw);
}
poll(); setInterval(poll, 5000); draw();
</script>
</body>
</html>