docs(queue-55): row 55 round 3 candidate packet, R3 and B1 (darkwing)

One rule for Filbert's R3 and Dewey's B1: y resolves only while the
whole decide box is shown. Adds N9 widths, the start-unguarded and
timeout fixes, and two defects found by the cheap tests (wrap at
graphemes, answer render throw). 51/51 mutants killed; gate on
122b1223 green, cli 110.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 14:59:13 -05:00
co-authored by Claude Opus 5.5
parent 0d79e04b4d
commit 26926d183a
21 changed files with 1077 additions and 64 deletions
+170 -1
View File
@@ -1,4 +1,4 @@
# Row 55 (#1544): the full-screen terminal app, candidate packet, rounds 1 and 2
# 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
@@ -375,3 +375,172 @@ sequence.
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=<checkout>/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`.
+243 -52
View File
@@ -22,7 +22,7 @@ index b96e9012..5a675611 100644
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 32977afa..dbb03138 100644
index 32977afa..8114753c 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -4,12 +4,16 @@ Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
@@ -44,7 +44,7 @@ index 32977afa..dbb03138 100644
scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
@@ -70,6 +74,65 @@ mosaic launches list [--json]
@@ -70,6 +74,74 @@ mosaic launches list [--json]
`launches list` does not, because the file is readable to the same user
anyway.
@@ -92,13 +92,22 @@ index 32977afa..dbb03138 100644
+ visible without colour). Frames fit 80x24; the layout is designed at
+ 120x36, and under 100 columns the nav shows keys only and the Inbox
+ drops RAISED BY. Below 40x10 the frame only says the terminal is too
+ small. Widths count terminal cells: CJK and emoji 2, a one-cell emoji
+ base with U+FE0F 2. ZWJ sequences and flags count more than they draw,
+ so such a row can come out short, never too wide.
+- A decide box taller than the screen cuts the question, not the lines
+ after it: the action and target, what the choice authorizes, the task,
+ your choice and the `[y/N]` question stay. The cut line points to
+ `mosaic inbox --json`.
+ small. If the decide question is open then, the frame says so, and any
+ key answers it No, `y` included. Widths count terminal cells: East
+ Asian Width W and F characters and emoji 2, a one-cell emoji base with
+ U+FE0F 2. ZWJ sequences and flags count more than they draw, so such a
+ row can come out short, never too wide. Wrapping breaks between
+ graphemes, so U+FE0F and combining marks stay with their base.
+- A decide box taller than the screen cuts the end of the question and
+ nothing else: the decision line, the options, the recommendation, the
+ action and target, what the choice authorizes, the task, your choice
+ and the `[y/N]` question stay. The cut line points to `mosaic inbox
+ --json`. When even those lines don't fit, the box says only "Too small
+ to show this decision. Any key answers No." `y` resolves only while the
+ frame shows the whole box apart from the cut, and the app checks the
+ size at each key, so a terminal shrunk after the question appeared
+ answers No. A gated decision with a task needs 20 rows at 80 columns
+ (21 with a cut question), and 29 rows at 40 columns.
+- The transport is the verbs' `spawnSync` child, so the screen does not
+ redraw while a read is in flight (up to its 30 s timeout).
+- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
@@ -112,10 +121,10 @@ index 32977afa..dbb03138 100644
```
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
new file mode 100644
index 00000000..3cb3ab71
index 00000000..3f5418b0
--- /dev/null
+++ b/packages/cli/src/app.mjs
@@ -0,0 +1,480 @@
@@ -0,0 +1,489 @@
+// The full-screen app: `mosaic` with no argument on a terminal (src/cli.mjs
+// main). Inbox, Tasks, Agents, Trail and Help over the same broker reads as
+// the verbs; src/screens.mjs draws the frames.
@@ -137,7 +146,7 @@ index 00000000..3cb3ab71
+import { resolveDecision } from "./cli.mjs";
+import { CliError } from "./errors.mjs";
+import { formatDecision } from "./format.mjs";
+import { draw } from "./screens.mjs";
+import { decideShown, draw } from "./screens.mjs";
+import { busExit } from "./transport.mjs";
+
+export const REFRESH_MS = 30000;
@@ -282,7 +291,10 @@ index 00000000..3cb3ab71
+ // any other key. Only a key typed after the question is up can answer:
+ // keys that arrive within askMs of drawing it are type-ahead (a paste, or
+ // keys typed while a read blocked the screen) and are dropped. Quitting
+ // answers no.
+ // answers no. y counts only while the frame shows the whole box, the end
+ // of a long question aside (decideShown); on a terminal too small for it,
+ // every key answers no, y included. The size is read at each key, so a
+ // shrink after the question is armed is covered.
+ #ask(question) {
+ this.state.overlay = { ...this.state.overlay, asking: question };
+ this.render();
@@ -291,13 +303,19 @@ index 00000000..3cb3ab71
+ clearTimeout(arm);
+ this.answer = this.cancelAsk = null;
+ this.state.overlay = { ...this.state.overlay, asking: null };
+ try {
+ this.render();
+ } catch (e) {
+ // The pump sends this to crash; the decide flow ends with No.
+ resolve(false);
+ throw e;
+ }
+ resolve(yes);
+ };
+ this.cancelAsk = () => answer(false);
+ const arm = setTimeout(() => {
+ this.keys = [];
+ this.answer = (k) => answer(k === "y" || k === "Y");
+ this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows));
+ this.wake?.();
+ }, this.askMs);
+ });
@@ -708,10 +726,10 @@ index 381596b4..7bbfe39c 100644
return [r.kind, r.actor_role ?? null, r.body?.operation ?? null, r.body?.decision ? `decision ${shortId(r.body.decision)}` : null].filter(Boolean).join(" ");
diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs
new file mode 100644
index 00000000..c0194cb2
index 00000000..38ac5f94
--- /dev/null
+++ b/packages/cli/src/screens.mjs
@@ -0,0 +1,405 @@
@@ -0,0 +1,429 @@
+// The full-screen app's frames (src/app.mjs). Pure: draw(state, opts) takes
+// the app state and returns exactly `rows` strings, each `cols` cells wide
+// once its escape codes are dropped. Nothing here reads the bus or the
@@ -750,12 +768,20 @@ index 00000000..c0194cb2
+ if (
+ /\p{Emoji_Presentation}/u.test(c) ||
+ (n >= 0x1100 && n <= 0x115f) ||
+ n === 0x2329 ||
+ n === 0x232a ||
+ (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
+ (n >= 0xa960 && n <= 0xa97c) ||
+ (n >= 0xac00 && n <= 0xd7a3) ||
+ (n >= 0xf900 && n <= 0xfaff) ||
+ (n >= 0xfe10 && n <= 0xfe19) ||
+ (n >= 0xfe30 && n <= 0xfe4f) ||
+ (n >= 0xfe50 && n <= 0xfe6b) ||
+ (n >= 0xff00 && n <= 0xff60) ||
+ (n >= 0xffe0 && n <= 0xffe6) ||
+ (n >= 0x16fe0 && n <= 0x16ff1) ||
+ (n >= 0x17000 && n <= 0x18d08) ||
+ (n >= 0x1aff0 && n <= 0x1b2fb) ||
+ (n >= 0x1f200 && n <= 0x1f2ff) ||
+ (n >= 0x20000 && n <= 0x3fffd)
+ )
@@ -790,8 +816,11 @@ index 00000000..c0194cb2
+ return out + "…" + " ".repeat(w - 1 - used);
+}
+
+const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
+
+// Splits `s` into lines of at most `w` cells, at spaces where it can;
+// later lines start with `indent`.
+// later lines start with `indent`. A long word breaks between graphemes,
+// so a mark or U+FE0F stays with its base.
+export function wrap(s, w, indent = "") {
+ const out = [];
+ let cur = "";
@@ -801,7 +830,7 @@ index 00000000..c0194cb2
+ };
+ for (const word of s.split(/(?<= )/)) {
+ if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
+ for (const c of word) {
+ for (const { segment: c } of graphemes.segment(word)) {
+ if (width(cur + c) > w) push();
+ cur += c;
+ }
@@ -1052,23 +1081,35 @@ index 00000000..c0194cb2
+// `room` is the body's height. A decide box taller than that loses the end
+// of the question, never the decision line, the options, the
+// recommendation, the authorization lines, the task, the choice, the
+// question to answer or its keys.
+function overlayBox(o, cols, room) {
+ if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
+ const w = Math.min(84, cols - 2);
+ if (o.kind === "decide") {
+// question to answer or its keys. If those don't fit either, the box only
+// says the decision is hidden, and App#ask answers every key No.
+function decideBox(o, w, room) {
+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]);
+ else lines.push("", ["resolving…", "muted"]);
+ const b = box("decide", lines, w);
+ if (b.length <= room) return b;
+ const q = o.lines.findIndex((t) => t.startsWith(" question: "));
+ const end = o.lines.findIndex((t, i) => i > q && t.startsWith(" options: "));
+ if (b.length <= room || q < 0 || end < 0) return b;
+ if (q < 0 || end < 0) return null;
+ const head = box("decide", lines.slice(0, q), w).slice(0, -1);
+ const question = box("decide", lines.slice(q, end), w).slice(1, -1);
+ const tail = box("decide", lines.slice(end), w).slice(1);
+ const keep = room - head.length - tail.length - 1;
+ return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine("question", w - 4), ...tail] : b;
+ return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine("question", w - 4), ...tail] : null;
+}
+
+// Whether a frame of cols x rows shows the whole decide box, the end of a
+// long question aside. y resolves only then (App#ask).
+export function decideShown(o, cols, rows) {
+ return cols >= MIN_COLS && rows >= MIN_ROWS && o?.kind === "decide" && decideBox(o, Math.min(84, cols - 2), rows - 5) !== null;
+}
+
+function overlayBox(o, cols, room) {
+ if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
+ const w = Math.min(84, cols - 2);
+ if (o.kind === "decide") {
+ return decideBox(o, w, room) ?? box("decide", [["Too small to show this decision.", "warning"], o.asking ? "Any key answers No." : ["resolving…", "muted"]], w);
+ }
+ if (o.kind === "unknown") {
+ return box("outcome unknown", [[o.message, "warning"], "", "Nothing was retried, and the choice was not sent twice.", "", "v open the trail r re-read the inbox Esc back"], w);
@@ -1079,7 +1120,7 @@ index 00000000..c0194cb2
+
+// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms).
+export function draw(state, { cols, rows, color = true, now = Date.now() }) {
+ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color);
+ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color, Boolean(state.overlay?.asking));
+ const room = rows - 5;
+ let body;
+ if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()];
@@ -1092,14 +1133,13 @@ index 00000000..c0194cb2
+ while (body.length < room) body.push(blank());
+ if (state.overlay) {
+ let b = overlayBox(state.overlay, cols, room);
+ // Still too tall (a small screen, or a box with no question): keep the
+ // top and, for decide, everything from the choice on; past that, the
+ // bottom wins, so the question to answer and its keys stay.
+ // Still too tall (help, or a long detail on a small screen; a decide
+ // box always fits): the top, a cut line and the last two lines. room is
+ // at least 5, so that is exactly room lines.
+ if (b.length > room) {
+ const at = b.findIndex((l) => l.pieces.some(([t]) => t.startsWith("your choice: ")));
+ const tail = b.slice(at > 0 ? at : -2);
+ const tail = b.slice(-2);
+ const inner = Math.min(84, cols - 2) - 4;
+ b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox --json"})`, inner), [" │", "accent"]), ...tail].slice(-room);
+ b = [...b.slice(0, Math.max(1, room - tail.length - 1)), line(["│ ", "accent"], fit(`… (cut to fit; the whole text is in ${state.overlay.whole ?? "mosaic inbox --json"})`, inner), [" │", "accent"]), ...tail];
+ }
+ const left = Math.max(0, Math.floor((cols - width(b[0].pieces.map(([t]) => t).join(""))) / 2));
+ const top = Math.max(0, Math.min(2, room - b.length));
@@ -1111,18 +1151,20 @@ index 00000000..c0194cb2
+}
+
+// The whole frame below MIN_COLS x MIN_ROWS: what is wrong, and how to quit.
+function tooSmall(cols, rows, color) {
+ const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. Esc closes what is open; q quits.`, Math.max(1, cols));
+// With the decide question open, any key answers it No (App#ask).
+function tooSmall(cols, rows, color, asking) {
+ const keys = asking ? "A decision question is open; any key answers No." : "Esc closes what is open; q quits.";
+ const say = wrap(`The terminal is ${cols}x${rows}; the app needs at least ${MIN_COLS}x${MIN_ROWS}. Make it bigger. ${keys}`, Math.max(1, cols));
+ const body = say.slice(0, Math.max(0, rows)).map((t) => line([t, "warning"]));
+ while (body.length < rows) body.push(blank());
+ return body.map((l) => paint(l, Math.max(0, cols), color));
+}
diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs
new file mode 100644
index 00000000..b01abcb6
index 00000000..a679886a
--- /dev/null
+++ b/packages/cli/tests/app.test.mjs
@@ -0,0 +1,719 @@
@@ -0,0 +1,868 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
@@ -1134,7 +1176,7 @@ index 00000000..b01abcb6
+import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs";
+import { CliError } from "../src/errors.mjs";
+import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
+import { draw, strip, width } from "../src/screens.mjs";
+import { decideShown, draw, strip, width, wrap as wrapCells } from "../src/screens.mjs";
+import { broker, humanEnv, io, tmp } from "./helpers.mjs";
+
+const here = dirname(fileURLToPath(import.meta.url));
@@ -1291,11 +1333,11 @@ index 00000000..b01abcb6
+ for (const color of [true, false]) {
+ for (const [label, frame] of await everyScreen(t, cols, rows, color)) fits(frame, cols, rows, `${cols}x${rows} ${color ? "colour" : "NO_COLOR"} ${label}`);
+ }
+ // A decide box taller than the screen keeps its question and keys.
+ // A decide box too tall to show, with no question to cut, says the
+ // decision is hidden and that any key answers No (Dewey B1, round 3).
+ const long = (await everyScreen(t, cols, rows, false)).find(([l]) => l === "overlay decide long")[1].join("\n");
+ assert.match(long, /resolve 11111111 with yes\?/, `${cols}x${rows}`);
+ assert.match(long, /y resolves/, `${cols}x${rows}`);
+ if (rows >= 24) assert.match(long, /cut to fit[\s\S]*your choice: yes \(Allow\)/, `${cols}x${rows}`);
+ assert.match(long, /Too small to show this decision\.[\s\S]*Any key answers No\./, `${cols}x${rows}`);
+ assert.doesNotMatch(long, /y resolves/, `${cols}x${rows}`);
+ }
+ // Too small: every screen still fits, and says why.
+ for (const [cols, rows] of TOO_SMALL) {
@@ -1312,9 +1354,14 @@ index 00000000..b01abcb6
+});
+
+test("width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0", async (t) => {
+ for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1]]) {
+ for (const [s, cells] of [["a", 1], ["発行", 4], ["✅", 2], ["⚡", 2], ["❌", 2], ["⭐", 2], ["😀", 2], ["🅰", 1], ["🅰\uFE0F", 2], ["❤\uFE0F", 2], ["😀\uFE0F", 2], ["e\u0301", 1], ["✓", 1], ["›", 1], ["\u{1F210}", 2]]) {
+ assert.equal(width(s), cells, JSON.stringify(s));
+ }
+ // East Asian Width W and F outside the blocks above: one from each range.
+ for (const n of [0x2329, 0x232a, 0xa960, 0xfe10, 0xfe50, 0x16fe0, 0x17000, 0x1aff0, 0x1b2fb]) assert.equal(width(String.fromCodePoint(n)), 2, n.toString(16));
+ // A line break never splits ❤️ from its U+FE0F; no line is wider than asked.
+ assert.deepEqual(wrapCells("abc❤\uFE0F x", 4), ["abc", "❤\uFE0F x"]);
+ for (const l of wrapCells("ab ❤\uFE0F❤\uFE0F e\u0301e\u0301e\u0301", 4)) assert.ok(width(l) <= 4, JSON.stringify(l));
+ // On screen, independent of width(): each wide character takes two cells
+ // for one UTF-16 unit, so a row of `cols` cells is that much shorter in
+ // units. 🅰️ is three units for two cells.
@@ -1419,6 +1466,69 @@ index 00000000..b01abcb6
+ }
+});
+
+test("below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3)", async (t) => {
+ const flat = (s) => s.a.frame.map(strip).join(" ").replace(/\s+/g, " ");
+ const notSent = (s, label) => {
+ assert.equal(s.a.state.overlay, null, label);
+ assert.equal(s.a.state.status, "not resolved", label);
+ assert.equal(s.read("inbox").length, 1, label);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
+ };
+ // Too small from the start: the frame says a question is open, and y answers No.
+ for (const [cols, rows] of [[30, 24], [80, 9]]) {
+ const s = app(t, { cols, rows });
+ s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ await s.a.input("1");
+ assert.ok(s.a.state.overlay?.asking, `${cols}x${rows}`);
+ assert.match(flat(s), /A decision question is open; any key answers No\./, `${cols}x${rows}`);
+ assert.doesNotMatch(flat(s), /q quits/, `${cols}x${rows}`);
+ await s.a.input("y");
+ notSent(s, `${cols}x${rows}`);
+ assert.match(flat(s), /Esc closes what is open; q quits\./, "the usual text once the question is gone");
+ }
+ // Shrunk while the question is open.
+ const s = app(t);
+ s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ await s.a.input("1");
+ assert.match(s.screen(), /y resolves/);
+ s.a.resize(30, 24);
+ assert.match(flat(s), /A decision question is open; any key answers No\./);
+ await s.a.input("Y");
+ notSent(s, "shrunk");
+ // Shrunk and grown back before the key: the question is on screen again, so y resolves.
+ const g = app(t);
+ const d = g.raise("git.push.protected", { target: "refactor" });
+ await g.a.start();
+ await g.a.input("1");
+ g.a.resize(30, 24);
+ g.a.resize(80, 24);
+ assert.match(g.screen(), /y resolves/);
+ await g.a.input("y");
+ assert.equal(g.a.state.status, `resolved ${d.id}: yes`);
+});
+
+test("a throw while answering the question goes to crash, sends nothing and leaves no job pending", async (t) => {
+ const boom = new Error("the terminal write failed");
+ let fail = false;
+ const s = app(t, {
+ write: () => {
+ if (fail) throw boom;
+ },
+ });
+ s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ await s.a.input("1");
+ assert.ok(s.a.state.overlay?.asking);
+ fail = true;
+ await s.a.input("y");
+ assert.deepEqual(s.out.quit, { how: "an error", resolved: 0, error: boom });
+ for (let i = 0; s.a.job && i < 100; i++) await new Promise((r) => setImmediate(r));
+ assert.equal(s.a.job, null, "the decide job ended");
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
+});
+
+test("type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs", async (t) => {
+ const asking = (s) => Boolean(s.a.state.overlay?.asking);
+ const sent = (s) => s.calls.filter((c) => c.verb === "decision.resolve").length;
@@ -1471,7 +1581,7 @@ index 00000000..b01abcb6
+ }
+});
+
+test("quitting at the question answers No and sends nothing, armed or not", async (t) => {
+test("quitting at the question answers No and sends nothing, armed or not", { timeout: 10000 }, async (t) => {
+ for (const askMs of [0, 60000]) {
+ const s = app(t, { askMs });
+ s.raise("git.push.protected", { target: "refactor" });
@@ -1529,6 +1639,81 @@ index 00000000..b01abcb6
+ fits(s.a.frame, 80, 24, "long question");
+});
+
+// A gated, blocking decision with a task, and every line its decide box
+// must show before y can resolve it (Dewey B1, round 3).
+function gated(t, cols, rows, long) {
+ const s = app(t, { cols, rows });
+ const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?";
+ const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
+ const keep = [`decision ${d.id}`, long ? "question: word0" : "question: Push the release?", "options: yes = Allow; no = Decline", "recommended: no", "action: git.push.protected on refactor", 'choosing "yes" authorizes it; any other choice declines', "task: vikunja:1/7", "your choice: yes (Allow); this authorizes the action", `resolve ${d.id.slice(0, 8)} with yes? [y/N]`, "y resolves · any other key cancels (the default is No)"];
+ return { ...s, d, keep };
+}
+// Box borders and wrapping removed, so a line broken across rows still matches.
+const squash = (text) => text.replace(/[\s│]/g, "");
+
+test("a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3)", async (t) => {
+ const hidden = async (s, key, label) => {
+ assert.ok(s.a.state.overlay?.asking, label);
+ assert.match(s.screen(), /Too small to show this decision\.[\s\S]*Any key answers No\./, label);
+ assert.doesNotMatch(s.screen(), /y resolves/, label);
+ await s.a.input(key);
+ assert.equal(s.a.state.overlay, null, label);
+ assert.equal(s.a.state.status, "not resolved", label);
+ assert.equal(s.read("inbox").length, 1, label);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), label);
+ };
+ // Dewey's probe sizes: a short question at 80x18 and 40x24, a long one at 80x20.
+ for (const [cols, rows, long] of [[80, 18, false], [40, 24, false], [80, 20, true], [120, 16, false], [80, 10, false]]) {
+ const s = gated(t, cols, rows, long);
+ await s.a.start();
+ await s.a.input("1");
+ await hidden(s, "y", `${cols}x${rows}${long ? " long" : ""}`);
+ }
+ // One row more and the whole box shows, so y resolves.
+ for (const [cols, rows, long] of [[80, 20, false], [80, 21, true]]) {
+ const s = gated(t, cols, rows, long);
+ await s.a.start();
+ await s.a.input("1");
+ for (const want of s.keep) assert.ok(squash(s.screen()).includes(squash(want)), `${cols}x${rows}: ${want}\n${s.screen()}`);
+ await s.a.input("y");
+ assert.equal(s.a.state.status, `resolved ${s.d.id}: yes`, `${cols}x${rows}`);
+ }
+ // Shrunk after the question is armed: checked at the key.
+ const s = gated(t, 80, 24, false);
+ await s.a.start();
+ await s.a.input("1");
+ assert.match(s.screen(), /y resolves/);
+ s.a.resize(80, 15);
+ await hidden(s, "Y", "shrunk to 80x15");
+});
+
+test("at every size the decide box shows each line y depends on, or says it is hidden", async (t) => {
+ for (const long of [false, true]) {
+ const s = gated(t, 120, 40, long);
+ await s.a.start();
+ await s.a.input("1");
+ const seen = { shown: 0, hidden: 0 };
+ for (let cols = 40; cols <= 120; cols += 4) {
+ for (let rows = 10; rows <= 40; rows++) {
+ const frame = draw(s.a.state, { cols, rows, color: false, now: NOW });
+ const label = `${cols}x${rows}${long ? " long" : ""}`;
+ fits(frame, cols, rows, label);
+ const text = frame.map(strip).join("\n");
+ if (decideShown(s.a.state.overlay, cols, rows)) {
+ seen.shown++;
+ for (const want of s.keep) assert.ok(squash(text).includes(squash(want)), `${label}: ${want}`);
+ } else {
+ seen.hidden++;
+ assert.match(text, /Too small to show this decision\.[\s\S]*Any key answers No\./, label);
+ assert.doesNotMatch(text, /y resolves/, label);
+ }
+ }
+ }
+ assert.ok(seen.shown > 100 && seen.hidden > 100, JSON.stringify(seen));
+ s.a.quit("q");
+ }
+});
+
+test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => {
+ let resolves = 0;
+ const s = app(t, {
@@ -1759,7 +1944,7 @@ index 00000000..b01abcb6
+ }
+});
+
+test("decide with --note still asks; a No sends nothing", async (t) => {
+test("decide with --note still asks; a No sends nothing", { timeout: 10000 }, async (t) => {
+ const s = broker(t);
+ const d = s.raise("git.push.protected", { target: "refactor" });
+ const call = s.transport("demo");
@@ -1795,9 +1980,13 @@ index 00000000..b01abcb6
+});
+
+// A terminal for runApp: raw mode and listeners recorded, writes kept.
+function terminal({ throwOnResize = false } = {}) {
+function terminal({ throwOnResize = false, throwOnFrame = false } = {}) {
+ const stdin = Object.assign(new EventEmitter(), { raw: [], setRawMode: (on) => stdin.raw.push(on), pause: () => {}, resume: () => {} });
+ const stdout = Object.assign(new EventEmitter(), { writes: [], write: (s) => stdout.writes.push(s), rows: 24 });
+ const write = (s) => {
+ if (throwOnFrame && s.startsWith("\x1b[H")) throw new Error("frame failed");
+ stdout.writes.push(s);
+ };
+ const stdout = Object.assign(new EventEmitter(), { writes: [], write, rows: 24 });
+ let resized = false;
+ Object.defineProperty(stdout, "columns", {
+ get: () => {
@@ -1810,7 +1999,7 @@ index 00000000..b01abcb6
+ return { io: { stdin, stdout }, stdin, stdout, signals, resize };
+}
+
+test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error", async () => {
+test("runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included", { timeout: 10000 }, async () => {
+ const cases = [
+ ["q", (x) => x.stdin.emit("data", Buffer.from("q"))],
+ ["end of input", (x) => x.stdin.emit("end")],
@@ -1818,18 +2007,20 @@ index 00000000..b01abcb6
+ ["SIGINT", (x) => x.signals.emit("SIGINT")],
+ ["SIGHUP", (x) => x.signals.emit("SIGHUP")],
+ ["an error", (x) => x.resize(), { throwOnResize: true }],
+ ["an error", () => {}, { throwOnFrame: true }],
+ ];
+ for (const [how, act, opts] of cases) {
+ const x = terminal(opts);
+ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals });
+ await new Promise((r) => setImmediate(r));
+ act(x);
+ const result = await running.then(
+ // Handled at once: an error on the first frame rejects before the next tick.
+ const running = runApp({ io: x.io, business: "demo", transport: () => async () => [], color: false, refreshMs: 0, askMs: 0, signals: x.signals }).then(
+ (r) => r,
+ (e) => ({ how: "an error", error: e }),
+ );
+ await new Promise((r) => setImmediate(r));
+ act(x);
+ const result = await running;
+ assert.equal(result.how, how);
+ if (how === "an error") assert.equal(result.error.message, "columns failed");
+ if (how === "an error") assert.equal(result.error.message, opts.throwOnFrame ? "frame failed" : "columns failed");
+ assert.equal(x.stdout.writes[0], "\x1b[?1049h\x1b[?25l", how);
+ assert.equal(x.stdout.writes.at(-1), "\x1b[?25h\x1b[?1049l", how);
+ assert.deepEqual(x.stdin.raw, [true, false], how);
@@ -1,9 +1,9 @@
fcc2791aa2f80d7e196b0ce18f02475f9fc6730c74cbb56d2db3f9aae333253e docs/TOOLS.md
ac058d0aa7eaaf7a52a681f79fcedb3ea15051226697542029cb5fa15fc15600 packages/cli/README.md
dfdeb9b91eb41f67ecec8e4835f631d6ec47b967897d349ed9b27d06045a9d93 packages/cli/src/app.mjs
08fbf0fd72ca5832a33b189a3a64c89dc10d752bfbb94b99042125e9f38cd30a packages/cli/README.md
6dd4cc049bdb166bcd63b708093e87bc77726001b83a5b0091bc70a24dc3dc3e packages/cli/src/app.mjs
c33d3fedb91be7977cf7b468c908719d8f07cd7a2dad1bb7d9c24d62bcdac432 packages/cli/src/cli.mjs
c2f6852226c7071dec132d422773d76bdfc356450f582ac28cb7a5077860b4c0 packages/cli/src/format.mjs
ff24eb0f78a64f98892eca372337ce141f30bc15169200fde37b6db34c6c8474 packages/cli/src/screens.mjs
10b15b25f607100f5a1a6e315334668c014d01452c92fbd1c04dd2bfba276d30 packages/cli/tests/app.test.mjs
3cc80840f7492b3271835cc88bd99420566b9079098fef8adb9d0d4258a2921c packages/cli/src/screens.mjs
1b7b31f0f438a6390315a824b0e300ab78f4caaa6885f86705a08860638ebd47 packages/cli/tests/app.test.mjs
bc972d153603311b08a2173d5b912f5fa7e9277605d3ce60d87161bf28ea80ab packages/queue/tests/dispatch.test.mjs
ab246b3e39ea0c503d97e7af93ef8b958caedb3cf3006e387f14fa93b329856c scripts/mosaic
@@ -0,0 +1,13 @@
round 3 apply check, 2026-10-11T19:52:01Z
base for apply: 122b122374ce93523efa13cd30676918c416f106
git apply --index build.patch: ok
9 files changed, 1932 insertions(+), 16 deletions(-)
docs/TOOLS.md: OK
packages/cli/README.md: OK
packages/cli/src/app.mjs: OK
packages/cli/src/cli.mjs: OK
packages/cli/src/format.mjs: OK
packages/cli/src/screens.mjs: OK
packages/cli/tests/app.test.mjs: OK
packages/queue/tests/dispatch.test.mjs: OK
scripts/mosaic: OK
@@ -0,0 +1,120 @@
✔ every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row (1641.123987ms)
✔ width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0 (91.625067ms)
✔ broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal (204.389251ms)
✔ NO_COLOR: no escape codes, except reverse video on the selected row (297.651664ms)
✔ 1-9 shows the decide flow's own lines and question; only y resolves (134.985026ms)
✔ the app shows the same decide text as mosaic decide (65.507892ms)
✔ any key but y or Y at the question answers No; nothing is sent (1393.693801ms)
✔ below 40x10 the question is hidden, so every key answers it No, y included (Filbert R3) (323.453488ms)
✔ a throw while answering the question goes to crash, sends nothing and leaves no job pending (100.439482ms)
✔ type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs (1158.438577ms)
✔ quitting at the question answers No and sends nothing, armed or not (173.081799ms)
✔ a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status (80.1729ms)
✔ a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1) (89.855673ms)
✔ a decide box too big for the terminal says so, and every key answers it No, y included (Dewey B1, round 3) (700.017659ms)
✔ at every size the decide box shows each line y depends on, or says it is hidden (11063.134882ms)
✔ outcome unknown: the CLI's sentence, no retry, then the trail or a re-read (170.541821ms)
✔ refused and failed reads keep the last list, marked stale; a refused decide says refused (114.46264ms)
✔ keys: views, moving, trail of a selection, the command line, help and quit (250.788525ms)
✔ one option: the status line says Press 1, not 1-1 (108.728774ms)
✔ no business: the app says so and :business picks one (54.240423ms)
✔ mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one (1.82506ms)
✔ scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted (211.846348ms)
✔ strings to keep exactly live once, in the decide flow and the transport; the app does not restate them (0.784937ms)
✔ decide with --note still asks; a No sends nothing (61.39156ms)
✔ the app's business comes from a live bus host only (1.253556ms)
✔ runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error, the first frame's included (9.450749ms)
✔ draw is pure: same state, same frame (1.014652ms)
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (160.228747ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (173.981743ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (178.01175ms)
✔ decide prints a declining choice as declining (186.91029ms)
✔ an unknown outcome is reported once and never resent (114.294151ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (228.413474ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (127.665424ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (103.648788ms)
✔ every human command refuses inside an agent run before it touches the bus (85.556081ms)
✔ usage errors exit 4; no business and no host is a usage error (77.506148ms)
✔ agents and tasks print through the broker (103.476436ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.603133ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (68.648006ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (42.037548ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (45.478142ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (41.178111ms)
✔ a business without tracker.baseUrl gets no trackers entry (36.924054ms)
✔ an unknown business and a broken system config refuse with exit 3 (80.616129ms)
✔ empty views say so (1.155964ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.417509ms)
✔ tasks print the tracker fields the snapshot carries (0.207258ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1017.640757ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (301.029227ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (181.630331ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1184.644329ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (629.304517ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (170.37376ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (356.511996ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (228.131282ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (148.593046ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (725.077976ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (137.715237ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (195.313058ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (33.728711ms)
✔ bus stop refuses to signal a live pid that is not a bus host (204.116213ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (285.402332ms)
✔ bus start refuses with exit 3 without a notifier config (120.124432ms)
✔ bus start runs until bus stop; status reports it while it runs (889.37193ms)
✔ bus-service.sh renders the unit and installs it into a given directory (41.46377ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1381.127614ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (736.176939ms)
✔ a runner that stops at once ends its launch with the runner's reason (231.500014ms)
✔ a host that died hard leaves its sessions to the next host, which kills them and ends their runs so the role can be launched again (1198.727217ms)
✔ a host that died hard leaves its sessions to the next host, which finds one already exited (23) and ends their runs so the role can be launched again (3819.084307ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (139.667775ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (152.104942ms)
✔ a launch client that never closes its side doesn't hold the host's close (148.765746ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1266.599402ms)
✔ zoned uses the IANA zone across DST (30.213719ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (145.882722ms)
✔ two blocking decisions get two DMs with different nonces (155.970689ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.394037ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (196.602478ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (177.977332ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (142.200148ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (200.443925ms)
✔ polled every POLL_MS against a permanent 403, a DM is sent at 0, 30, 60, 90 and 120 min and gives up only then (224.492906ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (157.445891ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (118.512391ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (116.363451ms)
✔ an inbox read failure is logged and the next poll retries (0.899012ms)
✔ no Discord id reaches the journal or the log (102.631507ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (26.753715ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (62.706102ms)
✔ the journal: a whole file that is one torn line truncates to empty (21.628484ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (1.956257ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (0.751353ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (8.66405ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.453577ms)
✔ the journal: a dangling directory link, a parent that is a file and a journal that is a directory each refuse with exit 3 (0.611063ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.507789ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.50937ms)
✔ digest content stays within Discord's 2000 characters (0.312002ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.79346ms)
task.close {} answered: invalid-request; fake saw 18 requests, first GET /info 200, GET /projects/1 200, GET /projects/1/views 200
task.close on a missing task answered: task-not-found; it made GET /tasks/999 404
✔ bootConfig trackers reach the S3 adapter in the real broker child, which goes ready against a fake Vikunja (455.287493ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (53.100004ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2188.870587ms)
✔ busExit and refuseInsideAgent (0.676038ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (265.655593ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1221.198035ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (109.069472ms)
✔ launches off and on go to the broker and change the business's launch state (101.594541ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (133.681892ms)
ℹ tests 110
ℹ suites 0
ℹ pass 110
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 18672.304682
@@ -0,0 +1,10 @@
#!/bin/bash
export TMPDIR=~/darkwing-scratch/r55/tmp
G=~/darkwing-scratch/r55/gate; O=~/darkwing-scratch/r55/out3; mkdir -p $O; cd $G
env -u NODE_TEST_CONTEXT node --test 'packages/cli/tests/*.test.mjs' > $O/cli-tests.log 2>&1; echo "cli rc=$?" >> $O/summary.txt
for s in scripts/test-*.sh; do
n=$(basename $s .sh)
if [ $n = test-task ]; then DOCKER_HOST=unix:///nonexistent bash $s > $O/$n.log 2>&1; else bash $s > $O/$n.log 2>&1; fi
echo "$n rc=$?" >> $O/summary.txt
done
echo DONE >> $O/summary.txt
@@ -0,0 +1,4 @@
(app closed with q; 0 decisions resolved this session)
EXIT=0
alternate screen entered: 1
alternate screen left: 1
@@ -0,0 +1,11 @@
cli rc=0
test-auth rc=0
test-conductor rc=0
test-config rc=0
test-discord rc=0
test-extension-package rc=0
test-foundation rc=0
test-queue rc=0
test-release rc=0
test-task rc=0
DONE
@@ -0,0 +1,17 @@
OK status with missing harness credential exits 3 and still lists accounts
OK status reports harness credential (read-only) + mosaic accounts
OK api key material never reaches output
OK oauth token material never reaches output
OK unparseable credential file exits 2
OK symlinked credential file exits 4
OK env-side credential names reported
OK env var values never reach output
OK accounts without an accounts dir reports none and creates nothing
OK accounts lists files and marks the active one
OK loose account perms flagged in listing
OK agent --auth with missing account file refuses (exit 4)
OK agent --auth with non-0600 account file refuses
OK agent --auth with invalid account name refuses
OK auth.sh without valid config refuses
selftest: 15 passed, 0 failed
@@ -0,0 +1,55 @@
Note: switching to '122b122374ce93523efa13cd30676918c416f106'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
Not currently on any branch.
nothing to commit, working tree clean
Note: switching to '122b122374ce93523efa13cd30676918c416f106'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
OK dry-run: allowed change, exit 0, nothing committed (exit 0)
OK dry-run committed nothing
OK apply: allowed change exits 0 (exit 0)
OK apply: attribution in commit subject
OK apply: target tree clean after commit
OK disallowed path refused (exit 1)
OK disallowed path: target untouched
OK syntax gate refused broken .mjs (exit 1)
OK syntax gate: target untouched
OK suite failure refused (exit 1)
OK suite failure: target reverted to clean
OK disabled policy refused (exit 2)
OK disabled policy: target untouched
OK failed run refused (exit 1)
OK failed run: target untouched
OK missing run exits 4 (exit 4)
OK invalid policy exits 2 (exit 2)
selftest: 17 passed, 0 failed
@@ -0,0 +1,26 @@
OK absent adapter defaults to pi
OK adapter mock validates (exit 0)
OK unsupported adapter exits 2 (exit 2)
OK env exports adapter
OK bootstrap creates default when absent (exit 0)
OK bootstrap wrote config file
OK bootstrap is idempotent on existing config (exit 0)
OK bootstrap did not rewrite existing config
OK validate missing config exits 3 (exit 3)
OK malformed JSON exits 2 (exit 2)
OK unsupported configVersion exits 2 (exit 2)
OK unknown top-level key exits 2 (exit 2)
OK unknown execution key exits 2 (exit 2)
OK unsupported backend exits 2 (exit 2)
OK unsupported environment exits 2 (exit 2)
OK relative dataRoot exits 2 (exit 2)
OK non-canonical dataRoot exits 2 (exit 2)
OK filesystem root dataRoot exits 2 (exit 2)
OK home directory dataRoot exits 2 (exit 2)
OK dataRoot containing config dir exits 2 (exit 2)
OK control character in provider exits 2 (exit 2)
OK symlinked config file exits 2 (exit 2)
OK env exports resolve correctly
OK failed validation modified nothing
selftest: 24 passed, 0 failed
@@ -0,0 +1,70 @@
toolchain: node v26.8.1
OK syntax: packages/discord/src/approvals.mjs
OK syntax: packages/discord/src/authorize.mjs
OK syntax: packages/discord/src/binding.mjs
OK syntax: packages/discord/src/cli.mjs
OK syntax: packages/discord/src/connector.mjs
OK syntax: packages/discord/src/context.mjs
OK syntax: packages/discord/src/engine-pi.mjs
OK syntax: packages/discord/src/errors.mjs
OK syntax: packages/discord/src/gateway.mjs
OK syntax: packages/discord/src/git.mjs
OK syntax: packages/discord/src/journal.mjs
OK syntax: packages/discord/src/notify.mjs
OK syntax: packages/discord/src/rest.mjs
OK syntax: packages/discord/src/setspark.mjs
OK syntax: packages/discord/src/tools.mjs
OK syntax: packages/discord/src/web.mjs
OK syntax: packages/discord/bin/git-credential.mjs
OK syntax: packages/discord/extension/tools.mjs
OK syntax: packages/discord/tests/approvals.test.mjs
OK syntax: packages/discord/tests/authorize.test.mjs
OK syntax: packages/discord/tests/binding.test.mjs
OK syntax: packages/discord/tests/connector.test.mjs
OK syntax: packages/discord/tests/context.test.mjs
OK syntax: packages/discord/tests/engine.test.mjs
OK syntax: packages/discord/tests/fake-pi.mjs
OK syntax: packages/discord/tests/gateway.test.mjs
OK syntax: packages/discord/tests/git.test.mjs
OK syntax: packages/discord/tests/helpers.mjs
OK syntax: packages/discord/tests/journal.test.mjs
OK syntax: packages/discord/tests/notify.test.mjs
OK syntax: packages/discord/tests/recover.test.mjs
OK syntax: packages/discord/tests/rest.test.mjs
OK syntax: packages/discord/tests/setspark.test.mjs
OK syntax: packages/discord/tests/tools.test.mjs
OK syntax: packages/discord/tests/web.test.mjs
OK syntax: packages/discord/fixtures/claim-worker.mjs
OK syntax: packages/discord/fixtures/legacy-owner-worker.mjs
OK syntax: scripts/discord.sh
OK syntax: scripts/discord-service.sh
OK packages/discord declares no dependencies
OK no bot-token-shaped string in packages/discord
OK fixture binding uses placeholder ids only
OK fixture binding validates
OK real pi with the extension exposes exactly list_dir, read_file, search and no built-in tool
OK real pi with a writable root exposes exactly the three reads plus write_file and edit_file, and writes nothing at start
OK real pi with a web key exposes the three reads plus web_fetch and web_search, and no write tool without a writable root
OK real pi with a git root exposes the reads, writes and the four git verbs, commits nothing at start, and never shows the token
OK real pi with protocol vault adds reserve_id to the git verbs
OK real pi with a setspark key exposes the reads and the eight record verbs, no counters, and never shows the key
OK real pi refuses a git key on a read-only root (fail closed)
OK real pi with the pilot flags (--no-tools) exposes no tool at all
OK real pi exits non-zero without MOSAIC_DISCORD_TOOLS: no session, no tools (fail closed)
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test packages/discord/tests/ (ℹ pass 178)
OK scripts/discord.sh --help exits 0
OK scripts/discord.sh check without a binding exits 4
OK scripts/discord.sh recover without a binding exits 4
OK scripts/discord.sh reload without a binding exits 4
OK scripts/discord-service.sh without a command exits 4
OK service unit renders with the repository path, a supervised run as the main process, exit 3 never retried, and reload as SIGHUP
OK service install writes the rendered unit (0644) and leaves no temp file
OK service install a second time reports unchanged
OK systemd-analyze verify accepts the rendered unit
OK service uninstall removes the unit file
OK service install with an unknown flag exits 4
OK service install with USER unset finishes and names the account for lingering
discord suite: 66 passed, 0 failed
@@ -0,0 +1,21 @@
OK initial ordinary-file install
OK installed tree matches canonical source
OK installed tree has no symlinks
OK check detects installation drift
OK sync refuses to overwrite installation drift
OK check detects an extra destination file
OK check detects an extra destination directory
OK check rejects a destination symlink
OK sync accepts a canonical source update
OK updated installation matches canonical source
scripts/test-extension-package.sh: line 14: 3158942 Killed "$@" > /dev/null 2>&1
OK forced interruption kills the replacing process
OK next invocation recovers old consistent installation
OK interrupted replacement rolled back
OK sync succeeds after interruption recovery
OK unlocked stale lock file does not block
OK active lock refuses a concurrent sync
OK source symlink fails closed
OK nested second entrypoint fails closed
extension package selftest: 18 passed, 0 failed
@@ -0,0 +1,53 @@
toolchain: node v26.8.1, python 3.12.8, jsonschema 4.26.0
OK syntax: scripts/foundation-inspect.mjs
OK syntax: scripts/foundation/strict-json.mjs
OK syntax: scripts/foundation/canonical.mjs
OK syntax: scripts/foundation/resolve.mjs
OK syntax: scripts/foundation/validate-record.mjs
OK syntax: scripts/foundation/fixtures/build-fixtures.mjs
OK syntax: scripts/foundation/canonical.test.mjs
OK syntax: scripts/foundation/cli.test.mjs
OK syntax: scripts/foundation/fixtures.test.mjs
OK syntax: scripts/foundation/resolve.test.mjs
OK syntax: scripts/foundation/strict-json.test.mjs
OK syntax: scripts/foundation/verify-schema.py (ast only; no bytecode written)
OK fixture generator runs
OK checked-in fixtures/bundles equal a fresh generation
OK checked-in fixtures/raw equal a fresh generation
OK checked-in fixtures/index.json equal a fresh generation
OK checked-in demo bundles equal a fresh generation
OK a failing nested test fails the run under a parent runner's NODE_TEST_CONTEXT
OK node --test scripts/foundation/ (ℹ pass 80)
OK differential schema oracle: PASS: differential schema oracle (finite corpus; compatibility evidence, not equivalence proof)
platform witness: strftime('%Y') for year 999 -> '999' (pinned checker refuses years 0001..0999)
node v26.8.1; corpus 1568 records (38 pinned fixtures, 478 unique bundle records, 1052 typeCase/mutation/lexical cases)
schema column: agree-valid 540, agree-invalid 991, DISAGREEMENTS 0; strict-only (parser-bound) cases: 27; unsupported-kind records not schema-assessed by the inspector: 10
profile column (schema-valid records only): profile-valid 510, profile-invalid 30
profile refusals asserted: 30 schema-agreed-valid records refused only by the strict typed-string profile (rule profile-pattern-mismatch), 12 declared by name; 73 named probes verified against declared schema/profile columns
OK oracle: zero schema-column disagreements with the pinned checker
OK oracle: strict-only profile refusals are counted and asserted
OK demo: permitted read preview exits 0 (exit 0)
OK demo: permitted file.change preview exits 0 (exit 0)
OK demo: assignment.change proposal is unresolved (exit 3) (exit 3)
OK demo: revoked registration is refused (exit 3) (exit 3)
OK demo: message is not authority (exit 3) (exit 3)
OK usage: no arguments exits 2 (exit 2)
OK io: missing file exits 4 (exit 4)
OK io: directory exits 4 (exit 4)
OK io: symlink exits 4 (O_NOFOLLOW) (exit 4)
OK bound: oversize fixture exits 2 (exit 2)
OK profile: one final LF in a typed selection id is refused before admission (exit 2) (exit 2)
OK profile: two final LFs fail the schema pattern itself (exit 2) (exit 2)
OK profile: escaped newlines in free-form text stay allowed (exit 0) (exit 0)
OK profile refusal is invalid-request/profile-pattern-mismatch with selection and operation withheld, value not echoed
OK text output starts with the disclaimer
OK json output is valid JSON with result allowed and exactly the charter §7 fields
OK json golden matches byte-for-byte
OK sandboxed bundle run (env -i, PATH=/nonexistent) produced the unresolved proposal
OK sandbox inventory (path/type/size/mode/uid/gid/inode/mtime/sha256) unchanged by runs
OK canary never printed (bundle run and credential-file run)
OK a non-bundle JSON file is refused at the shape gate, not read into output
OK no field of the non-bundle file is echoed
selftest: 44 passed, 0 failed
@@ -0,0 +1,35 @@
toolchain: node v26.8.1, git version 2.55.0
OK syntax: packages/queue/src/cli.mjs
OK syntax: packages/queue/src/errors.mjs
OK syntax: packages/queue/src/io.mjs
OK syntax: packages/queue/src/lock.mjs
OK syntax: packages/queue/src/queue.mjs
OK syntax: packages/queue/src/review.mjs
OK syntax: packages/queue/src/store.mjs
OK syntax: packages/queue/tests/commit.test.mjs
OK syntax: packages/queue/tests/data.test.mjs
OK syntax: packages/queue/tests/dispatch.test.mjs
OK syntax: packages/queue/tests/helpers.mjs
OK syntax: packages/queue/tests/lock.test.mjs
OK syntax: packages/queue/tests/migration.test.mjs
OK syntax: packages/queue/tests/review.test.mjs
OK syntax: packages/queue/tests/store.test.mjs
OK syntax: packages/queue/tests/write.test.mjs
OK syntax: packages/queue/tests/fixtures/fake-gitea.mjs
OK syntax: packages/queue/tests/fixtures/kill-at.mjs
OK syntax: packages/queue/tests/fixtures/lock-child.mjs
OK syntax: packages/queue/tests/fixtures/mosaic-pre-a2.sh
OK syntax: scripts/queue-commit.sh
OK syntax: scripts/git-hooks/pre-commit
OK syntax: scripts/mosaic
OK queue-commit.sh, the guard and scripts/mosaic are executable
OK packages/queue declares no dependencies
ℹ tests 150
ℹ pass 150
ℹ fail 0
OK node --test packages/queue/tests/
OK scripts/mosaic queue help
skip queue verify and render --check: this checkout (/home/jwoltje/darkwing-scratch/r55/gate) is not the queue's canonical root (/mnt/storage/src/mosaic-stack)
queue suite: 27 passed, 0 failed
@@ -0,0 +1,16 @@
OK valid RELEASE resolves (exit 0)
OK invalid RELEASE exits 1 (exit 1)
OK missing RELEASE exits 1 (exit 1)
OK valid RELEASE leaves image tag consistent with version
OK status safe on empty state (exit 0)
OK status created no pointer
OK fault-injected activation refuses (exit 1)
OK refused activation wrote no pointer
OK refusal logged exactly once with valid fields
OK healthy activation succeeds (exit 0)
OK pointer written with valid fields
OK repeat activation succeeds (log grows) (exit 0)
OK log is append-only across activations
OK rollback without previous refuses (exit 1)
selftest: 14 passed, 0 failed
@@ -0,0 +1,32 @@
OK valid task validates (exit 0)
OK unknown task key exits 2 (exit 2)
OK unsupported taskVersion exits 2 (exit 2)
OK invalid task id exits 2 (exit 2)
OK empty prompt exits 2 (exit 2)
OK NUL in expectExact exits 2 (exit 2)
OK out-of-range timeout exits 2 (exit 2)
OK missing mission file exits 4 (exit 4)
OK task with valid mission validates (exit 0)
OK invalid mission exits 2 (exit 2)
OK validate missing task exits 4 (exit 4)
OK validation does not modify the task file
OK prune dry-run exits 0 (exit 0)
OK dry-run deleted nothing
OK prune --keep=2 --yes removes oldest (exit 0)
OK kept exactly 2 newest runs
OK newest run kept, oldest pruned
OK append-only receipt written (3 entries)
OK sessions/workspaces untouched by prune
OK prune with invalid keep exits 4 (exit 4)
skip adapter seam cases (docker daemon unavailable)
skip workspace/capability cases (docker daemon unavailable)
skip live task cases (docker unavailable)
OK onboard without name exits 4 (non-interactive) (exit 4)
OK onboard --name renders profile (exit 0)
OK profile written
OK canon structure: required filled, optional placeholdered
OK canon sections present
skip live recall pair: user recall run, recalled user name (docker daemon unavailable)
OK no agent identity on headless run
selftest: 26 passed, 0 failed
@@ -0,0 +1,52 @@
resolve-ask-skipped-when-note: killed by decide with --note still asks; a No sends nothing (
unknown-space-only: killed by outcome unknown: the CLI's sentence, no retry, then the tr
restore-no-altleave: killed by runApp leaves the terminal as it found it on q, end of inp
restore-no-raw-off: killed by runApp leaves the terminal as it found it on q, end of inp
no-sigterm: killed by runApp leaves the terminal as it found it on q, end of inp
no-sigint: killed by runApp leaves the terminal as it found it on q, end of inp
no-sighup: killed by runApp leaves the terminal as it found it on q, end of inp
resize-unguarded: killed by runApp leaves the terminal as it found it on q, end of inp
paint-no-reverse: killed by NO_COLOR: no escape codes, except reverse video on the sel
cells-no-cjk: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
cells-no-emoji-presentation: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F | runLoop never overlaps ticks and stops after the one in fl
cells-no-fe0f: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
b1-cut-keeps-end: killed by a long question is cut inside the question; the action, ta | a decide box too big for the terminal says so, and every k
app-host-not-live: killed by the app's business comes from a live bus host only (
quit-no-answer: killed by quitting at the question answers No and sends nothing, arm
refresh-in-overlay: killed by a timer tick reads nothing under an overlay, never answers
refresh-answers: killed by a timer tick reads nothing under an overlay, never answers
refresh-drops-resolved: killed by a timer tick reads nothing under an overlay, never answers
unknown-esc-stays: killed by outcome unknown: the CLI's sentence, no retry, then the tr
trail-task-id: killed by keys: views, moving, trail of a selection, the command lin
business-keeps-data: killed by keys: views, moving, trail of a selection, the command lin
nav-no-brackets: killed by every screen and overlay fits 120x36, 80x24 and the narrow
typeahead-kept: killed by type-ahead never answers the question: a paste, keys typed
no-arm-delay: killed by type-ahead never answers the question: a paste, keys typed
other-key-waits: killed by any key but y or Y at the question answers No; nothing is
no-capital-y: killed by 1-9 shows the decide flow's own lines and question; only y
command-types-names: killed by keys: views, moving, trail of a selection, the command lin
press-1-1: killed by one option: the status line says Press 1, not 1-1 (
tiny-no-guard: killed by every screen and overlay fits 120x36, 80x24 and the narrow | below 40x10 the question is hidden, so every key answers i
r3-y-ignores-shown: killed by below 40x10 the question is hidden, so every key answers i | a decide box too big for the terminal says so, and every k
r3-shown-is-min-size: killed by a decide box too big for the terminal says so, and every k
r3-shown-at-arm: killed by below 40x10 the question is hidden, so every key answers i | a decide box too big for the terminal says so, and every k
r3-no-question-whole-box: killed by every screen and overlay fits 120x36, 80x24 and the narrow
r3-keep-zero: killed by a decide box too big for the terminal says so, and every k | at every size the decide box shows each line y depends on,
r3-hidden-no-any-key: killed by every screen and overlay fits 120x36, 80x24 and the narrow | a decide box too big for the terminal says so, and every k
r3-start-unguarded: killed by runApp leaves the terminal as it found it on q, end of inp
r3-text-never-asking: killed by below 40x10 the question is hidden, so every key answers i
r3-text-always-asking: killed by below 40x10 the question is hidden, so every key answers i
r3-answer-render-unguarded: killed by a throw while answering the question goes to crash, sends
r3-answer-throw-keeps-yes: killed by a throw while answering the question goes to crash, sends
r3-wrap-code-points: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-2329: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-232a: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-a960: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-fe10: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-fe50: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-16fe0: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-17000: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-1aff0: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-1aff0-short: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
r3-cells-no-1f200: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
survived 0
@@ -0,0 +1,82 @@
// Round 3 mutants: round 2's 30 less overlay-cut-loses-prompt (its line is
// gone), four re-anchored to lines round 3 changed, then round 3's own,
// Dewey's B1 among them. Each edits the source, runs
// the cli suite, restores.
import { readFileSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
const CLI = process.argv[2];
const M = [
["resolve-ask-skipped-when-note", "cli.mjs", "if (ask && !(await ask(", "if (ask && note === undefined && !(await ask("],
["unknown-space-only", "app.mjs", "/^outcome[ -]unknown/", "/^outcome unknown/"],
["restore-no-altleave", "app.mjs", 'stdout.write("\\x1b[?25h\\x1b[?1049l");', ""],
["restore-no-raw-off", "app.mjs", "stdin.setRawMode?.(false);", ""],
["no-sigterm", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGINT", "SIGHUP"];'],
["no-sigint", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGTERM", "SIGHUP"];'],
["no-sighup", "app.mjs", 'const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];', 'const SIGNALS = ["SIGTERM", "SIGINT"];'],
["resize-unguarded", "app.mjs", "const onResize = () => guard(() => app.resize(stdout.columns || 80, stdout.rows || 24));", "const onResize = () => app.resize(stdout.columns || 80, stdout.rows || 24);"],
["paint-no-reverse", "screens.mjs", "return l.sel ? `\\x1b[7m${body}\\x1b[27m` : body;", "return body;"],
["cells-no-cjk", "screens.mjs", "(n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||", ""],
["cells-no-emoji-presentation", "screens.mjs", "/\\p{Emoji_Presentation}/u.test(c) ||", ""],
["cells-no-fe0f", "screens.mjs", "if (n === 0xfe0f) return prev && /\\p{Emoji}/u.test(prev) && cells(prev) === 1 ? 1 : 0;", ""],
["b1-cut-keeps-end", "screens.mjs", "return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine(\"question\", w - 4), ...tail] : null;", "return b;"],
["app-host-not-live", "cli.mjs", "const business = state?.live ? state.business : null;", "const business = state?.business ?? null;"],
["quit-no-answer", "app.mjs", " this.cancelAsk?.();\n this.wake?.();", " this.answer?.(\"y\");\n this.wake?.();"],
["refresh-in-overlay", "app.mjs", "s.overlay || s.command !== null ? null : this.#read", "this.#read"],
["refresh-answers", "app.mjs", 'if (k !== "refresh") this.answer(k);', "this.answer(k);"],
["refresh-drops-resolved", "app.mjs", 'this.#read(s.view, s.statusStyle === "success")', "this.#read(s.view)"],
["unknown-esc-stays", "app.mjs", ' if (k === "esc") this.state.overlay = null;', ""],
["trail-task-id", "app.mjs", 'view === "tasks" ? item.task_ref : view === "trail"', 'view === "tasks" ? item.title : view === "trail"'],
["business-keeps-data", "app.mjs", " this.state.data = {};", ""],
["nav-no-brackets", "screens.mjs", "piece(`[${label}]`, \"bold\")", "piece(` ${label} `, \"bold\")"],
["typeahead-kept", "app.mjs", " this.keys = [];\n this.answer", " this.answer"],
["no-arm-delay", "app.mjs", " }, this.askMs);", " }, 0);"],
["other-key-waits", "app.mjs", 'this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows));', 'this.answer = (k) => (k === "y" || k === "Y" ? answer(decideShown(this.state.overlay, this.cols, this.rows)) : ["n", "N", "esc", "enter", "q"].includes(k) ? answer(false) : null);'],
["no-capital-y", "app.mjs", '(k === "y" || k === "Y") && decideShown', '(k === "y") && decideShown'],
["command-types-names", "app.mjs", '[...k].length === 1 && k >= " "', 'k >= " "'],
["press-1-1", "app.mjs", 'n === 1 ? "1" : `1-${n}`', "`1-${n}`"],
["tiny-no-guard", "screens.mjs", "if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color, Boolean(state.overlay?.asking));", ""],
// Round 3.
["r3-y-ignores-shown", "app.mjs", '(k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows)', '(k === "y" || k === "Y")'],
["r3-shown-is-min-size", "app.mjs", "&& decideShown(this.state.overlay, this.cols, this.rows))", "&& this.cols >= 40 && this.rows >= 10)"],
["r3-shown-at-arm", "app.mjs", 'this.answer = (k) => answer((k === "y" || k === "Y") && decideShown(this.state.overlay, this.cols, this.rows));', 'const shown = decideShown(this.state.overlay, this.cols, this.rows);\n this.answer = (k) => answer((k === "y" || k === "Y") && shown);'],
["r3-no-question-whole-box", "screens.mjs", "if (q < 0 || end < 0) return null;", "if (q < 0 || end < 0) return b;"],
["r3-keep-zero", "screens.mjs", "return keep >= 1 ?", "return keep >= 0 ?"],
["r3-hidden-no-any-key", "screens.mjs", 'o.asking ? "Any key answers No." : ', ""],
["r3-start-unguarded", "app.mjs", "guard(() => void app.start());", "void app.start();"],
["r3-text-never-asking", "screens.mjs", "Boolean(state.overlay?.asking)", "false"],
["r3-text-always-asking", "screens.mjs", "const keys = asking ?", "const keys = true ?"],
["r3-answer-render-unguarded", "app.mjs", "try {\n this.render();\n } catch (e) {\n // The pump sends this to crash; the decide flow ends with No.\n resolve(false);\n throw e;\n }", "this.render();"],
["r3-answer-throw-keeps-yes", "app.mjs", "resolve(false);\n throw e;", "resolve(yes);\n throw e;"],
["r3-wrap-code-points", "screens.mjs", "for (const { segment: c } of graphemes.segment(word))", "for (const c of word)"],
["r3-cells-no-2329", "screens.mjs", " n === 0x2329 ||\n", ""],
["r3-cells-no-232a", "screens.mjs", " n === 0x232a ||\n", ""],
["r3-cells-no-a960", "screens.mjs", " (n >= 0xa960 && n <= 0xa97c) ||\n", ""],
["r3-cells-no-fe10", "screens.mjs", " (n >= 0xfe10 && n <= 0xfe19) ||\n", ""],
["r3-cells-no-fe50", "screens.mjs", " (n >= 0xfe50 && n <= 0xfe6b) ||\n", ""],
["r3-cells-no-16fe0", "screens.mjs", " (n >= 0x16fe0 && n <= 0x16ff1) ||\n", ""],
["r3-cells-no-17000", "screens.mjs", " (n >= 0x17000 && n <= 0x18d08) ||\n", ""],
["r3-cells-no-1aff0", "screens.mjs", " (n >= 0x1aff0 && n <= 0x1b2fb) ||\n", ""],
["r3-cells-1aff0-short", "screens.mjs", "(n >= 0x1aff0 && n <= 0x1b2fb)", "(n >= 0x1aff0 && n <= 0x1b2fa)"],
["r3-cells-no-1f200", "screens.mjs", " (n >= 0x1f200 && n <= 0x1f2ff) ||\n", ""],
];
const only = process.argv.slice(3);
let survived = 0;
for (const [name, file, from, to] of M) {
if (only.length && !only.includes(name)) continue;
const path = join(CLI, "src", file);
const orig = readFileSync(path, "utf8");
const n = orig.split(from).length - 1;
if (n !== 1) { console.log(`${name}: SKIP, found ${n} times`); continue; }
try {
writeFileSync(path, orig.replace(from, to));
const r = spawnSync(process.execPath, ["--test", "--test-timeout=20000", "tests/"], { cwd: CLI, encoding: "utf8", timeout: 180000 });
const fails = (r.stdout.match(/^✖ (.*?) \(/gm) ?? []).slice(0, 2).map((s) => s.slice(2, 60));
const killed = r.status !== 0;
if (!killed) survived++;
console.log(`${name}: ${killed ? "killed" : "SURVIVED"}${killed ? ` by ${[...new Set(fails)].join(" | ")}` : ""}`);
} finally {
writeFileSync(path, orig);
}
}
console.log(`survived ${survived}`);
@@ -0,0 +1,25 @@
import { test } from "node:test";
// The smallest row count that shows a gated decision with a task, per width, for a short and a long
// question. Run: CLI=<checkout>/packages/cli node --test probe-round3.mjs
const CLI = process.env.CLI;
const { App } = await import(`${CLI}/src/app.mjs`);
const { decideShown } = await import(`${CLI}/src/screens.mjs`);
const { broker } = await import(`${CLI}/tests/helpers.mjs`);
test("probe", async (t) => {
for (const long of [false, true]) {
const bus = broker(t);
const question = long ? Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ") : "Push the release?";
bus.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
const a = new App({ business: "demo", transport: (b) => bus.transport(b), cols: 120, rows: 40, refreshMs: 0, askMs: 0 });
await a.start(); await a.input("1");
const o = a.state.overlay;
const out = [];
for (const cols of [40, 50, 60, 80, 120]) {
let min = null;
for (let rows = 10; rows <= 40; rows++) if (decideShown(o, cols, rows)) { min = rows; break; }
out.push(`${cols}:${min}`);
}
console.log(long ? "long" : "short", out.join(" "));
a.quit("q");
}
});
@@ -0,0 +1,11 @@
short 40:29 50:26 60:22 80:20 120:20
long 40:30 50:27 60:23 80:21 120:21
✔ probe (843.280351ms)
ℹ tests 1
ℹ suites 0
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 974.31265