From 9354f455b80527403f9c87d4fcb468138de7617a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Mon, 26 Oct 2026 17:45:26 -0400 Subject: [PATCH 1/2] refactor(core): move the Inbox thread's pure rules into @plannotator/core Slice 1 of the Inbox thread extract. folding.ts, deliveryLine.ts, openQuestions.ts and format.ts move from packages/inbox to core as inbox-folding, inbox-delivery-line, inbox-open-questions and inbox-format (the inbox- prefix keeps the Inbox e2e workflow triggering). Only their import specifiers change (@plannotator/core/x -> ./x, ./format -> ./inbox-format). The old paths are export * shims, so every Inbox import and test runs unedited. Four new core subpaths; no version bump (the owner publishes core by hand). --- docs/agent-guides/inbox-compose.md | 2 +- docs/agent-guides/inbox-messages.md | 8 +- docs/agent-guides/inbox-window.md | 2 +- packages/core/inbox-delivery-line.ts | 304 ++++++++++++++++++++++ packages/core/inbox-folding.ts | 346 +++++++++++++++++++++++++ packages/core/inbox-format.ts | 118 +++++++++ packages/core/inbox-open-questions.ts | 181 ++++++++++++++ packages/core/package.json | 4 + packages/inbox/deliveryLine.ts | 306 +--------------------- packages/inbox/folding.ts | 348 +------------------------- packages/inbox/format.ts | 120 +-------- packages/inbox/openQuestions.ts | 183 +------------- packages/ui/HANDOFF.md | 11 + 13 files changed, 986 insertions(+), 947 deletions(-) create mode 100644 packages/core/inbox-delivery-line.ts create mode 100644 packages/core/inbox-folding.ts create mode 100644 packages/core/inbox-format.ts create mode 100644 packages/core/inbox-open-questions.ts diff --git a/docs/agent-guides/inbox-compose.md b/docs/agent-guides/inbox-compose.md index b570bf7a06..daf921c264 100644 --- a/docs/agent-guides/inbox-compose.md +++ b/docs/agent-guides/inbox-compose.md @@ -52,7 +52,7 @@ - The catalog: `GET /api/inbox/skills` (`packages/server/inbox-skills.ts`), plan review's `handleReferenceSkills`: the global skill folders only, read fresh. The window installs it as ui's `skillCatalogTransport`, so the comment composers on attached files list it too, and sets `skillInstructionsDestination: 'your reply'` (their human-only line says the instructions go with the reply). - At Send the window composes ONE block at the end of the message (`packages/inbox/skills.ts`, Option B: the client composes the body, as plan review composes its feedback): references are read from the words and every comment on an attached file the Send carries (not a guided review's diff comments), once each; `renderSkillReferencesBlock` (ui) writes Plannotator's block in "the person … this reply / this message" wording (`@plannotator/core/skill-reference-block`): a model-invocable skill by name, a human-only skill's SKILL.md (read then from `GET /api/inbox/skills/content`, 20,000-character cap, truncation notice, marker lines neutralized) between BEGIN/END markers with `Skill directory:` naming its folder. At most 40,000 characters of instructions per message (`INBOX_SKILL_INSTRUCTIONS_BUDGET`); past it, or unreadable, a skill is named with "read SKILL.md in ". A reply carries the block in its `feedback` (after the annotations' feedback, so it is the body's end; the server is unchanged); a New message appends it to `body`. The per-comment "Skills referenced" list is off in the Inbox (`configurePlannotatorUI({ skillReferenceExport: false })`), so a comment's `$name` stays plain text and the block covers it. - Trust: only the person's Send composes a block; MCP has no skills field, and an agent's `$readback` is plain text. The window folds a block only under the person's own messages: `MessageBody` splits it off with `splitInboxSkillsBlock`, which takes the LAST Inbox heading (after a blank line) whose tail is exactly the shape the window composes: list lines, then nothing but blank lines and closed BEGIN/END sections for skills the list includes, to the end of the body. The real block is always last, so agent text a reply quotes (a guided-review comment's diff lines, an attached-file comment's `Feedback on:` quote) can never fold the person's feedback, not even with a fake unterminated BEGIN running on to the real END; fence parity is not read, since the agent controls it. Both code exporters (the review app's `exportFeedback.ts` and the Inbox's `guideCommentEntry`) also fence quoted code with more backticks than any run in it (`codeFenceFor`, `@plannotator/core/markdown-structure`; unchanged bytes for code without a backtick run). It draws `SkillsLine` (`components/SkillsLine.tsx`): "Skills: readback, write-better · readback's instructions went with this reply (0.4 KB) · Show", a `
` that opens on the block as the agent got it. The references in the sent words are painted as the composer painted them (`skillHighlight.ts`: the CSS Custom Highlight API, `::highlight(ib-skill-ref)` / `ib-skill-ref-human` with the dotted underline, matched against the skills the block names, never the live catalog, only in the person's own words (before the answers or feedback headings), at a priority below search's landing highlight; nothing painted where the API is missing). An agent's message is shown whole. -- Snippets and search read the person's words: `replyWords` and the folded line's snippet (`folding.ts`), and search's `inboxSearchReplyText`, drop the block (the `$name` stays in the words, so search still finds the reply by the skill's name; the SKILL.md text is not indexed for that reply). The stored body, `read_thread`, `wait_for_reply`, the wake and the phone carry the whole text (the accepted cost of Option B). +- Snippets and search read the person's words: `replyWords` and the folded line's snippet (`@plannotator/core/inbox-folding`), and search's `inboxSearchReplyText`, drop the block (the `$name` stays in the words, so search still finds the reply by the skill's name; the SKILL.md text is not indexed for that reply). The stored body, `read_thread`, `wait_for_reply`, the wake and the phone carry the whole text (the accepted cost of Option B). - Phone (`PHONE_QUERY`): the picker opens full width directly above the reply box (no sheet, so the keyboard stays up and a tap inserts on pointerdown), 44 px rows with the description under the name, at most about 4½ rows. - On the phone's device door (contract 7.46, 7.47). Tests: `packages/server/inbox-skills.test.ts`, `packages/inbox/skills.test.ts`, the DOM test `packages/inbox/skillReferences.test.tsx` (the whole window over a real Inbox; the DOM server pins the skill folders under the test's scratch HOME). diff --git a/docs/agent-guides/inbox-messages.md b/docs/agent-guides/inbox-messages.md index f59844970f..2aa430a274 100644 --- a/docs/agent-guides/inbox-messages.md +++ b/docs/agent-guides/inbox-messages.md @@ -12,7 +12,7 @@ **Mark read and the Send that skips** (design record "inbox-mark-read", not in this repo; approved 2026-10-11, with the owner's later rulings that Mark read closes the questions it leaves and that Sent sits below New). Two ways to get a thread out of the top without answering every question. - **A Send skips a question only when the person presses a Skip** (owner approval "inbox-explicit-skip", 2026-10-21, design brief "explicit-skip", option A "never skip on its own"; it replaces the rule of 2026-10-11 that every Send skips what it leaves unanswered, and the 2026-10-13 amendment that skipped once a Send answered one question: on 2026-10-21 the owner answered 1 of 11 questions in one session's 8 messages, and that rule skipped the other 10 in 8 replies). Sending an answer never skips anything else. The reply box (and the foot while picks or annotations wait) shows the chip "N questions stay open · Skip them", always, whatever the Send carries; "Skip them" (or "Skip it") turns it into "↷ N questions will be skipped · Keep open" for that one Send, and is the only way a Send skips. The window remembers exactly the questions the chip counted when it was pressed (`skipArmed` in `Thread.tsx`, ids `/`), so a question that lands while the person types is never skipped; at Send it passes them to `sendTargetsOf`, which keeps those still open (a pick saved meanwhile is sent as the pick), and the reply names them in `skip` (see "Questions and Send" for one reply per session). Keep open, the thread changing, or the Send going clears the choice. Skip on a card, Mark read and Re-ask work as before. The server takes only the explicit list: `skip: [key | { message_id?, key }]` (`InboxReplyInput.skip`, checked by `planSkips` in the store: a list, each entry a question of a message `sendMessageOf` accepts, else `422 validation_error` / `404 question_not_found`, nothing written; a listed question that is no longer open, answered, picked, closed or replaced, is left alone so a race never refuses a Send). The old `skip_unanswered` flag is ignored by the reply route and gone from the store (the iPhone app never shipped it; contract 7.11 is rewritten): an older client that sends it skips nothing. A skip-only Send whose listed questions all closed meanwhile answers `{ reply: null, nothing_to_send: true }`, a no-op, so the rest of a Send to several sessions still goes. Each skip is a sent skip, `answer: { skipped: true, by_reply: true }` (`inboxSkipAnswer`; `by_reply` is additive on `QuestionAnswer`, kept by `parseQuestionAnswer` only beside `skipped`), so the card says "Skipped ·