Skip to content

Plannotator Inbox: decisions (step 3) - #1757

Merged
backnotprop merged 1 commit into
mainfrom
feat/inbox-decisions
Oct 7, 2026
Merged

backnotprop merged 1 commit into
mainfrom
feat/inbox-decisions

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

The story

The card's "Records a decision" tag is now a real switch saved on the question, and its words open the approved decision card, drafted answer first. At Send, every sent answer whose switch is on becomes a decision in the project's own decisions.jsonl, and the thread says "Settled: ". The Decisions page lists Waiting, Settled, and "Replaced or retired" folded, with a row that opens beside the list for Retire and Replace. Agents read the list with list_decisions and write to it with record_decision.

Plannotator Inbox step 3 (PLAN.md). Based on main after the window (PR 1755) and notifications (PR 1756, d5e0062ac).

What changed

  • packages/core/inbox-types.ts: InboxDecision (Workspaces' fields: text, reason, source, state current | replaced | retired, version, replaces_id, replacement_id), the decision store line, dec_ ids, and decision_recording / decision_draft on the question record and wire. inboxAgentName moved here from the window's format.ts, so the server drafts the same "Asked by " words.
  • packages/core/inbox-questions.ts (appended, browser-safe): the drafted words (draftInboxDecisionText, draftInboxDecisionReason, inboxDecisionWords) and the switch default (inboxDecisionRecording). They live in an existing core subpath on purpose: a new subpath fails the ui package smoke against the published core 0.25.12, and ui does not import these. So core's exports are unchanged and neither core nor ui needs a publish.
  • packages/shared/inbox/store.ts and schema.ts: decisions.jsonl read at start, replayed by seq like every other line, counted in the store's size; writeDecision and writeQuestionDecision. writeQuestionDecision never touches an answer or its revision, so a switch never races a pick.
  • packages/server/inbox-decisions.ts (new): the rules (setQuestionDecision, recordDecisionsForReply, retireDecision, replaceDecision, recordAgentDecision, waitingDecisions) and the window routes: GET /api/inbox/decisions?project=, POST /api/inbox/messages/:id/decision, POST /api/inbox/decisions/:id/retire|replace.
  • packages/server/inbox.ts, in its own block: the decision routes behind the same Host, Origin and serverSession guards. Three small edits in existing code: the reply route records decisions after the reply lands, the thread route returns the thread's decisions, and the list model carries decisions_waiting (the sidebar count). The event stream also carries decision lines.
  • packages/server/inbox-mcp.ts: list_decisions and record_decision, plus one line in the instructions (now 1,518 characters, under 2,048). apps/hook/server/inbox-mcp-shim.ts fills project_path and agent_session on both.
  • Window (packages/inbox): components/DecisionCard.tsx (the card), components/Decisions.tsx (the page and the row pane), Thread.tsx (the switch on the server instead of localStorage, the card, the "Settled" line, the "Not recorded" line), App.tsx (#decisions=prj_…&decision=dec_…), Sidebar.tsx (Decisions live, with its count), inbox.css (the record's values with the ib- prefix), api.ts, icons.tsx (folder, diamond).
  • packages/ui/components/Viewer.tsx: one additive seam, questionDecisionLocked(question). It is noted in packages/ui/HANDOFF.md under "unreleased", and Plannotator passes none, so its render is unchanged. A question it returns true for draws its decision tag as a plain tag (no switch, no opener) while the other questions in the same message keep theirs. The Inbox locks a question once its answer is sent, so read-only is decided per question, not per message (review blocker 1).
  • scripts/inbox-sim.ts: listDecisions and recordDecision on SimAgent.
  • AGENTS.md: the Inbox section gains "Decisions".

Record sections this makes real

  • 3.1: the tag is the switch (diamond = aria-pressed toggle, words = card opener, ui 0.52.1 onOpenQuestionDecision); off is dimmed with a dotted outline and the row hidden (approved Off option A); the card is 400 px with "Record as a decision", "Decision" (focused, the answer), "Why (optional)" ("Asked by Claude Code: "), "In billing-svc, when you send", Cancel and Done. Proof frames: 3.1-decision-card, 3.1-switch-off, 3.1-settled.
  • 3.2: "Decisions" with the project switch, "What holds true in , and what waits on a call.", Waiting (with Open), Settled ("From your answer to Claude Code · Recorded at Send", "Recorded by Claude Code · record_decision"), "Replaced or retired" folded, no star. Proof frames: 3.2-decisions, 3.2-decision-open, 3.2-retired-folded, 3.2-replaced-history.
  • Owner Q5: decisions are structured files in the Inbox's own store under the Plannotator data dir; nothing goes to the feedback archive.

Proofs and how to run them

  • tests/e2e/inbox-decisions.spec.ts, 13 tests, Playwright headless Chromium against the compiled binary, with agents going through plannotator inbox mcp and the SDK's stdio client:
    1. The tag is on for Decision: when answered and off for a plain question (also read back over MCP).
    2. Switching it off hides the row and holds across a reload.
    3. The words open the card just under the tag, right edges aligned, with the drafted statement and Why. Escape cancels without closing the thread.
    4. Done keeps edits on the question record, across a reload.
    5. Send records exactly one decision, the switched-on question's, read from decisions.jsonl on disk. The switched-off question records none, and the thread shows "Settled: ".
    6. A sent card keeps its tag but has no switch and no card opener.
    7. list_decisions returns the decision.
    8. The Decisions page shows Waiting and Settled, the Settled link opens the row beside the list, and Open on a waiting question opens its thread.
    9. A record_decision from the simulation appears live, and a retry with the same key appears once.
    10. Retire moves the decision into the folded group (version 2 on disk).
    11. Replace writes a new decision and keeps the old one as replaced, linked both ways.
    12. Read-only per question: in a half-sent message, the sent card keeps a plain tag with no switch or opener, and the open card keeps both. Its switch saves with no refusal.
    13. No page or CSP errors.
  • packages/server/inbox-decisions.test.ts, real server, fetch and the MCP SDK client:
    • a decision that cannot be written (its file blocked) never undoes the sent answer, and the Send's idempotent retry records it exactly once (PLAN step 3: "a refused decision never undoes the sent answer");
    • the switch, the card's words and the decisions come back after a restart;
    • the refusals: a stale version, an ended decision, a replace with the same words, an idempotency key reused with other words, blank text, a switch on a sent answer, a stale tab, a cross-site request.
  • Run:
    bun run --cwd apps/review build && bun run build:hook && bun build apps/hook/server/index.ts --compile --no-compile-autoload-bunfig --define '__CLI_VERSION__="0.0.0-dev"' --outfile .local/plannotator
    bun run test:e2e:inbox
    PLANNOTATOR_DATA_DIR=$(mktemp -d) bun test packages/server/inbox-decisions.test.ts
    
  • CI: the existing path-gated inbox-e2e.yml job now runs both specs (tests/e2e/playwright.config.ts testMatch). No new workflow job.
  • tests/e2e/inbox.spec.ts changes:
    • its decision test now finds the switch by "Record as a decision", because the words became a separate button;
    • it clears only its own top-level captures, so the decisions spec's .local/proof/decisions/ survives.

Gates run

  • bun run typecheck: green (it already carries the explicit packages/inbox and apps/inbox entries).
  • bun test packages apps/hook tests scripts under a temp PLANNOTATOR_DATA_DIR and HOME: 5,783 pass, 1 fail. The one failure is apps/hook/server/annotate-cli.test.ts, "annotate CLI on an occupied fixed port". It fails identically on a clean origin/main worktree on this machine, and this PR does not touch annotate.
  • Rerun on the final code: the Inbox server, threads, store, CLI, window, core, tests/entry-assets.test.ts and tests/favicon-surfaces.test.ts: 399 pass, 0 fail.
  • bun run --cwd packages/ui smoke:package: green (core and ui changed; core's exports did not).
  • ui DOM tests for the question card and Viewer (DOM_TESTS=1): green apart from Viewer.consumer.test.tsx "readOnly fenced code never opens composers" (a code-highlighting assertion). It fails identically on a clean origin/main worktree.
  • bun run check:release-version: green.
  • bun run test:e2e:inbox against the compiled binary, after the rebase onto d5e0062ac: 33 of 33 (window, notifications, decisions).
  • git merge-tree --write-tree origin/main HEAD: no conflict.
  • DOM tests: none added, so the DOM_TESTS=1 allowlist is unchanged.
  • The guide chain's CSS is untouched (no guides-show rebuild needed).

What it leaves out

  • Writing decisions into the repository.
  • The feedback archive line (not asked for).
  • Un-retiring, editing a decision in place, deleting one.
  • An agent replacing or retiring a decision (record_decision only adds).
  • A per-decision star or pin (the record says none).

Deviations from the record, each with its reason

  1. The row opening beside the list is not drawn in 3.2. It is built from the overseer's brief, in the thread pane's pattern (the list narrows, a pane opens on the right). It shows the state, the statement, Why, Source (with "Open the thread" for an answer), Recorded, "Replaces" / "Replaced … by" links, and "Retired" with its date. A current decision gets Replace and Retire. Replace opens an inline form with the card's two fields ("Decision", "Why (optional)"). Retire takes one click with no confirmation, because nothing is deleted and the decision stays readable in the folded group. The record does say the group "stays folded with its history", which needs somewhere to read that history.
  2. The sidebar's Decisions count is the number of questions that will record a decision once sent, across all projects. That is what the record's "Decisions 1" matches in both 3.1 and 3.2 (one Waiting).
  3. The project switch is a native <select> styled as the record's select-like control (same height, border, chevron). It keeps keyboard and screen-reader behaviour for free. Only the open menu is the OS's.
  4. Card placement: right edges aligned to the tag's words, 6 px under them, and flipped above when there is no room below. The record's overlay sits about 90 px further right, past the tag. Aligning to the tag keeps the card inside the pane at any width, and the approved Workspaces card aligns the same way (align="end").
  5. "Settled: " under the cards is not drawn in the Plannotator record. It is the approved Workspaces line the Inbox inherits, and it is how the brief's "the thread shows Settled" reads. The text links to the decision's row. A sent card still shows ui's own static tag and its "Answering this records a decision" row. Hiding that row would need host CSS reaching into ui's card, which this PR avoids.
  6. "Not recorded as a decision: . The answer was sent." is not drawn. It appears only when a decision write fails, so the person knows. The PLAN requires that the answer stays sent in that case.
  7. Empty groups: Waiting and "Replaced or retired" are hidden when empty. An empty Settled reads "Nothing recorded in yet." The record draws only the full state.
  8. Row wording beyond the record:
    • a replacement reads "Written by you · Replace";
    • folded rows start with their state ("Retired · …", "Replaced · …");
    • the date column uses the list's rule (the time today, "Oct 3" before).
  9. One route for the switch and the card's words: POST /api/inbox/messages/:id/decision { key, recording?, draft? }. The brief suggested /api/inbox/questions/<id>/decision-recording. This one sits beside the message's existing picks, reply and resolve routes.
  10. Version follows Workspaces exactly (the sync target): version counts changes to one record, so retire and replace bump it and a stale version is refused. A replacement is a new record at version 1 with replaces_id, and the old record gets replacement_id and state replaced. The brief's "replace writes a new version" is read this way.
  11. Fields beyond the brief's list: source.kind (answer | agent | person), source.thread_id, replaces_id, replacement_id, changed_at (Workspaces' fields, for the history), and idempotency_key. record_decision takes an idempotency_key so an agent's retry never records twice, as send_message already does.
  12. No length bounds on decision text. Workspaces clips the drafts at 500 and 1,500 characters. The owner ruled caps out for the Inbox.

Dependencies

None open. This branch was started on feat/inbox-window, rebased onto main when PR 1755 merged, and rebased again onto PR 1756. Conflicts were resolved by keeping both sides: notifications and decisions side by side in App.tsx, playwright.config.ts and AGENTS.md.

The card's "Records a decision" tag is the switch, kept on the question
record; its words open the decision card, drafted answer first, and Done
keeps the words. At Send, each sent answer whose switch is on becomes a
decision in the project's decisions.jsonl (Workspaces' fields), and the
thread says "Settled". Read-only is decided per question (ui seam
questionDecisionLocked). The Decisions page lists Waiting, Settled, and
Replaced or retired folded, with a row opening beside the list for Retire
and Replace. MCP list_decisions and record_decision.

Proved by tests/e2e/inbox-decisions.spec.ts (Playwright against the
compiled binary, agents through the stdio shim) and
packages/server/inbox-decisions.test.ts.
@backnotprop
backnotprop force-pushed the feat/inbox-decisions branch from 8349e18 to 30ac4ab Compare October 7, 2026 15:26
@backnotprop
backnotprop merged commit 9391852 into main Oct 7, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant