From fc24b667ed3503b652fef79abbc2f7cc5e96ce0e Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 16 Aug 2026 08:07:10 -0700 Subject: [PATCH] Drop the proposed state: boards hold only accepted work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan proposals live in the conversation (plan-approval flow); items are created open/unassigned and work starts at assignment — the granted, revocable authority. Also closes a verify gap: tail truncation is now caught against the stored head hash. --- coworker/server/app.py | 4 - coworker/server/manager.py | 13 --- coworker/teams/journal.py | 3 + coworker/teams/model.py | 17 ++-- coworker/teams/store.py | 26 +++--- coworker/teams/tools.py | 22 +++-- surfaces/gui/e2e/board.spec.ts | 50 +++++----- surfaces/gui/e2e/fixtures.ts | 28 ++---- surfaces/gui/src/App.tsx | 16 +--- surfaces/gui/src/api.ts | 7 +- surfaces/gui/src/components/BoardPanel.tsx | 104 ++++----------------- surfaces/gui/src/styles.css | 18 +--- tests/test_team_board.py | 36 +++---- tests/test_team_journal.py | 1 - tests/test_team_store.py | 8 +- 15 files changed, 106 insertions(+), 247 deletions(-) diff --git a/coworker/server/app.py b/coworker/server/app.py index aeacc4ef..e0e1b276 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -750,10 +750,6 @@ def create_app(manager: SessionManager) -> FastAPI: comment=str(body.get("comment", "")), ) - @app.post("/v1/sessions/{session_id}/board/approve") - def session_board_approve(session_id: str) -> dict[str, Any]: - return manager.board_approve(session_id) - @app.get("/v1/teams/journal") def teams_journal() -> dict[str, Any]: return {"cases": manager.journal_overview()} diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 9a9cedb3..f54336df 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -1412,19 +1412,6 @@ class SessionManager: except (TeamsBoardError, ValueError) as error: return {"error": str(error)} - def board_approve(self, session_id: str) -> dict[str, Any]: - """The plan gate's action: approve every proposed item on the session's board.""" - space = self._board_space(session_id) - if space is None: - return {"error": "this session has no board"} - approved = 0 - for entry in self.team_store.list_items( - space, self._user_actor(), state="proposed" - ): - self.team_store.transition(space, self._user_actor(), entry["id"], "approved") - approved += 1 - return {"approved": approved, **self.session_board(session_id)} - def journal_overview(self) -> list[dict[str, Any]]: return self.journal_store.overview(self._user_actor()) diff --git a/coworker/teams/journal.py b/coworker/teams/journal.py index 648f62f5..1cdc180d 100644 --- a/coworker/teams/journal.py +++ b/coworker/teams/journal.py @@ -368,6 +368,9 @@ class JournalStore: if _hash(record, fields=_HASHED_FIELDS) != row["hash"]: raise ChainError(f"entry {row['seq']}: content does not match hash") prev = row["hash"] + # Tail truncation is invisible to the chain itself; the stored head sees it. + if rows and prev != self._head_hash(case): + raise ChainError("case log ends before the recorded head — tail deleted") return len(rows) def close(self) -> None: diff --git a/coworker/teams/model.py b/coworker/teams/model.py index 3ed6c630..2228e661 100644 --- a/coworker/teams/model.py +++ b/coworker/teams/model.py @@ -14,8 +14,7 @@ from pathlib import Path class ItemState(str, Enum): - PROPOSED = "proposed" - APPROVED = "approved" + OPEN = "open" IN_PROGRESS = "in_progress" BLOCKED = "blocked" REVIEW = "review" @@ -23,17 +22,19 @@ class ItemState(str, Enum): CANCELED = "canceled" -# Legal edges of the state machine. Approval and done carry extra authority rules -# (see TeamStore.transition): proposed→approved is the human decomposition gate, -# review→done is the lead's verification gate. canceled→approved is reopen. +# Legal edges of the state machine. There is NO draft/proposed state (decided +# 2026-08-16): a plan proposal lives in the conversation (plan-approval flow) and +# the board only ever contains accepted work — items are created `open`, and the +# control point for work starting is ASSIGNMENT (a granted, revocable authority), +# not a per-item approval. review→done stays the verification gate; canceled→open +# is reopen. EDGES: dict[ItemState, set[ItemState]] = { - ItemState.PROPOSED: {ItemState.APPROVED, ItemState.CANCELED}, - ItemState.APPROVED: {ItemState.IN_PROGRESS, ItemState.CANCELED}, + ItemState.OPEN: {ItemState.IN_PROGRESS, ItemState.CANCELED}, ItemState.IN_PROGRESS: {ItemState.BLOCKED, ItemState.REVIEW, ItemState.CANCELED}, ItemState.BLOCKED: {ItemState.IN_PROGRESS, ItemState.CANCELED}, ItemState.REVIEW: {ItemState.DONE, ItemState.IN_PROGRESS, ItemState.CANCELED}, ItemState.DONE: set(), - ItemState.CANCELED: {ItemState.APPROVED}, + ItemState.CANCELED: {ItemState.OPEN}, } # Targets a worker may move its OWN item to. Workers never approve, never close: diff --git a/coworker/teams/store.py b/coworker/teams/store.py index e5180316..9e413d78 100644 --- a/coworker/teams/store.py +++ b/coworker/teams/store.py @@ -301,6 +301,10 @@ class TeamStore: if _hash(record) != row["hash"]: raise ChainError(f"event {row['seq']}: content does not match hash") prev = row["hash"] + # The chain alone can't see TAIL truncation (a shortened log still links); + # the stored head can. + if rows and prev != self._head_hash(space): + raise ChainError("log ends before the recorded head — tail deleted") return len(rows) def rebuild(self, space: str) -> None: @@ -342,11 +346,13 @@ class TeamStore: parent: Optional[int] = None, case: Optional[str] = None, ) -> dict[str, Any]: - """New item in `proposed`. Acceptance criteria are load-bearing — required. + """New item, `open` and unassigned. Acceptance criteria are load-bearing — + required. Workers may create too — a bug spotted in passing, a follow-up — because - proposing is harmless: nothing runs until the item crosses the approval - gate.""" + filing is harmless: nothing runs until the item is ASSIGNED, and assign + authority stays with the lead/user (the lead triages worker filings: + assign or cancel).""" self._require(actor, {Role.USER, Role.LEAD, Role.WORKER}, "create_item") if not (title or "").strip(): raise BoardError("title is required") @@ -501,10 +507,9 @@ class TeamStore: with self._lock: item = self._item(space, item_id) state = ItemState(item["state"]) - if state in (ItemState.PROPOSED, ItemState.DONE, ItemState.CANCELED): + if state in (ItemState.DONE, ItemState.CANCELED): raise BoardError( - f"cannot assign an item in state {state.value} — items are" - " assigned after approval" + f"cannot assign an item in state {state.value} — reopen it first" ) event = self.append_event( space, @@ -603,7 +608,7 @@ class TeamStore: payload.get("title") or "", payload.get("description") or "", payload.get("criteria") or "", - ItemState.PROPOSED.value, + ItemState.OPEN.value, actor_id, payload.get("case") or "", ts, @@ -664,13 +669,6 @@ class TeamStore: ) -> None: if actor.role == Role.SYSTEM: raise AuthorityError("system events cannot transition items") - if current == ItemState.PROPOSED and target == ItemState.APPROVED: - if actor.role != Role.USER: - raise AuthorityError( - "only the user approves proposed items — that is the" - " decomposition gate" - ) - return if target == ItemState.DONE and actor.role == Role.WORKER: raise AuthorityError( "workers finish by moving to review — done is the verdict after" diff --git a/coworker/teams/tools.py b/coworker/teams/tools.py index 50deef56..fe22a209 100644 --- a/coworker/teams/tools.py +++ b/coworker/teams/tools.py @@ -21,7 +21,7 @@ from .store import TeamStore LEAD_VERBS = ("create_item", "list_items", "transition", "comment", "assign", "link") # Workers file items too (a bug spotted in passing, a follow-up) — new items land -# in `proposed`, so the approval gate catches everything a worker proposes. +# `open` and unassigned; nothing runs until the lead/user assigns them. WORKER_VERBS = ("create_item", "list_items", "transition", "comment") JOURNAL_VERBS = ("journal_append", "journal_read") @@ -33,10 +33,11 @@ _CREATE_ITEM_SCHEMA = { "function": { "name": "create_item", "description": ( - "Create a work item in the proposed state. `criteria` is the acceptance" - " criteria — what gets verified before the item can be done; required." - " `parent` links it under another item; `case` names its journal case" - " (children inherit the parent's case by default)." + "Create a work item (open, unassigned — work starts when it is" + " assigned). `criteria` is the acceptance criteria — what gets verified" + " before the item can be done; required. `parent` links it under" + " another item; `case` names its journal case (children inherit the" + " parent's case by default)." ), "parameters": { "type": "object", @@ -74,10 +75,11 @@ def board_tools( parent: Optional[int] = None, case: str = "", ) -> dict: - """Create a work item in the proposed state. `criteria` is the acceptance - criteria — what gets verified before the item can be done; required. - `parent` links it under another item; `case` names its journal case - (children inherit the parent's case by default).""" + """Create a work item (open, unassigned — work starts when it is + assigned). `criteria` is the acceptance criteria — what gets verified + before the item can be done; required. `parent` links it under another + item; `case` names its journal case (children inherit the parent's case + by default).""" return _call( store.create_item, space, @@ -91,7 +93,7 @@ def board_tools( def list_items(state: str = "", assignee: str = "") -> dict: """List work items on the board, optionally filtered by state - (proposed/approved/in_progress/blocked/review/done/canceled) or assignee.""" + (open/in_progress/blocked/review/done/canceled) or assignee.""" try: return {"items": store.list_items(space, actor, state=state or None, assignee=assignee or None)} except (BoardError, ValueError) as error: diff --git a/surfaces/gui/e2e/board.spec.ts b/surfaces/gui/e2e/board.spec.ts index 1d76099a..1ac8f1e7 100644 --- a/surfaces/gui/e2e/board.spec.ts +++ b/surfaces/gui/e2e/board.spec.ts @@ -1,7 +1,9 @@ // Agent teams (OPE-96): the board in the session UI — rail section (grouped by -// state, blocked on top), the plan gate (decomposition approval), and the expanded -// Linear-shaped overlay. The fake agent files items on "plan the work"; approve and -// transition round-trip through the mocked /board endpoints. +// state, blocked on top) and the expanded Linear-shaped overlay. There is no +// draft/proposed state: plan approval is a conversation-layer moment (the existing +// plan-approval flow); the board only ever holds accepted work. The fake agent +// files items on "plan the work"; transitions round-trip through the mocked +// /board endpoints as the user. import { expect } from "@playwright/test"; import { test } from "./fixtures"; @@ -9,7 +11,7 @@ async function planTheWork(page: import("@playwright/test").Page) { await page.goto("/"); await page.getByPlaceholder(/Ask the coworker/).fill("plan the work"); await page.getByRole("button", { name: "Send" }).click(); - await expect(page.getByText(/approve the plan and I'll get started/)).toBeVisible(); + await expect(page.getByText(/filed 5 work items/)).toBeVisible(); } test("plain sessions carry zero board chrome", async ({ page }) => { @@ -18,53 +20,43 @@ test("plain sessions carry zero board chrome", async ({ page }) => { await page.getByRole("button", { name: "Send" }).click(); await expect(page.getByText("Echo: hello")).toBeVisible(); await expect(page.getByTestId("board-rail")).toHaveCount(0); - await expect(page.getByTestId("plangate-card")).toHaveCount(0); }); -test("a decomposition turn raises the plan gate; approving moves items to Approved", async ({ +test("filed items appear grouped in the rail, blocked on top, open items listed", async ({ page, }) => { await planTheWork(page); - const gate = page.getByTestId("plangate-card"); - await expect(gate).toBeVisible(); - // 3 visible + expander with the true remainder (mock UX-030: expander, true count in header) - await expect(gate).toContainText("Proposed plan — 4 work items"); - await expect(gate).toContainText("Done when:"); - await expect(gate.getByText("Code security review — api")).toBeVisible(); - await expect(gate.getByText("Rate-limit audit — public endpoints")).toHaveCount(0); - await gate.getByRole("button", { name: /1 more item/ }).click(); - await expect(gate.getByText("Rate-limit audit — public endpoints")).toBeVisible(); - - // Blocked renders on top in the rail; proposed items collapse to ONE line — - // the gate card is the only place the plan renders in full (no double listing). const rail = page.getByTestId("board-rail"); await expect(rail).toBeVisible(); const groups = rail.locator(".board-group"); await expect(groups.first()).toHaveText("Blocked"); - await expect(page.getByTestId("board-proposed-note")).toHaveText("4 items awaiting your approval."); - await expect(rail.getByText("Dependency audit — lockfiles")).toHaveCount(0); - - await page.getByTestId("plangate-approve").click(); - await expect(page.getByTestId("plangate-card")).toHaveCount(0); - await expect(page.getByTestId("board-proposed-note")).toHaveCount(0); - await expect(rail).toContainText("Approved"); - await expect(rail.getByText("Dependency audit — lockfiles")).toBeVisible(); + // No gate, no draft: open items are real items, listed like any other state. + await expect(rail).toContainText("Open"); + await expect(rail.getByText("Secrets — git history, both repos")).toBeVisible(); + await expect( + page.getByRole("button", { name: /Board · 1 blocked · 1 review · 1 in progress · 2 open/ }), + ).toBeVisible(); }); -test("expand opens the overlay board; Esc closes; the user can act on a review item", async ({ +test("expand opens the overlay board; the user verifies review items and removes open ones", async ({ page, }) => { await planTheWork(page); await page.getByTestId("board-expand").click(); const overlay = page.getByTestId("board-overlay"); await expect(overlay).toBeVisible(); - // Columns render need-attention first; the review item offers the user verbs. await expect(page.getByTestId("board-col-blocked")).toBeVisible(); + + // review → done (the verification gate stays a human/lead call) const reviewCol = page.getByTestId("board-col-review"); - await expect(reviewCol).toContainText("Report rollup"); await reviewCol.getByRole("button", { name: "Mark done" }).click(); await expect(page.getByTestId("board-col-done")).toContainText("Report rollup"); + // open → removed (lead/user triage of filed items; maps to canceled underneath) + const openCol = page.getByTestId("board-col-open"); + await openCol.getByRole("button", { name: "Remove" }).first().click(); + await expect(page.getByTestId("board-col-canceled")).toBeVisible(); + await page.keyboard.press("Escape"); await expect(page.getByTestId("board-overlay")).toHaveCount(0); }); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index eac71e4e..8f328869 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -562,16 +562,15 @@ export async function mockApi(page: import("@playwright/test").Page) { let stagedSkill: any = null; // Agent teams (OPE-96): the session's board — empty until a test opts in by sending - // "plan the work" (the fake agent then files items into the proposed gate). Mutable - // so approve/transition round-trip through the real endpoints. + // "plan the work" (the fake agent then files items; no draft state — the board only + // holds accepted work). Mutable so transitions round-trip through the real endpoints. const boardItems: any[] = []; const seedBoard = () => { if (boardItems.length) return; boardItems.push( - { id: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] }, - { id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] }, - { id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] }, - { id: 6, title: "Rate-limit audit — public endpoints", description: "", criteria: "every unauthenticated route has a limit or a reason", state: "proposed", assignee: "", creator: "lead", refs: [], links: [] }, + { id: 1, title: "Code security review — api", description: "", criteria: "every finding triaged with file:line evidence", state: "open", assignee: "", creator: "lead", refs: [], links: [] }, + { id: 2, title: "Secrets — git history, both repos", description: "", criteria: "every hit dismissed-with-reason or rotation-instructed", state: "open", assignee: "", creator: "lead", refs: [], links: [] }, + { id: 3, title: "Dependency audit — lockfiles", description: "", criteria: "reachable vs theoretical separated; upgrade branch green", state: "in_progress", assignee: "dep-audit", creator: "lead", refs: [], links: [] }, { id: 4, title: "Cloud posture — infra", description: "", criteria: "trivy config clean or findings triaged", state: "blocked", assignee: "cloud-posture", creator: "lead", refs: [], links: [] }, { id: 5, title: "Report rollup", description: "", criteria: "one report, all sections", state: "review", assignee: "security", creator: "lead", refs: [], links: [] }, ); @@ -630,12 +629,13 @@ export async function mockApi(page: import("@playwright/test").Page) { }); return; // suspended on the approval } - // Agent teams (OPE-96): a decomposition turn — the agent files work items and - // the board (rail section + plan gate) appears on the next board fetch. + // Agent teams (OPE-96): a decomposition turn — the plan was approved in + // conversation (plan-approval flow); the agent files the items and the + // board rail appears on the next board fetch. if (/plan the work/i.test(msg.text)) { seedBoard(); send("assistant_message", { - text: "Split it into 5 work items — approve the plan and I'll get started.", + text: "Plan approved — filed 5 work items on the board.", }); send("turn_done"); return; @@ -924,16 +924,6 @@ export async function mockApi(page: import("@playwright/test").Page) { } if (/\/v1\/sessions\/[^/]+\/artifacts\/reveal$/.test(p)) return json({ ok: true }); // Agent teams (OPE-96): board reads + the user-side mutations. - if (/\/v1\/sessions\/[^/]+\/board\/approve$/.test(p)) { - let approved = 0; - for (const item of boardItems) { - if (item.state === "proposed") { - item.state = "approved"; - approved += 1; - } - } - return json({ approved, ...boardPayload() }); - } if (/\/v1\/sessions\/[^/]+\/board\/transition$/.test(p)) { const b = req.postDataJSON() || {}; const item = boardItems.find((i) => i.id === Number(b.item)); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 1308948d..5b185d36 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -3,7 +3,6 @@ import { announceInboxUnlock, createTempWorkspace, finalizeAutomationRun, - boardApprove, boardTransition, getArtifacts, getBoard, @@ -76,7 +75,7 @@ import { ApprovalCard } from "./components/ApprovalCard"; import { ToolRequestCard } from "./components/ToolRequestCard"; import { DirectoryRequestCard } from "./components/DirectoryRequestCard"; import { PlanCard } from "./components/PlanCard"; -import { BoardOverlay, PlanGateCard } from "./components/BoardPanel"; +import { BoardOverlay } from "./components/BoardPanel"; import { WorkspaceTrustPrompt } from "./components/WorkspaceTrustPrompt"; const newId = () => @@ -275,7 +274,6 @@ export function App() { // Agent teams (OPE-96): board for the current session's workspace space. const [board, setBoard] = useState(null); const [boardOpen, setBoardOpen] = useState(false); - const [planBusy, setPlanBusy] = useState(false); const [railHidden, setRailHidden] = useState(false); // Left-nav collapse (⌘B): when collapsed the sidebar leaves the grid so content reclaims the // width; hovering the left edge peeks it back as a floating overlay. Persisted per-device. @@ -966,15 +964,6 @@ export function App() { }, [agent, surface, sessionId, browserRefreshKey, running]); const refreshBoard = () => getBoard(sessionId).then(setBoard).catch(() => {}); - const approvePlan = async () => { - setPlanBusy(true); - try { - await boardApprove(sessionId); - await refreshBoard(); - } finally { - setPlanBusy(false); - } - }; const moveBoardItem = async (item: number, to: string) => { await boardTransition(sessionId, item, to); await refreshBoard(); @@ -1947,9 +1936,6 @@ export function App() { ) : sessionInbox[0] ? ( // Unattended session blocked on an Inbox item — answer it in context. - ) : board && board.items.some((i) => i.state === "proposed") ? ( - // Agent teams: the decomposition gate — proposed items awaiting approval. - ) : undefined } /> diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 84aaf210..2c96fb4c 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -215,7 +215,7 @@ export interface BoardItem { title: string; description: string; criteria: string; - state: "proposed" | "approved" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string; + state: "open" | "in_progress" | "blocked" | "review" | "done" | "canceled" | string; assignee: string; creator: string; refs: string[]; @@ -239,11 +239,6 @@ export async function getBoard(sessionId: string): Promise { return res.json(); } -export async function boardApprove(sessionId: string): Promise { - const res = await fetch(`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/board/approve`, { method: "POST" }); - return res.json(); -} - export async function boardTransition( sessionId: string, item: number, diff --git a/surfaces/gui/src/components/BoardPanel.tsx b/surfaces/gui/src/components/BoardPanel.tsx index c1508029..c8048366 100644 --- a/surfaces/gui/src/components/BoardPanel.tsx +++ b/surfaces/gui/src/components/BoardPanel.tsx @@ -1,10 +1,11 @@ -// Agent teams (OPE-96): the board in three shapes — +// Agent teams (OPE-96): the board in two shapes — // - BoardSection: the right-rail summary (grouped by state, blocked on top) // - BoardOverlay: the expanded, Linear-shaped view covering the chat column -// - PlanGateCard: the decomposition gate (proposed items awaiting the user) -// All three render the same Board data App owns; mutations go through the -// /board endpoints and act as the USER — the human side of the gates. -import { useEffect, useMemo, useState } from "react"; +// Both render the same Board data App owns; mutations go through the /board +// endpoints and act as the USER. There is NO proposed/draft state: a plan +// proposal lives in the conversation (plan-approval flow); the board only ever +// contains accepted work, and work starts at ASSIGNMENT. +import { useEffect } from "react"; import type { Board, BoardItem } from "../api"; import { Icon } from "./Icon"; @@ -13,8 +14,7 @@ const GROUPS: { state: string; label: string }[] = [ { state: "blocked", label: "Blocked" }, { state: "review", label: "Review" }, { state: "in_progress", label: "In progress" }, - { state: "approved", label: "Approved" }, - { state: "proposed", label: "Proposed" }, + { state: "open", label: "Open" }, { state: "done", label: "Done" }, { state: "canceled", label: "Canceled" }, ]; @@ -34,21 +34,15 @@ export function boardSummary(board: Board): string { if (counts.blocked) parts.push(`${counts.blocked} blocked`); if (counts.review) parts.push(`${counts.review} review`); if (counts.in_progress) parts.push(`${counts.in_progress} in progress`); - if (counts.proposed) parts.push(`${counts.proposed} proposed`); + if (counts.open) parts.push(`${counts.open} open`); return parts.join(" · "); } export function BoardSection({ board, onExpand }: { board: Board; onExpand: () => void }) { - // Proposed items are the plan gate's content — the rail collapses them to one - // line (mock UX-030 state 1: "4 items awaiting your approval") instead of - // listing the same items twice on screen. - const proposed = board.items.filter((i) => i.state === "proposed"); - const groups = GROUPS.filter((g) => g.state !== "proposed") - .map((g) => ({ - ...g, - items: board.items.filter((i) => i.state === g.state), - })) - .filter((g) => g.items.length > 0); + const groups = GROUPS.map((g) => ({ + ...g, + items: board.items.filter((i) => i.state === g.state), + })).filter((g) => g.items.length > 0); return (
{groups.map((group) => ( @@ -67,11 +61,6 @@ export function BoardSection({ board, onExpand }: { board: Board; onExpand: () = ))}
))} - {proposed.length > 0 && ( -
- {proposed.length} item{proposed.length === 1 ? "" : "s"} awaiting your approval. -
- )} ); } @@ -99,7 +88,7 @@ export function BoardOverlay({ const columns = GROUPS.map((g) => ({ ...g, items: board.items.filter((i) => i.state === g.state), - })).filter((g) => g.items.length > 0 || ["in_progress", "approved", "review"].includes(g.state)); + })).filter((g) => g.items.length > 0 || ["in_progress", "open", "review"].includes(g.state)); return (
@@ -145,13 +134,13 @@ function BoardCard({ }) { // The user can always act; offer the obvious next moves for the state. const moves: { to: string; label: string }[] = - item.state === "proposed" - ? [{ to: "approved", label: "Approve" }, { to: "canceled", label: "Cancel" }] - : item.state === "review" - ? [{ to: "done", label: "Mark done" }, { to: "in_progress", label: "Send back" }] - : item.state === "done" || item.state === "canceled" + item.state === "review" + ? [{ to: "done", label: "Mark done" }, { to: "in_progress", label: "Send back" }] + : item.state === "canceled" + ? [{ to: "open", label: "Reopen" }] + : item.state === "done" ? [] - : [{ to: "canceled", label: "Cancel" }]; + : [{ to: "canceled", label: "Remove" }]; return (
@@ -178,58 +167,3 @@ function BoardCard({ ); } -// The decomposition gate: proposed items awaiting the user's approval, rendered in -// the composer head like the other request cards. Visible layer = the decisions -// (items + criteria); editing happens by replying — no in-card reply surface. -export function PlanGateCard({ - board, - onApprove, - busy, -}: { - board: Board; - onApprove: () => void; - busy?: boolean; -}) { - const proposed = useMemo(() => board.items.filter((i) => i.state === "proposed"), [board.items]); - const [expanded, setExpanded] = useState(false); - if (proposed.length === 0) return null; - const visible = expanded ? proposed : proposed.slice(0, 3); - const hidden = proposed.length - visible.length; - return ( -
-
- - - Proposed plan — {proposed.length} work item{proposed.length === 1 ? "" : "s"} - - board: {board.name} -
- {visible.map((item) => ( -
- #{item.id} - - {item.title} - {item.criteria && ( - - Done when: {item.criteria} - - )} - -
- ))} - {hidden > 0 && ( - - )} -
- Reply to edit the plan; nothing runs until you approve. - - -
-
- ); -} diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index 689055ac..b3df2477 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -1713,20 +1713,4 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: .journal-case { color: var(--ink); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .journal-count { font-size: 11px; color: var(--faint); } -/* Plan gate (decomposition gate) — rides the dirreq-card frame. */ -.plangate-card { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } -.plangate-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } -.plangate-title { font-weight: 600; font-size: 13px; color: var(--ink); } -.plangate-board { margin-left: auto; font-size: 11.5px; color: var(--faint); } -.plangate-item { display: flex; gap: 9px; padding: 7px 0; border-top: 1px solid var(--line); } -.plangate-num { color: var(--faint); font-size: 12px; padding-top: 1px; } -.plangate-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } -.plangate-item-title { font-size: 12.5px; color: var(--ink); } -.plangate-ac { font-size: 11.5px; color: var(--muted); } -.plangate-ac b { font-weight: 600; } -.plangate-more { - display: flex; align-items: center; gap: 5px; border: 0; background: transparent; - color: var(--accent); font-size: 12px; cursor: pointer; padding: 6px 0; -} -.plangate-note { font-size: 11.5px; color: var(--faint); } -.board-proposed-note { color: var(--faint); font-size: 12px; padding: 8px 6px 2px; } + diff --git a/tests/test_team_board.py b/tests/test_team_board.py index 54cdecdd..d9f3d320 100644 --- a/tests/test_team_board.py +++ b/tests/test_team_board.py @@ -21,7 +21,6 @@ def store(tmp_path): def assigned_item(store, assignee="worker-1"): item = store.create_item(SPACE, LEAD, title="Task", criteria="tests pass") - store.transition(SPACE, USER, item["id"], "approved") store.assign(SPACE, LEAD, item["id"], assignee) return item["id"] @@ -33,19 +32,21 @@ def test_acceptance_criteria_are_required(store): store.create_item(SPACE, LEAD, title="Vague hope", criteria=" ") -def test_workers_file_items_into_the_proposed_gate(store): +def test_workers_file_items_open_and_unassigned(store): mine = assigned_item(store) filed = store.create_item( SPACE, WORKER, title="Rounding bug in invoices", criteria="repro + fix", parent=mine, ) - assert filed["state"] == "proposed" + assert filed["state"] == "open" + assert filed["assignee"] == "" assert filed["creator"] == "worker-1" - # the worker sees its own filing; nothing runs until the user approves it + # the worker sees its own filing; nothing runs until the lead/user assigns it, + # and the filer can't assign it to itself visible = {item["id"] for item in store.list_items(SPACE, WORKER)} assert filed["id"] in visible - with pytest.raises(AuthorityError, match="decomposition gate"): - store.transition(SPACE, WORKER, filed["id"], "approved") + with pytest.raises(AuthorityError): + store.assign(SPACE, WORKER, filed["id"], "worker-1") other_worker = {item["id"] for item in store.list_items(SPACE, OTHER)} assert filed["id"] not in other_worker @@ -76,15 +77,7 @@ def test_illegal_edges_rejected(store): with pytest.raises(BoardError, match="illegal transition"): store.transition(SPACE, USER, item["id"], "done") with pytest.raises(BoardError, match="illegal transition"): - store.transition(SPACE, USER, item["id"], "in_progress") - - -def test_only_the_user_approves(store): - item = store.create_item(SPACE, LEAD, title="T", criteria="c") - with pytest.raises(AuthorityError, match="decomposition gate"): - store.transition(SPACE, LEAD, item["id"], "approved") - approved = store.transition(SPACE, USER, item["id"], "approved") - assert approved["state"] == "approved" + store.transition(SPACE, USER, item["id"], "review") def test_workers_never_mark_done(store): @@ -110,8 +103,8 @@ def test_worker_cannot_cancel(store): def test_cancel_is_a_board_verb_and_reopen_works(store): item_id = assigned_item(store) store.transition(SPACE, LEAD, item_id, "canceled") - reopened = store.transition(SPACE, LEAD, item_id, "approved") - assert reopened["state"] == "approved" + reopened = store.transition(SPACE, LEAD, item_id, "open") + assert reopened["state"] == "open" assert reopened["assignee"] == "worker-1" # reassignable — assignment survives @@ -136,10 +129,11 @@ def test_rework_loop(store): # ---------------------------------------------------------------- assign / link -def test_cannot_assign_before_approval(store): - item = store.create_item(SPACE, LEAD, title="T", criteria="c") - with pytest.raises(BoardError, match="after approval"): - store.assign(SPACE, LEAD, item["id"], "worker-1") +def test_cannot_assign_closed_items(store): + item_id = assigned_item(store) + store.transition(SPACE, LEAD, item_id, "canceled") + with pytest.raises(BoardError, match="reopen"): + store.assign(SPACE, LEAD, item_id, "worker-2") def test_workers_cannot_assign_or_link(store): diff --git a/tests/test_team_journal.py b/tests/test_team_journal.py index 065ad0c7..7d18efe9 100644 --- a/tests/test_team_journal.py +++ b/tests/test_team_journal.py @@ -38,7 +38,6 @@ def board(tmp_path, journal): def case_item(board, case="findings", assignee="worker-1", space=SPACE): item = board.create_item(space, LEAD, title="Task", criteria="c", case=case) - board.transition(space, USER, item["id"], "approved") board.assign(space, LEAD, item["id"], assignee) return item["id"] diff --git a/tests/test_team_store.py b/tests/test_team_store.py index e48a4583..4d6494c9 100644 --- a/tests/test_team_store.py +++ b/tests/test_team_store.py @@ -18,17 +18,15 @@ def store(tmp_path): def seed(store, space="proj"): - item = store.create_item( + return store.create_item( space, LEAD, title="Review api", criteria="every route triaged" ) - store.transition(space, USER, item["id"], "approved") - return item def test_events_hash_chain_verifies(store): seed(store) store.create_item("proj", LEAD, title="Second", criteria="done means done") - assert store.verify_chain("proj") == 3 + assert store.verify_chain("proj") == 2 def test_out_of_band_edit_breaks_the_chain(store): @@ -61,7 +59,7 @@ def test_chains_are_per_space(store): conn.execute("UPDATE team_events SET taint = 1 WHERE space = 'beta'") conn.commit() conn.close() - assert store.verify_chain("alpha") == 2 # untouched space still verifies + assert store.verify_chain("alpha") == 1 # untouched space still verifies with pytest.raises(ChainError): store.verify_chain("beta")