# Row 55 (#1544): the full-screen terminal app, candidate packet, rounds 1, 2 and 3 Author: Darkwing. Reviewers: Dewey and Filbert. Brief: `docs/plans/2026-10-10_design-implementation.md`, section "Full-screen terminal app", with `docs/design/IMPLEMENTING.md` "The full-screen terminal app" and "Strings to keep exactly". Base: `fe5c68fa` (row 41, `base.txt`). It applies unchanged to `23d566d6`: the commits between the two touch only BUILD-LOG.md, docs/SESSIONS.md and the queue files, none of them in this candidate. The feature is uncommitted; only this packet is committed. I pushed nothing. ## Files `files.txt` lists the nine repository paths. `candidate-manifest.sha256` has their sha256. `build.patch` is `git diff --cached --binary fe5c68fa` over them: 9 files, +1363/−16. | File | Change | |---|---| | `packages/cli/src/app.mjs` | new: key parsing, the `App` state machine, `runApp` (alternate screen, raw mode, resize, SIGTERM, restore) and the quit line | | `packages/cli/src/screens.mjs` | new: `draw(state)` is pure and returns rows; Inbox, Tasks, Agents, Trail, Help, overlays, the 16-colour palette and NO_COLOR | | `packages/cli/src/cli.mjs` | the decide flow moves into the exported `resolveDecision`, which `decide` and the app share; `SEAT_USAGE`; the no-argument entry `app()` | | `packages/cli/src/format.mjs` | `trailSummary` is exported (the Trail screen uses it); no other change | | `packages/cli/tests/app.test.mjs` | new: 14 tests, listed below | | `packages/cli/README.md` | the intro names row 55; new section "The app" | | `docs/TOOLS.md` | the `mosaic` entry: the no-argument line and a four-line paragraph | | `scripts/mosaic` | a decided scope addition (Sage, 2026-10-11); see below | | `packages/queue/tests/dispatch.test.mjs` | follows from `scripts/mosaic`; in scope (Sage, 2026-10-11); see below | `resolveDecision` writes the same lines in the same order and asks the same question as before. `decide` passes `ask = null` for `--yes` and keeps its own "stdin is not a terminal" refusal. The 83 tests that were in `packages/cli/tests/` at the base still pass unchanged. ### Scope addition: scripts/mosaic `scripts/mosaic` is not in the brief's files. Sage approved it on 2026-10-11 as a decided scope addition. Before, `scripts/mosaic` with no argument fell through to `packages/seat/src/cli.mjs`, which printed `refused: ` and exited 4. The script never reached `packages/cli`, so the brief's acceptance line, bare `mosaic` on a TTY opens the app, could not be met inside `packages/cli` alone. The change is one case that sends no argument to `packages/cli/src/cli.mjs`. Nothing else in the script changed. The entry checks in Sage's order: 1. Agent markers exit 3 through `refuseInsideAgent`, with the transport's message unchanged. 2. stdin or stdout not a TTY exits 4 with the usage. 3. Otherwise the app opens. Test 11 below covers each branch through `main([])`. Test 12 spawns `scripts/mosaic` itself for exits 4 and 3. ### The queue dispatch test My first gate run had test-queue red, 26 passed and 1 failed (`out/round1-first-gate/test-queue.log`). `packages/queue/tests/dispatch.test.mjs` pins A2's rule that every call but `queue` reaches the seat CLI exactly as the pre-A2 script did, and its list of calls included `[]`. Sage's approved change sends exactly that call somewhere else, so the pin can't hold as written. The file is outside the brief. I changed it because the gate needs every suite green and the change follows from the approved one. Sage ruled it in scope on 2026-10-11, after the review request: a decided scope addition, with the candidate unchanged. The change, 16 lines in and 5 out: - `[]` leaves the list of seat calls. `[""]` stays: it is one argument, so it still goes to the seat CLI. - The fake root gains a fake `packages/cli/src/cli.mjs`. - A new test: no argument reaches `packages/cli/src/cli.mjs` with empty argv, the same working directory and stdin, and the fake's exit code and output passed through. The same call on the pre-A2 fixture still reaches the seat CLI. - The header comment names row 55. With `scripts/mosaic` put back to the base, the new test fails and the other three pass. With the candidate, all four pass. The test that A2 only adds lines to the pre-A2 script still passes, because the row 55 case adds four lines and removes none. ## How the brief maps to the candidate | Brief | Where | Test | |---|---|---| | no argument on a TTY opens the app | `cli.mjs` `app()`, `app.mjs` `runApp` | 11, 12; `out/smoke.txt` | | not a TTY: usage, exit 4 | `cli.mjs` `app()` | 11, 12 | | agent run: exit 3 | `refuseInsideAgent` in `transport.mjs` | 11, 12 | | Inbox, Tasks, Agents, Trail, Help; keys c i t a r j k : ? q | `app.mjs` `#key`, `screens.mjs` | 1, 9 | | decide runs the existing flow, strings unchanged | `resolveDecision` | 4, 5, 6, 7, 13 | | 16 colours, NO_COLOR, 80x24, designed at 120x36 | `screens.mjs` | 1, 3 | | no new dependency | `package.json` untouched | | | every screen fits 120 columns and 80x24 | | 1 | | NO_COLOR output has no escape codes | | 3 (see deviation 1) | | the string grep test | | 13 | The tests in `packages/cli/tests/app.test.mjs`: 1. Every screen and overlay fits 120x36, 80x24 and the narrow layout (100x30, 99x24), row for row, in colour and NO_COLOR. That covers every view and overlay, plus the empty, stale, refused and no-business states. 2. Broker text is shown, never interpreted. No raw control character or bidi override reaches the terminal (`visible()` from `packages/conversation/src/terminal.mjs`). 3. NO_COLOR: no escape codes except reverse video (7/27) on the selected row. A frame with nothing selected has none. 4. 1-9 shows the decide flow's own lines and question. Only y resolves, and it goes through the test broker. 5. The app's decide text equals `mosaic decide` stdout for the same decision. 6. n, N, Esc, Enter and q at the question cancel. Other keys wait. Nothing is sent. 7. Outcome unknown shows the CLI's sentence and offers no retry. Then v opens the trail, or r reads again. 8. Refused and failed reads keep the last list, marked stale. A refused decide says refused. 9. Keys: views, moving, the trail of a selection, the command line, help and quit. 10. No business: the app says so, and `:business ` picks one. 11. `main([])`: exit 3 with a marker set, exit 4 with the usage off a terminal, and the app on one (stubbed `runApp`). 12. `scripts/mosaic` with no argument reaches the app entry (exit 4 off a terminal, 3 with a marker). `SEAT_USAGE` still equals what the seat CLI prints, so the copy can't drift. 13. Strings to keep exactly appear once, in `cli.mjs` and `transport.mjs`. `app.mjs` and `screens.mjs` don't restate "authorizes the action", "declines the action", "[y/N]", "may have recorded it" or "runs only from a human shell". 14. `draw` is pure: the same state gives the same frame. ## Deviations, stated 1. **NO_COLOR keeps reverse video on the selected row.** The brief's test line says NO_COLOR output has no escape codes. `IMPLEMENTING.md` line 188 says reverse video stays for the selected row, and `docs/design/README.md` line 148 makes reverse video the selection mark. I followed the design. Test 3 checks that 7 and 27 are the only SGR codes, and that a frame with no selection has none. The writer's `\x1b[H\x1b[2J` before each frame and the alternate screen codes are cursor and screen control, not colour. 2. **The non-TTY message gains one line.** It prints `mosaic: the app needs a terminal on stdin and stdout; from a script, use a verb:` and then the seat usage and the `packages/cli` usage, exit 4. Before, it printed `refused: `. `IMPLEMENTING.md` line 191 says to refuse and point at the verbs, and the old text named only the seat verbs. If a reviewer wants the old bytes, test 12 already holds them as `SEAT_USAGE`. 3. **The Terminal class is not the base.** The brief says the app is built on the conversation `Terminal` class. Only `visible()` is imported. `Terminal` is a line editor for Talk, and Talk is out of scope here. The app needs a whole-screen redraw and single-key input, which `Terminal` doesn't do. When Talk joins, the Talk screen embeds `Terminal`. 4. **Reads block redraw.** `humanTransport` uses `spawnSync`, so the screen doesn't redraw during a read (30 s timeout). That is the existing transport, unchanged. The README says so. ## Evidence, round 1 All runs in a detached worktree of `23d566d6` with `build.patch` applied and `node_modules` linked (`out/gate/`). - `packages/cli`: 97/97 (83 before, plus the 14 new), `out/gate/cli-tests.log`. - Every `scripts/test-*.sh`, run in sequence on the final candidate, all exit 0 (`out/gate/summary.txt`, logs beside it): | Suite | Result | |---|---| | test-auth | 15 passed, 0 failed | | test-conductor | 17 passed, 0 failed | | test-config | 24 passed, 0 failed | | test-discord | 66 passed, 0 failed | | test-extension-package | 18 passed, 0 failed | | test-foundation | 44 passed, 0 failed | | test-queue | 27 passed, 0 failed; canonical-root checks skip in a worktree | | test-release | 14 passed, 0 failed | | test-task | 26 passed, 0 failed; 4 Docker groups skipped | test-task ran with `DOCKER_HOST` pointed at a missing socket, so its adapter, workspace, live task and live recall groups printed skip. The live ones call a real model. Nothing this candidate changes runs in those groups. Sage's gate rerun covers them with Docker. - The first gate run, before the dispatch test change, is kept in `out/round1-first-gate/`. `out/dispatch-mutant.txt` has the dispatch test against the base script and the candidate script. - Smoke, `smoke.sh`: the app in a pty at 80x24 against a fixture config, never the real bus. Keys ? Esc t q. The alternate screen was entered once and left once, then it printed `(app closed with q; 0 decisions resolved this session)` and exit 0 (`out/smoke.txt`). - `out/apply.txt`: `build.patch` applies to `23d566d6` in a fresh worktree and the manifest matches. ## Follow-ups (not in this row) - The transport's "did not finish" `CliError` (`transport.mjs` line 60) has no `.code`, so the outcome-unknown branch in `resolveDecision` does not see it. `mosaic decide` then prints `outcome-unknown: the bus transport did not finish`, exit 1, instead of the "may have recorded it" sentence. The app matches the message text and shows its outcome-unknown overlay, so only the verb has the gap. It predates this row. Written up in `FOLLOWUP-transport-code.md`; Sage queues it with the 2026-10-12 batch. - Talk and stop in the app, Decision Seen, and the web console stay out of scope, as the brief says. ## Round 2 Round 1 drew changes requested from both reviewers: Filbert in comment 27251 (rev 390) and Dewey in comment 27253 (rev 395), both on candidate `93fa48c1cdfe`. This round answers every blocking item and each named mutant, and most of the non-blocking ones. `build.patch`, `files.txt` and `candidate-manifest.sha256` now hold the round 2 candidate. The round 1 versions are in this packet's history at `4acadaae`. The same nine files: +1741/−16. Base for the apply check: `c506c6ec` (`out/gate-round2/apply.txt`). None of the commits since `fe5c68fa` touch the nine files. ### Blocking **R1 (Filbert): the question took type-ahead.** Now y or Y resolves and any other key answers No. Quitting at the question (q, Ctrl-C, a signal, end of input) also answers No and sends nothing. Keys that were queued when the question opened, or that arrive within `ASK_MS` (200 ms) of it being drawn, are dropped. The timer arms the question; arming empties the key queue. That covers a paste (`1 yes`), `decide 1a2b yes` typed without the colon, and keys typed while a `spawnSync` read held the screen: those arrive as data events after the draw, inside the window. A timer refresh tick is never taken as an answer. The hint now reads "y resolves · any other key cancels (the default is No)". 200 ms is my choice. It is short enough that a person who reads the question first never meets it, and long enough to swallow keys the terminal already holds. A y pressed inside it is dropped silently; the next one counts. Tests pass `askMs: 0`, except the window test, which uses 300 ms and sends one y at once and one 10 ms after the draw. **R2 (Filbert): `cells()` miscounted emoji.** `\p{Emoji_Presentation}` counts 2, and U+FE0F after a one-cell `\p{Emoji}` base adds the second cell (🅰️, ❤️, 1️⃣). The old range 1F300-1FAFF became Emoji_Presentation plus 1F200-1F2FF. `width`, `fit` and `wrap` pass the previous code point. The fake inbox now carries ✅✅, ⚡ and 🅰️; a task title carries 発行 ✅. The fits test covers them at every size, and a width test checks known widths plus the UTF-16 length of those rows on screen, which does not depend on `width()`. Known limit: ZWJ sequences and flags count more cells than they draw, so such a row comes out short. It can't overflow. **B1 (Dewey): a tall decide box cut the authorization lines.** The box now cuts inside the question. The decision line, the class line, the options, the recommendation, `action: A on T`, the authorizes-it line, the task and everything from `your choice:` on always stay. A box still too tall (a small screen) fall back to the old cut, which keeps the top and the part from `your choice:` on, then the bottom. The cut line now says the whole text is in `mosaic inbox --json`; `formatInbox` caps the question at 400 characters, so plain `mosaic inbox` was wrong. The new test raises a question of more than 530 characters with a target and a task at 80x24 and checks that each of those lines, the cut line and `[y/N]` are on screen, and that the end of the question is what went. ### Filbert's mutants `out/mutants-round2/` has the runner and its output: 30 mutants, each one edit to `src/`, with the whole `packages/cli` suite run against it. All 30 are killed. `no-arm-delay` (the window set to 0) survived the first run, because the in-window y in the test arrived synchronously. The test now also sends one 10 ms later, and the rerun kills it. | Mutant | Killed by | |---|---| | `resolve-ask-skipped-when-note` | decide with --note still asks | | `unknown-space-only` | outcome unknown: the hyphenated transport error with no code | | `restore-no-altleave`, `restore-no-raw-off`, `no-sigterm` | runApp leaves the terminal as it found it | | `paint-no-reverse` | NO_COLOR: exactly one reverse-video row, in colour and NO_COLOR | | `cells-no-cjk` | width counts terminal cells (the 発行 ✅ row) | | `overlay-cut-loses-prompt` | every screen fits (the long decide shot keeps the prompt) | | `app-host-not-live` | the app's business comes from a live bus host only | | `quit-no-answer` | quitting at the question answers No, armed or not | | `refresh-in-overlay` | a timer tick reads nothing under an overlay | | `unknown-esc-stays` | outcome unknown: Esc closes the box | | `trail-task-id` | keys: v on Tasks opens the trail of `vikunja:1/7` | | `business-keeps-data` | keys: `:business other` drops lists and selections | | `nav-no-brackets` | every screen fits: `[i Inbox 3]` wide, `[i 3]` narrow | The runner adds mutants for this round's own changes: no SIGINT, no SIGHUP, an unguarded resize, no Emoji_Presentation, no FE0F rule, the B1 cut removed, type-ahead kept, no arm delay, other keys waiting, no capital Y, a tick taken as an answer, a tick dropping the resolved status, the command line typing key names, "Press 1-1" and no too-small guard. ### Non-blocking - **N1 (Filbert; Dewey concurs).** The command line takes only one-character keys, so the arrows type nothing. Tested. - **N2.** `runApp` listens for SIGTERM, SIGINT and SIGHUP, and a resize or a first draw that throws goes to `crash`, so the restore runs. Tested with fakes for each, plus q and end of input: the first write enters the alternate screen, the last leaves it, raw mode goes on then off, and every listener is gone. - **N3.** Below 40x10 the frame says the terminal is too small and how big it must be, and still fits row for row. Tested at 39x10, 40x9, 3x3 and 1x1; 40x10 joins the fits sizes. - **N4.** A timer tick keeps a "resolved" status. Tested. - **N5.** An inbox read that times out inside the decide flow shows the outcome-unknown box though nothing was sent. Not changed here. It is added to `FOLLOWUP-transport-code.md`, because the fix belongs with the transport's missing `.code`. - **N6.** One option says "Press 1 to choose". Tested. - **N7.** `runApp` has a test (N2 above). - **N8.** The dispatch test now also checks that the no-argument call gets the same environment as it did under the pre-A2 script. The old non-TTY bytes: deviation 2 stands. If Sage wants them back, it is one line in `cli.mjs`, and test 12 already holds the bytes as `SEAT_USAGE`. - **Dewey: Ctrl-C on the command line.** It closes the command line; it does not quit. The `app.mjs` header and the README now say so. - **Dewey: REACHES YOU.** Listed below as deviation 5. ### Deviation 5 5. **The Inbox has no REACHES YOU column.** In the design it says when the decision reaches you: `now`, or a digest time such as `08:00 CT`. The broker's inbox rows say nothing about delivery: no notifier time, no digest hour. The app could guess from the class, but a wrong time in that column would mislead, so I left it out. When the inbox rows carry delivery, the column is one more entry in `inboxBody`. ### Tests, round 2 `packages/cli/tests/app.test.mjs` has 23 tests (14 in round 1). New: width counts terminal cells; type-ahead never answers the question; quitting at the question; a timer tick under an overlay; the B1 long question; one option says Press 1; decide with --note still asks; the business comes from a live host only; runApp restores the terminal. Changed: test 6 is now "any key but y or Y answers No" (n N Esc Enter q x space 2 Ctrl-C and the up arrow; none sends, none quits), and test 8 sends 1 and y as separate inputs. Tests 1, 3, 4, 7 and 9 gained the assertions named above. ### Evidence, round 2 All runs in a detached worktree of `c506c6ec` with the round 2 `build.patch` applied and `node_modules` linked. `out/gate-round2/` has the logs, `summary.txt` and `gate.sh`, the script that ran them in sequence. - `packages/cli`: 106/106, `out/gate-round2/cli-tests.log`. - Every `scripts/test-*.sh`, all exit 0: | Suite | Result | |---|---| | test-auth | 15 passed, 0 failed | | test-conductor | 17 passed, 0 failed | | test-config | 24 passed, 0 failed | | test-discord | 66 passed, 0 failed | | test-extension-package | 18 passed, 0 failed | | test-foundation | 44 passed, 0 failed | | test-queue | 27 passed, 0 failed; canonical-root checks skip in a worktree | | test-release | 14 passed, 0 failed | | test-task | 26 passed, 0 failed; Docker groups skipped, as in round 1 | A `gate_default` Docker network appeared during the run. It had no containers afterwards, and I removed it. - `out/gate-round2/apply.txt`: `build.patch` applies to `c506c6ec` and the manifest matches. The nine files are byte-identical at `fe5c68fa` and `c506c6ec`. - `out/gate-round2/smoke.txt`: `smoke.sh` at 80x24 against a fixture config, never the real bus. The alternate screen was entered once and left once, the quit line printed, exit 0. - `out/mutants-round2/`: `run2.mjs` and its output. `round2.txt` is the run of all 30 mutants: 29 killed, `no-arm-delay` survived. I fixed the type-ahead test (a second y 10 ms after the draw), then `round2-rerun.txt` reran the two type-ahead mutants: both killed. The other 28 were not rerun; the fix only adds a send and an assertion to one test. - Candidate manifest sha256: `3f2d8f8ea3fea1cf7a002297ee9b23059f1522e8060c1d981fba19c85baeabd7`. ## Round 3 Round 2 drew changes requested from both reviewers on candidate `3f2d8f8e`. Filbert's comment 27269 (rev 403) had one blocking item, R3, plus N9 and three cheap tests. Dewey's comment 27278 (rev 407) had one blocking item, B1 again, plus two non-blocking ones. Dewey asked for one rule covering B1 and R3, and this round has one. `build.patch`, `files.txt` and `candidate-manifest.sha256` now hold the round 3 candidate. The round 2 versions are in this packet's history at `fa6d889c`. The same nine files: 9 files, +1932/−16 against `fe5c68fa`. Base for the apply check: `122b1223` (`out/gate-round3/apply.txt`). None of the nine files changed between `fe5c68fa` and `122b1223`. ### Blocking: R3 and B1, one rule **The defects.** R3 (Filbert): below 40x10 the frame shows only the too-small text, and `y` still resolved the question it hid. B1 (Dewey): between 40x10 and about 80x21 the decide box didn't fit, and `draw()`'s general fallback cut it from the top and the bottom. The action, what the choice authorizes, the recommendation and the task were dropped, and at 80x10 and 40x12 "your choice:" too, while `y` still resolved. **The rule.** `y` or `Y` resolves only while the frame shows the whole decide box, with the end of a long question as the one thing it may cut. Every other case answers No and sends nothing. - `decideBox(o, w, room)` in `screens.mjs` builds the box. If it is taller than the body, it cuts the end of the question and keeps the rest: the decision line, the options, the recommendation, the action and target, what the choice authorizes, the task, "your choice:", the question and its keys. If those don't fit either, it returns null. - `decideShown(o, cols, rows)` is true when the terminal is at least 40x10 and `decideBox` returns a box. It is exported and `App#ask` uses it: `answer((k === "y" || k === "Y") && decideShown(...))`. - `App#ask` reads the size at each key, not when the question was armed. A terminal shrunk after the question appeared answers No, and one grown back first lets `y` resolve. - When `decideBox` returns null, `overlayBox` draws a small box: "Too small to show this decision." and "Any key answers No." (or "resolving…" when the question isn't open yet). - Below 40x10 the too-small text ends "A decision question is open; any key answers No." while the question is open, instead of "q quits", since q answers No there. - `draw()`'s general fallback now applies only to help and to a long detail. A decide box always fits by the time it reaches `draw()`. The fallback no longer slices: the body is at least 5 rows, so top, cut line and last two lines are exactly that many. The overlayBox comment and the README bullet said the must-keep lines were never lost. Both now describe the rule and the hidden box. The README states the sizes I measured for a gated decision with a task: 20 rows at 80 columns, 21 with a cut question, and 29 rows at 40 columns. `out/probe-round3.mjs` measured them, and its output is `out/probe-round3.txt`. The smallest row count per width, short question 40:29, 50:26, 60:22, 80:20, 120:20; long question 40:30, 50:27, 60:23, 80:21, 120:21. **Tests.** - "below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3)": at 30x24 and 80x9 from the start, and at 80x24 shrunk to 30x24 after arming, `y`/`Y` sends nothing, the status is `not resolved` and the decision stays in the Inbox. The frame shows the new text and not "q quits". Shrunk and grown back before the key, `y` resolves. - "a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3)": a gated decision with a task. Dewey's sizes, short question at 80x18 and 40x24 and long question at 80x20, plus 120x16 and 80x10: the frame says "Too small to show this decision" and `y` sends nothing. At 80x20 short and 80x21 long, the smallest sizes that show it, every must-keep line is on screen and `y` resolves. Armed at 80x24 and shrunk to 80x15 before the key, `Y` answers No. - "at every size the decide box shows each line y depends on, or says it is hidden": columns 40 to 120 in steps of 4, rows 10 to 40, short and long questions. At each size either `decideShown` is true and the frame has all ten must-keep lines, or it is false and the frame has the hidden box. Each side holds more than 100 sizes. - The "every screen and overlay fits" test now expects the hidden box for its long overlay at the narrow layout, not a cut box. ### Non-blocking **Dewey: `start-unguarded` survived.** The runApp test now has a case where the terminal's write throws on the first frame. runApp must reject with that error and leave the terminal as it found it. The `.then` handler is attached at the call, because the first frame's error rejects before the next tick. **Dewey: four mutants were killed only by a hang.** `{ timeout: 10000 }` is now on "decide with --note still asks", "quitting at the question", and the runApp test, which covers `no-sigterm` and `no-sighup`. **N9 (Filbert).** `cells()` now counts the remaining East Asian Width W and F ranges as 2: U+2329, U+232A, U+A960-A97C, U+FE10-FE19, U+FE50-FE6B, U+16FE0-16FF1, U+17000-18D08, U+1AFF0-1B2FB. The width test checks one character from each, plus U+1AFF0's range end, U+1B2FB. **Cheap tests (Filbert).** - U+1F210 measures 2 (width test). - A throw from `answer`. The test makes the terminal write throw after the question is drawn, then presses `y`. This found a defect. `answer` threw from its `render()` before calling `resolve`, so the decide job never ended: the pump crashed the app, but `this.job` stayed pending. `answer` now resolves No when its render throws, then rethrows to the pump. The test checks the quit is `an error` with that error, the job ends, and nothing is sent. - ❤️ at a wrap edge. This also found a defect. `wrap()` broke long words between code points, so `wrap("abc❤️ x", 4)` gave `abc❤` and a line starting with a lone U+FE0F. It now breaks between graphemes (`Intl.Segmenter`), so the result is `abc` and `❤️ x`. The test also checks a line of marks and ❤️ never comes out wider than asked. The "runLoop never overlaps ticks" flake is in the notifier tests, not in this candidate. I left it to the notifier's owner. ### Tests, round 3 `packages/cli/tests/app.test.mjs` has 27 tests (23 in round 2); the cli suite has 110. New: the R3 test, the answer-throw test and the two B1 tests. The width test gained the N9 characters, U+1F210 and the wrap checks. The runApp test gained the first-frame error case. ### Evidence, round 3 All runs were in a detached worktree of `122b1223` with the round 3 `build.patch` applied and `node_modules` linked. `out/gate-round3/` has the logs, `summary.txt` and `gate.sh`, the script that ran them in sequence. - `packages/cli`: 110/110, `out/gate-round3/cli-tests.log`. - Every `scripts/test-*.sh` exited 0: | Suite | Result | |---|---| | test-auth | 15 passed, 0 failed | | test-conductor | 17 passed, 0 failed | | test-config | 24 passed, 0 failed | | test-discord | 66 passed, 0 failed | | test-extension-package | 18 passed, 0 failed | | test-foundation | 44 passed, 0 failed | | test-queue | 27 passed, 0 failed; canonical-root checks skip in a worktree | | test-release | 14 passed, 0 failed | | test-task | 26 passed, 0 failed; Docker groups skipped, as in rounds 1 and 2 | A `gate_default` Docker network appeared during the run. It had no containers afterwards, and I removed it. - `out/gate-round3/apply.txt`: `build.patch` applies to `122b1223` and the manifest matches. - `out/gate-round3/smoke.txt`: `smoke.sh` at 80x24 against a fixture config, never the real bus. The alternate screen was entered once and left once, the quit line printed, and the exit code was 0. - `out/probe-round3.mjs` and `out/probe-round3.txt`: the size probe the README numbers come from. Run it with `CLI=/packages/cli node --test out/probe-round3.mjs`. - `out/mutants-round3/`: `run3.mjs` and its output `round3.txt`, 51 mutants. Round 2's 30 less `overlay-cut-loses-prompt`, which edited a line this round removed. Four re-anchored to lines this round changed (`other-key-waits`, `no-capital-y`, `tiny-no-guard`, `b1-cut-keeps-end`). Then 22 for round 3, among them `r3-y-ignores-shown`, `r3-shown-is-min-size` (y checks only 40x10), `r3-shown-at-arm` (the size read once, when armed), `r3-no-question-whole-box` and `r3-start-unguarded`. All 51 were killed, and the three source files matched the candidate afterwards. One run, no fix needed. - Candidate manifest sha256: `59f1b9b4dabfb47dc79641de209317cfd538cb27bd484b5391a0703a8c84c60b`.