Skip to content

feat(PG-1023): an Inbox card shows a Keychain value on the owner's click - #1063

Merged
nrjdalal merged 8 commits into
canaryfrom
PG-1023-secret-cards
Nov 5, 2026
Merged

nrjdalal merged 8 commits into
canaryfrom
PG-1023-secret-cards

Conversation

@nrjdalal

@nrjdalal nrjdalal commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

TL;DR

  • A session can now ask you for a key through the Inbox and also show you one: a new card names a key this Mac keeps, such as playground-glm, and says why you need it. Its Show draws the key for 30 seconds, and its Copy puts it on your clipboard without drawing it.
  • The key comes from this Mac's Keychain only when you click, and goes nowhere else: not to the Inbox's storage, a log, the chat, the session or the hosted relay. An ask for a key can now save to any of Playground's own keys too, not only Slack and Yard.

Summary

POST /api/v1/inbox/:id/reveal          (new; a POST, so the relay never carries it)
  guardWrites: trusted origin, then loopback
  authMiddleware, then the owner rung
  refuseAgent                          403 for the agent sign-in, which holds the owner rung
  revealSecret(db, id, readFromKeychain())
    not a reveal card            -> 400
    closed, dismissed, archived  -> 409   (an old card reads nothing)
    readsLive() ? security find-generic-password -w   (value on stdout)
                : FIXTURE_REVEAL                       (made up; no security run)
    no value of its shape        -> 404
  200 { value }, Cache-Control: no-store; writes nothing

POST /api/v1/inbox/:id/secret          (exists)
+ refuseAgent
  keychain sink item: slack | yard | any playground- service (keychainSpec)
 keychain-items.ts
+  PLAYGROUND_SERVICE = /^playground-[a-z0-9][a-z0-9-]{0,62}$/
+  keychainSpec(item): a typed item (slack, yard, by name or service) keeps its check;
+                      another playground- service takes printable words with no quote,
+                      backslash or line break; any other service -> null (refused)
 previewSchema
+  { type: "reveal", service: <playground- service>, why }
 <RevealField> (web/next/src/components/dashboard/inbox.tsx)
+  masked until Show; Hide, a fold or 30 s clear it; Copy -> clipboard, never drawn

Departures from the issue and the plan:

  • A reveal writes nothing to its card's row. The plan said each Show or Copy would record shown, but the Done when asks for no record, and a read route that writes nothing is simpler. The builder accepted this one.
  • On a fixture or test stack, a reveal shows a fixed made-up value (FIXTURE_REVEAL), not a fake Keychain kept in the fixture's scratch folder. The builder accepted this one too.
  • A card names its item by the service alone, with no account. Both the read and the write use this Mac's signed-in user, which is the only account the plan allowed a card to name, so an account field could hold one value only.
  • The Done when asks for a test that the WebSocket frame holds no value. That limit holds by construction, and no test reads a frame: the dashboard's socket sends only entries, error and workers frames (DashboardFrame in api/hono/src/lib/dashboard/hub.ts) and an open chat's activity, none of them an Inbox row, and revealSecret takes no deliver and writes no row, so the Inbox's own reads see nothing new. The response log has a test (tests/api/hono/src/lib/base-app.test.ts).

Round 1 added a guard: a reply or an answer's note on a reveal card that holds its value, such as a Copy pasted into the card's own comment box, is refused before anything is sent (holdsRevealed). A reveal card's Reply stays in that box rather than the chat's composer, and its Edit is refused.

After canary's read-only hosted build (c1ca5a4), a reveal card on the hosted build draws no Show or Copy, since its relay carries no POST: the card names the item and its why, and the read-only line stands for the buttons. That hiding is the second layer: the hosted API mounts no /reveal or /secret route at all, since hosted.ts mounts sign-in, health and Yard alone, which tests/api/hono/src/hosted.test.ts pins by listing every mounted path.

