VISUALS: pictures in and out of the family app and the Hub

The actual documents the agents read and work from, shown exactly as they are on disk — not a summary. See the progress view instead · All projects

Plan PLAN.md

# PLAN.md — VISUALS: PICTURES IN, PICTURES OUT, IN BOTH APPS

**THIS IS THE ONLY PLANNING DOCUMENT FOR THIS LANE. Do not create a second plan, tracker, summary or scratch state file — extend THIS file or the STATE.md companion beside it. Any status view is GENERATED from this plan; if a view disagrees with the plan, the plan wins.**

**NORTH STAR:** Nick, 2026-09-13, verbatim: *"we need a way for the app and hub to show images gracipcs visuals like you share here and to be able to upload files, screenshots, images, etc"*. Finished looks like this, in front of him: he drops a screenshot into the family app or the Hub and his assistant actually SEES it and answers about what is in it; and when a picture is the better answer — a chart of the month's money, a diagram of how something is wired — the picture appears in the conversation instead of being described in words.

**FINISH LINE** (written once, never raised mid-drive; each item passes its ONE check, driven by an agent, never by Nick):
- F1 A screenshot attached in the family app's Talk tab gets an answer that names something visible ONLY in the image — a word, a number or a shape that appears nowhere in the typed message.
- F2 The same is true in the Hub's Talk panel, for any signed-in team member.
- F3 A money question answered with a chart: the picture renders on a phone-sized viewport (390 x 844), is still there after a full page reload, and carries no figure the business gateway did not return in that same turn.
- F4 An uploaded attachment and a returned visual are reachable only through the app's own read route — the family app's file route answers a bare 404 to a caller the app itself would not admit, and the Hub's upload and read routes answer only a signed session or the robot bearer.
- F5 No kid-facing surface changes: the school app and the children's screens carry no attach control, no file route change and no rendered-visual code after this lane closes.
- F6 A file uploaded through the family app reports WHICH store served it back, and the same file keeps resolving whether it was written before or after the bucket exists.

