diff --git a/coworker/agent.py b/coworker/agent.py index a9f391ce..451871f8 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -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] diff --git a/coworker/conversations.py b/coworker/conversations.py index 2915a3a9..d77fdb8a 100644 --- a/coworker/conversations.py +++ b/coworker/conversations.py @@ -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).""" diff --git a/coworker/engine.py b/coworker/engine.py index c2ab0f02..bd389537 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -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 diff --git a/coworker/events.py b/coworker/events.py index 1934436f..bc01ea73 100644 --- a/coworker/events.py +++ b/coworker/events.py @@ -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" diff --git a/coworker/personas/builtin/swe-lead/manifest.md b/coworker/personas/builtin/swe-lead/manifest.md index ebee5b28..7e2e35a2 100644 --- a/coworker/personas/builtin/swe-lead/manifest.md +++ b/coworker/personas/builtin/swe-lead/manifest.md @@ -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. diff --git a/coworker/personas/builtin/test-worker/manifest.md b/coworker/personas/builtin/test-worker/manifest.md index b69e7c85..827616bf 100644 --- a/coworker/personas/builtin/test-worker/manifest.md +++ b/coworker/personas/builtin/test-worker/manifest.md @@ -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 diff --git a/coworker/server/app.py b/coworker/server/app.py index e2807b89..aca7c588 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -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( { diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 16743968..42889b91 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -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 diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index 52893c7a..39bbc142 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -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 = "" diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 2a18f789..9d58a0fb 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -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 diff --git a/surfaces/gui/e2e/team.spec.ts b/surfaces/gui/e2e/team.spec.ts index 48c6ce29..656be826 100644 --- a/surfaces/gui/e2e/team.spec.ts +++ b/surfaces/gui/e2e/team.spec.ts @@ -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"); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 70096be7..4a9f636b 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -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" ? ( + ) : !unattended && pendingItemsReq?.kind === "itemsreq" ? ( + ) : !unattended && pendingTeam?.kind === "teamreq" ? ( ) : !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--) { diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 7f959a9a..b428f65f 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -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 }); diff --git a/surfaces/gui/src/components/WorkItemsCard.tsx b/surfaces/gui/src/components/WorkItemsCard.tsx new file mode 100644 index 00000000..e3c3f48b --- /dev/null +++ b/surfaces/gui/src/components/WorkItemsCard.tsx @@ -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; + 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 ( +
+
+ + + Proposed work items — {item.items.length} + +
+ {item.note &&
{item.note}
} + {visible.map((entry, i) => ( +
+ {i + 1}. + + {entry.title} + + Done when: {entry.criteria} + + +
+ ))} + {hidden > 0 && ( + + )} +
+ + Reply to edit the split; approval creates these on the board. + + + + +
+
+ ); +} diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 8479978a..4f890678 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -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); } diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index b0b25a0c..7693e4c3 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -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"; diff --git a/tests/test_team_wake.py b/tests/test_team_wake.py index 909477c6..70ee36e1 100644 --- a/tests/test_team_wake.py +++ b/tests/test_team_wake.py @@ -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"