Evidence

  • Before (canary e69658a, its own inboxInputSchema): a reveal card reads refused, and a secret ask whose keychain sink names playground-glm reads refused.
    After (this head): both read accepted.
    The fixture stack's shots are in this PR's first comment. They show the card masked, then showing the fixture's made-up value after Show, and a secret ask saving to playground-glm.
  • In the browser (fixture stack at 912a686, headless Chrome):
    • Show drew made-up-value-from-the-fixture and turned to Hide. 30 seconds later the field read masked again.
    • Copy toasted Copied, and the field stayed masked.
    • From the page, the reveal answered 200, cache-control: no-store. On the secret ask's card it answered 400 Only a reveal card shows a value.
  • The agent sign-in, end to end (the same stack with AGENT_SIGNIN_ENABLED=true, curl): the agent read GET /api/v1/inbox with 200, so it passes the owner rung. It then got 403 A secret moves on the owner's own click alone from both POST .../reveal and POST .../secret.
  • Tests:
    • tests/api/hono/src/routers/inbox.test.ts > "Submit and Show each carry refuseAgent": red on canary's router (12 pass, 1 fail), green at this head (13 pass).
    • The same file: refuseAgent turns the agent away with 403 and lets the owner through. Show is refused off the dashboard's origin, off loopback and with no session.
    • tests/api/hono/src/lib/dashboard/inbox.test.ts, the Postgres-backed store with Docker reachable: 173 pass, 0 fail, 0 skipped.
      • A reveal returns the value, and the row, the version, the captured log and every table hold none of it.
      • A done, dismissed or archived card, another card or a missing id each read nothing and never call the reader.
      • A reveal names a playground- service alone.
      • A secret ask saves to playground-glm after refusing a"b.
    • tests/api/hono/src/lib/dashboard/secret-sink.test.ts > a fixture stack's reveal runs no security and shows the made-up value.
    • tests/api/hono/src/lib/keychain.test.ts:
      • keychainSpec takes any playground- service and refuses github.com, Chrome Safe Storage, toString and others.
      • A typed item keeps its check.
      • An untyped value's limits hold.
      • A write goes on stdin and a read on stdout, with no value in an argv.
    • tests/api/hono/src/lib/relay/protocol.test.ts: the relay carries neither secret route by any method, and neither RELAY_GETS nor the paired key's signed RELAY_WRITES names either one. A reveal card is not in RELAY_ANSWER_KINDS, so Yard cannot answer one either.
    • tests/api/hono/src/lib/dashboard/inbox.test.ts, round 1:
      • A reveal card's reply, or an answer's note, that holds the value reads That is the card's value: it stays out of a comment, sends nothing and leaves no table holding it, while other words go.
      • Its Edit is refused.
      • A comment on another card never reads the Keychain.
    • tests/api/hono/src/lib/base-app.test.ts: the request log every build mounts writes POST /api/v1/inbox/i1/reveal and none of the response's value.
    • tests/web/next/src/components/dashboard/read-only.test.ts pins the reveal's !config.hosted gate beside the secret field's.
    • Every touched test file, with check-types clean: 578 pass, 0 fail across 15 files (with hosted.test.ts, and the relay's mac.test.ts and relay-write.test.ts), at the merge of canary 752a41d.
  • CI at 1af2e05 (run 42122911094): Suite (7) failed in tests/scripts/ops/run-slot-gauges-lock.test.ts (Expected: 1, Received: 2), the known flake run-slot gauges lock test flakes: eight looks with no reading run top 6 times #956, which this PR does not touch, and Audit, Lint, and Build failed on that suite alone. The head after it reruns CI.
  • Docs, each checked by doc-sync and a writing-for-agents read:
    • .agents/dashboard/asks.md:
      • the routes line names /reveal;
      • preview kinds go from eleven to twelve with reveal;
      • the keychain sink's allowlist now names keychainSpec;
      • the refuseAgent line;
      • a new reveal card entry with its ask new example;
      • the hosted Inbox's rule names the reveal's Show and Copy.
    • .agents/dashboard/writes.md: Show and Copy are confirmations, and refuseAgent is on both secret writes.
    • extensions/core/skills/to-inbox/SKILL.md and docs/playground/to-inbox.md: a reveal card's row.
    • web/next/content/docs/inbox.mdx: Give a secret names playground- items and the Show a secret card.
    • packages/scripts/src/verify/dashboard-map.json: POST /api/v1/inbox/:id/reveal.
    • extensions/core/rules/ask.md: the secret line now names keychainSpec (round 1).
    • List grep for the preview types (grant.{0,20}schema.{0,20}secret and others): asks.md:296's link-free list alone, which now names reveal.
    • Changed-term grep for keychain, KEYCHAIN_ITEMS and secret ask over the docs: the hits in yard.md, the Slack extension and setup.md name the slack and yard items, which keep their names and checks.

Merge Danger

Door: two-way

The change is a new card type, a new read route and a wider keychain allowlist, with no schema change. A revert removes the card and the route. A card posted before the revert stays in the Inbox, and its preview no longer parses.

Blast Radius: Inbox

Any session can now post a card that shows a playground- Keychain item, such as the Claude login or GLM keys. The value still moves only on the owner's click on this Mac, the card names the item in full, and a closed card reads nothing. A value shown in the dashboard's page sits in that browser's memory for up to 30 seconds, so a session driving that same browser could read it. Playground's sessions drive their own headless browsers, never the owner's.

Links

🤖 Generated with Claude Code

A reveal card names one of Playground's own Keychain items by its full
service and why; its Show draws the value for 30 seconds and its Copy
puts it on the clipboard, each fetched by POST /api/v1/inbox/:id/reveal
in that one response and stored nowhere. A secret ask's keychain sink
takes any playground- service too. Both secret routes refuse the agent
sign-in, and reveal refuses a closed or archived card.
@nrjdalal

nrjdalal commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Shots from a fixture stack at 912a686, taken in headless Chrome at a 1280x900 viewport. The machine and every value shown are made up.

There is no before shot, since canary has no such card: canary's own inboxInputSchema refuses a reveal card, and refuses a secret ask that saves to playground-glm (see the body's Evidence).

  • After, masked: the Show a secret card names playground-glm and its why. Its value reads as dots, with Show and Copy.
  • After, shown: Show draws the fixture's made-up value and turns to Hide. The value hid itself again 30 seconds later.
  • After, the secret ask: it saves to playground-glm, an item outside the old slack and yard pair.

After: the reveal card, masked

After: Show draws the fixture value

After: a secret ask saving to playground-glm

@nrjdalal

nrjdalal commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

ReviewBot · round 1: 4/5 at 03660f1 · 0 blocking, 4 should-fix, 2 nits

Round 1 at 03660f1 (canary 03e3db6 merged in, clean; the PR's own commits 912a686 and 97b32a3). Read against issue #1023's build, limits and Done when, the plan the Planner and the builder approved, and the Planner's three points.

Spec (the right thing)

  • Build 1, ask for a secret into any playground- item: delivered (keychainSpec, api/hono/src/lib/keychain-items.ts:31; secretSinkSchema, api/hono/src/lib/dashboard/inbox.ts:164). The item is named by its service alone; see the first should-fix.

  • Build 2, Show and Copy: delivered (RevealField, web/next/src/components/dashboard/inbox.tsx:496; POST /api/v1/inbox/:id/reveal, api/hono/src/routers/inbox.ts:300; revealSecret, api/hono/src/lib/dashboard/inbox.ts:1899). Hide, a fold (preview = folded ? null, inbox.tsx:988, unmounts the field), an answer or archive (the status === "open" && !isArchived guard) and the 30 s timer each clear it.

  • L1 / P1, the value stays between the Keychain and the click: delivered in the code, partial in the tests: the row, the version, every table and a console spy hold none of it (store tests); argv is held for both read and write (keychain.test.ts); no test or Evidence line covers the route's response log or a WebSocket frame. See the first should-fix.

  • L2 / P2, loopback, trusted origin and the owner rung, and the agent's 403: delivered (refuseAgent runs after the router's requireConsoleRole("owner"), routers/inbox.ts:79; route tests for origin, loopback and no session on /reveal; refuseAgent in both routes' handler lists).

  • L3, only the owner's click moves the value: partial, see the second should-fix (a Copy pasted into the card's own comment box reaches the poster).

  • P3, relay: delivered (protocol.test.ts: neither route by GET or POST, RELAY_GETS names neither). feat(PG-1010): send a message and answer an Inbox card from Yard, signed by the paired key #1044 is still open, so the RELAY_WRITES line the builder asked for waits on whichever lands second.

  • P4, a closed, dismissed or archived card reads nothing: delivered (409 before any read; the store test also proves the reader is never called).

  • P5, a fixture or test stack runs no security: delivered (secret-sink.test.ts).

  • D4, dashboard-map.json, writes.md, asks.md, to-inbox, inbox.mdx: delivered, but the changed-term grep missed one rule file; see the third should-fix.

  • [should-fix] PR body, Summary: two departures from the issue are not named, beside the two it names. (1) The issue says "A card names a Keychain item by service and account", and the approved plan kept an account beside the service; the build names the item by its service alone (item on the sink, service on the reveal card) and always reads and writes as the signed-in user. (2) Done when: "A test holds each limit: no value in the row, the response log, the WebSocket frame or argv". The row and argv each have one; the console spy wraps store.revealSecret, so no test runs either route through Hono's logger(), and no test reads a WebSocket frame. Fix: either add those two tests, or name both as departures in the Summary with how each limit holds by construction (the dashboard socket sends only workers, entries, activity and error frames, api/hono/src/routers/dashboard.ts:1240, and revealSecret takes no deliver and leaves the row's version as it was; the route logs nothing itself and logger() writes the method, path and status), so the builder can rule on them. (reasoned; the frame types and the logger's mount read from the head)

  • [should-fix] web/next/src/components/dashboard/inbox.tsx:957: L3 "Only the owner's click reads or writes the value". Copy puts the value on the clipboard right beside the card's own comment box, whose Reply and note go to the poster's transcript; the secret ask guards the same box (commentRefused, web/next/src/lib/inbox.ts:57), the reveal card does not. Fix: a Reply, note or Edit text on a reveal card that holds the item's value is refused before it is sent, as a secret ask's Reply is, for a value the card never holds after Copy too (for example the answer and reply routes reading the item through readFromKeychain() and refusing a text that contains it), and Edit is refused on a reveal card as on apps and grant (api/hono/src/lib/dashboard/inbox.ts:768), since its two choices take no words; or, if the builder rules the paste the owner's own act, say so in Merge Danger. (reasoned)

Standards (built well)

  • [should-fix] extensions/core/rules/ask.md:9 still says a secret goes to "a Keychain item KEYCHAIN_ITEMS allows"; the allowlist is now keychainSpec, which takes any playground- item. AGENTS.md: keep docs in sync, and Shipping step 1's changed-term grep (the body's grep for KEYCHAIN_ITEMS names yard.md, the Slack extension and setup.md alone). Fix: the patch below. (measured: git grep -n "KEYCHAIN_ITEMS. allows" hits this line alone)
  • [should-fix] web/next/src/components/dashboard/inbox.tsx:491: Preview's own comment ("Exactly what Approve does, the card's description ...") now sits above REVEAL_SHOWN_MS, two blocks before Preview at line 549, so Preview has none and the constant has two. Fix: the patch below moves it back over Preview. (measured)
  • [nit] api/hono/src/lib/dashboard/inbox.ts:311: previewSchema's comment names a secret but not a reveal. The patch below adds one clause.
  • [nit] REVEAL_SHOWN_MS is exported and read nowhere else (git grep -n REVEAL_SHOWN_MS hits the file and asks.md); take or leave.