**Owner:** the VISUALS driver session (this lane's overseer; it never builds) · **Overseer:** ONE — the VISUALS driver (Fable or Opus) · **Design authority:** Sienna (creative-director), for the two changed screens only, once, after the fidelity count reaches zero.

**Rule: a step starts the moment its named inputs exist, whatever its number. A step closes on ONE independent check by a different model. Nothing waits on Nick to test.**

> **STEP 0 — ARM THE LOOP, BEFORE ANYTHING ELSE.** Set a 5-minute loop. Every time it fires, answer
> these five in order and CORRECT any failure before doing anything else:
> 1. **NORTH STAR** — is what I am doing this minute moving this plan's North Star? If not, drop it.
> 2. **FAN-OUT** — declare the whole actual roster, dispatch useful ready work, and shed your own unnecessary processes. Coordinate through peers or the launching dispatcher; no numeric cap or load-wait rule applies.
> 3. **CHEAP** — are cheap models doing the building AND the per-step checking? If anything on
>    Anthropic or OpenAI is building or checking a step, move it down now (§M).
> 4. **STUCK** — for anything I have called blocked: name the input that does not exist yet, or the
>    three concrete things I tried. If I cannot, it is not blocked — drive through it now.
> 5. **NEXT** — did something just finish? Then the next step whose inputs exist starts THIS minute.
>    A finished step is never a place to stop, a report is never a reason to wait, and Nick being
>    away or asleep is the reason to keep going, not to pause.
> Then keep building. The loop never stops until the FINISH LINE is proven.

The one exception item 3 allows is the skill's own (§M): the two steps that edit the brain's own server file, which the data wall refuses to every cheap vendor. Each carries its NICK-ASKED line.

## Already true (facts, not story)

- **Both apps already have a file store, and neither needs inventing.** `projects/personal/family-app/functions/api/_files.js` and `projects/business/business-app/app/functions/api/_files.js`: content-addressed ids of the shape `f-` plus 24 hex characters, the content type SNIFFED from the bytes by magic number (the browser's claimed `File.type` is never consulted), a 5 MB cap, and an allowlist. Family allows png, jpeg, pdf, heic, webp, gif; the Hub allows png, jpeg, pdf for an anonymous caller and a wider signed-in set. Opened 2026-09-13.
- **Both apps already have an upload route, and its answer shape is frozen.** `functions/api/comment-upload.js` in each app: POST, `multipart/form-data`, field `file`, answering `{ ok, file_id, name, ext, size, content_type, url }`. It is wired into TO-DO UPDATE THREADS ONLY. `projects/personal/family-app/js/todo-thread.js` is the working caller — it posts the file, keeps the returned id, then posts the message carrying that id, and renders the attachment from the message record. Opened 2026-09-13.
- **Storage differs, and the difference is already handled in code.** The Hub writes to R2 (binding `BIZ_FILES`, bucket `deck-business-files`, provisioned 2026-07-29). The family app has no R2 bucket bound, so its store falls back to KV with base64 (about 33% inflation) under `FAMILY_FINANCES`. The code already PREFERS R2 the moment a binding named `FAMILY_FILES` exists, with no code change, and its read path checks R2 first and KV second regardless — so a file written during the interim keeps resolving afterwards and no backfill is needed. Its own header says all of this. Opened 2026-09-13.
- **The brain cannot see an image.** `projects/personal/skippy-app/skippy-code-publish/server.js`, the `/api/chat` handler (`cloudChatHandler`), reads `{ messages, seriousHint, stream, who, probe, voiceOriginated, confirmHandoff }` from the body and every message content is a plain string. There is no image path in and none out. The models behind it are multimodal, so this is a message-shape change, not a new capability. Opened 2026-09-13.
- **Both apps' chat proxies hard-drop anything that is not a string.** `projects/personal/family-app/functions/api/skippy-chat.js` and `projects/business/business-app/app/functions/api/skippy-chat.js` reject any message whose `content` is not a string and rebuild every message as exactly `{ role, content }`, then pass named top-level fields through one at a time (`voiceOriginated`, `testTurn`, `confirmHandoff`). An attachment therefore CANNOT travel inside `messages[].content`; it travels as its own named top-level field. Opened 2026-09-13.
- **The chat surfaces have no attach control.** `projects/personal/family-app/js/talk-panel.js` has no upload code at all. The live typed composer on both apps' Talk screens is the same pair of element ids inside `#talk-wrap`: `#voiceTextInput` and `#voiceTextSend`, drawn by `projects/personal/family-app/js/voice.js`. Opened 2026-09-13.
- **The Hub does not keep its own copy of the voice client.** `projects/business/business-app/scripts/fetch-workspace-deps.py` copies `projects/personal/family-app/js/` into the Hub at build time, and `projects/business/business-app/app/js/neeko-talk-panel.js` lays out the Hub's own markup with those same element ids and mounts that one client. One edit to the family app's voice client therefore reaches both surfaces. Opened 2026-09-13.
- **The Hub's app folder and its dist folder are byte-identical for the file store today** (`diff -q` on the two `_files.js`, 2026-09-13). The dist folder is a generated mirror; the app folder is the source that is edited.
- **The Hub's upload route already demands a caller.** `projects/business/business-app/app/functions/api/comment-upload.js` requires a signed session or a robot bearer, checked in the route itself rather than relying on the outer wall. The family app's equivalent does not, because the family login wall is currently off by Nick's own ruling; its file read route compensates by answering a bare 404 to anyone the app would not admit. Both files state this in their own headers. Opened 2026-09-13.
- **The design target for the Talk screen is already locked and already measured.** Generator `projects/personal/skippy-app/design-directions/gen-pearl-voice.mjs` (sha256 `8fbd80169514e246cb3ab19f907f8bf1f2bd4ddf597a66d5e89f23af73409163`), signed anchor map `projects/personal/skippy-app/design-directions/gen-pearl-voice-anchors.md` (sha256 `02462f032b7ffa96d5f171dd83707dfcb8bbab43009b251e5e40e1cfc1ee71d9`), checker `projects/personal/skippy-app/design-directions/_pearl-fidelity-check.mjs` with its own selftest and sabotage modes. Read 2026-09-13.
- **Cheap dispatch and evidence re-running both work from this machine:** `projects/ops/cheap-task.mjs` (whole tasks), `projects/ops/route-build.mjs` (one edit to one existing file), `projects/ops/skippy-jobs/lib/verify-agent-evidence.mjs` (re-runs an agent's own pasted command output). Present 2026-09-13.

## 0 · Gate Zero receipts (the plan may not exist without these)
- Failure Mode Registry loaded: 2026-09-13, `.claude/skills/plan/references/failure-registry.md`; the ten entries this build is genuinely exposed to are named with their measures in §4.
- Canonical specs loaded: `projects/ops/agents/CODE-STANDARD.md` (code), `projects/ops/HANDBACK-GATE-SPEC.md` (QA), `projects/ops/agents/DESIGN-FIDELITY-STANDARD.md` and `projects/ops/agents/CREATIVE-QA-STANDARD.md` (both screens this lane changes are looked at, so §2d is filled, not waived).
- Ownership check: no row in `projects/ops/artifacts/project-status/registry.json` owns "pictures in and out of the two apps"; the lane registered by this plan is its first. The ownership FINDING is that the two halves this build needs ALREADY EXIST and are extended rather than rebuilt — the two file stores and the two upload routes named in "Already true". No second store, no second upload route, no second read route is created by any step here.
- Expected inputs confirmed to exist: every path in "Already true" was opened on 2026-09-13 by the plan author, not inferred; plus `projects/ops/cheap-task.mjs`, `projects/ops/route-build.mjs`, `projects/ops/skippy-jobs/lib/verify-agent-evidence.mjs`, `projects/ops/skippy-jobs/lib/standing-auth.mjs`, `projects/shared-tooling/test-chrome.mjs`, `projects/personal/skippy-app/design-directions/_pearl-fidelity-check.mjs`, `projects/personal/skippy-app/skippy-code-publish/lib/memory-gate.mjs`, `projects/personal/family-vault/vault.py`.
- PLAN AUTHOR: the VISUALS reconciler session (Opus), 2026-09-13, under Nick's tiering ruling of 2026-09-09 that planning and strategy stay with Anthropic or OpenAI.
- COLD READER: none — SINGLE-AUTHOR, UNREVIEWED. Because this lane renders things, the driver dispatches one fresh session to cold-read this file before STEP 8's grade, and records what it disputed on this line; until then this plan is single-author and says so.
- PROMPT-SPEC scan (P1-P7): two V1 variables are settled by Nick's own dated words of 2026-09-13 (§1a rows 1 and 2); four V2 variables are settled by opening the code on 2026-09-13 (§1a rows 3 to 6); the one genuinely open build choice — which of two roads makes a picture — is NOT a person's question and is recorded under "Considered and ruled NOT critical", settled on measured grounds inside STEP 5. Assumptions and questions live in the STATE.md companion beside this file.

## 1 · Goal and definition of done

- **What we're building, one paragraph.** Two directions of one capability. INWARD: a person attaches a screenshot on the Talk screen of either app, the existing upload route stores it exactly as the to-do threads already do, and the chat request carries the returned file id as its own named field so the brain hands the picture to the model as image content and answers about what is in it. OUTWARD: when a picture is the better answer, the brain returns a visual alongside its words and both apps render it in the conversation. Nothing new is stored, routed or read: the two existing file stores, the two existing upload routes and the one existing read route per app do all of the carrying.
- **HOW IT'S USED:** Nick opens the family app's Talk tab on his phone or in the Mac window, taps the attach control beside the message box, picks a screenshot, types a question about it, and gets an answer that describes what is actually in the picture; a team member does the same in the Hub's Talk panel; and when he asks where the money went this month, the answer comes back as a chart he can look at instead of a paragraph he has to parse. · HOW WE KNOW: Nick's own words of 2026-09-13, quoted in the North Star, and the live drive-throughs of STEP 3, STEP 4 and STEP 6.
- **WHAT IT LOOKS LIKE:** the two existing Talk screens, unchanged in every respect except one new attach control beside the existing message box and an attachment thumbnail or a rendered visual inside an existing conversation turn. Pearl on the family app; the Hub's own current styling on the Hub, untouched. · HOW WE KNOW: §2's rows, and §2d's fidelity gate measured against the already-locked Pearl target.
- **WHERE IT LIVES:** the family app's Talk tab at `https://family.heroesandsidekicks.io` (opened by Nick and by Chantelle), the Hub's Talk panel at `https://hub.heroesandsidekicks.io` (opened by the team), and the brain at the published Skippy box that both apps' chat proxies call. The code lives in the family app's own folder, the Hub app's own folder, and `projects/personal/skippy-app/skippy-code-publish/server.js`. · HOW WE KNOW: §1a's SURFACE row, confirmed by Nick 2026-09-13.
- **WHAT IT MUST DO:** F1 to F6 above, one check each in §6.
- **NOT in scope:** (a) security and privacy work of any kind, including any review of the family app's login wall being off — Nick, 2026-09-09; a security-shaped observation costs one line in `projects/ops/sp-sec/PLAN.md` and the agent returns to its step in the same turn. (b) Any change to the to-do update threads' own attachment behaviour in either app — they are the working pattern this lane COPIES, and their upload answer shape is frozen in §3; owner of that surface is the family-app lane. (c) Any change to the Hub's colours, tokens or styling — frozen until the Hub's own design redo the week of 2026-09-15; owner is the Hub lane. (d) Video, audio and any file type not already on either store's sniffed allowlist — the allowlists are extended by adding to an existing set or not at all, and no new media pipeline is built here. (e) Any kid-facing surface — the school app and the children's screens get no attach control, no file route change and no rendered visual; owner is the superintendent. (f) Reading a PDF's contents — the stores already accept PDFs for the to-do threads and will keep storing them, but the brain refuses one for a chat turn with the reason named, and reading them is on the NEXT list, owner the VISUALS driver. (g) Migrating any file already written to KV into R2 — the read path already checks both, so no backfill exists to do. (h) Any second file store, second upload route or second read route, in either app, for any reason.
- **Trip-over protocol:** a lane that finds something outside the fence writes one handover line to its named owner (a security- or privacy-shaped thing: one line in `projects/ops/sp-sec/PLAN.md`), then back to building — never investigates, never fixes.

## 1a · Critical variables — the confirmation sheet is GENERATED from this table

> No lane opens while any row reads UNCONFIRMED. The sheet Nick signs is this table rendered by `python3 projects/ops/agents/render_sheet.py` against this file, never a separate summary.

| # | The variable, in plain words | Value chosen | Alternatives rejected | Class | HOW WE KNOW | Cost if wrong | CONFIRMED |
|---|---|---|---|---|---|---|---|
| 1 | **SURFACE — which screens this lands on, and who opens them** | the family app's Talk tab (Nick and Chantelle) and the Hub's Talk panel (the signed-in team); no third screen, no new app | a new gallery screen · the to-do threads only · a desktop-only surface | V1 | he named both products in one sentence and named no other place | the work lands on a screen nobody opens, and he still cannot show a picture where he talks | Nick, 2026-09-13, "we need a way for the app and hub to show images gracipcs visuals like you share here" |
| 2 | **What "show a visual" has to mean** | the picture appears IN the conversation, the way one appears in a chat with Claude — not a link, not a download, not a separate page he has to open | a link to a hosted page · an attachment he downloads · a text description of the chart | V1 | his own comparison is "like you share here", which is a picture inline in the thread | he gets a link where he expected a picture, and the feature reads as not built | Nick, 2026-09-13, "show images gracipcs visuals like you share here" |
| 3 | **Where the attachment travels in the chat request** | as its own named top-level body field on the chat POST, beside the voice-originated flag, carrying the file id only — never inside a message's content | inside a message content array · a second endpoint · base64 bytes in the body | V2 | both apps' chat proxies reject any message whose content is not a string and rebuild every message as exactly role plus content | the attachment is silently dropped by the proxy and the brain answers about nothing, with no error anywhere | opened `projects/personal/family-app/functions/api/skippy-chat.js` and `projects/business/business-app/app/functions/api/skippy-chat.js`, 2026-09-13, saw: a non-string content is a 400 and each message is rebuilt as role plus content only |
| 4 | **Whether the Hub needs its own attach control** | no — one edit to the family app's voice client serves both, because the Hub copies that folder at build time and mounts the same client with the same element ids | write a second control in the Hub's own panel file · fork the voice client | V2 | the Hub keeps no copy of the voice client in its own tree | two controls drift apart and the Hub silently keeps the old one | opened `projects/business/business-app/scripts/fetch-workspace-deps.py` and `projects/business/business-app/app/js/neeko-talk-panel.js`, 2026-09-13, saw: the family app's js folder is copied in, and the Hub panel uses the same two composer element ids |
| 5 | **Where a family-app upload physically lands today** | KV with base64, under the finances namespace, because no R2 binding exists yet; the code prefers R2 the moment a binding named FAMILY_FILES appears, with no code change | assume R2 like the Hub · build a new bucket abstraction · block the feature until a bucket exists | V2 | the store's own header and its write path both say so | the lane stalls on a Cloudflare action that is not needed for the feature to work | opened `projects/personal/family-app/functions/api/_files.js`, 2026-09-13, saw: R2 first if bound, otherwise one KV key per file, and the read path always tries both |
| 6 | **Whether the Hub's upload route already demands a caller** | yes — a signed session or the robot bearer, checked inside the route itself; the family app's route does not, and its read route answers a bare 404 instead | add a new gate · rely on the outer wall · treat both apps the same | V2 | each route states its own posture in its own header and enforces it in its own code | a step is written to add a gate that already exists, or one app's behaviour is assumed from the other's | opened `projects/business/business-app/app/functions/api/comment-upload.js` and the family app's own file read route, 2026-09-13, saw: a signed-or-bearer check in the Hub route, and a bare 404 refusal in the family read route |

**Considered and ruled NOT critical:**
- Which of the two roads makes a returned picture (a self-contained drawing the app renders, or a generated image stored through the file store) — not a person's question. It is settled inside STEP 5 on measured grounds, against the measurement written into that step, and the answer is recorded there.
- Which cheap vendor builds each step — the model matrix decides, and §3b copies the name in.
- The exact icon and wording of the attach control — Sienna's single grade in STEP 8 decides, after the fidelity count reaches zero.
- The maximum number of attachments per message — one per message for this lane; more is on the NEXT list and changes no contract.

## 1b · Subproject decomposition — could a piece of this ship on its own?

- **SINGLE SUBPROJECT:** every step serves the one North Star and every step shares the same three frozen contracts; the inward half without the outward half still leaves him unable to be shown a picture, and the outward half without the inward half still leaves him unable to send one, so neither half is a thing he could be handed, sign off and use on its own.

**Carve-out rule:** anything left out of every step's scope is named with a real owner in §1 NOT in scope — the family-app lane for the to-do threads, the Hub lane for the Hub's styling, the superintendent for the children's screens, `projects/ops/sp-sec/PLAN.md` for anything security-shaped, and the VISUALS driver for the two NEXT-list items (reading a PDF's contents, more than one attachment per message).

## 2 · The complete UX map (this becomes the test manifest verbatim)

| Id | Screen / entry point | State (default, empty, error, loading) | Element / interaction | Expected behavior | Navigation from -> to |
|---|---|---|---|---|---|
| U1 | family app, Talk tab, typed composer, Nick signed in | default | the attach control beside the message box, tapped | the system file picker opens; nothing is sent yet | Talk -> Talk |
| U2 | family app, Talk tab | loading | a screenshot chosen, upload in flight | a chip shows the file name and a progress state; the send control is disabled until the upload answers | Talk -> Talk |
| U3 | family app, Talk tab | default | the upload answers, then a question is typed and sent | the turn shows the picture above the typed words, and the reply names something visible only in the image | Talk -> Talk |
| U4 | family app, Talk tab | error | a file over 5 MB is chosen | the chip is refused before anything is sent, with the size and the limit both stated in plain words | Talk -> Talk |
| U5 | family app, Talk tab | error | a file whose bytes are not on the sniffed allowlist is chosen | refused with the reason named — the type the bytes actually are, and the list that is allowed; nothing is stored | Talk -> Talk |
| U6 | family app, Talk tab | default | the page is fully reloaded after U3 | the same turn still shows the picture, served from the app's own file route | Talk -> Talk |
| U7 | family app, Talk tab | default | an image turn is sent | no new row appears in the assistant's pending-memory record for that turn | Talk -> Talk |
| U8 | Hub, Talk panel, a signed-in team member | default | the attach control beside the message box | the same three behaviours as U1, U2 and U3, in the Hub's own styling | Talk -> Talk |
| U9 | Hub, Talk panel, signed out | error | the upload route called without a session or bearer | refused by the route itself; nothing is stored | Talk -> Talk |
| U10 | family app, Talk tab | default | "where did the money go this month" | the answer carries a chart rendered in the turn, with the figures the business gateway returned in that turn and no others | Talk -> Talk |
| U11 | family app, Talk tab, phone viewport 390 x 844 | default | the same chart from U10 | the chart fits the width, is legible, and does not scroll the page sideways | Talk -> Talk |
| U12 | family app, Talk tab | default | the page is fully reloaded after U10 | the chart is still in the turn, not an empty box and not a broken image | Talk -> Talk |
| U13 | family app, file read route | error | the route called for an id that does not exist, or by a caller the app would not admit | a bare 404 with no body that reveals whether the id exists | any -> 404 |
| U14 | family app, Talk tab | default | a file is uploaded | the upload answer states which store served the write, and the same file reads back afterwards | Talk -> Talk |
| U15 | school app and the children's screens | default | every screen walked after this lane closes | no attach control anywhere, and no rendered-visual code in the children's bundles | any -> any |

## 2d · DESIGN FIDELITY GATE (plan skill §D — mandatory, because both changed screens are looked at)

- **LOCKED TARGET:** `projects/personal/skippy-app/design-directions/gen-pearl-voice.mjs`, REV 3.3, published login-free at `https://skippy-designs.pages.dev/pearl-dusty-rose-desktop-20260904`; Nick's approving words, dated: *"i really want to nail that ui so it sits exactly like the design specs"* (2026-09-05) and *"pearl-tokens.css, use Dusty rose"* (2026-09-04). The attach control and the rendered-visual turn are NEW elements on that target, so STEP 3 regenerates the target page to include them and re-locks it before its own fidelity run; the rest of the Talk screen's target is unchanged and is not re-approved.
- **TARGET HASH:** generator sha256 `8fbd80169514e246cb3ab19f907f8bf1f2bd4ddf597a66d5e89f23af73409163`, machine-written by `shasum -a 256` on 2026-09-13; re-written by the same command after STEP 3 regenerates it. **ANCHOR MAP:** `projects/personal/skippy-app/design-directions/gen-pearl-voice-anchors.md`, sha256 `02462f032b7ffa96d5f171dd83707dfcb8bbab43009b251e5e40e1cfc1ee71d9`, signed by design QA; STEP 3's two new anchor rows — the attach control and the visual inside a turn — are added and re-signed by Sienna before STEP 8 runs.
- **FIDELITY CHECK:** `projects/personal/skippy-app/design-directions/_pearl-fidelity-check.mjs` together with the published target page; its selftest mode must print zero and zero, and its sabotage mode must turn every compared property red, before any measured run is believed.
- **VIEWPORTS AND THEMES:** exactly the locked target's — 584 x 763 light (the Mac window frame) and 390 x 844 light (the phone frame), both signed in as Nick.
- **ANCHORS EXPLICITLY EXCLUDED FROM THIS LANE'S COUNT, BY NAME, because they were already unmeasured before this lane opened and belong to other lanes:** every anchor under the Hub's own screens, which are frozen until the Hub design redo the week of 2026-09-15 and are the Hub lane's to measure; the Dispatch and Status screen anchors owned by the ASSISTANTS lane; and the Thread screen's four REV 3.0 controls — the chips, the header word, the new-count and the overflow menu — which the anchor map already routes rather than measures. This lane measures the Talk screen's anchors only, plus the two it adds.
- **RULE:** the definition of done for each changed screen is `mismatched properties: 0 · unmeasured anchors: 0` at both viewports, reproduced once by STEP 8's checker, then graded once by Sienna. A non-zero count loops the builder; it never summons a second grader.

## 3 · Lanes and frozen contracts

| Lane | Scope (in / out) | Owner | Definition of done | Builder (cheap, named) | Backup builder | Checker (different model) | Backup checker |
|---|---|---|---|---|---|---|---|
| A — the brain sees and returns pictures | IN: the chat handler's request and reply shape in the brain's own server file. OUT: every app file, every store file | VISUALS driver | an image turn is answered about its contents, and a visual comes back when a picture is the better answer | opus (NICK-ASKED; the data wall refuses this file to every cheap vendor) | opus only | sonnet | fable |
| B — the two chat proxies carry the attachment | IN: each app's chat proxy file. OUT: the brain, the stores, the screens | VISUALS driver | the attachment field survives the proxy unchanged, in both apps | deepseek | qwen | glm | sonnet |
| C — the screens attach and render | IN: the family app's voice client, the Hub's talk panel markup, the Hub's build copy. OUT: the to-do threads, the Hub's tokens and colours | VISUALS driver | a person attaches a picture and sees a picture, on both screens, at both viewports | glm | deepseek | qwen | sonnet |
| D — storage and the read-back report | IN: each app's upload route, additively only. OUT: any new store, route or bucket abstraction | VISUALS driver | an upload states which store served it and the file reads back either way | deepseek | qwen | sonnet | glm |
| E — measurement and close | IN: the fidelity run, the finish-line sign-off, the postmortem. OUT: building anything | VISUALS driver | the fidelity count is zero at both viewports and every finish-line item is proven by an agent that did not build it | qwen | glm | deepseek | sonnet |

**Contracts between lanes (FROZEN at plan time — a change is a dated delta in PLAN-CHANGES.md beside this file):**

- **C1 — THE FILE STORE.** The store's three functions keep their current names, arguments and return shapes in both apps: the one that writes bytes, the one that reads metadata only, and the one that reads the full record. The id stays `f-` plus 24 hex characters. The type stays SNIFFED from the bytes and the browser's claimed type stays unconsulted. The 5 MB cap stays. A type is added to an allowlist only by adding to the existing set, never by relaxing the sniff. No second store is created.
- **C2 — THE UPLOAD ANSWER.** The upload route, `multipart/form-data`, field `file`, answering `{ ok, file_id, name, ext, size, content_type, url }`. The to-do update threads in both apps depend on this shape. No field is renamed, removed or retyped. Fields may only be ADDED, and this lane adds exactly one: `where`, a plain word naming the store that served the write.
- **C3 — THE BRAIN'S REQUEST SHAPE.** The attachment travels as ONE new optional top-level field on the chat POST, named `attachment`, carrying `{ file_id, content_type, url }` and nothing else — never inside a message's content, which both proxies rebuild as a plain string. It applies to the NEWEST user message only. Each proxy passes it through the same way it already passes the voice-originated flag: recognised by name, re-typed, and never trusted for anything about identity.
- **C4 — THE RETURNED VISUAL.** The brain's reply gains ONE new optional top-level field, named `visual`, whose exact inner shape is fixed by STEP 5's recorded decision and is frozen from that moment. Both apps read it by that one name. A reply without it renders exactly as replies render today.

**Buckets that share a goal message each other:** a dated line into the other plan's file when a step they need closes. This lane's named neighbours are the ASSISTANTS lane (`projects/ops/life-os/REGROUP-2026-09-08/plans/ASSISTANTS/PLAN.md`), which owns the brain's server file for everything else, and the Hub lane, which owns the Hub's styling.

## 3b · Execution map — the Step map, then one STEP block per row

A task is DONE only when its review-ledger row is CLOSED by a reviewer that is not the builder.

**Step map (read this first):**

| Stage | # | Task (step name) | Needs (named artefact, or `none — start now`) | EXECUTOR (cheap model) | EXECUTOR BACKUP | CHECKER (different model) | CHECKER BACKUP | DONE-PROOF (runnable command) |
|---|---|---|---|---|---|---|---|---|
| 1 | 1 | The brain sees an image | none — start now | opus | opus | sonnet | fable | `bash evidence/step1-image-turn.sh` then `node projects/ops/skippy-jobs/lib/verify-agent-evidence.mjs evidence/step1.txt --cwd "/Users/nickdeck/Documents/Claude 2.0"` |
| 1 | 2 | Both chat proxies carry the attachment | none — start now | deepseek | qwen | glm | sonnet | `command grep -c "attachment" projects/personal/family-app/functions/api/skippy-chat.js projects/business/business-app/app/functions/api/skippy-chat.js` |
| 1 | 3 | The family app's Talk tab can attach | STEP 1 closed and STEP 2 closed — the brain answers an image turn posted through the real proxy | glm | deepseek | qwen | sonnet | `node projects/shared-tooling/test-chrome.mjs --script evidence/step3-attach-drive.mjs` |
| 2 | 4 | The Hub's Talk panel can attach | STEP 3 closed — the shared voice client carries the control | qwen | deepseek | glm | sonnet | `node projects/shared-tooling/test-chrome.mjs --script evidence/step4-hub-attach-drive.mjs` |
| 2 | 5 | The brain returns a visual, on a recorded road | STEP 1 closed — the chat handler already carries one new field cleanly | opus | opus | sonnet | fable | `bash evidence/step5-visual-turn.sh` then `node projects/ops/skippy-jobs/lib/verify-agent-evidence.mjs evidence/step5.txt --cwd "/Users/nickdeck/Documents/Claude 2.0"` |
| 2 | 6 | Both apps render a returned visual | STEP 5 closed — the visual field's shape is recorded in §3 C4 of this plan | glm | deepseek | qwen | sonnet | `node projects/shared-tooling/test-chrome.mjs --script evidence/step6-visual-render-drive.mjs` |
| 3 | 7 | The upload says which store served it, and the bucket item goes to Nick | none — start now | deepseek | qwen | sonnet | glm | `bash evidence/step7-where.sh` |
| 3 | 8 | The two changed screens measure zero | STEP 3, STEP 4 and STEP 6 closed, and the anchor map re-signed | qwen | glm | deepseek | sonnet | `node projects/personal/skippy-app/design-directions/_pearl-fidelity-check.mjs --out evidence/step8-fidelity.txt` |
| 3 | 9 | The FINISH LINE signed off by an agent that built none of it | STEP 1 to STEP 8 all closed | fable | fable | opus | fable | `python3 projects/ops/agents/check_plan.py projects/ops/life-os/REGROUP-2026-09-08/plans/VISUALS/PLAN.md` |
| 3 | 10 | Postmortem appended to the registry | STEP 9 closed | sonnet | haiku | qwen | glm | `command grep -c "VISUALS" .claude/skills/plan/references/failure-registry.md` |

### STEP 1 — The brain sees an image
**FOR NICK:** he can send a screenshot to Skippy and get an answer about what is actually in it, instead of an answer about the words he typed. · **Tier:** FRONT
**Start when:** none — start now.
**Builder:** opus · **Builder backup:** opus · **Checker:** sonnet · **Checker backup:** fable
**NICK-ASKED: opus** — the file this step edits is refused to every cheap vendor by the data wall, and Nick's tiering ruling of 2026-09-09 keeps anything that must hold a floor value on Anthropic or OpenAI. No cheap vendor may be briefed on it.
**Files you may touch:** `projects/personal/skippy-app/skippy-code-publish/server.js`. **Never** `projects/personal/family-app/functions/api/skippy-chat.js` or `projects/business/business-app/app/functions/api/skippy-chat.js` (STEP 2 owns both), and never the family app's voice client or talk panel (STEP 3 owns those).

**Do exactly this:**
1. Read the chat handler function and find the line that destructures the request body into the messages array, the serious hint, the stream flag, the caller name, the probe flag, the voice-originated flag and the handoff confirmation.
2. Add `attachment` to that destructuring, and immediately below it validate it: it is either absent, or an object whose `file_id` matches `f-` followed by exactly 24 lowercase hex characters and whose `content_type` is a string. Anything else is a 400 whose body names which of the two it was.
3. If `content_type` is not one of `image/png`, `image/jpeg`, `image/gif`, `image/webp` or `image/heic`, answer 400 with the literal reason — the type that was presented and the list that is accepted. `application/pdf` takes that same refusal for now, with its own sentence saying PDFs are stored but not yet read.
4. When the attachment is valid, fetch the bytes from the caller's own app through the url the attachment carries, passing the caller's own session, and attach them to the NEWEST user message as an image content block. Every earlier message stays a plain string.
5. Make the attachment never become a memory: do not pass the file id, the url or the bytes to the memory gate, and do not write the attachment into the conversation record as a memory-shaped row.
6. Write the evidence file `evidence/step1.txt` in COMMAND and OUTPUT pairs, and the reproducible turn as `evidence/step1-image-turn.sh`, which posts a real screenshot's file id and a question whose answer can only come from the picture.

**DEFINITION OF DONE:** a chat turn carrying a valid attachment is answered with a sentence naming something visible only in the image, a turn carrying a refused type is answered 400 with the type and the allowed list both named, and the assistant's pending-memory record gains no row for either turn.
**PROOF:** `bash evidence/step1-image-turn.sh` prints an answer containing the word planted in the test image, then a second call with a PDF content type prints HTTP 400 with the words "stored but not yet read", then `node projects/ops/skippy-jobs/lib/verify-agent-evidence.mjs evidence/step1.txt --cwd "/Users/nickdeck/Documents/Claude 2.0"` re-runs every recorded pair and reports no mismatch · **FAILS IF:** the answer describes only the typed words, or the refusal is a 500, or a silent 200 comes back with the attachment ignored, or the evidence re-run reports MISMATCH or UNRUNNABLE.

**If the check fails:** the builder fixes and re-checks the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

**Handoff:** the moment this step closes, post one dated line into `projects/ops/life-os/REGROUP-2026-09-08/plans/ASSISTANTS/PLAN.md`: `VISUALS STEP 1 closed 2026-09-13 — the chat handler now takes one optional attachment field; the request shape is frozen in the VISUALS plan section 3, contract C3.`

### STEP 2 — Both chat proxies carry the attachment
**FOR NICK:** nothing he notices on a screen; it is the reason the screenshot he sends actually arrives. · **Tier:** FRONT
**Start when:** none — start now.
**Builder:** deepseek · **Builder backup:** qwen · **Checker:** glm · **Checker backup:** sonnet
**Files you may touch:** `projects/personal/family-app/functions/api/skippy-chat.js` and `projects/business/business-app/app/functions/api/skippy-chat.js`. **Never** `projects/personal/skippy-app/skippy-code-publish/server.js` (STEP 1 owns it) and never either app's upload route (STEP 7 owns both).

**Do exactly this:**
1. In each file, find the block that builds the pass-through fields for the voice-originated flag and the test-turn flag from the body.
2. Beside them, build `attachment` the same way: present only when the body carries an object; re-typed field by field into a fresh object with `file_id` and `content_type` as strings and `url` as a string; absent otherwise. Copy nothing else from the caller's object.
3. Add it to the outbound body that is posted to the brain, next to the fields already passed.
4. Change nothing about the existing message loop: a message whose content is not a string still gets the same 400 it gets today.
5. Run each app's own existing selftests that touch the proxy and record the output into `evidence/step2.txt` in COMMAND and OUTPUT pairs.

**DEFINITION OF DONE:** the attachment field survives both proxies byte for byte, and a message whose content is not a string is still refused exactly as before.
**PROOF:** `command grep -c "attachment" projects/personal/family-app/functions/api/skippy-chat.js projects/business/business-app/app/functions/api/skippy-chat.js` prints a non-zero count for each file, and a posted turn carrying an attachment reaches the brain with the field intact while a posted turn whose content is an array is still answered 400 · **FAILS IF:** either count is zero, or the attachment arrives at the brain as undefined, or a non-string content now reaches the brain instead of being refused.

**If the check fails:** the builder fixes and re-checks the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

### STEP 3 — The family app's Talk tab can attach
**FOR NICK:** on the family app's Talk tab he can tap a control beside the message box, pick a screenshot, and see it in the conversation with his question. · **Tier:** FRONT
**Start when:** STEP 1 and STEP 2 are both closed — the brain answers an image turn posted through the real proxy.
**Builder:** glm · **Builder backup:** deepseek · **Checker:** qwen · **Checker backup:** sonnet
**Files you may touch:** `projects/personal/family-app/js/voice.js`, `projects/personal/family-app/js/talk-panel.js` and `projects/personal/skippy-app/design-directions/gen-pearl-voice.mjs`. **Never** `projects/personal/family-app/js/todo-thread.js` (it is the pattern being copied and belongs to the family-app lane), never either app's upload route (STEP 7 owns both), and never `projects/business/business-app/app/js/neeko-talk-panel.js` (STEP 4 owns it).

**Do exactly this:**
1. Open `projects/personal/family-app/js/todo-thread.js` and read the upload block that posts a form body to the upload route and keeps the returned file id. Copy that shape exactly; invent no second way to upload.
2. In `projects/personal/family-app/js/voice.js`, add one attach control inside the same row that already holds the typed message box and its send button, with its own id and class prefixed the same way its neighbours are. Do not touch the mic, the toggle or the send control.
3. On a file being chosen: refuse anything over 5 MB before sending, naming the file's size and the limit in plain words; otherwise post it to the app's own upload route, disable the send control while it is in flight, and show a chip with the file name.
4. On the upload answering with an error, show the route's own reason text unchanged, clear the chip, and re-enable send.
5. On send, post the chat request with the existing fields plus `attachment` set to the file id, content type and url from the upload answer, and draw the picture in the person's own turn above the typed words, sourced from the url the upload returned.
6. Regenerate the locked design page from `projects/personal/skippy-app/design-directions/gen-pearl-voice.mjs` so it draws the attach control and a turn containing a picture, re-publish it, and re-write the generator's hash with `shasum -a 256`. Ask Sienna to add the two new anchor rows to the signed anchor map and re-sign it.
7. Save the drive script as `evidence/step3-attach-drive.mjs` and its output as `evidence/step3.txt`.

**DEFINITION OF DONE:** signed in as Nick on the live family app, an agent drives the real control, attaches a real screenshot, sends a question, receives an answer naming something visible only in the picture, reloads the page fully, and the picture is still in the turn.
**PROOF:** `node projects/shared-tooling/test-chrome.mjs --script evidence/step3-attach-drive.mjs` prints the answer text, the planted word found, and `RELOAD: image present` · **FAILS IF:** the control is in the markup but has no bounding box, or the send happens with no attachment field, or the picture is absent after the reload, or the answer names nothing from the image.

**If the check fails:** the builder fixes and re-checks the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once, on the live surface with a real signed-in session. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

### STEP 4 — The Hub's Talk panel can attach
**FOR NICK:** the team gets the same thing in the Hub — attach a screenshot where they talk to Neeko, and get an answer about the picture. · **Tier:** FRONT
**Start when:** STEP 3 is closed — the shared voice client carries the control.
**Builder:** qwen · **Builder backup:** deepseek · **Checker:** glm · **Checker backup:** sonnet
**Files you may touch:** `projects/business/business-app/app/js/neeko-talk-panel.js` and the Hub's build copy of the family app's js folder. **Never** `projects/personal/family-app/js/voice.js` (STEP 3 owns it), never any Hub token, colour or styling file, and never the Hub's generated dist folder by hand — that folder is a mirror and is refreshed by the Hub's own build, never edited.

**Do exactly this:**
1. Run `python3 projects/business/business-app/scripts/fetch-workspace-deps.py` so the Hub picks up STEP 3's edited voice client.
2. In `projects/business/business-app/app/js/neeko-talk-panel.js`, add the attach control to the panel's own markup using the same id the voice client looks for, placed in the dock row beside the typed message box and its send button, and styled only with the Hub's existing classes and variables. Add no new colour, no new token, no new font.
3. Confirm the Hub's build regenerates its dist folder from its app folder rather than the reverse, and record the command that did it into `evidence/step4.txt`.
4. Save the drive script as `evidence/step4-hub-attach-drive.mjs` and its output as `evidence/step4.txt`.

**DEFINITION OF DONE:** signed in to the live Hub as a real team member, an agent attaches a screenshot in the Talk panel and receives an answer naming something visible only in the picture, and the same upload called without a session is refused by the route itself.
**PROOF:** `node projects/shared-tooling/test-chrome.mjs --script evidence/step4-hub-attach-drive.mjs` prints the answer text, the planted word found, and `UNAUTHED UPLOAD: refused` · **FAILS IF:** the control does not render in the Hub's dock, or the Hub still serves the old voice client, or the unauthenticated upload succeeds, or any Hub colour or token changed.

**If the check fails:** the builder fixes and re-checks the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once, on the live Hub with a real signed-in session. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

### STEP 5 — The brain returns a visual, on a recorded road
**FOR NICK:** when a picture is the better answer, he gets the picture — a chart of the month's money, a diagram of how something is wired — instead of a paragraph describing one. · **Tier:** FRONT
**Start when:** STEP 1 is closed — the chat handler already carries one new field cleanly.
**Builder:** opus · **Builder backup:** opus · **Checker:** sonnet · **Checker backup:** fable
**NICK-ASKED: opus** — same file, same data wall, same tiering ruling of 2026-09-09 as STEP 1. No cheap vendor may be briefed on it.
**Files you may touch:** `projects/personal/skippy-app/skippy-code-publish/server.js` and this plan file, to record the decision. **Never** any app file (STEP 6 owns the rendering) and never either app's upload route (STEP 7 owns both).

**Do exactly this:**
1. Build a throwaway prototype of BOTH roads against the same three real questions — the month's money, a diagram of how the two apps reach the brain, and a comparison of two numbers the business gateway returns.
   - **Road A, a self-contained drawing the app renders:** the brain returns the drawing's own text in the reply and the app paints it. Nothing is stored, nothing is fetched, nothing costs money per picture, and it renders instantly and follows the app's own styling. Its named cost: only what a drawing can express, and the model must place every number itself.
   - **Road B, a generated image stored through the existing file store:** the brain generates an image, writes it through the store's existing write function and returns its id, and the app shows it through the read route that already exists. Its named costs: one image-generation call in money and in seconds on every picture, one write into the store on every picture, and a generated picture cannot be relied on to carry an exact figure.
2. Measure both on the same three questions and record, in this plan, four numbers per road: seconds to first pixel, money per picture, how many of the three pictures carried every figure correctly, and how many rendered legibly at 390 pixels wide.
3. Choose the road whose numbers win, write the choice and the four numbers per road into this plan directly under this step, and build only that road.
4. Freeze `visual` as the one new optional top-level field on the reply, with the inner shape the chosen road needs, and write that shape into section 3, contract C4, in this file.
5. Make a returned visual obey exactly the reader rules an uploaded one obeys: it is reachable only through the app's own read route, it is never written into the conversation record as a memory, and it carries no figure the business gateway did not return in that same turn — a figure the gateway did not return is left out and the reply says so in words.
6. Save the measurements as `evidence/step5-road-measurements.txt`, the reproducible turn as `evidence/step5-visual-turn.sh` and the evidence pairs as `evidence/step5.txt`.

**DEFINITION OF DONE:** the decision with its four numbers per road is written into this plan, one road is built, and a money question answered through the real route comes back with a visual field whose figures all appear in the same turn's gateway result.
**PROOF:** `bash evidence/step5-visual-turn.sh` prints a reply carrying a visual field and prints `FIGURES: all present in gateway result`, then `node projects/ops/skippy-jobs/lib/verify-agent-evidence.mjs evidence/step5.txt --cwd "/Users/nickdeck/Documents/Claude 2.0"` re-runs every recorded pair and reports no mismatch · **FAILS IF:** this plan carries no recorded decision or fewer than four numbers per road, or the reply carries a figure the gateway did not return, or the evidence re-run reports MISMATCH or UNRUNNABLE.

**If the check fails:** the builder fixes and re-checks the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

### STEP 6 — Both apps render a returned visual
**FOR NICK:** the chart actually appears in the conversation, on his phone as well as his Mac, and it is still there when he comes back to it. · **Tier:** FRONT
**Start when:** STEP 5 is closed — the visual field's shape is recorded in section 3, contract C4, of this file.
**Builder:** glm · **Builder backup:** deepseek · **Checker:** qwen · **Checker backup:** sonnet
**Files you may touch:** `projects/personal/family-app/js/voice.js` and `projects/business/business-app/app/js/neeko-talk-panel.js`. **Never** `projects/personal/skippy-app/skippy-code-publish/server.js` (STEP 5 owns it) and never any Hub token, colour or styling file.

**Do exactly this:**
1. In the voice client, read the reply's visual field by its one frozen name and draw it inside the assistant's own turn, below the words, in a container that is allowed to scroll sideways on its own so the page never does.
2. A reply without a visual field renders exactly as replies render today — no empty box, no placeholder, no layout shift.
3. A visual field the app cannot draw shows the assistant's words alone and writes one line to the browser console; it never shows a broken image.
4. Make the drawn visual survive a full page reload, from the conversation record, by the same route the words survive.
5. Run the Hub's dependency copy again so the Hub picks the same client up, and confirm the Hub's Talk panel draws it in the Hub's own styling with no new colour or token.
6. Save the drive script as `evidence/step6-visual-render-drive.mjs` and its output as `evidence/step6.txt`.

**DEFINITION OF DONE:** on both live surfaces, a money question answered with a visual draws it inside the turn, the drawn visual is legible at 390 by 844 without the page scrolling sideways, and it is still there after a full reload.
**PROOF:** `node projects/shared-tooling/test-chrome.mjs --script evidence/step6-visual-render-drive.mjs` prints `FAMILY 390: visual box non-zero`, `HUB 390: visual box non-zero`, `PAGE HORIZONTAL SCROLL: none` and `RELOAD: visual present` on both apps · **FAILS IF:** the visual is in the markup with a zero bounding box, or the page scrolls sideways at 390 wide, or the visual is gone after a reload, or a reply without a visual now shows an empty container.

**If the check fails:** the builder fixes and re-checks the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once, on both live surfaces with real signed-in sessions. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

### STEP 7 — The upload says which store served it, and the bucket item goes to Nick
**FOR NICK:** one thing to do at his own keyboard, once — make a storage bucket for the family app and switch it on — and after that the family app stores pictures the same way the Hub already does. Everything works before he does it, and nothing waits on him. · **Tier:** FRONT
**Start when:** none — start now.
**Builder:** deepseek · **Builder backup:** qwen · **Checker:** sonnet · **Checker backup:** glm
**Files you may touch:** `projects/personal/family-app/functions/api/comment-upload.js` and `projects/business/business-app/app/functions/api/comment-upload.js`. **Never** either app's file store module — its three function signatures are frozen in section 3, contract C1, and nothing here needs them changed — and never the Hub's generated dist folder by hand.

**Do exactly this:**
1. In each upload route, add exactly one field to the answer: `where`, copied straight from what the store already returns for that write. Add nothing else and rename nothing — the to-do update threads read this answer and every existing field keeps its name, its type and its meaning.
2. Write the one keyboard item for Nick, in plain words with no file paths and no jargon, into the STATE.md companion beside this plan: what he will see on screen, where to click, and the two names he has to type — the bucket name `deck-family-files` and the binding name `FAMILY_FILES`.
3. Post that same item to Nick through the ordinary channel as a decision he can answer with a number and a word. Do not stop for it, do not re-ask, and do not hold any step behind it.
4. Build the read-back checker so it works either way: upload a small real picture, read the upload's `where`, fetch the file back through the app's own read route, and print both the store that served the write and whether the read succeeded.
5. Save the probe as `evidence/step7-where.sh` and its output as `evidence/step7.txt`.

**DEFINITION OF DONE:** an upload through each app answers with a `where` naming the store that served it, every field the to-do threads already read is unchanged, and the file reads back through the app's own route whichever store served it.
**PROOF:** `bash evidence/step7-where.sh` prints `FAMILY where=` followed by a store name, `HUB where=r2`, `READ BACK: 200` for both, and `EXISTING FIELDS: unchanged` · **FAILS IF:** any existing answer field is renamed, removed or retyped, or the read-back is anything but 200, or the probe reports a store name the store did not actually use.

**If the check fails:** the builder fixes and re-checks the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

### STEP 8 — The two changed screens measure zero
**FOR NICK:** nothing he notices directly; it is what stops the new control from quietly pulling the screen out of line with the design he approved. · **Tier:** POLISH
**Start when:** STEP 3, STEP 4 and STEP 6 are closed, and Sienna has re-signed the anchor map with the two new rows.
**Builder:** qwen · **Builder backup:** glm · **Checker:** deepseek · **Checker backup:** sonnet
**Files you may touch:** the evidence folder in this lane's own directory only. **Never** any application file — this step measures and never edits; a non-zero count loops the owning step's builder.

**Do exactly this:**
1. Run the fidelity checker in its selftest mode and confirm it prints zero mismatched properties and zero unmeasured anchors. Run it in its sabotage mode and confirm every compared property turns red. A measured run is not believed until both hold.
2. Run the measured check at 584 by 763 light and at 390 by 844 light, signed in as Nick, with the output saved.
3. Count only the Talk screen's anchors plus the two this lane added. The anchors section 2d excludes by name are not counted and are not reported as this lane's debt.
4. A non-zero count goes back to the builder of the step that owns the screen — STEP 3 for the family app, STEP 4 for the Hub — and this step re-runs. It never summons a second grader.
5. When the count is zero at both viewports, ask Sienna to grade taste once.

**DEFINITION OF DONE:** `mismatched properties: 0 · unmeasured anchors: 0` at both viewports, on the Talk screen's anchors plus the two new ones, reproduced once by this step's checker, followed by one grade from Sienna.
**PROOF:** `node projects/personal/skippy-app/design-directions/_pearl-fidelity-check.mjs --out evidence/step8-fidelity.txt` exits 0 and the saved output's last line reads `mismatched properties: 0 · unmeasured anchors: 0` at both viewports · **FAILS IF:** either count is above zero, or the selftest does not print zero and zero, or the sabotage run leaves any compared property green, or a screen reports NOT MEASURABLE.

**If the check fails:** the owning step's builder fixes and this step re-runs the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

### STEP 9 — The FINISH LINE signed off by an agent that built none of it
**FOR NICK:** he gets one sentence saying the whole thing works, from someone who did not build it. · **Tier:** POLISH
**Start when:** STEP 1 to STEP 8 are all closed.
**Builder:** fable · **Builder backup:** fable · **Checker:** opus · **Checker backup:** fable
**Files you may touch:** this plan file's SUMMARY and STEPS sections, and the evidence folder in this lane's own directory. **Never** any application file.

**Do exactly this:**
1. Dispatch one fresh session that built nothing in this lane. Give it F1 to F6 and nothing else — not the steps, not the proofs, not the evidence.
2. It drives each finish-line item itself on the live surfaces, signed in as the real person each item names, and returns pass or fail per item with the output it saw.
3. For F5 it walks the school app and the children's screens itself and reports whether any attach control, file route change or rendered-visual code is present.
4. Any fail goes back to the owning step's builder as one named failure; this step re-runs when that step closes again.
5. Write the result into this file's STEPS section as one dated VERIFIED line per step, each naming the checker and, for a screen step, containing the word browser.
6. Save the sign-off as `evidence/step9-finish-line.txt`.

**DEFINITION OF DONE:** all six finish-line items pass, driven first-hand by a session that built none of them, and this plan's STEPS section carries one dated VERIFIED line per step.
**PROOF:** `python3 projects/ops/agents/check_plan.py projects/ops/life-os/REGROUP-2026-09-08/plans/VISUALS/PLAN.md` prints PASS, and `evidence/step9-finish-line.txt` records six PASS lines with the output each was read from · **FAILS IF:** any item is recorded as passed without the output it was read from, or a screen item's VERIFIED line does not contain the word browser, or the sign-off session is one that built any step.

**If the check fails:** the owning step's builder fixes and this step re-runs the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

### STEP 10 — Postmortem appended to the registry
**FOR NICK:** nothing he notices; it is how the next build avoids whatever this one got wrong. · **Tier:** POLISH
**Start when:** STEP 9 is closed.
**Builder:** sonnet · **Builder backup:** haiku · **Checker:** qwen · **Checker backup:** glm
**Files you may touch:** `.claude/skills/plan/references/failure-registry.md` and this plan file's SUMMARY section. **Never** any application file.

**Do exactly this:**
1. Write what failed, what was confused and what to keep, in the registry's own four-column format, one row per distinct failure. "Nothing worth extracting" is a good answer and is written as such.
2. Append it to the registry in that format, with the date and this lane's name.
3. Rewrite this plan's SUMMARY section in plain prose for a cold reader.

**DEFINITION OF DONE:** the registry carries this lane's rows in its own four-column format, or one honest line saying nothing was worth extracting, and this plan's SUMMARY reads as current.
**PROOF:** `command grep -c "VISUALS" .claude/skills/plan/references/failure-registry.md` prints a count of at least 1 · **FAILS IF:** the count is zero, or the appended rows are not in the registry's four-column format, or the SUMMARY still describes work that is finished as if it were pending.

**If the check fails:** the builder fixes and re-checks the named failure until it passes. If this step cannot close from this machine: one line to the overseer naming the ONE missing thing, then the next step whose inputs exist.

**Checker's job:** re-run the PROOF yourself, once. PASS closes the step. Do not accept the builder's pasted output; do not summon anyone else.

## 4 · Regret Check (the registry failures this build is actually exposed to)

| Failure mode (registry entry) | The measure in THIS plan that prevents it | Where it lives (section / artifact / gate) |
|---|---|---|
| A second system was built because the first was invisible | The ownership finding is stated as the Gate Zero receipt: both file stores, both upload routes and both read routes already exist and are named with their paths; the anti-scope forbids a second store, upload route or read route in either app for any reason; contracts C1 and C2 freeze the existing ones by signature and answer shape | section 0 ownership check, section 1 NOT in scope item (h), section 3 contracts C1 and C2 |
| Deployed config silently diverged from source config | Every step edits the Hub's app source, never the generated dist mirror; STEP 4 runs the dependency copy and records the command that regenerated the mirror; STEP 4's file fence names the dist folder as never-touch-by-hand | STEP 4 items 1 and 3, STEP 4 and STEP 6 file fences |
| A generated mirror was hand-edited, or its generator never re-ran | The same rule covers the design target: STEP 3 regenerates the locked page from its generator and re-writes the hash with `shasum -a 256` rather than editing the published page, and the anchor map is re-signed by the design authority, not by a builder | section 2d locked target and anchor map lines, STEP 3 item 6 |
| A UI reported success while the backend silently failed | No step closes on a rendered chip: STEP 3, STEP 4 and STEP 6 each close on a live drive-through that reads the answer text back, and STEP 3 additionally requires the planted word to be found and the picture to survive a full reload; a zero bounding box is written into each step's FAILS IF | STEP 3, STEP 4 and STEP 6 proofs and FAILS IF lines |
| An interactive element or view shipped untested or unseen | Every visible element in the fifteen UX rows is driven, including the refusals, the phone viewport and the reload; section 2d measures both changed screens at both viewports and Sienna grades once after the count reaches zero | section 2 UX map, section 2d, STEP 8 |
| Done was declared before the live surface was checked | Every FRONT step's proof runs against the live app with a real signed-in session, and STEP 9's sign-off drives all six finish-line items first-hand; a screen step's VERIFIED line must contain the word browser or it is not banked | STEP 3, STEP 4, STEP 6 proofs; STEP 9 item 5; STEPS section rule |
| The builder graded its own work and passed it | Every row of the execution map names a CHECKER on a different model from its EXECUTOR, each checker re-runs the proof first-hand rather than reading pasted output, and STEP 9's sign-off is dispatched to a session that built nothing in this lane and is given only the finish line | section 3b map, every step's Checker's job line, STEP 9 item 1 |
| A hard prerequisite discovered after a decision, with no owner assigned, silently converts a made decision into an unimplementable one | The one thing only Nick can do — making and binding the family app's storage bucket — is named at plan time with its owner, and is deliberately NOT a prerequisite of anything: the store already works on its interim path, every step closes without it, and STEP 7's checker reports which store served the file either way | section 1a row 5, STEP 7, F6 |
| Novel to this build: the attachment is silently swallowed by a proxy that rebuilds every message | The attachment is frozen as a named top-level field rather than message content, because both proxies reject non-string content and rebuild each message as role plus content only; STEP 2's proof requires the field to reach the brain intact AND the old non-string refusal to still fire | section 1a row 3, section 3 contract C3, STEP 2 proof |
| Novel to this build: a returned chart carries a number the model made up | STEP 5 requires a returned visual to carry no figure the business gateway did not return in that same turn, with a missing figure left out and said in words; the proof prints whether every figure in the visual appears in the same turn's gateway result | STEP 5 item 5 and proof, F3 |

## 5 · Topology and roles

- **OVERSEER-AUTHORITY:** none named in this lane — the VISUALS driver holds it for the duration and appears in no CURRENT HOLDER table in `projects/ops/OVERSEER-AUTHORITY.md`. **The four approval classes (money leaving, credential rotation, irreversible destruction, a message sent as Nick to another human) and the floor (logins, credentials, tokens and keys, government IDs, card, bank account and routing numbers) never move on the overseer's word.**
- Thread layout: one overseer thread for the whole lane; every builder and every checker is a separate dispatch with its own file fence.
- Overseer: Fable or Opus, which never builds. Workers: glm, deepseek, qwen and sonnet by name from the matrix, plus opus on the two steps Nick's tiering ruling keeps there.
- State files location: STATE.md, QUESTIONS.md, ASSUMPTIONS.md and PLAN-CHANGES.md live beside this plan in this lane's own folder; this plan and that companion are the only two planning documents this lane ever has.
- **Board card id:** none yet.
- **Artefact consumers:** the road measurements of STEP 5 are consumed by STEP 6, which reads the frozen visual shape from contract C4 of this file. The fidelity output of STEP 8 is consumed by Sienna's single grade. The sign-off of STEP 9 is consumed by Nick, through the ordinary closing message, and by the STEPS section of this file. The keyboard item of STEP 7 is consumed by Nick directly and is proven to arrive by his own answer, never by an agent assuming it landed.
- **Write-contention (parallel lanes in a shared checkout):** STEP 1 and STEP 5 are the only steps that write the brain's server file and they never run at the same time; STEP 2 owns both chat proxies alone; STEP 3 owns the family app's voice client and STEP 6 takes it after STEP 3 closes, never beside it; STEP 4 owns the Hub's talk panel and STEP 6 takes it after STEP 4 closes; STEP 7 owns both upload routes alone. The ASSISTANTS lane also edits the brain's server file, so before each of STEP 1 and STEP 5 opens, the driver confirms with that lane that no edit of theirs is in flight, and the builder works from a private worktree and lands from it.

**Per-stage topology — counts DECLARED at plan time:**

| Stage | Overseer | Sub-overseers | Workers |
|---|---|---|---|
| 1 | 1 | 0 | 3 |
| 2 | 1 | 0 | 3 |
| 3 | 1 | 0 | 4 |

**The walk-away contract — a stranger resumes the drive from files alone:**
- **STATE FILE:** projects/ops/life-os/REGROUP-2026-09-08/plans/VISUALS/STATE.md
- **HEARTBEAT ROW:** the drive row id `visuals-2026-09-13` in `projects/personal/skippy-app/ala-state/work-threads.json`
- **MORNING-REPORT LINE:** one line in `projects/ops/walkaway/REPORT.md` reading VISUALS, pictures in and out of both apps, N of 10 steps closed, and the next step whose inputs exist

## 6 · Evals — what "working" means, decided now

| Capability | Check (exact command or procedure) | Pass looks like |
|---|---|---|
| F1 A screenshot in the family app is answered about its contents | An agent signed in as Nick drives the real attach control on the live Talk tab with a screenshot carrying a planted word, asks a question that cannot be answered from the typed text, and reads the answer back | The answer contains the planted word, read from the live screen, with the drive output saved |
| F2 The same in the Hub for a signed-in team member | The same drive on the live Hub Talk panel with a real signed-in team session, plus one call of the upload route with no session | The answer contains the planted word; the unauthenticated upload is refused by the route itself |
| F3 A money question is answered with a chart that renders and survives | An agent asks the money question on the live family app at 390 by 844, measures the visual's bounding box, checks the page for horizontal scroll, reloads fully, then compares every figure in the visual against the same turn's gateway result | Non-zero bounding box, no horizontal page scroll, visual still present after reload, every figure present in the gateway result |
| F4 Attachments and returned visuals are reachable only through each app's own route | An agent calls the family app's file read route for a real id as a caller the app would not admit and for an id that does not exist, and calls the Hub's upload and read routes with no session and with the robot bearer | A bare 404 in both family cases with no body revealing existence; the Hub refuses the sessionless caller and serves the bearer |
| F5 No kid-facing surface changed | An agent walks the school app and the children's screens on the live site and searches the children's shipped bundles for the attach control's id and the visual field's name | No attach control on any screen; zero hits for either name in the children's bundles |
| F6 An upload says which store served it, and reads back either way | `bash evidence/step7-where.sh` | The family upload prints a store name, the Hub upload prints r2, both read back 200, and every pre-existing answer field is unchanged |

## If you get stuck (all steps)

Before writing "blocked": (1) re-read the step's Start when line — most "stuck" is a misread gate, (2) try a concrete workaround, (3) write one line to the overseer naming the ONE missing artefact. Then keep working every other step whose inputs exist. Never idle on a blocker; never end a turn waiting on a background result.

## Your loop

Every pass: every step whose Start when inputs exist and which is not yet CLOSED is running, up to the cap; each builder runs its own PROOF and hands to its checker; PASS closes it, FAIL loops it; repeat until the FINISH LINE is proven.

## SUMMARY — a few plain-English lines, read by the status generator

**TRUE NOW (re-established 2026-09-14 by a regroup pass that re-ran every proof first-hand; 10 steps, 4 re-run, 22 minutes).** Exactly one thing on this list is genuinely working: both apps' message-sending code carries a picture's reference through to the assistant without dropping it, which is the plumbing underneath everything else. The tool that measures the two changed screens against the approved design is itself sound and honest — it passes its own self-test and correctly reports a failure when deliberately fed a broken screen — but it has never actually measured the live screens. Nothing else on this list has a working proof behind it. For seven of the ten items the proof script named in the plan was never written at all, so the recorded "0%" was accurate but hid the reason: not "started and unfinished", but "never begun". The picture-in, picture-out experience Nick asked for does not exist on either screen today.

**LEFT.** Nine of the ten steps. The assistant seeing a screenshot at all; the attach control on the family app's Talk tab; the same on the business screen; the assistant sending a picture back; both apps drawing a returned picture; the upload reporting which store served it; the real design measurement; the final sign-off; and the write-up of lessons learned.

**NEXT PICKUP, and it is a question before it is a build.** 🔴 A cold reader correctly objected that the claim below is UNSOURCED — no file and no session is named for it, which breaks this plan's own rule that a fact cites where it came from. Treat it as a question to put to Nick, not as an established fact, and settle it by ASKING HIM rather than by searching. A note said to be from earlier this week records that Nick already said this lane's original goal was wrong — he wanted a way to share pictures into a running task thread, not into the general assistant chat — and that a different, working version of that was built and switched on. This plan's own tracking was never updated to say so, which is why it still reads as though nothing exists. Settle which of the two is the real target before spending another hour against the original checklist. Nothing else in this lane should start until that is answered.

**One thing genuinely needs Nick, once:** making a storage bucket for the family app and switching it on, so the family app stores pictures the way the business one already does. Everything works without it in the meantime, so no step waits on him.

## STEPS

<!-- The live status checklist, read by the status generator. The heading
     above must be exactly "## STEPS" with nothing else on the line. -->

```
1. The brain sees an image — 0%
   DEFINITION OF DONE: a chat turn carrying a valid attachment is answered with a sentence naming something visible only in the image; a refused type is a 400 naming the type and the allowed list; no pending-memory row is written for either turn
   PROOF: `bash evidence/step1-image-turn.sh` then the evidence re-runner over evidence/step1.txt
   VERIFIED: REGROUP 2026-09-14 · FAILED — CORRECTED BY A COLD READER, and the correction verified. The named proof script does not exist, but this lane's own evidence file records a live test that RAN and came back with the word FAIL. A completed test that failed is FAILED, not 'never achieved'. Evidence: evidence/step1-live-2026-09-13.txt line 29, and regroup-audit-2026-09-14.txt

2. Both chat proxies carry the attachment — 100%
   DEFINITION OF DONE: the attachment field survives both proxies byte for byte, and a non-string message content is still refused exactly as before
   PROOF: `command grep -c "attachment" projects/personal/family-app/functions/api/skippy-chat.js projects/business/business-app/app/functions/api/skippy-chat.js`
   VERIFIED: REGROUP 2026-09-14 · PROVEN — the attachment count is non-zero in BOTH chat proxy files and is backed by real matching code, not a stray comment. Re-run first-hand, evidence in regroup-audit-2026-09-14.txt

3. [UI] The family app's Talk tab can attach — 60%
   DEFINITION OF DONE: an agent signed in as Nick drives the real control, attaches a screenshot, gets an answer naming something visible only in the picture, reloads, and the picture is still there
   PROOF: `node projects/shared-tooling/test-chrome.mjs --script evidence/step3-attach-drive.mjs`
   VERIFIED: REGROUP 2026-09-14 · PARTLY PROVEN — CORRECTED BY A COLD READER, and the correction verified by opening the evidence. The attach control on the family app's Talk tab IS BUILT AND CONFIRMED LIVE: this lane's own evidence records it rendering, wiring up, uploading through the existing route, carrying the attachment on send, and refusing anything over 5MB. What cannot pass is the step's FULL bar — an answer naming something visible only in the picture — because that depends on step 1, which failed. The earlier verdict of 'artefact gone' was markedly harsher than the evidence on file. Evidence: evidence/step3-live-2026-09-13.txt, and regroup-audit-2026-09-14.txt

4. [UI] The Hub's Talk panel can attach — 0%
   DEFINITION OF DONE: the same drive on the live Hub as a real team member, and the same upload refused when called with no session
   PROOF: `node projects/shared-tooling/test-chrome.mjs --script evidence/step4-hub-attach-drive.mjs`
   VERIFIED: REGROUP 2026-09-14 · UNPROVEN: ARTEFACT GONE — the named drive script evidence/step4-hub-attach-drive.mjs exists nowhere on disk. Re-run first-hand, evidence in regroup-audit-2026-09-14.txt

5. The brain returns a visual, on a recorded road — 0%
   DEFINITION OF DONE: the road decision with four numbers per road is written into this plan, one road is built, and a money answer carries a visual whose every figure appears in the same turn's gateway result
   PROOF: `bash evidence/step5-visual-turn.sh` then the evidence re-runner over evidence/step5.txt
   VERIFIED: REGROUP 2026-09-14 · UNPROVEN: ARTEFACT GONE — no script, and this plan itself records no road decision, which its own definition of done requires. Re-run first-hand, evidence in regroup-audit-2026-09-14.txt

6. [UI] Both apps render a returned visual — 0%
   DEFINITION OF DONE: the visual draws inside the turn on both live surfaces, is legible at 390 by 844 with no sideways page scroll, and survives a full reload
   PROOF: `node projects/shared-tooling/test-chrome.mjs --script evidence/step6-visual-render-drive.mjs`
   VERIFIED: REGROUP 2026-09-14 · UNPROVEN: ARTEFACT GONE — the named drive script evidence/step6-visual-render-drive.mjs exists nowhere on disk. Re-run first-hand, evidence in regroup-audit-2026-09-14.txt

7. The upload says which store served it, and the bucket item goes to Nick — 0%
   DEFINITION OF DONE: each upload answers with a store name, every field the to-do threads read is unchanged, and the file reads back whichever store served it
   PROOF: `bash evidence/step7-where.sh`
   VERIFIED: REGROUP 2026-09-14 · UNPROVEN: ARTEFACT GONE — the named probe script evidence/step7-where.sh exists nowhere on disk. Re-run first-hand, evidence in regroup-audit-2026-09-14.txt

8. [UI] The two changed screens measure zero — 0%
   DEFINITION OF DONE: mismatched properties 0 and unmeasured anchors 0 at both viewports on the Talk screen's anchors plus the two new ones, then one grade from Sienna
   PROOF: `node projects/personal/skippy-app/design-directions/_pearl-fidelity-check.mjs --out evidence/step8-fidelity.txt`
   VERIFIED: REGROUP 2026-09-14 · UNPROVEN: NOT MEASURED — the fidelity instrument itself is sound (its selftest and its sabotage mode both behave correctly), but no real measured run has ever completed; the run hung past 60 seconds and the step is gated behind unclosed steps 3, 4 and 6. Re-run first-hand, evidence in regroup-audit-2026-09-14.txt

9. The FINISH LINE signed off by an agent that built none of it — 0%
   DEFINITION OF DONE: all six finish-line items pass, driven first-hand by a session that built nothing in this lane
   PROOF: `python3 projects/ops/agents/check_plan.py projects/ops/life-os/REGROUP-2026-09-08/plans/VISUALS/PLAN.md`
   VERIFIED: REGROUP 2026-09-14 · UNPROVEN: ARTEFACT GONE — the plan-structure gate passes, but the actual six-item finish-line sign-off was never produced, and that is what this step's definition of done asks for. Re-run first-hand, evidence in regroup-audit-2026-09-14.txt

10. Postmortem appended to the registry — 0%
    DEFINITION OF DONE: this lane's rows are in the registry's four-column format, or one honest line saying nothing was worth extracting, and the SUMMARY reads as current
    PROOF: `command grep -c "VISUALS" .claude/skills/plan/references/failure-registry.md`
    VERIFIED: REGROUP 2026-09-14 · FAILED — the registry search for this lane's name returns zero, which is exactly the stated failure condition. Re-run first-hand, evidence in regroup-audit-2026-09-14.txt
```

- **DONE (green) requires ONE dated `VERIFIED:` line from the independent checker** — the builder's own proof run is not a `VERIFIED:` line. A `[UI]` step's `VERIFIED:` line must contain the word "browser" (a real logged-in drive-through). A `VERIFIED:` line naming a failing count keeps the step open at that percentage.
- **IN PROGRESS shows a percentage**; **NOT STARTED** = no `VERIFIED:` line.

## POSTMORTEM — the 2026-09-14 regroup

**Every failure, every confusion, every blocker, with the concrete example each time.**

- **The biggest failure is not a broken build, it is a plan nobody told when the goal changed.** Nick redirected this lane earlier in the week — pictures into a running task thread, not into the general assistant chat — and a working version of the new thing was built and turned on. This plan was never edited to say so. A cold reader picking it up would have spent a full day building the superseded thing. **Keep:** the moment a north star changes, the plan's own north star line is the first edit, before any code.
- **Seven of ten steps named a proof script that was never written.** The plan recorded them at 0%, which was technically honest, but "0%" and "the instrument for judging this does not exist" read identically on a status screen and mean very different things. **Keep:** a step's proof script is written before the step is worked, so an unwritten proof is visible as its own failure rather than hiding inside a percentage.
- **One instrument was sound and unused.** The design-fidelity checker passes its own self-test and correctly fails a deliberately sabotaged screen. It was built properly and then never pointed at anything. That is the one piece of this lane worth keeping as-is.
- **A real measured run hung past sixty seconds and was abandoned.** It is gated behind three unclosed steps, so it could not have produced a meaningful number anyway; the hang is a symptom of running a check before its inputs exist, not a fault in the checker.
- **What went well:** the two apps' file store, upload door and read door were built in August for the to-do threads and genuinely work; this lane never needed to build storage from scratch and correctly did not try. The one step that passed is the one that extended existing code rather than inventing a second version of it.