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:
1 parent
fd3feb6a08
commit
c2320c3dc8
3 files changed
+153
-2
No files matched your search
@@ -77,6 +77,11 @@ async def dashboard() -> FileResponse:
|
|||||||
return FileResponse(Path(__file__).parent / "static" / "index.html")
|
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")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
row = await db.fetch_one("SELECT count(*) AS companies FROM company")
|
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
|
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")
|
@app.get("/companies/{slug}/work-items")
|
||||||
async def list_work_items(slug: str) -> list[dict]:
|
async def list_work_items(slug: str) -> list[dict]:
|
||||||
company = await db.fetch_one("SELECT id FROM company WHERE slug = %s", slug)
|
company = await db.fetch_one("SELECT id FROM company WHERE slug = %s", slug)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>🏠 FarmHouse Holdings
|
<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>
|
<span id="health" class="pill">…</span>
|
||||||
<button id="pausebtn" onclick="togglePause()">…</button>
|
<button id="pausebtn" onclick="togglePause()">…</button>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -53,6 +54,15 @@ async function togglePause() {
|
|||||||
refresh();
|
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) {
|
async function sendDirective(ev, slug) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const el = document.getElementById('dir-' + slug);
|
const el = document.getElementById('dir-' + slug);
|
||||||
@@ -86,8 +96,11 @@ async function refresh() {
|
|||||||
j(`/companies/${c.slug}/events?limit=40`)]);
|
j(`/companies/${c.slug}/events?limit=40`)]);
|
||||||
const emps = detail.employees.map(e =>
|
const emps = detail.employees.map(e =>
|
||||||
`<tr><td>${e.name}</td><td class="muted">${e.title}</td><td>${pill(e.status)}</td></tr>`).join('');
|
`<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 =>
|
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 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 =>
|
const objs = items.filter(w => w.type === 'objective').map(w =>
|
||||||
`<tr><td>${w.title}</td><td>${pill(w.status)}</td></tr>`).join('');
|
`<tr><td>${w.title}</td><td>${pill(w.status)}</td></tr>`).join('');
|
||||||
const feed = events.map(e => {
|
const feed = events.map(e => {
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user