Checks at 03660f1, read right before this post: test and Lint and Build green; Suite (2, clock ahead) failed once on tests/api/hono/src/lib/dev/stop.test.ts ("leaves a newer launcher's record that arrives while it stops the old one", a timing case this PR does not touch) and is running again.

Patch (wording only)

Covers the two Standards should-fixes and the first nit. Checked in the clone: scripts/check-em-dashes.sh clean; bun run test:file on tests/packages/scripts/src/trace-count/count.test.ts, tests/scripts/hooks/rules.test.ts, tests/extensions/core/skills/playbook/core.test.ts and tests/AGENTS.test.ts: 73 pass, 0 fail; then restored, and git apply --check passes at 03660f1.

diff --git a/api/hono/src/lib/dashboard/inbox.ts b/api/hono/src/lib/dashboard/inbox.ts
index d77759b3..9e9d911e 100644
--- a/api/hono/src/lib/dashboard/inbox.ts
+++ b/api/hono/src/lib/dashboard/inbox.ts
@@ -308,7 +308,7 @@ export type KeychainRead = (item: string) => Promise<string | null>
 
 const OPTIONS_MESSAGE = `An ask offers 1 to ${MAX_OPTIONS} options, each with a label: one for a yes or no, two or more for lettered choices`
 
-// What Approve does, one shape per kind of approval the owner keeps (playbook's ask rule): an extension accept, an apps change or a grant's System Settings pane, which the dashboard carries out itself, a Slack message, a ship of canary, a new Linear ticket, a delete, a schema, a budget, or a plain ask, whose words say what its poster does on Approve, for a decision no other shape fits (a production port, a work request from outside the chain, or a step only he can take, such as plugging in the Mac, where Approve means done and Deny means he can't). An ask labels its options in ELI5 / ASD-STE100, one line each: one option and deny for a yes or no, or 2 to 5 for lettered choices, A to E, then None of these (choicesOf); the card shows its text as the quoted block above them. A secret asks for a value instead of a yes, which goes straight to its sink and is kept nowhere.
+// What Approve does, one shape per kind of approval the owner keeps (playbook's ask rule): an extension accept, an apps change or a grant's System Settings pane, which the dashboard carries out itself, a Slack message, a ship of canary, a new Linear ticket, a delete, a schema, a budget, or a plain ask, whose words say what its poster does on Approve, for a decision no other shape fits (a production port, a work request from outside the chain, or a step only he can take, such as plugging in the Mac, where Approve means done and Deny means he can't). An ask labels its options in ELI5 / ASD-STE100, one line each: one option and deny for a yes or no, or 2 to 5 for lettered choices, A to E, then None of these (choicesOf); the card shows its text as the quoted block above them. A secret asks for a value instead of a yes, which goes straight to its sink and is kept nowhere. A reveal shows the owner a Keychain item's value on the owner's click instead, kept nowhere either.
 export const previewSchema = z.discriminatedUnion("type", [
   z.strictObject({
     approve: oneLine(LIMITS.line).optional(),
diff --git a/extensions/core/rules/ask.md b/extensions/core/rules/ask.md
index c6e06e5f..40d26806 100644
--- a/extensions/core/rules/ask.md
+++ b/extensions/core/rules/ask.md
@@ -6,7 +6,7 @@ Decide every two-way door yourself and record it; only a one-way door goes to th
 The owner reads the Inbox, not the chat, so a question asked in chat goes unanswered, and a session waiting on one holds up the factory.
 A two-way door is a choice that can be undone (a design, an order, scope inside an approved ask, upkeep of our own tools with a backup first, a recovery a machine can finish): take the recommendation, record it in an ADR when its reason should outlast the work, else on the issue, and tell the owner only when it changes what the owner sees or does.
 A one-way door is a send that commits the owner to something new, an approval in the owner's name, a ship outside the standing yes, a new ticket, an irreversible delete or breaking change, a breaking schema or production data change, or a budget, each one decision whose preview is exactly what Approve does.
-A step only the owner's hands can take (a login, a plug, a key) goes as a plain ask whose text is that step, and a secret value the owner holds goes to a Vercel project or a Keychain item `KEYCHAIN_ITEMS` allows through a `secret` ask, whose card hands it straight to its sink, or anywhere else where the item says (1Password or a file), so it never reaches chat or any stored row.
+A step only the owner's hands can take (a login, a plug, a key) goes as a plain ask whose text is that step, and a secret value the owner holds goes to a Vercel project or a Keychain item `keychainSpec` allows (Slack's, Yard's or another `playground-` item) through a `secret` ask, whose card hands it straight to its sink, or anywhere else where the item says (1Password or a file), so it never reaches chat or any stored row.
 A message to a developer about their work posts directly, while a message to anyone else, and a post in a public channel other than the review channel, comes to the Inbox as a draft first; the org's own skill names who is who.
 An ask to quit, restart, close or stop something posts only once nothing depends on it, its `checked` line saying what the four checks found, since a stop made on a session's word can take the Dashboard down with it.
 One decision is one item and one thread is one card: a sub-choice goes in the main item's preview, progress edits the item in place, and a later outcome arrives as an update.
diff --git a/web/next/src/components/dashboard/inbox.tsx b/web/next/src/components/dashboard/inbox.tsx
index ca4268ff..36e91ffd 100644
--- a/web/next/src/components/dashboard/inbox.tsx
+++ b/web/next/src/components/dashboard/inbox.tsx
@@ -488,7 +488,6 @@ function Shown({ preview }: { preview: Pick<InboxUpdate, "attachments" | "text">
   )
 }
 
-// Exactly what Approve does, the card's description: our own words around the session's, a Slack message's plain text that hides nothing and an ask's rendered as its chat would show them.
 // How long a shown value stays on screen before it hides itself.
 export const REVEAL_SHOWN_MS = 30_000
 
@@ -546,6 +545,7 @@ function RevealField({ id }: { id: string }) {
   )
 }
 
+// Exactly what Approve does, the card's description: our own words around the session's, a Slack message's plain text that hides nothing and an ask's rendered as its chat would show them.
 function Preview({ item }: { item: Extract<InboxItem, { kind: "decision" }> }) {
   const preview = item.preview
   switch (preview.type) {

Threads

None open; this round opens one per finding with a line in the diff.

Checked

  • Read in full: the PR's own diff (20 files) at 03660f1, the issue, the plan, the PR's history; routers/inbox.ts guards, routers/dashboard.ts's socket, answerItem's refusals, getItem, copyToClipboard.
  • Ran: the brief's harness from the clone with no .env and Docker named: routers/inbox.test.ts, lib/keychain.test.ts, lib/dashboard/secret-sink.test.ts, lib/relay/protocol.test.ts and lib/dashboard/inbox.test.ts: 207 pass, 0 fail, 0 skipped. A first run gave 1 fail from a stale packages/config/dist in my reused clone (built before reveal joined defaultLabels); after rebuilding it, all green. The fault was my clone's, not the PR's.
  • Greps: the list grep for the preview kinds (asks.md:228 and :297 both name reveal; packages/config/src/inbox.ts's union and labels, DECISION_ICONS and kindWord hold it); the changed-term grep for KEYCHAIN_ITEMS and "Keychain item" over the docs (ask.md:9 above).

Not verified

  • No browser run of my own; the worker's shots stand for the card.
  • Safari's clipboard after an awaited fetch (Copy writes after the reveal's response): reasoned to work in Chrome, not tried elsewhere.

ReviewBot · round 1. Push back on any finding you think is wrong.

@nrjdalal nrjdalal left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewBot round 1 inline findings at 03660f1; the verdict is the round's comment.

…ts Reply stays in its box

A reply or an answer's note on a reveal card that holds the card's value is
refused before anything is sent, a reveal card's Edit is refused, and its
Reply keeps to its own box rather than the chat's composer. ask.md names
keychainSpec, and a test shows the request log holds no reveal's value.
@nrjdalal

nrjdalal commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

ReviewBot · round 2: 3/5 at 1af2e05 · 1 blocking, 0 should-fix, 2 nits

Round 2 at 1af2e05: the PR's own 7fc4369, a merge of canary c1ca5a4 (32e767f, one conflict, the imports in inbox.tsx), then a clean merge of canary c79b571 (1af2e05, whose remerge diff is empty). Every round 1 should-fix is closed; the one blocking finding is in how the merge was made, not in what it does.

Spec (the right thing)

  • Round 1's departures: closed. The Summary now names the account and the WebSocket frame as departures, with how each holds, and tests/api/hono/src/lib/base-app.test.ts covers the request log (the logger writes POST /api/v1/inbox/i1/reveal and none of the value).
  • Round 1's paste into the card's own box: closed. holdsRevealed (api/hono/src/lib/dashboard/inbox.ts:1934) refuses a reply or an answer's note that holds the value, before any delivery; Edit is refused (:769); keepsReply keeps a reveal card's Reply in its own box. The store test drives replyItem and answerItem with a reader and finds nothing sent and no row holding the value, and a comment on any other card reads no Keychain.
  • The merge's hosted rule (feat(PG-1008): the hosted dashboard is read-only, and a returning tab reads the relay again #1054): Show and Copy are hidden under config.hosted, since the hosted relay carries no POST; the Summary says so. Right behaviour; see the blocking finding for where it lives.
  • Every other spec line stands as round 1 marked it.

Standards (built well)

  • [blocking] Merge 32e767f holds edits of the PR's own outside its one conflict: git show --remerge-diff 32e767f lists hunks in .agents/dashboard/asks.md:509 (the hosted Inbox line gains "reveal's Show or Copy") and tests/web/next/src/components/dashboard/read-only.test.ts:44 (a new pinned row), both files outside the merge's Conflicts list (git merge-tree --write-tree --name-only 7fc4369 c1ca5a4 names inbox.tsx alone), and in inbox.tsx a !config.hosted hunk at the RevealField line beside the import conflict it resolved. code-review-loop's step 2 scores an edit made inside a merge as blocking, since pr:merge's keeps-canary gate (packages/scripts/src/pr-merge/README.md) refuses the squash when such a hunk drops a line canary added; the asks.md hunk rewrites the hosted line feat(PG-1008): the hosted dashboard is read-only, and a returning tab reads the relay again #1054 added, which that gate passes only while the rewrite stays 80% alike, and an edit hidden in a merge is one no git log --no-merges read of the PR's own commits shows. Fix, as that README gives it: reset the branch to 7fc4369, merge origin/canary (now 539537e) plainly, resolving only its conflicts, then put the three hosted edits in a commit of the PR's own after the merge, and push with --force-with-lease; 1af2e05's own merge is clean and drops away in that redo. (measured: the remerge diff and the merge-tree list above)
  • [nit] holdsRevealed runs before the card's status is checked, so a note on an answered or archived reveal card still reads the Keychain once (answerItem, inbox.ts:1372). Its result is a yes or no, never the value, and a full guess is the only thing it confirms; take or leave.
  • [nit] Round 1's REVEAL_SHOWN_MS export, left as it was; take or leave.

Checks at 1af2e05, read right before this post: GitGuardian green, test queued, the build not yet started. At 32e767f, Lint and Build was green and test was running.

Threads

Closed 2 at 7fc4369 (the moved Preview comment and the paste into the card's box). One stays open, round 1's REVEAL_SHOWN_MS nit.

Checked

  • Read in full: git log --no-merges -p 03660f1..1af2e05 ^origin/canary (7fc4369), git show --remerge-diff of 32e767f and of 1af2e05 (empty); the PR body's Summary, its departures and its hosted line (hosted.ts mounts no /reveal or /secret).
  • Ran, at 32e767f (1af2e05 adds only canary's c79b571, clean): the brief's harness plus base-app.test.ts, the web inbox.test.ts, read-only.test.ts and AGENTS.test.ts, from the clone with no .env and Docker named: 337 pass, 0 fail, 0 skipped.
  • Canary has moved to 539537e since this head (feat(PG-1039): no-playground-leak refuses a gated org's Slack posts but its self-DM #1053, a hook); git merge-tree --write-tree --name-only 1af2e05 origin/canary merges clean.

Not verified

  • No browser run of my own; the hosted build's card was read from the source and read-only.test.ts, not drawn.

ReviewBot · round 2. Push back on any finding you think is wrong.

@nrjdalal nrjdalal left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewBot round 2 inline findings at 1af2e05; the verdict is the round's comment.

Comment thread .agents/dashboard/asks.md Resolved
Comment thread api/hono/src/lib/dashboard/inbox.ts Resolved
# Conflicts:
#	web/next/src/components/dashboard/inbox.tsx
Show and Copy fetch the value through POST /reveal outside settle(), which the hosted build
stands down, and its relay carries no write, so the hosted Inbox draws neither. read-only.test
pins the gate and asks.md's hosted rule names the two buttons. REVEAL_SHOWN_MS is no longer
exported, since only its own file reads it.
@nrjdalal

nrjdalal commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

ReviewBot · round 3: 5/5 at 9362085 · 0 blocking, 0 should-fix, 3 nits

Round 3 at 9362085, force-pushed from 1af2e05: the PR's own 7fc4369, a plain merge of canary fe8cb9b (c5bfeee, whose remerge diff drops the import conflict's markers and nothing else), then the PR's own 9362085 with the three hosted edits.

Spec (the right thing)

  • Build 1, a secret ask into any playground- item: delivered (keychainSpec, api/hono/src/lib/keychain-items.ts:31).
  • Build 2, Show and Copy: delivered (RevealField, web/next/src/components/dashboard/inbox.tsx; POST /api/v1/inbox/:id/reveal; revealSecret). Hide, a fold, an answer or archive and the 30 s timer each clear it.
  • L1 / P1: delivered. Tests hold the row, every table, the store's console, the request log (tests/api/hono/src/lib/base-app.test.ts) and argv; the WebSocket holds by construction, a departure the Summary names with its reason. A reply or note holding the value is refused before any delivery (holdsRevealed), Edit is refused, and Reply keeps to the card's box.
  • L2 / P2: delivered (the router-wide owner rung, which tests/api/hono/src/routers/guards.test.ts holds for every dashboard router; origin, loopback and session tests on /reveal; refuseAgent on both routes after the rung).
  • L3: delivered; P3 (relay), P4 (closed, dismissed, archived) and P5 (no security on a fixture or test stack): delivered, each with its test.
  • Hosted: the hosted build draws no Show or Copy (!config.hosted, pinned by read-only.test.ts), and hosted.ts mounts neither route.
  • D4 docs: delivered (dashboard-map.json, writes.md, asks.md, to-inbox and its docs page, inbox.mdx, and extensions/core/rules/ask.md).
  • Departures, each named in the Summary with its reason: no row write on a reveal, the fixture's made-up value, the service alone with no account, and the WebSocket frame by construction.
  • feat(PG-1010): send a message and answer an Inbox card from Yard, signed by the paired key #1044's RELAY_WRITES assertion: not yet due; the Planner's order lands feat(PG-1010): send a message and answer an Inbox card from Yard, signed by the paired key #1044 first, and that merge returns here for a round.

Standards (built well)

  • [nit] api/hono/src/lib/keychain.ts:39: a read keeps the write's shape check, so an existing playground- item whose value has a quote, a backslash, non-ASCII text or edge spaces reads as That Keychain item holds no value. KeychainRead's comment says "null when it holds none of its shape", so this is deliberate; a message naming the shape would read truer. Take or leave. (reasoned)
  • [nit] The reveal option on answerItem and replyItem is a Keychain reader; a name such as keychainRead would say so. Take or leave.
  • [nit] Round 2's holdsRevealed read before the status check: the author's reason holds (a Reply goes on an answered card too, .agents/dashboard/asks.md:114), so it stays as written; listed here, its thread resolved.

The fresh full read (both axes, from the merge base fe8cb9b over the whole diff) found nothing blocking or should-fix; its nits are listed above for the worker to take or leave.

check-types: PR CI green at 9362085 on canary fe8cb9b
touched tests: PR CI green at 9362085 on canary fe8cb9b: https://github.com/nrjdalal/playground/actions/runs/42125250618 (test green too, run 42125250617; canary has not moved since that run base)

Threads

Closed 3 at 9362085: round 2's merge edits (now commit 9362085 of the PR's own), round 1's REVEAL_SHOWN_MS export (unexported in 9362085), and round 2's read-order nit (the author is right). None stay open.

Checked

  • Read in full: git show --remerge-diff c5bfeee and git show 9362085; git diff 1af2e05 9362085 (canary's own change plus the export); the whole diff from fe8cb9b in two fresh sub-agent reads; keychain.ts's read; guards.test.ts's owner-rung walk.
  • Ran in round 2, at 32e767f: the brief's harness plus the new tests, 337 pass, 0 fail, 0 skipped.

Not verified

  • No browser run of my own; the worker's shots stand for the card.

ReviewBot · round 3. Push back on any finding you think is wrong.

# Conflicts:
#	tests/api/hono/src/lib/relay/protocol.test.ts
#	tests/web/next/src/components/dashboard/read-only.test.ts
RELAY_WRITES, the writes a paired key signs, matches neither the secret ask's Submit nor a
reveal card's Show by path, and names no secret or reveal route, beside the reads' check.
nrjdalal added a commit that referenced this pull request Nov 5, 2026
…buntu-26.04 (#1076)

## TL;DR

- 🧪 The run-slot lock tests stop failing now and then on GitHub's Linux runner. One such failure held up a ship.
- 🔍 The cause was the runner's own `mkdir` command, which can tell two programs that each made the same folder. The tests now use the runner's other `mkdir`, which tells only one, as the Mac's does.

## Summary

```diff
 tests/scripts/ops/run-slot-fake.ts, machine()
   slowChild("python3", bin)
+  if (GNU_MKDIR)             // Bun.which("gnumkdir"): ubuntu-26.04 only
+    bin/mkdir = exec gnumkdir "$@"
```

- GitHub's `ubuntu-latest` is ubuntu-26.04, whose `/usr/bin/mkdir` is uutils coreutils 0.10.0 (`rust-coreutils` 0.10.0-1ubuntu2~26.04.1). Its `create_dir` checks `path.exists()`, then maps a failed `mkdir(2)` to success with `Err(_) if path.is_dir() => Ok(())`, even without `-p`. So when two processes race, both exit 0.
- run-slot.sh takes every lock with a plain `mkdir`: the gauges refresh lock, the run slots and the model slots. On that runner, two looks can each hold the gauges lock and both run `top`: "eight looks with no reading yet run top once" got 6.
- The image keeps GNU's mkdir as `gnumkdir`, and each fake machine's bin now runs that one wherever it exists. On the Mac and on GNU images nothing changes.
- `.agents/testing.md`, Traps: one new trap, the racing `mkdir` and how a test avoids it.

**Decisions:**
- **The fix is in the test, not in run-slot.sh.** run-slot.sh runs for real only on the Mac, whose BSD `mkdir` is atomic: 60 rounds of the eight-look case there each ran one `top`. Only CI runs the script on Linux.
- **The dead-lock takeover case stays out of this PR.** It is a separate race, not yet reproduced alone (Evidence, #959's row), now #1077, so this PR keeps to the two "run top once" cases #956 names.
- **The reported lead is refuted.** The lead was that a reading stamped with the look's start time goes stale after a refresh slower than 10 s. The red run took 1.6 s, and six one-second tops in 1.6 s means they ran side by side. A stale stamp cannot do that, while two lock holders can.
- **Nothing is posted upstream.** uutils fixed this in 0.11.0 (uutils/coreutils#13970, uutils/coreutils#13986), and Ubuntu's request to bring that fix to 26.04 (LP #2171333) already has a patch waiting for a sponsor. A comment would add no new facts. Once 26.04 takes that patch, the shim is harmless and can go.

## Evidence

- **Cause, in an `ubuntu:26.04` container:** 8 racing `mkdir L`, 200 rounds each:

  | mkdir | rounds with more than one winner |
  | --- | --- |
  | `/usr/bin/mkdir` (uutils 0.10.0) | 33 of 200, and 38 of 200 on a second run |
  | `gnumkdir` (GNU 9.7) | 0 of 200 |

- **No repro without uutils:** the eight-look case ran exactly one `top` in 60 of 60 rounds on the Mac (BSD mkdir) and 80 of 80 on `oven/bun:debian` (GNU mkdir, bash 5.2).
- **Before / after in `ubuntu:26.04`** (`--cpus 2`, Linux bun 1.4.4):

  | run | canary's fake | this head's fake |
  | --- | --- | --- |
  | probe: 60 rounds of 8 looks with no reading, rounds that ran `top` more than once | 10 of 60 (2 or 3 tops) | 0 of 60 |
  | `run-slot-gauges-lock.test.ts`, 20 runs, the two "run top once" cases | failed in 6 of 20: the stale-reading case (line 46) in runs 1, 14 and 15, the no-reading case (line 52) in runs 5, 7 and 13 | failed in 0 of 20 |
- **At this head on the Mac:** 20 runs in a row of `bun run test:file tests/scripts/ops/run-slot-gauges-lock.test.ts`: 20 of 20 passed, each 10 pass, 0 fail. `bun run check-types:tests`: no errors. `bun run test:skills`: all 13 passed.
- **Sightings on CI:**
  - canary at d216f23, Suite (8): "eight looks with no reading yet run top once", expected 1, received 6, in 1631 ms.
  - #1063 at 1af2e05, Suite (7) (job 127580442051), ubuntu-26.04: "eight looks at a stale reading run top once", expected 1, received 2, in 1526 ms. The same case fails 3 of 20 on canary's fake above and 0 of 20 at this head.
  - #959 at 3006e0a, attempt 1, Suite (5) (job 127180040487): line 124 of the same file, "eight looks at a dead lock together each read the stale or the new reading", expected true, received false, in 1627 ms.
    **Not this race, and not fixed here.** That case still failed 1 of 20 runs at this head in `ubuntu:26.04` (run 12, line 125), with GNU's `mkdir` in the fake. A probe of the case alone found 0 bad rounds in 60 with GNU's `mkdir` and 0 in 60 with all 104 `gnu*` tools on PATH, so no tool swap is shown to fix it. It is #1077.
- **doc-sync:** the change touches the test fake alone. `git grep -n -iE "mkdir"` over `.agents`, `scripts/README.md`, `scripts/ops/README.md`, `scripts/hooks/README.md`, `docs` and the core skills finds no line that calls `mkdir` atomic. The new trap in `.agents/testing.md` is the one place that says it is not on ubuntu-26.04.

## Merge Danger

**Door:** two-way

A revert brings back the uutils `mkdir` in the fake, and the flake with it.

**Blast Radius:** small

Tests only: every `run-slot*.test.ts` machine on a runner that has `gnumkdir`. run-slot.sh and every other script are unchanged.

## Links

- Closes #956
- uutils/coreutils#13970 (the report) and uutils/coreutils#13986 (the fix, in 0.11.0)
- #1077: the dead-lock takeover race, left out of this PR
- Ubuntu: https://bugs.launchpad.net/ubuntu/+source/rust-coreutils/+bug/2171333

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@nrjdalal

nrjdalal commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

ReviewBot · round 4: 5/5 at 1406197 · 0 blocking, 0 should-fix, 4 nits

Round 4 at 1406197: a plain merge of canary 752a41d (dd48185, #1044 in; two conflicts, both test files, each kept both sides, and git show --remerge-diff dd48185 touches those two files alone), then the PR's own 1406197, the RELAY_WRITES assertion the builder asked for once #1044 landed.

Spec (the right thing)

  • P3 with feat(PG-1010): send a message and answer an Inbox card from Yard, signed by the paired key #1044 in: delivered. tests/api/hono/src/lib/relay/protocol.test.ts:75 holds pathOn(RELAY_WRITES, ...) false for /inbox/i1/secret and /inbox/i1/reveal and no RELAY_WRITES entry naming either; pathOn turns each :id into a pattern, so a secret or reveal entry added later would turn it red.
  • Yard and a reveal card: reveal is off RELAY_ANSWER_KINDS (packages/message/src/index.ts:40, which protocol.test.ts pins whole), so the Mac refuses a relayed answer, schedule, dismiss or re #iN reply on one before any route runs (api/hono/src/lib/relay/mac.ts:158), and the hosted Inbox draws it with no answers and no Show or Copy (!config.hosted, pinned by read-only.test.ts).
  • Every other spec line stands as round 3 marked it, delivered, with the same four departures named in the Summary.

Standards (built well)

  • [nit] packages/message/src/index.ts:39: the comment names the kinds that stay on the Mac (an accept, an apps change, a grant, a delete, a secret ask) and covers "one added later" without naming the reveal card; a clause such as "and a reveal card, whose value shows on the Mac's own click" would list it, and protocol.test.ts:126's loop could take "reveal" beside "secret". Take or leave. (reasoned)
  • [nit] holdsRevealed matches the value as a substring, so a one- or two-character item would refuse most comments; a limit of a best-effort guard, not a leak. Take or leave. (reasoned)
  • [nit] Round 3's two, still open to take or leave: api/hono/src/lib/keychain.ts:39's read answers "holds no value" for an off-shape item, and the reveal option on answerItem and replyItem could read keychainRead.

The fresh full read (both axes, from the merge base 752a41d over the whole diff) found nothing blocking or should-fix. Two Spec-axis notes were checked and stand as they are: the owner-rung refusal is held router-wide by tests/api/hono/src/routers/guards.test.ts, and a message the owner types in the chat composer is the owner's own, with Reply on a reveal card kept to its box (keepsReply).

check-types: PR CI green at 1406197 on canary 25501e1
touched tests: PR CI green at 1406197 on canary 25501e1: https://github.com/nrjdalal/playground/actions/runs/42131270383 (test green too, run 42131270374; canary has since moved to 87d60fd, #1076, which changes none of this PR's files, and the PR adds no repo scanner)

Threads

None open.

Checked

Not verified

  • No browser run of my own; the worker's shots stand for the card.

ReviewBot · round 4. Push back on any finding you think is wrong.

@nrjdalal
nrjdalal marked this pull request as ready for review November 5, 2026 23:54
@nrjdalal
nrjdalal merged commit 32c8d56 into canary Nov 5, 2026
19 checks passed
@nrjdalal
nrjdalal deleted the PG-1023-secret-cards branch November 5, 2026 23:55
@nrjdalal

nrjdalal commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

Merged by bun run pr:merge at 1406197.

  • open: open
  • author: by nrjdalal
  • held: not in held-prs.json
  • ready: ready for review
  • base: into canary
  • up to date: canary has 2 commits 1406197 lacks; it merges clean with canary 87d60fd
  • keeps canary: no merge of canary into the PR dropped a line git's own merge keeps
  • ci: 19 checks passed
  • body: the PR template
  • review: ## ReviewBot · round 4: 5/5 at 1406197 · 0 blocking, 0 should-fix, 4 nits
  • types: the merged tree c5d899b (feat(PG-1023): an Inbox card shows a Keychain value on the owner's click #1063 at 1406197 onto canary 87d60fd) type-checks: bundles 0s, check-types 8s, build 0s, metrics 0s, check-types:tests 6s

nrjdalal added a commit that referenced this pull request Nov 6, 2026
…d battery.ts (#1127)

## TL;DR

- The battery check now shows ask's own refusal when an Inbox post fails, not bun's "script exited" line. A `hold=` on a roster row that is not parked now stops with a reason, where before it was dropped without a word.
- Six smaller review notes from merged PRs land with them: a relay comment, a reveal-card guard limit, one shared test type, two plainer lines in the stack rules, and a test comment that says what its row guards.

## Summary

The review nits left on #1063, #1004, #1079, #1093, #1120 and `battery.ts`, as #1102 lists them:

```text
packages/message/src/index.ts:39            the stay-on-the-Mac list names the reveal card
api/hono/src/lib/dashboard/inbox.ts         holdsRevealed matches a value of 8+ characters (REVEAL_MATCH_MIN)
tests/.../ops-tick/fake-run.ts              exports Answers; three tests use it
.agents/dashboard/stack.md:29-30            "the CA stays when the trash refuses its leaves"; all four shareCa() false answers
packages/scripts/src/ops-awake/battery.ts   bun run --silent ... ask
scripts/roster.ts parkHold                  hold=<ref> on a row left not parked: refused, naming status=parked
tests/packages/settings/src/items.test.ts   the other-item rev row's comment says what it guards
```

```diff
 parkHold(entry, pairs, moved)
   if entry ends not parked
+    if the call names a non-empty hold
+      refuse: "... so add status=parked to the same call"
     drop its hold
```

### Decisions

- **REVEAL_MATCH_MIN = 8 narrows what the reveal guard matches.** A `playground-` item may hold a value of 1 character (`ANY_VALUE` in `keychain-items.ts`), and a substring match of a short value would refuse most comments on its card. So a value under 8 characters is no match, and a comment holding it goes. A short value can then reach its poster in a comment: the guard is a best-effort catch of a pasted Copy, and its card still shows the value only on the owner's click.
- A call that leaves the park and names a hold (`status=working hold=#5`) is refused too, where #1093 dropped the hold. Its test changed with it.

## Evidence

- **Before**, measured: canary's `battery.ts`, `roster.ts` and `inbox.ts` swapped in at d014737, `bun run test:file` on their three test files: 244 pass, 3 fail, exactly the new rows:
  - `ask runs under bun run --silent, so a refusal reads as ask's own line` (the argv held no `--silent`)
  - `a hold on a row the call leaves not parked is refused naming status=parked, and stores nothing` (exit 0)
  - `a reveal card's value under 8 characters refuses no comment` (the reply was refused); at 668a65c the row is `a reveal card's value refuses a comment from 8 characters on: 7 is no match`

  **After**, measured at d014737, through run-slot:
  - `bun run test:file` on battery, roster, inbox, protocol and the three ops-tick files: 355 pass, 0 fail
  - `bun run check-types` and `bun run check-types:tests`: both exit 0
  - `bun run test:skills`, since `scripts/roster.ts` is a script: exit 0, every suite passed
- **The minimum is pinned at 8**, measured at 900cc5c (round 1's should-fix): `bun run test:file tests/api/hono/src/lib/dashboard/inbox.test.ts` is 183 pass, 0 fail at the head; with `REVEAL_MATCH_MIN = 7` and with `= 9` swapped in, each is 182 pass, 1 fail, the minimum's row alone (a 7-character value `about t` goes, an 8-character `about th` is refused, on the comment `about the tab`).
- Docs, each read for its new rule with a writing-for-agents pass:
  - `.agents/dashboard/asks.md:355` and `web/next/content/docs/inbox.mdx:122`: the 8 characters, checked against `REVEAL_MATCH_MIN` in `api/hono/src/lib/dashboard/inbox.ts`
  - `extensions/core/skills/playbook/ROSTER.md:46-47`: the refusal, checked against `parkHold` in `scripts/roster.ts` and its test row
  - `.agents/dashboard/stack.md:29-30`: checked against `shareCa()`'s four `false` answers in `api/hono/src/lib/dev/proxy.ts`
- The changed-term grep for the old hold drop (`git grep "drops its .hold."` over the docs) finds ROSTER.md:46 alone, now naming the refusal; `REVEAL_MATCH_MIN` is named in inbox.ts and asks.md alone.

## Merge Danger

**Door:** two-way

Each change is small and reverts on its own.

**Blast Radius:** small

`roster.ts set` refuses one more call shape, so a lead's script that sent `hold=` to a working row now exits 2 with the fix named. A reveal card's comment holding a value under 8 characters now goes.

## Links

- Closes #1102
- Review nits from #1063, #1004, #1079 and #1093

🤖 Generated with [Claude Code](https://claude.com/claude-code)
nrjdalal added a commit that referenced this pull request Nov 6, 2026
… Your message (#1140)

## TL;DR

- ✉️ In your Inbox, a Slack draft's words now sit under **Your message**, not "Your reply". Reply means one thing: a comment back to the session that posted the card.
- 🗂️ The In progress, Later, Archive and Done rows now act by where a card sits, not by the row's words. Renaming a row changes only what it shows: Archive's ✓ still dismisses each card, and Done's ✓ still marks what is unread as read.

## Summary

```diff
 Slack draft card (web/next/src/components/dashboard/inbox.tsx)
-  So far … / Your reply / <the words to send>
+  So far … / Your message / <the words to send>

 the shelves under the list (inbox.tsx, Inbox)
-  [label, icon, cards, place]; key, value, open shelves and a link's shelf by label
-  label === "Archive" -> Dismiss all;  label === "Done" -> Mark all read, unread, muted
-  label !== "Later" && label !== "In progress" -> show the ✓
+  [place, icon, cards]; key, value, open shelves and a link's shelf by place
+  shelfRow(place, cards)        web/next/src/lib/inbox.ts
+    archived -> { bulk: { each: true, ids: every card } }
+    done     -> { bulk: { each: false, ids: the unread }, unread, muted at 0 unread }
+    progress, snoozed -> { bulk: null }
+  SHELF_LABELS[place] -> the row's words, and the ✓'s name: Dismiss all in <words>, Mark all in <words> as read
```

Two-way decisions taken here:

- The draft label reads **Your message**, matching the box's own name (`Message to <to>`); "To send" was the other choice.
- The Preview row keeps its `"Preview"` key: it is not a shelf and has no place.

## Evidence

- **Tests**, red first: with `web/next/src/lib/inbox.ts` and `inbox.tsx` back at canary, both test files fail at import (`Export named 'SHELF_LABELS' not found`), 0 pass, 2 fail, since canary has no place-keyed shelf to rename. At e0db978, 367 pass, then the packages build, web and repo `check-types` and `check-types:tests`; at the head d84fdce, every web test that reads `inbox.tsx` or `lib/inbox` (15 files) gives 571 pass, 0 fail, then the packages build and web `check-types` (repo-wide checks left to CI during the ship's load hold).

  ```text
  shelfRow   archived: every card, each in turn; done: the unread, muted at 0; progress, snoozed: no ✓; an empty shelf: no ✓
  Inbox      a whole Inbox rendered with fake cards: Archive's header names Dismiss all in Archive, Done's Mark all in Done as read
             with 1 unread, In progress and Later no ✓; Done with nothing unread reads muted, its ✓ gone
  rename     every row renamed (Bench, Tally, Forge, Drift): the same Inbox, word for word once the words are put back
  draft      So far, then Your message, then the box; never Your reply
  ```

  ib-6's and #1048's tests (`reply`, `drafts`, `outbox`, `verify reply-focus`) pass in the same run.
- **After the canary merge:** 8c32aeb takes canary to 6b24f2b with no conflict and a tree equal to `git merge-tree`'s; none of its 6 commits touches the Inbox, its tests or its docs.
- **Round 1 (3/5):** `read-only.test.ts` still pinned the old shelf guard (`shelf.length > 0 && !config.hosted`), which CI caught; it now pins `{sweep && !config.hosted && (`, and every web test that reads `inbox.tsx` or `lib/inbox` runs in this PR's harness (15 files). The behaviour rows now pin each shelf's count badge (Done's counting its read history) and Done's renamed ✓ name (`Mark all in Tally as read`), which two mutations had left green; `shelfRow`'s one click is named `sweep` in the map, since `bulk` is the Inbox's own write helper and a destructured name keeps TypeScript's null narrowing inside `onDismiss`.
- **Before:** on a fixture stack at canary 5b69245, 1280x800, a fake Slack draft card reads its words under `Your reply` ([shots](#1140 (comment))).
  **After:** the same card at e0db978 reads `Your message`. Driven in the browser: Archive's header reads `Dismiss all in Archive` and its row opens by place; Dismiss all moved the card to Done, muted at 0 unread with its ✓ gone. `verify screenshots /dashboard` at 1280x800, 1782x972 and 390x844, light and dark, in the same comment.
- **Docs and skills**, each through `bun run precommit:light` and a writing-for-agents read: `.agents/dashboard/asks.md` (Your message; a shelf's place decides its row, its words from `SHELF_LABELS`), `skills/zerostarter/design/SKILL.md` (the same, and the draft label), `web/next/content/docs/inbox.mdx` (Your message), `packages/scripts/src/verify/dashboard-map.json`. doc-sync: no other doc names the draft label or how a shelf row decides.
- **Folded in** from an earlier saved patch for #985's wording: a secret ask's Reply refuses words equal to *what the field holds*, so a value still in the field never reaches a chat through Reply (asks.md, inbox.mdx, dashboard-map.json), since `commentRefused` compares only with the field.
- **Changed-term greps** (`git grep -l -F`, tests aside): `Your reply`: 0. `label === "` / `label !== "`: no hit in the Inbox; the rest are other components' own labels. `value={label}`: 0. `Dismiss all in Archive`, `Mark all in Done as read`: asks.md and the design skill, each still the row's words. `the value itself`, `equal to the field`: no hit left about the secret ask.

## Merge Danger

**Door:** two-way

Browser only: no API, payload or stored value changes. A revert brings back the old label and the label-keyed rows.

**Blast Radius:** Inbox

Every shelf's row in the Inbox: what each one's ✓, count and muting do now comes from its place. No open state is kept across page loads, so the new keys carry nothing over.

## Links

- Closes #1074
- Builds on #978 (Later, Archive, Reply), #1006 and #1063; folds in a saved patch for #985's secret-ask wording

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Secrets go in and come out through Inbox cards

1 participant