diff --git a/db/schema.sql b/db/schema.sql index 134b0dc..270928e 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -19,7 +19,7 @@ CREATE TABLE employee ( name text NOT NULL, title text NOT NULL, role text NOT NULL DEFAULT 'engineer' - CHECK (role IN ('manager','engineer','qa')), + CHECK (role IN ('manager','engineer','qa','consultant')), department text NOT NULL DEFAULT '', manager_id uuid REFERENCES employee(id), hermes_profile text, -- Hermes profile name (ADR-001) diff --git a/engine/dispatcher.py b/engine/dispatcher.py index ac22582..896bd99 100644 --- a/engine/dispatcher.py +++ b/engine/dispatcher.py @@ -163,6 +163,25 @@ class Dispatcher: event_type="milestone.update", company_id=row["company_id"], work_item_id=row["id"], payload={"objective_title": row["title"], "summary": update}) + # milestone hit + consultant employed -> independent delivery check + consultant = await self._consultant_for(row["company_id"]) + if consultant: + await conn.execute( + "INSERT INTO work_item (company_id, parent_id, type, title, " + "description, status, owner_id, acceptance_criteria) " + "VALUES (%s, %s, 'review', %s, %s, 'ready', %s, %s)", + (row["company_id"], row["id"], + f"Milestone delivery check: {row['title'][:60]}", + "The objective just completed. Independently verify delivery: " + "read the deliverable files in the workspace produced by its " + "tasks (their reports are appended below), check each against " + "the stated acceptance criteria, and write " + "milestone-check.md (overwrite if present) with a verdict per " + "deliverable — delivered / partial / missing — plus any " + "defects found. Do not fix anything yourself.", + consultant["id"], + db.Json(["milestone-check.md exists with a verdict per " + "deliverable and evidence for each verdict"]))) log.info("objective %s rolled up to done", row["id"]) async def _dispatch_ready(self) -> None: @@ -284,10 +303,45 @@ class Dispatcher: elif status == "blocked": await self._block(item, kt.get("block_reason") or "hermes reported blocked", event_type="task.blocked") + # machine failure (crashed worker, capability gap) -> consultant; + # needs_input stays blocked for the shareholder answer button + if kt.get("block_kind") != "needs_input": + await self._escalate(item, "local worker failed " + f"({kt.get('last_failure_error') or kt.get('block_kind') or 'blocked'})") elif await self._over_budget(item): await self._cap(item, kt) + await self._escalate(item, "wall-clock budget exceeded") # todo/scheduled/ready/running: still waiting + async def _consultant_for(self, company_id) -> dict | None: + return await db.fetch_one( + "SELECT id, name FROM employee WHERE company_id = %s AND role = 'consultant' " + "ORDER BY created_at LIMIT 1", company_id) + + async def _escalate(self, item: dict, reason: str) -> bool: + """Manager policy: hand a failing task to the company consultant. + Only fires when a consultant is employed (hiring one = opting in to + external billing) and the failing owner isn't already the consultant.""" + consultant = await self._consultant_for(item["company_id"]) + if not consultant or consultant["id"] == item["owner_id"]: + return False + note = (f"\n\n[Escalated to consultant {consultant['name']}: {reason}. " + "A previous attempt by local staff did not deliver; produce the " + "deliverable yourself, completely.]") + async with db.pool.connection() as conn: + async with conn.transaction(): + await conn.execute( + "UPDATE work_item SET owner_id=%s, status='ready', hermes_task_id=NULL, " + "description=description || %s, updated_at=now() WHERE id=%s", + (consultant["id"], note, item["id"])) + await conn.execute( + "UPDATE employee SET status='idle' WHERE id=%s", (item["employee_id"],)) + await db.append_event(conn, actor="system", event_type="task.escalated", + company_id=item["company_id"], work_item_id=item["id"], + payload={"to": consultant["name"], "reason": reason}) + log.info("escalated %s to consultant (%s)", item["id"], reason) + return True + async def _resume(self, item: dict) -> None: async with db.pool.connection() as conn: async with conn.transaction(): diff --git a/engine/static/office.html b/engine/static/office.html index 80af465..03ab78c 100644 --- a/engine/static/office.html +++ b/engine/static/office.html @@ -63,7 +63,7 @@ const ROOMS = { conf: { test:(x,y)=> x>18*T && y<8*T, W:[17*T+8, 4*T+8], D:[19*T+8, 4*T+8] }, lab: { test:(x,y)=> x>20*T && y>12*T, W:[24*T+8, 11*T+8], D:[24*T+8, 13*T+8] }, }; -const ROLE_SHIRT = { manager:'#e0b354', engineer:'#5c9de0', qa:'#b48ce0' }; +const ROLE_SHIRT = { manager:'#e0b354', engineer:'#5c9de0', qa:'#b48ce0', consultant:'#e6e2d8' }; 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);