Office v1: top-down pixel office + sibling-context handoff in dispatch
- /office rebuilt Chrono Trigger style: tile map (work floor, walled conference room with door, break area), 4-direction sprites with walk cycles; employees path through the door and walk to their desk when working (typing anim, lit monitor), to the conference room with a "?" bubble when blocked on an answer, to the water cooler when idle. All movement derives from polled engine state only. - Dispatcher now appends completed-sibling task reports to each task body so decomposed tasks are no longer blind to each other's output (fixes the "apply the patch task A made" capability block). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c2320c3dc8
commit
b3138fb5b0
2 files changed
+194
-56
No files matched your search
+27
-1
@@ -136,12 +136,38 @@ class Dispatcher:
|
||||
for item in ready:
|
||||
await self._dispatch_one(item)
|
||||
|
||||
async def _sibling_context(self, item: dict) -> str:
|
||||
"""Completed-sibling reports appended to the task body: kanban runs are
|
||||
isolated, so without this each task is blind to its siblings' output."""
|
||||
if not item.get("parent_id"):
|
||||
return ""
|
||||
rows = await db.fetch_all(
|
||||
"""
|
||||
SELECT e.payload FROM event e
|
||||
JOIN work_item w ON w.id = e.work_item_id
|
||||
WHERE w.parent_id = %s AND w.id <> %s AND w.status = 'done'
|
||||
AND e.event_type = 'task.completed'
|
||||
ORDER BY e.created_at
|
||||
""", item["parent_id"], item["id"])
|
||||
lines = []
|
||||
for r in rows:
|
||||
p = r["payload"] or {}
|
||||
s = p.get("summary") or p.get("detail")
|
||||
if s:
|
||||
lines.append(f"- {str(s)[:400]}")
|
||||
if not lines:
|
||||
return ""
|
||||
return ("\n\n## What teammates already completed on this objective\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\nDo not assume their intermediate files exist; only the "
|
||||
"shared workspace directory persists between runs.")
|
||||
|
||||
async def _dispatch_one(self, item: dict) -> None:
|
||||
budget = item["budget"]
|
||||
try:
|
||||
task = await self.hermes.create_task(
|
||||
item["title"],
|
||||
body=_task_body(item),
|
||||
body=_task_body(item) + await self._sibling_context(item),
|
||||
assignee=item["hermes_profile"],
|
||||
tenant=item["company_slug"],
|
||||
idempotency_key=str(item["id"]),
|
||||
|
||||
+167
-55
@@ -9,81 +9,193 @@
|
||||
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;
|
||||
max-width:100%; }
|
||||
#ticker { max-width:960px; margin:.5rem auto; color:#8a94a0; font-size:.8rem;
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
#legend { color:#8a94a0; font-size:.75rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🏠 FarmHouse Office <a href="/">← dashboard</a></h1>
|
||||
<canvas id="c" width="400" height="220" style="width:800px"></canvas>
|
||||
<canvas id="c" width="480" height="320" style="width:960px"></canvas>
|
||||
<div id="legend">at desk = working · conference room = blocked, waiting on your answer · water cooler = idle</div>
|
||||
<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.
|
||||
// Top-down pixel office. Every position/animation derives from real engine
|
||||
// state (PRD 12: no fake activity): working -> walk to own desk and type,
|
||||
// blocked -> walk to conference room and wait with a "?" bubble,
|
||||
// idle -> stand at the break area.
|
||||
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;
|
||||
const T = 16, COLS = 30, ROWS = 20;
|
||||
|
||||
async function j(u) { return (await fetch(u)).json(); }
|
||||
// # wall . carpet , conference wood floor + door (passable)
|
||||
const MAP = [
|
||||
"##############################",
|
||||
"#.................#,,,,,,,,,,#",
|
||||
"#.................#,,,,,,,,,,#",
|
||||
"#.................#,,,,,,,,,,#",
|
||||
"#.................+,,,,,,,,,,#",
|
||||
"#.................#,,,,,,,,,,#",
|
||||
"#.................#,,,,,,,,,,#",
|
||||
"#.................#,,,,,,,,,,#",
|
||||
"#.................############",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"#............................#",
|
||||
"##############################",
|
||||
];
|
||||
const DESKS = [[3,10],[8,10],[13,10],[3,14],[8,14],[13,14]]; // desk tile; worker sits above it
|
||||
const CONF_SEATS = [[21,2],[23,2],[25,2],[21,6],[23,6],[25,6]]; // around the table
|
||||
const CONF_TABLE = {x:20, y:3, w:7, h:3};
|
||||
const BREAK_SPOTS = [[24,15],[26,16],[23,17],[25,17],[22,15],[27,17]];
|
||||
const DOOR = [18,4];
|
||||
const ROLE_SHIRT = { manager:'#e0b354', engineer:'#5c9de0', qa:'#b48ce0' };
|
||||
const HAIR = ['#3a2a1c','#151412','#c9a24b','#8a3b22','#4a4e57'];
|
||||
const px = (x,y,w,h,c) => { ctx.fillStyle=c; ctx.fillRect(x|0,y|0,w,h); };
|
||||
const hash = s => [...s].reduce((a,ch)=>(a*31+ch.charCodeAt(0))>>>0, 7);
|
||||
const center = ([tx,ty]) => [tx*T+T/2, ty*T+T/2];
|
||||
|
||||
let tick = 0, actors = {}, companyName = '…';
|
||||
|
||||
// ---------------------------------------------------------------- tiles
|
||||
function drawMap() {
|
||||
for (let r=0;r<ROWS;r++) for (let c=0;c<COLS;c++) {
|
||||
const t = MAP[r][c], x=c*T, y=r*T;
|
||||
if (t==='#') { px(x,y,T,T,'#35465c'); px(x,y,T,4,'#465b76'); px(x,y+T-2,T,2,'#22303f'); }
|
||||
else if (t===',') { px(x,y,T,T,(r+c)%2?'#6b4a33':'#64452f'); px(x,y,T,1,'#77543b'); }
|
||||
else if (t==='+') { px(x,y,T,T,'#64452f'); px(x,y,2,T,'#2b2620'); px(x+T-2,y,2,T,'#2b2620'); }
|
||||
else { px(x,y,T,T,(r+c)%2?'#3f5d56':'#3a5850'); if((r*7+c*3)%11===0) px(x+7,y+7,2,2,'#456660'); }
|
||||
}
|
||||
// conference table
|
||||
const {x,y,w,h} = CONF_TABLE;
|
||||
px(x*T+4, y*T+4, w*T-8, h*T-8, '#7a4f34'); px(x*T+4, y*T+4, w*T-8, 3, '#8f5f40');
|
||||
px(x*T+6, y*T+7, w*T-12, h*T-14, '#6b4229');
|
||||
// desks: wood top + monitor + chair above
|
||||
for (const [dc,dr] of DESKS) {
|
||||
const dx=dc*T, dy=dr*T;
|
||||
px(dx-4, dy, T+8, 12, '#8a5c3b'); px(dx-4, dy, T+8, 3, '#9c6b46'); px(dx-4, dy+10, T+8, 2, '#5e3d26');
|
||||
px(dx+3, dy-1, 10, 7, '#20262e'); // monitor (glow drawn per-actor)
|
||||
}
|
||||
// break area: rug, water cooler, plant
|
||||
px(22*T, 15*T, 6*T, 3*T, '#4c5a68'); px(22*T, 15*T, 6*T, 2, '#5a6a7a');
|
||||
px(27*T+2, 14*T, 8, 14, '#bcd8e8'); px(27*T+3, 14*T+2, 6, 6, '#8fc4e0'); px(27*T+2, 14*T+12, 8, 4, '#6a7684');
|
||||
px(20*T+4, 16*T, 8, 6, '#a25636'); px(20*T+2, 16*T-8, 4, 8, '#3e7d46'); px(20*T+8, 16*T-8, 4, 8, '#356e3e'); px(20*T+5, 16*T-12, 4, 8, '#478a4f');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sprite
|
||||
// 12x18 character, feet anchored at (x,y). dir: 0 down, 1 up, 2 left, 3 right.
|
||||
function drawChar(x, y, a) {
|
||||
const L=x-6, Tp=y-18, step = a.moving ? (tick>>3)%2 : 0;
|
||||
const bob = a.anim==='type' && (tick>>4)%2 ? 1 : 0;
|
||||
const skin='#e8c39e', pants='#2e3440', shoe='#1c2026';
|
||||
// legs
|
||||
if (a.moving) { px(L+2,Tp+13,3,4-step,pants); px(L+7,Tp+13,3,3+step,pants);
|
||||
px(L+2,Tp+16-step,3,2,shoe); px(L+7,Tp+16+ (step?0:-1),3,2,shoe); }
|
||||
else { px(L+2,Tp+13,3,4,pants); px(L+7,Tp+13,3,4,pants); px(L+2,Tp+16,3,2,shoe); px(L+7,Tp+16,3,2,shoe); }
|
||||
// torso + arms
|
||||
px(L+1,Tp+7+bob,10,6,a.shirt);
|
||||
if (a.dir===2) px(L,Tp+8+bob,2,5,a.shirt); else if (a.dir===3) px(L+10,Tp+8+bob,2,5,a.shirt);
|
||||
else { px(L-1,Tp+8+bob,2,5,a.shirt); px(L+11,Tp+8+bob,2,5,a.shirt); }
|
||||
// head
|
||||
px(L+2,Tp+1+bob,8,7,skin);
|
||||
// hair by facing
|
||||
if (a.dir===1) px(L+1,Tp+bob,10,6,a.hair);
|
||||
else { px(L+1,Tp+bob,10,3,a.hair); px(L+1,Tp+bob,2,5,a.hair); px(L+9,Tp+bob,2,5,a.hair); }
|
||||
// eyes
|
||||
if (a.dir===0) { px(L+3,Tp+4+bob,2,2,'#20262e'); px(L+7,Tp+4+bob,2,2,'#20262e'); }
|
||||
else if (a.dir===2) px(L+3,Tp+4+bob,2,2,'#20262e');
|
||||
else if (a.dir===3) px(L+7,Tp+4+bob,2,2,'#20262e');
|
||||
}
|
||||
|
||||
function drawLabel(x, y, text, color) {
|
||||
ctx.font='7px monospace';
|
||||
const w = ctx.measureText(text).width;
|
||||
px(x-w/2-2, y, w+4, 9, 'rgba(16,20,24,.75)');
|
||||
ctx.fillStyle = color; ctx.fillText(text, x-w/2, y+7);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- actors
|
||||
function inConf(x) { return x > 18*T; }
|
||||
|
||||
function route(a, target) {
|
||||
const [dx,dy] = center(DOOR);
|
||||
const path = inConf(a.x) !== inConf(target[0]) ? [[dx,dy],target] : [target];
|
||||
a.path = path;
|
||||
}
|
||||
|
||||
function targetFor(a, i) {
|
||||
if (a.status === 'working') { const [c,r]=DESKS[i%DESKS.length]; return [c*T+T/2, r*T-2]; }
|
||||
if (a.status === 'blocked') return center(CONF_SEATS[i%CONF_SEATS.length]);
|
||||
return center(BREAK_SPOTS[i%BREAK_SPOTS.length]);
|
||||
}
|
||||
|
||||
function step(a) {
|
||||
if (!a.path?.length) { a.moving=false; return; }
|
||||
const [tx,ty] = a.path[0], SPD=0.8;
|
||||
a.moving = true;
|
||||
if (Math.abs(tx-a.x) > SPD) { a.dir = tx>a.x?3:2; a.x += tx>a.x?SPD:-SPD; }
|
||||
else if (Math.abs(ty-a.y) > SPD) { a.x=tx; a.dir = ty>a.y?0:1; a.y += ty>a.y?SPD:-SPD; }
|
||||
else { a.x=tx; a.y=ty; a.path.shift();
|
||||
if (!a.path.length) { a.moving=false; a.dir = a.status==='working'?1:0; } }
|
||||
}
|
||||
|
||||
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;
|
||||
const companies = (await (await fetch('/companies')).json()).filter(c=>c.lifecycle_state==='active');
|
||||
const want = new URLSearchParams(location.search).get('company');
|
||||
const co = companies.find(c=>c.slug===want) ?? companies[0];
|
||||
if (!co) return;
|
||||
companyName = co.name;
|
||||
const [detail, items, events] = await Promise.all([
|
||||
fetch(`/companies/${co.slug}`).then(r=>r.json()),
|
||||
fetch(`/companies/${co.slug}/work-items`).then(r=>r.json()),
|
||||
fetch(`/companies/${co.slug}/events?limit=1`).then(r=>r.json())]);
|
||||
const working = Object.fromEntries(items.filter(w=>w.status==='in_progress'&&w.owner_name)
|
||||
.map(w=>[w.owner_name, w.title]));
|
||||
detail.employees.forEach((e,i) => {
|
||||
let a = actors[e.id];
|
||||
if (!a) { const [bx,by]=center(BREAK_SPOTS[i%BREAK_SPOTS.length]);
|
||||
a = actors[e.id] = { x:bx, y:by, dir:0, path:[], status:'', shirt:ROLE_SHIRT[e.role]??'#8a94a0',
|
||||
hair:HAIR[hash(e.name)%HAIR.length], name:e.name, i }; }
|
||||
a.task = working[e.name];
|
||||
if (e.status !== a.status) { a.status = e.status; route(a, targetFor(a, i)); }
|
||||
a.anim = !a.moving && a.status==='working' ? 'type' : a.status;
|
||||
});
|
||||
const ev = events[0];
|
||||
if (ev) document.getElementById('ticker').textContent =
|
||||
`${new Date(ev.created_at).toLocaleTimeString()} — ${ev.event_type}: ${JSON.stringify(ev.payload ?? {}).slice(0, 110)}`;
|
||||
`${new Date(ev.created_at).toLocaleTimeString()} — ${ev.event_type}: ${JSON.stringify(ev.payload??{}).slice(0,120)}`;
|
||||
} 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); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- frame
|
||||
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;
|
||||
drawMap();
|
||||
const list = Object.values(actors).sort((a,b)=>a.y-b.y); // painter's order
|
||||
for (const a of list) {
|
||||
step(a);
|
||||
if (!a.moving && a.anim==='type') { // lit monitor while typing
|
||||
const [c,r] = DESKS[a.i%DESKS.length];
|
||||
px(c*T+4, r*T, 8, 5, (tick>>4)%2 ? '#9fd0ff' : '#7db8ec');
|
||||
}
|
||||
y += 80;
|
||||
drawChar(a.x, a.y, a);
|
||||
if (!a.moving && a.status==='blocked') { // waiting on shareholder
|
||||
const by = a.y - 26 - ((tick>>4)%2);
|
||||
px(a.x+4, by, 9, 9, '#f5efdc'); px(a.x+6, by+9, 2, 2, '#f5efdc');
|
||||
ctx.fillStyle='#a3402e'; ctx.font='bold 8px monospace'; ctx.fillText('?', a.x+7, by+8);
|
||||
}
|
||||
drawLabel(a.x, a.y+2, a.name, '#d8dee6');
|
||||
}
|
||||
ctx.fillStyle='#d8dee6'; ctx.font='8px monospace';
|
||||
ctx.fillText(companyName, 8, 12);
|
||||
ctx.fillStyle='#8a94a0'; ctx.fillText('CONFERENCE', 20*T, 1.5*T);
|
||||
requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user