Dogfood round 1: propose_work_items gate, team-tie survives turn saves, board wakes render as cards

Leads lose propose_plan (trait-derived exclusion — plan mode is meaningless without execution tools) and gain propose_work_items: mode-independent decomposition whose approval creates the items.
Team field moves off the turn-save upsert to a dedicated setter (workers detached from their lead after one turn); board deliveries carry a MessageSource sidecar; test-worker prefers project-local tool installs.
This commit is contained in:
Rohit C Prasad
2026-08-16 15:43:06 -07:00
committed by Rohit P
parent 13f9c0b6c0
commit 844a6510a2
17 changed files with 508 additions and 25 deletions
+14 -7
View File
@@ -214,6 +214,7 @@ def build_engine(
question_asker: Optional[Any] = None,
tool_requester: Optional[Any] = None,
team_approver: Optional[Any] = None,
items_approver: Optional[Any] = None,
subscription_store: Optional[Any] = None,
channel_buffer: Optional[Any] = None,
routing_targets: Optional[list[str]] = None,
@@ -424,16 +425,21 @@ def build_engine(
roots=root_list or None,
risk_overrides=risk_overrides,
)
# The plan-mode exit door. Always registered (surfaces can flip a live session into
# plan mode via set_mode, and the registry is fixed at build); the engine rejects the
# call whenever the session isn't actually in plan mode.
registry.register(propose_plan_tool())
# The plan-mode exit door — mutually exclusive with the board's decomposition
# gate, DERIVED from the team trait (owner call 2026-08-16): a lead never
# implements, so plan mode is meaningless for it, and shipping both tools made
# the lead pick the wrong one (dogfood-hit: propose_plan denied outside plan
# mode). Solo/worker personas keep propose_plan as always (mode can flip
# mid-session; the engine rejects the call outside plan mode).
if agent.team != "lead":
registry.register(propose_plan_tool())
# The staffing gate — leads only. The engine intercepts it (out-of-band approval);
# approval pre-spawns the worker sessions and returns actor ids to assign to.
# The lead's gates: propose_work_items (decomposition → items on approval, any
# mode) and propose_team (staffing → pre-spawn on approval).
if agent.team == "lead":
from .teams.tools import propose_team_tool
from .teams.tools import propose_team_tool, propose_work_items_tool
registry.register(propose_work_items_tool())
registry.register(propose_team_tool())
# Per-turn ephemeral context, appended to the latest user message since mid-thread system
@@ -511,6 +517,7 @@ def build_engine(
question_asker=question_asker,
tool_requester=tool_requester,
team_approver=team_approver,
items_approver=items_approver,
)
engine.executor = executor # type: ignore[attr-defined]
engine.todo = todo # type: ignore[attr-defined]
+12 -1
View File
@@ -200,7 +200,6 @@ class ConversationStore:
title = COALESCE(sessions.title, excluded.title), agent = excluded.agent,
n_msgs = excluded.n_msgs, messages = NULL, extra_roots = excluded.extra_roots,
grants = excluded.grants, compaction = excluded.compaction,
team = excluded.team,
updated_at = CURRENT_TIMESTAMP
""",
(
@@ -258,6 +257,18 @@ class ConversationStore:
team=_load_grants(row["team"] if "team" in row.keys() else None),
)
def set_team(self, session_id: str, team: dict) -> None:
"""Persist the session's team tie independent of the turn-save path. The
upsert deliberately never touches `team` — a per-turn save rebuilds the
record without it, and letting the rebuild win detached workers from their
lead's sidebar entry the moment they ran a turn (owner-hit 2026-08-16)."""
with self._lock:
self._conn.execute(
"UPDATE sessions SET team = ? WHERE session_id = ?",
(json.dumps(team or {}), session_id),
)
self._conn.commit()
def set_extra_roots(self, session_id: str, extra_roots: list[dict]) -> None:
"""Persist just the session's added folders, independent of its message log — used when
the user adds/removes a folder (which may happen with no active engine)."""
+65
View File
@@ -92,6 +92,9 @@ class TurnEngine:
team_approver: Optional[
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
] = None,
items_approver: Optional[
Callable[[dict[str, Any]], "Awaitable[dict[str, Any]]"]
] = None,
# Called (thread-safe, best-effort) when the user stops the turn — e.g. the
# executor's kill for a running shell command.
interrupt_hooks: Optional[list[Callable[[], None]]] = None,
@@ -126,6 +129,12 @@ class TurnEngine:
# for the user's decision; approval pre-spawns the worker sessions and the result
# carries the roster (actor ids). None on surfaces that can't prompt.
self.team_approver = team_approver
# Handles `propose_work_items` (the decomposition gate): emits ITEMS_PROPOSED,
# waits; approval creates the items on the board. Mode-independent by design —
# unlike propose_plan it carries no permission-mode semantics: propose_plan is
# an IMPLEMENTATION plan (steps/files, plan-mode exit); this is a team
# decomposition onto the board.
self.items_approver = items_approver
# Handles the `ask_user` tool: turns a question into an Inbox item and waits for the answer
# (answerable inline in a live session or from the Inbox when unattended). None on surfaces
# that can't ask (the tool then no-ops).
@@ -655,6 +664,10 @@ class TurnEngine:
async for event in self._handle_team_proposal(tool_call):
yield event
continue
if tool_call.name == "propose_work_items":
async for event in self._handle_items_proposal(tool_call):
yield event
continue
if tool_call.name == "ask_user":
async for event in self._handle_ask_user(tool_call):
yield event
@@ -930,6 +943,58 @@ class TurnEngine:
except Exception:
pass
async def _handle_items_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]:
"""The decomposition gate: emit the proposed items, await the user's decision.
Approval creates them on the board (server-side, inside the approver) and the
result carries their ids; rejection returns feedback for a revised split."""
args = tool_call.arguments or {}
items = args.get("items") or []
valid = [
i
for i in items
if isinstance(i, dict)
and str(i.get("title", "")).strip()
and str(i.get("criteria", "")).strip()
]
if not valid or len(valid) != len(items):
result: dict[str, Any] = {
"approved": False,
"error": "every proposed item needs a title and acceptance criteria",
}
elif self.items_approver is None:
result = {
"approved": False,
"error": "item proposals aren't available in this surface",
}
else:
yield Event(
EventType.ITEMS_PROPOSED,
{"items": valid, "note": str(args.get("note", ""))},
)
self._audit(tool_call, stage="items_proposed")
result = await self._interruptible(
self.items_approver(dict(args), tool_call.id),
interrupted={"approved": False, "error": "interrupted by user"},
) or {"approved": False, "error": "no response"}
status = "ok" if result.get("approved") else "denied"
self.messages.append(_tool_result_message(tool_call, result))
self._audit(
tool_call,
stage="finished",
status=status,
result=result,
result_preview=_preview(result),
)
yield Event(
EventType.TOOL_FINISHED,
{
"name": tool_call.name,
"status": status,
"result_preview": _preview(result),
},
)
async def _handle_team_proposal(self, tool_call: ToolCall) -> AsyncIterator[Event]:
"""The staffing gate: emit the proposed roster, await the user's out-of-band
decision. Approval PRE-SPAWNS the worker sessions (server-side, inside the
+4
View File
@@ -29,6 +29,10 @@ class EventType(str, Enum):
TEAM_PROPOSED = (
"team_proposed" # a lead proposes a worker roster (the staffing gate)
)
ITEMS_PROPOSED = (
"items_proposed" # a lead proposes work items (the decomposition gate);
# unlike propose_plan this is mode-independent — approval creates the items
)
TOOL_STARTED = "tool_started"
TOOL_FINISHED = "tool_finished"
ITERATION_END = "iteration_end"
@@ -20,8 +20,10 @@ How you run a piece of work:
1. UNDERSTAND: read enough of the repo (files, search) to decompose honestly.
2. PLAN: split the work into items with crisp acceptance criteria — "Done when:" that a
verifier can actually check. Acceptance criteria are the single biggest quality lever
you own; vague criteria produce vague work. Present the decomposition to the user with
propose_plan and revise until approved. Only then create the items (create_item).
you own; vague criteria produce vague work. Present the decomposition with
propose_work_items (works in any mode; approval creates the items on the board and
returns their ids) and revise until the user approves. Use create_item only for
one-off additions after the plan is approved.
3. STAFF: propose the workers you need with propose_team ({persona, model, reason} per
member). Approval creates their sessions and returns actor ids. Only team-capable
worker coworkers can be staffed.
@@ -20,6 +20,10 @@ How you verify:
- Start from the item under verification: its criteria are your checklist, one by one.
Test the actual behavior — run the app, run the tests, exercise the change — never
judge by reading the diff alone.
- Missing a test tool? Prefer a PROJECT-LOCAL install first (`npm i -D playwright`,
`pip install pytest` — inside the workspace, like any developer would). Use
request_tool only for system-level binaries the project can't carry; if neither
works, verify what you can and say exactly which checks you couldn't run.
- Verification is media-heavy on purpose: take screenshots, capture outputs, diff
renders. That cost lands in YOUR context so the builder's stays for building. Save
captures as files in the workspace and reference them by path — never describe pixels
+33 -1
View File
@@ -1872,6 +1872,37 @@ def create_app(manager: SessionManager) -> FastAPI:
enable_chat=bool(_args.get("enable_chat", False)),
)
async def items_approver(_args: dict, tool_call_id=None) -> dict:
# The decomposition gate. TEAMS-flavored sibling of plan_approver:
# park a durable Inbox item, wait, and on approval create the items.
items = _args.get("items") or []
body = "\n".join(
f"- {i.get('title', '?')} — Done when: {i.get('criteria', '?')}"
for i in items
if isinstance(i, dict)
)
item = manager.inbox.add_plan(
session_id,
"Approve the proposed work items?",
body=body,
inbox=_route(),
visibility=_visibility(),
tool_call_id=tool_call_id,
)
if item.state == "pending":
manager.persist_session(session_id)
if item.visibility == VIS_INBOX:
await _mirror(item)
resp = _parse_json(await manager.inbox.wait(item.id))
if not resp.get("approved"):
return {
"approved": False,
"feedback": resp.get("feedback") or "the user declined the split",
}
return manager.board_create_items(
session_id, [i for i in items if isinstance(i, dict)]
)
async def _apply_model(model: Optional[str]) -> None:
# Mid-session rebind is allowed (roadmap item 3, supersedes the 2026-07-04
# lock): history is canonical and providers convert per call. A real switch
@@ -1912,6 +1943,7 @@ def create_app(manager: SessionManager) -> FastAPI:
question_asker=question_asker,
tool_requester=tool_requester,
team_approver=team_approver,
items_approver=items_approver,
)
if engine is None:
await ws.send_json(
@@ -2062,7 +2094,7 @@ def create_app(manager: SessionManager) -> FastAPI:
}
)
)
elif kind == "team_response":
elif kind in ("team_response", "items_response"):
_resolve_pending(
json.dumps(
{
+95 -14
View File
@@ -484,6 +484,7 @@ class SessionManager:
question_asker: Optional[Any] = None,
tool_requester: Optional[Any] = None,
team_approver: Optional[Any] = None,
items_approver: Optional[Any] = None,
) -> Optional[TurnEngine]:
engine = self._engines.get(session_id)
if engine is not None:
@@ -499,6 +500,8 @@ class SessionManager:
engine.tool_requester = tool_requester
if team_approver is not None:
engine.team_approver = team_approver
if items_approver is not None:
engine.items_approver = items_approver
return engine
record = self.session_store.load(session_id)
@@ -576,6 +579,7 @@ class SessionManager:
or self.inbox_question_asker(session_id, agent),
tool_requester=tool_requester,
team_approver=team_approver,
items_approver=items_approver,
subscription_store=self.subscriptions,
channel_buffer=self.channel_buffer,
routing_targets=self._routing_targets(session_id, agent),
@@ -1422,6 +1426,47 @@ class SessionManager:
except (TeamsBoardError, ValueError) as error:
return {"error": str(error)}
def board_create_items(
self, session_id: str, items: list[dict[str, Any]]
) -> dict[str, Any]:
"""The decomposition gate's approved action: create the proposed items as
the LEAD (its identity is the creator; the user's approval is the gate that
let this run). Validates everything up front so a bad batch creates nothing."""
record = self.session_store.load(session_id)
if record is None or not record.workspace:
return {"approved": False, "error": "the session has no workspace"}
space = space_for_workspace(record.workspace)
actor = TeamActor(
id=f"{record.agent}:{session_id[:8]}",
role=TeamRole.LEAD,
persona=record.agent,
session_id=session_id,
)
for entry in items:
if not str((entry or {}).get("title", "")).strip() or not str(
(entry or {}).get("criteria", "")
).strip():
return {
"approved": False,
"error": "every item needs a title and acceptance criteria",
}
created = []
for entry in items:
item = self.team_store.create_item(
space,
actor,
title=str(entry["title"]),
criteria=str(entry["criteria"]),
description=str(entry.get("description", "")),
case=str(entry.get("case", "")) or None,
)
created.append({"id": item["id"], "title": item["title"]})
return {
"approved": True,
"items": created,
"note": "items created on the board — staff and assign to start work",
}
def journal_overview(self) -> list[dict[str, Any]]:
return self.journal_store.overview(self._user_actor())
@@ -1585,14 +1630,20 @@ class SessionManager:
mode=record.mode,
messages=[],
agent=pid,
team={
"role": "worker",
"actor": actor,
"lead_session": session_id,
"space": space,
},
)
)
# Written via the dedicated setter: the turn-save upsert never touches
# `team`, so a worker's first turn can't detach it from its lead.
self.session_store.set_team(
worker_sid,
{
"team_id": "", # patched below once the team exists
"role": "worker",
"actor": actor,
"lead_session": session_id,
"space": space,
},
)
workers.append(
TeamWorker(actor=actor, persona=pid, session_id=worker_sid, model=model)
)
@@ -1604,14 +1655,26 @@ class SessionManager:
chat_enabled=enable_chat,
)
for worker in workers:
self.session_store.set_team(
worker.session_id,
{
"team_id": team.team_id,
"role": "worker",
"actor": worker.actor,
"lead_session": session_id,
"space": space,
},
)
self._emit_session_created(worker.session_id, worker.persona)
record.team = {
"team_id": team.team_id,
"role": "lead",
"actor": team.lead_actor,
"space": space,
}
self.session_store.save(record)
self.session_store.set_team(
session_id,
{
"team_id": team.team_id,
"role": "lead",
"actor": team.lead_actor,
"space": space,
},
)
return {
"approved": True,
"team_id": team.team_id,
@@ -1665,10 +1728,11 @@ class SessionManager:
return 0
message = self._team_digest(team, directs, subs, is_lead=is_lead)
self._team_inflight.add(session_id)
source = self._board_source(team, message)
async def _deliver() -> None:
try:
await self.deliver_to_session(session_id, message)
await self.deliver_to_session(session_id, message, source=source)
# Consume only after the turn dispatched: a crash before this replays
# the batch next tick (at-least-once, never silently lost).
if directs:
@@ -1734,6 +1798,23 @@ class SessionManager:
" Journal evidence as you go."
)
@staticmethod
def _board_source(team, message: str) -> dict[str, Any]:
"""Display-only MessageSource sidecar for board deliveries — the same
mechanism connector messages use, so the GUI renders a structured card
instead of a fake user bubble (owner ask 2026-08-16). The framed message
stays the model-facing text; this only shapes presentation."""
return {
"connector": "board",
"kind": "channel",
"channel_id": team.space,
"channel_name": "Team board",
"sender_id": "board",
"sender_name": "Board",
"ts": time.time(),
"text": message,
}
def team_staleness_digest(self, session_id: str) -> str:
"""Attached to a lead's TIMER wakes: pure code over the board — a
nothing's-wrong wake is one cheap glance, never a re-survey. Scoped by
+62
View File
@@ -256,6 +256,68 @@ _PROPOSE_TEAM_SCHEMA = {
}
# The decomposition gate's schema carrier — the board-flavored sibling of
# propose_plan, usable in ANY permission mode (proposing costs nothing; the board
# only ever holds accepted work). The engine intercepts it; approval creates the
# items and returns their ids.
_PROPOSE_ITEMS_SCHEMA = {
"type": "function",
"function": {
"name": "propose_work_items",
"description": (
"Present your decomposition to the user as proposed WORK ITEMS for the"
" team board. Approval creates them on the board (ids come back in the"
" result); rejection returns feedback to revise. Each item needs a"
" title and acceptance criteria — what gets verified before it can be"
" done. This is not propose_plan: it carries no implementation steps"
" and works in any mode — it is how a lead plans and coordinates via"
" the board."
),
"parameters": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"criteria": {"type": "string"},
"description": {"type": "string"},
"case": {"type": "string"},
},
"required": ["title", "criteria"],
},
},
"note": {"type": "string"},
},
"required": ["items"],
},
},
}
def propose_work_items_tool() -> object:
def propose_work_items(items: Optional[list] = None, note: str = "") -> dict:
"""Present proposed work items ({title, criteria, description?, case?})
for the user's approval; approval creates them on the board."""
return {
"approved": False,
"error": "item proposals aren't available in this surface",
}
wrapped = ai.tool(
propose_work_items,
metadata=ai.ToolMetadata(
category="team",
risk_level="low",
capabilities=["team"],
),
)
wrapped.__coworker_schema__ = _PROPOSE_ITEMS_SCHEMA
return wrapped
def propose_team_tool() -> object:
def propose_team(
members: Optional[list] = None, enable_chat: bool = False, note: str = ""
+24
View File
@@ -629,6 +629,20 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
return; // suspended on the approval
}
// Agent teams: the decomposition gate — the lead proposes work items and
// SUSPENDS until the items_response verdict arrives (approval creates them).
if (/propose the split/i.test(msg.text)) {
send("items_proposed", {
items: [
{ title: "Statement API endpoint", criteria: "returns opening/closing balances; 8 endpoint tests green" },
{ title: "Statements dashboard page", criteria: "renders seeded data for Ada / Northgate; empty + error states covered" },
{ title: "Statement totals reconcile", criteria: "running balance matches invoices minus payments for the range" },
{ title: "Verification pass", criteria: "tester confirms page renders with live API data" },
],
note: "Shared journal case: statements.",
});
return; // suspended on the items decision
}
// Agent teams (OPE-97): the staffing gate — the lead proposes a roster and
// SUSPENDS until the team_response verdict arrives.
if (/staff the team/i.test(msg.text)) {
@@ -841,6 +855,16 @@ export async function mockApi(page: import("@playwright/test").Page) {
send("assistant_message", { text: `Done via ${pendingTool} [decision=${msg.decision}]` });
}
send("turn_done");
} else if (msg.type === "items_response") {
if (msg.approved) {
seedBoard(); // "created on the board" — the board fetch now shows them
send("assistant_message", {
text: "Items created on the board — staffing next.",
});
} else {
send("assistant_message", { text: "Understood — reworking the split." });
}
send("turn_done");
} else if (msg.type === "team_response") {
if (msg.approved) {
// Server-side create_team pre-spawned the workers; surface them in the
+29
View File
@@ -12,6 +12,35 @@ async function proposeTeam(page: import("@playwright/test").Page) {
await expect(page.getByTestId("teamreq-card")).toBeVisible();
}
test("the decomposition gate shows items with criteria; approval lands them on the board", async ({
page,
}) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
await page.getByRole("button", { name: "Send" }).click();
const card = page.getByTestId("itemsreq-card");
await expect(card).toBeVisible();
await expect(card).toContainText("Proposed work items — 4");
await expect(card).toContainText("Done when:");
// 3 visible + expander with the true remainder
await expect(card.getByText("Verification pass")).toHaveCount(0);
await card.getByRole("button", { name: /1 more item/ }).click();
await expect(card.getByText("Verification pass")).toBeVisible();
await page.getByTestId("itemsreq-approve").click();
await expect(page.getByText(/Items created on the board/)).toBeVisible();
await expect(page.getByTestId("board-rail")).toBeVisible();
});
test("declining the split returns feedback to the lead", async ({ page }) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
await page.getByRole("button", { name: "Send" }).click();
await page.getByTestId("itemsreq-card").waitFor();
await page.getByRole("button", { name: "Not now" }).click();
await expect(page.getByText(/reworking the split/)).toBeVisible();
});
test("the staffing gate shows the roster and the grant sentence", async ({ page }) => {
await proposeTeam(page);
const card = page.getByTestId("teamreq-card");
+34
View File
@@ -77,6 +77,7 @@ import { DirectoryRequestCard } from "./components/DirectoryRequestCard";
import { PlanCard } from "./components/PlanCard";
import { BoardOverlay } from "./components/BoardPanel";
import { TeamRequestCard } from "./components/TeamRequestCard";
import { WorkItemsCard } from "./components/WorkItemsCard";
import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt";
const newId = () =>
@@ -770,6 +771,18 @@ export function App() {
},
]);
break;
case "items_proposed":
// The decomposition gate — approval creates the items on the board.
if (unattendedRef.current) break;
setItems((p) => [
...p,
{
kind: "itemsreq",
items: Array.isArray(d.items) ? d.items : [],
note: d.note || "",
},
]);
break;
case "question_requested":
// ask_user in an attended session — answered inline (not routed to the Inbox).
setItems((p) => [
@@ -1035,6 +1048,12 @@ export function App() {
dropSessionInbox("plan"); // the gate parks as a plan-kind Inbox item
sessionRef.current?.respondTeam(approved, feedback);
};
const respondItemsReq = (approved: boolean, feedback?: string) => {
setItems((p) => resolveLastItemsReq(p, approved ? "approved" : "rejected"));
dropSessionInbox("plan");
sessionRef.current?.respondItems(approved, feedback);
if (approved) setTimeout(refreshBoard, 400); // the items just landed
};
const respondDirectory = (granted: boolean, path?: string, writable?: boolean) => {
setItems((p) => resolveLastDirReq(p, granted ? "granted" : "denied"));
dropSessionInbox("directory");
@@ -1409,6 +1428,7 @@ export function App() {
const pendingToolReq = [...items].reverse().find((i) => i.kind === "toolreq" && !i.resolved);
const pendingPlan = [...items].reverse().find((i) => i.kind === "planreq" && !i.resolved);
const pendingTeam = [...items].reverse().find((i) => i.kind === "teamreq" && !i.resolved);
const pendingItemsReq = [...items].reverse().find((i) => i.kind === "itemsreq" && !i.resolved);
const pendingQuestion = [...items].reverse().find((i) => i.kind === "question" && !i.resolved);
// Facts subtitle (§22): the session's FIXED facts, not controls — model (+ the
// workspace folder for project-scoped sessions). Renders only once the session has history;
@@ -1924,6 +1944,8 @@ export function App() {
// parked in the Inbox and surfaced via the answer-in-context card below.
!unattended && pendingPlan?.kind === "planreq" ? (
<PlanCard item={pendingPlan} onRespond={respondPlan} />
) : !unattended && pendingItemsReq?.kind === "itemsreq" ? (
<WorkItemsCard item={pendingItemsReq} onRespond={respondItemsReq} />
) : !unattended && pendingTeam?.kind === "teamreq" ? (
<TeamRequestCard item={pendingTeam} onRespond={respondTeam} />
) : !unattended && pendingToolReq?.kind === "toolreq" ? (
@@ -2141,6 +2163,18 @@ function resolveLastTeam(items: Item[], resolved: "approved" | "rejected"): Item
return copy;
}
function resolveLastItemsReq(items: Item[], resolved: "approved" | "rejected"): Item[] {
const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) {
const it = copy[i];
if (it.kind === "itemsreq" && !it.resolved) {
copy[i] = { ...it, resolved };
break;
}
}
return copy;
}
function resolveLastQuestion(items: Item[], answer: string): Item[] {
const copy = [...items];
for (let i = copy.length - 1; i >= 0; i--) {
+8
View File
@@ -2197,6 +2197,14 @@ export class Session {
});
}
respondItems(approved: boolean, feedback?: string) {
this.send({
type: "items_response",
approved,
...(feedback ? { feedback } : {}),
});
}
// Answer a live `ask_user` prompt (attended sessions; unattended ones answer via the Inbox).
respondQuestion(answer: string) {
this.send({ type: "question_response", answer });
@@ -0,0 +1,63 @@
// The decomposition gate (agent teams): a lead proposes work items; approval
// creates them on the board. The board-flavored sibling of PlanCard — items with
// acceptance criteria as the primary text, 3 visible + expander with the true
// count in the header, no in-card reply surface (editing happens by replying).
import { useState } from "react";
import type { Item } from "../types";
import { Icon } from "./Icon";
export function WorkItemsCard({
item,
onRespond,
}: {
item: Extract<Item, { kind: "itemsreq" }>;
onRespond: (approved: boolean, feedback?: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const visible = expanded ? item.items : item.items.slice(0, 3);
const hidden = item.items.length - visible.length;
return (
<div className="dirreq-card itemsreq-card" data-testid="itemsreq-card">
<div className="itemsreq-head">
<Icon name="table" size={15} />
<span className="itemsreq-title">
Proposed work items {item.items.length}
</span>
</div>
{item.note && <div className="itemsreq-note">{item.note}</div>}
{visible.map((entry, i) => (
<div className="itemsreq-item" key={i}>
<span className="itemsreq-num">{i + 1}.</span>
<span className="itemsreq-body">
<span className="itemsreq-item-title">{entry.title}</span>
<span className="itemsreq-ac">
<b>Done when:</b> {entry.criteria}
</span>
</span>
</div>
))}
{hidden > 0 && (
<button className="itemsreq-more" onClick={() => setExpanded(true)}>
{hidden} more item{hidden === 1 ? "" : "s"}
<Icon name="chevronDown" size={12} />
</button>
)}
<div className="dirreq-actions">
<span className="itemsreq-grant">
Reply to edit the split; approval creates these on the board.
</span>
<span className="spacer" />
<button className="btn" onClick={() => onRespond(false)}>
Not now
</button>
<button
className="btn primary"
data-testid="itemsreq-approve"
onClick={() => onRespond(true)}
>
Approve items
</button>
</div>
</div>
);
}
+14
View File
@@ -1734,3 +1734,17 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color:
.team-dot.in_progress { background: var(--ok-dot); }
.team-dot.blocked { background: var(--danger); }
.team-dot.review { background: var(--warn-ink); }
/* Decomposition gate (proposed work items) */
.itemsreq-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.itemsreq-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.itemsreq-title { font-weight: 600; font-size: 13px; color: var(--ink); }
.itemsreq-note { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.itemsreq-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); }
.itemsreq-num { color: var(--faint); font-size: 12px; padding-top: 1px; }
.itemsreq-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.itemsreq-item-title { font-size: 12.5px; color: var(--ink); }
.itemsreq-ac { font-size: 11.5px; color: var(--muted); }
.itemsreq-ac b { font-weight: 600; }
.itemsreq-more { display: flex; align-items: center; gap: 5px; border: 0; background: transparent; color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; }
.itemsreq-grant { font-size: 11.5px; color: var(--faint); }
+8
View File
@@ -12,6 +12,7 @@ export type EventType =
| "question_requested"
| "plan_proposed"
| "team_proposed"
| "items_proposed"
| "tool_started"
| "tool_finished"
| "iteration_end"
@@ -164,6 +165,13 @@ export type Item =
note?: string;
resolved?: "approved" | "rejected";
}
| {
// The decomposition gate: a lead proposes work items; approval creates them.
kind: "itemsreq";
items: { title: string; criteria: string; description?: string }[];
note?: string;
resolved?: "approved" | "rejected";
}
| {
// A live ask_user prompt (attended sessions answer inline; unattended ones route to the Inbox).
kind: "question";
+35
View File
@@ -231,3 +231,38 @@ def test_team_options_lists_only_enabled_workers(manager):
assert {"swe-worker", "design-worker", "test-worker"} <= workers
assert "swe-lead" not in workers # leads staff, they aren't staffed
assert "security" not in workers # solo coworkers are not team-eligible
def test_turn_saves_never_detach_a_worker_from_its_team(manager, monkeypatch):
from coworker.agents.base import Agent
from coworker.sessions import SessionRecord
worker_agent = Agent(name="swe-worker", title="SWE", system_prompt="p", team="worker")
monkeypatch.setattr("coworker.server.manager.get_agent", lambda name: worker_agent)
manager.session_store.save(
SessionRecord(
session_id="lead-sid",
workspace=manager.default_workspace,
model="m",
mode="interactive",
messages=[],
agent="swe-lead",
)
)
result = manager.create_team("lead-sid", [{"persona": "swe-worker"}])
wid = result["workers"][0]["session_id"]
# A per-turn save rebuilds the record WITHOUT the team field (the engine doesn't
# carry it) — owner-hit 2026-08-16: this detached workers from the lead's entry.
record = manager.session_store.load(wid)
manager.session_store.save(
SessionRecord(
session_id=wid,
workspace=record.workspace,
model=record.model,
mode=record.mode,
messages=[{"role": "user", "content": "hi"}],
agent=record.agent,
)
)
assert manager.session_store.load(wid).team["lead_session"] == "lead-sid"
assert manager.session_store.load("lead-sid").team["role"] == "lead"