queue-55: round 2 candidate packet (darkwing)

Answers Filbert's comment 27251 and Dewey's comment 27253: R1 type-ahead
window, R2 terminal cell widths, B1 cut inside the question, 30 mutants
killed, N1-N8. Candidate manifest sha256 3f2d8f8e. Gate on c506c6ec:
cli 106/106 and every scripts/test-*.sh exit 0. The feature stays
uncommitted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 13:39:05 -05:00
co-authored by Claude Opus 5.5
parent c506c6ec12
commit fa6d889cb7
21 changed files with 1181 additions and 80 deletions
+174 -2
View File
@@ -1,4 +1,4 @@
# Row 55 (#1544): the full-screen terminal app, candidate packet, round 1 # Row 55 (#1544): the full-screen terminal app, candidate packet, rounds 1 and 2
Author: Darkwing. Reviewers: Dewey and Filbert. Brief: Author: Darkwing. Reviewers: Dewey and Filbert. Brief:
`docs/plans/2026-10-10_design-implementation.md`, section "Full-screen `docs/plans/2026-10-10_design-implementation.md`, section "Full-screen
@@ -156,7 +156,7 @@ The tests in `packages/cli/tests/app.test.mjs`:
screen doesn't redraw during a read (30 s timeout). That is the existing screen doesn't redraw during a read (30 s timeout). That is the existing
transport, unchanged. The README says so. transport, unchanged. The README says so.
## Evidence ## Evidence, round 1
All runs in a detached worktree of `23d566d6` with `build.patch` applied All runs in a detached worktree of `23d566d6` with `build.patch` applied
and `node_modules` linked (`out/gate/`). and `node_modules` linked (`out/gate/`).
@@ -203,3 +203,175 @@ and `node_modules` linked (`out/gate/`).
queues it with the 2026-10-12 batch. queues it with the 2026-10-12 batch.
- Talk and stop in the app, Decision Seen, and the web console stay out of - Talk and stop in the app, Decision Seen, and the web console stay out of
scope, as the brief says. 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`.
@@ -48,3 +48,15 @@ No other check in `packages/cli/src` compares `.code` against
Files: `packages/cli/src/transport.mjs`, `packages/cli/tests/cli.test.mjs`, Files: `packages/cli/src/transport.mjs`, `packages/cli/tests/cli.test.mjs`,
`packages/cli/tests/transport.test.mjs`. `packages/cli/tests/transport.test.mjs`.
## A related case: a timed-out inbox read (Filbert N5, row 55 round 1)
`resolveDecision` reads the inbox before it sends anything. If that read
hits the same did-not-finish error, the app shows its outcome-unknown
overlay ("the broker may have recorded it"), though no choice was sent.
Once the transport sets `error.code = "outcome-unknown"`, `mosaic decide`
would print the same sentence for that read. The fix should tell the two
apart: only an error from `decision.resolve` is outcome unknown. A failed
inbox read before it is a plain failure, exit 1, with nothing sent. A test
for each, in `packages/cli/tests/cli.test.mjs` and
`packages/cli/tests/app.test.mjs`.
+450 -72
View File
@@ -22,7 +22,7 @@ index b96e9012..5a675611 100644
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`. `3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
diff --git a/packages/cli/README.md b/packages/cli/README.md diff --git a/packages/cli/README.md b/packages/cli/README.md
index 32977afa..3c3255f5 100644 index 32977afa..dbb03138 100644
--- a/packages/cli/README.md --- a/packages/cli/README.md
+++ b/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`, @@ -4,12 +4,16 @@ Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
@@ -44,7 +44,7 @@ index 32977afa..3c3255f5 100644
scripts/mosaic inbox scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it" scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7 scripts/mosaic trail 3f2a9c1e-… # then: scripts/mosaic trail vikunja:1/7
@@ -70,6 +74,52 @@ mosaic launches list [--json] @@ -70,6 +74,65 @@ mosaic launches list [--json]
`launches list` does not, because the file is readable to the same user `launches list` does not, because the file is readable to the same user
anyway. anyway.
@@ -68,13 +68,16 @@ index 32977afa..3c3255f5 100644
+- Keys: `c i t a r` switch views (Talk, Inbox, Tasks, Agents, Trail). `↑↓` +- Keys: `c i t a r` switch views (Talk, Inbox, Tasks, Agents, Trail). `↑↓`
+ or `j k` move, PgUp and PgDn page, Enter opens the selection whole, `v` + or `j k` move, PgUp and PgDn page, Enter opens the selection whole, `v`
+ opens its trail, `:` the command line, `?` help, Esc closes a box, `q` + opens its trail, `:` the command line, `?` help, Esc closes a box, `q`
+ or Ctrl-C quits. Commands: `:decide <decision> <option>`, `:trail + or Ctrl-C quits. On the command line, Esc or Ctrl-C closes it and the
+ arrows type nothing. Commands: `:decide <decision> <option>`, `:trail
+ <task|decision>`, `:business <id>`, `:q`. + <task|decision>`, `:business <id>`, `:q`.
+- Deciding: in the Inbox, digit `n` picks the selected decision's `n`th +- Deciding: in the Inbox, digit `n` picks the selected decision's `n`th
+ option and runs `resolveDecision` in `src/cli.mjs`, the code `mosaic + option and runs `resolveDecision` in `src/cli.mjs`, the code `mosaic
+ decide` runs. The box shows the same lines and the same `[y/N]` + decide` runs. The box shows the same lines and the same `[y/N]`
+ question; only `y` resolves. `n`, Esc, Enter and `q` cancel, and other + question. `y` or `Y` resolves; any other key answers No, and so does
+ keys wait. After `outcome unknown` the app shows the CLI's sentence and + quitting. Keys that arrive within 200 ms of the question being drawn
+ are type-ahead and are dropped: a paste such as `1 yes`, or keys typed
+ while a read held the screen, never answer it. After `outcome unknown` the app shows the CLI's sentence and
+ offers the trail (`v`) or a re-read (`r`). No key sends the choice + offers the trail (`v`) or a re-read (`r`). No key sends the choice
+ again. + again.
+- Reads happen on entering a view, after a decision, and every 30 s while +- Reads happen on entering a view, after a decision, and every 30 s while
@@ -88,21 +91,31 @@ index 32977afa..3c3255f5 100644
+ reverse video on the selected row (the design's tokens keep selection + reverse video on the selected row (the design's tokens keep selection
+ visible without colour). Frames fit 80x24; the layout is designed at + visible without colour). Frames fit 80x24; the layout is designed at
+ 120x36, and under 100 columns the nav shows keys only and the Inbox + 120x36, and under 100 columns the nav shows keys only and the Inbox
+ drops RAISED BY. + 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`.
+- The transport is the verbs' `spawnSync` child, so the screen does not +- 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). + redraw while a read is in flight (up to its 30 s timeout).
+- On quit the app restores the screen and the cursor, then prints +- On quit, end of input, SIGTERM, SIGINT, SIGHUP or an error (one in a
+ `(app closed with q; N decisions resolved this session)`. + resize redraw included) the app restores the screen, the cursor and
+ cooked mode. After anything but an error it then prints
+ `(app closed with q; N decisions resolved this session)`; an error
+ goes to stderr instead.
+ +
## Bus host ## Bus host
``` ```
diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs diff --git a/packages/cli/src/app.mjs b/packages/cli/src/app.mjs
new file mode 100644 new file mode 100644
index 00000000..208bc41f index 00000000..3cb3ab71
--- /dev/null --- /dev/null
+++ b/packages/cli/src/app.mjs +++ b/packages/cli/src/app.mjs
@@ -0,0 +1,447 @@ @@ -0,0 +1,480 @@
+// The full-screen app: `mosaic` with no argument on a terminal (src/cli.mjs +// 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 +// main). Inbox, Tasks, Agents, Trail and Help over the same broker reads as
+// the verbs; src/screens.mjs draws the frames. +// the verbs; src/screens.mjs draws the frames.
@@ -110,13 +123,16 @@ index 00000000..208bc41f
+// Reads and the decide flow go through the human transport, like the verbs. +// Reads and the decide flow go through the human transport, like the verbs.
+// A decision resolves only through resolveDecision (src/cli.mjs), the code +// A decision resolves only through resolveDecision (src/cli.mjs), the code
+// `mosaic decide` runs: a digit picks an option, the app shows the CLI's +// `mosaic decide` runs: a digit picks an option, the app shows the CLI's
+// lines and its yes/no question, and only y resolves. After "outcome unknown" +// lines and its yes/no question. y or Y resolves; any other key, or quitting,
+// the app offers the trail and a re-read, never a retry. +// answers No. Keys that arrive within ASK_MS of the question being drawn are
+// type-ahead and never answer it. After "outcome unknown" the app offers the
+// trail and a re-read, never a retry.
+// +//
+// Keys: c i t a r views; ↑↓ or j k move; PgUp PgDn page; Enter opens the +// Keys: c i t a r views; ↑↓ or j k move; PgUp PgDn page; Enter opens the
+// selection's detail; v the trail of the selection; 1-9 choose; `:` the +// selection's detail; v the trail of the selection; 1-9 choose; `:` the
+// command line; ? help; Esc back; q or Ctrl-C quits. Talk (c) is a pointer +// command line (Esc or Ctrl-C closes it); ? help; Esc back; q or Ctrl-C
+// to `mosaic talk`; the conversation pane is a later row. +// quits. Below 40x10 the frame says the terminal is too small. Talk (c) is a
+// pointer to `mosaic talk`; the conversation pane is a later row.
+ +
+import { resolveDecision } from "./cli.mjs"; +import { resolveDecision } from "./cli.mjs";
+import { CliError } from "./errors.mjs"; +import { CliError } from "./errors.mjs";
@@ -125,6 +141,8 @@ index 00000000..208bc41f
+import { busExit } from "./transport.mjs"; +import { busExit } from "./transport.mjs";
+ +
+export const REFRESH_MS = 30000; +export const REFRESH_MS = 30000;
+// How long the decide question is up before a key can answer it.
+export const ASK_MS = 200;
+ +
+// Terminal input to key names: "up", "down", "pgup", "pgdn", "enter", "esc", +// Terminal input to key names: "up", "down", "pgup", "pgdn", "enter", "esc",
+// "tab", "backspace", "ctrl-c", or the character itself. Other escape +// "tab", "backspace", "ctrl-c", or the character itself. Other escape
@@ -157,7 +175,7 @@ index 00000000..208bc41f
+export class App { +export class App {
+ // transport(business) returns call(verb, args). business may be null: + // transport(business) returns call(verb, args). business may be null:
+ // the app then says so and waits for :business <id>. + // the app then says so and waits for :business <id>.
+ constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS }) { + constructor({ business = null, transport, write = () => {}, cols = 80, rows = 24, color = true, now = () => Date.now(), onQuit = () => {}, refreshMs = REFRESH_MS, askMs = ASK_MS }) {
+ this.transport = transport; + this.transport = transport;
+ this.write = write; + this.write = write;
+ this.cols = cols; + this.cols = cols;
@@ -165,6 +183,7 @@ index 00000000..208bc41f
+ this.color = color; + this.color = color;
+ this.now = now; + this.now = now;
+ this.onQuit = onQuit; + this.onQuit = onQuit;
+ this.askMs = askMs;
+ this.state = { business, view: "inbox", data: {}, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "", statusStyle: null }; + this.state = { business, view: "inbox", data: {}, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "", statusStyle: null };
+ this.call = business ? transport(business) : null; + this.call = business ? transport(business) : null;
+ this.resolved = 0; + this.resolved = 0;
@@ -172,6 +191,7 @@ index 00000000..208bc41f
+ this.keys = []; + this.keys = [];
+ this.job = null; + this.job = null;
+ this.answer = null; + this.answer = null;
+ this.cancelAsk = null;
+ this.wake = null; + this.wake = null;
+ this.idle = []; + this.idle = [];
+ this.pumping = false; + this.pumping = false;
@@ -219,7 +239,14 @@ index 00000000..208bc41f
+ for (;;) { + for (;;) {
+ if (this.closed) break; + if (this.closed) break;
+ if (this.answer && this.keys.length) { + if (this.answer && this.keys.length) {
+ this.answer(this.keys.shift()); + // A timer tick is not an answer.
+ const k = this.keys.shift();
+ try {
+ if (k !== "refresh") this.answer(k);
+ } catch (e) {
+ this.crash(e);
+ break;
+ }
+ continue; + continue;
+ } + }
+ if (this.job) { + if (this.job) {
@@ -251,22 +278,28 @@ index 00000000..208bc41f
+ for (const r of this.idle.splice(0)) r(); + for (const r of this.idle.splice(0)) r();
+ } + }
+ +
+ // Asks the decide flow's question; resolves true on y. n, Esc, Enter, q + // Asks the decide flow's question; resolves true on y or Y and false on
+ // and Ctrl-C say no; other keys are ignored. + // 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.
+ #ask(question) { + #ask(question) {
+ this.state.overlay = { ...this.state.overlay, asking: question }; + this.state.overlay = { ...this.state.overlay, asking: question };
+ this.render(); + this.render();
+ return new Promise((resolve) => { + return new Promise((resolve) => {
+ const take = (k) => { + const answer = (yes) => {
+ if (k === "y" || k === "Y") resolve(true); + clearTimeout(arm);
+ else if (["n", "N", "esc", "enter", "q", "ctrl-c"].includes(k)) resolve(false); + this.answer = this.cancelAsk = null;
+ else return void (this.answer = take);
+ this.answer = null;
+ this.state.overlay = { ...this.state.overlay, asking: null }; + this.state.overlay = { ...this.state.overlay, asking: null };
+ this.render(); + this.render();
+ resolve(yes);
+ }; + };
+ this.answer = take; + this.cancelAsk = () => answer(false);
+ const arm = setTimeout(() => {
+ this.keys = [];
+ this.answer = (k) => answer(k === "y" || k === "Y");
+ this.wake?.(); + this.wake?.();
+ }, this.askMs);
+ }); + });
+ } + }
+ +
@@ -278,7 +311,10 @@ index 00000000..208bc41f
+ // Handles one key; returns a promise when it starts a read or a decision. + // Handles one key; returns a promise when it starts a read or a decision.
+ #key(k) { + #key(k) {
+ const s = this.state; + const s = this.state;
+ if (k === "start" || k === "refresh") return k === "refresh" && (s.overlay || s.command !== null) ? null : this.#read(s.view); + // A timer tick keeps a "resolved" status; it reads nothing under an
+ // overlay or the command line.
+ if (k === "start") return this.#read(s.view);
+ if (k === "refresh") return s.overlay || s.command !== null ? null : this.#read(s.view, s.statusStyle === "success");
+ if (s.command !== null) return this.#commandKey(k); + if (s.command !== null) return this.#commandKey(k);
+ if (k === "ctrl-c") return this.quit("Ctrl-C"); + if (k === "ctrl-c") return this.quit("Ctrl-C");
+ if (s.overlay) return this.#overlayKey(k); + if (s.overlay) return this.#overlayKey(k);
@@ -437,7 +473,8 @@ index 00000000..208bc41f
+ try { + try {
+ const list = await this.call(view, view === "trail" ? { subject: before.subject } : {}); + const list = await this.call(view, view === "trail" ? { subject: before.subject } : {});
+ this.state.data[view] = { subject: before.subject, list, at: new Date(this.now()).toISOString() }; + this.state.data[view] = { subject: before.subject, list, at: new Date(this.now()).toISOString() };
+ if (!keep) this.#status(view === "inbox" && list.length ? `Press 1-${Math.min(9, this.#selected("inbox")?.options.length ?? 1)} to choose. You will see the decision again and confirm with y.` : ""); + const n = Math.min(9, this.#selected("inbox")?.options.length ?? 1);
+ if (!keep) this.#status(view === "inbox" && list.length ? `Press ${n === 1 ? "1" : `1-${n}`} to choose. You will see the decision again and confirm with y.` : "");
+ } catch (e) { + } catch (e) {
+ if (!(e instanceof CliError) && !e.code) throw e; + if (!(e instanceof CliError) && !e.code) throw e;
+ // Exit 3 codes are refusals (src/transport.mjs), not failed reads. + // Exit 3 codes are refusals (src/transport.mjs), not failed reads.
@@ -456,7 +493,7 @@ index 00000000..208bc41f
+ const text = s.command.trim(); + const text = s.command.trim();
+ s.command = null; + s.command = null;
+ return this.#run(text); + return this.#run(text);
+ } else if (k.length <= 2 && k >= " ") s.command += k; + } else if ([...k].length === 1 && k >= " ") s.command += k;
+ this.render(); + this.render();
+ } + }
+ +
@@ -487,7 +524,7 @@ index 00000000..208bc41f
+ if (this.closed) return; + if (this.closed) return;
+ this.closed = true; + this.closed = true;
+ if (this.timer) clearInterval(this.timer); + if (this.timer) clearInterval(this.timer);
+ this.answer?.("q"); + this.cancelAsk?.();
+ this.wake?.(); + this.wake?.();
+ this.onQuit({ how, resolved: this.resolved, error: this.error }); + this.onQuit({ how, resolved: this.resolved, error: this.error });
+ } + }
@@ -507,30 +544,39 @@ index 00000000..208bc41f
+} +}
+ +
+// Runs the app on a terminal until it quits: raw mode, the alternate screen, +// Runs the app on a terminal until it quits: raw mode, the alternate screen,
+// no cursor; all three restored on quit, on an error and on SIGTERM. +// no cursor; all three restored on quit, on an error (a render that throws
+// Resolves with { how, resolved }. +// included) and on SIGTERM, SIGINT or SIGHUP. Resolves with { how, resolved }.
+export function runApp({ io, business, transport, color, refreshMs }) { +export function runApp({ io, business, transport, color, refreshMs, askMs, signals = process }) {
+ const { stdin, stdout } = io; + const { stdin, stdout } = io;
+ const SIGNALS = ["SIGTERM", "SIGINT", "SIGHUP"];
+ return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => {
+ let app; + let app;
+ const restore = () => { + const restore = () => {
+ stdin.off("data", onData); + stdin.off("data", onData);
+ stdin.off("end", onEnd); + stdin.off("end", onEnd);
+ stdout.off?.("resize", onResize); + stdout.off?.("resize", onResize);
+ process.off("SIGTERM", onTerm); + for (const [name, on] of onSignal) signals.off(name, on);
+ stdin.setRawMode?.(false); + stdin.setRawMode?.(false);
+ stdin.pause?.(); + stdin.pause?.();
+ stdout.write("\x1b[?25h\x1b[?1049l"); + stdout.write("\x1b[?25h\x1b[?1049l");
+ }; + };
+ const guard = (f) => {
+ try {
+ f();
+ } catch (e) {
+ app.crash(e);
+ }
+ };
+ const onData = (c) => void app.input(c.toString("utf8")); + const onData = (c) => void app.input(c.toString("utf8"));
+ const onEnd = () => app.quit("end of input"); + const onEnd = () => app.quit("end of input");
+ const onResize = () => app.resize(stdout.columns || 80, stdout.rows || 24); + const onResize = () => guard(() => app.resize(stdout.columns || 80, stdout.rows || 24));
+ const onTerm = () => app.quit("SIGTERM"); + const onSignal = SIGNALS.map((name) => [name, () => app.quit(name)]);
+ app = new App({ + app = new App({
+ business, + business,
+ transport, + transport,
+ color, + color,
+ refreshMs, + refreshMs,
+ askMs,
+ cols: stdout.columns || 80, + cols: stdout.columns || 80,
+ rows: stdout.rows || 24, + rows: stdout.rows || 24,
+ write: (s) => stdout.write(s), + write: (s) => stdout.write(s),
@@ -545,9 +591,9 @@ index 00000000..208bc41f
+ stdin.on("data", onData); + stdin.on("data", onData);
+ stdin.on("end", onEnd); + stdin.on("end", onEnd);
+ stdout.on?.("resize", onResize); + stdout.on?.("resize", onResize);
+ process.on("SIGTERM", onTerm); + for (const [name, on] of onSignal) signals.on(name, on);
+ stdin.resume?.(); + stdin.resume?.();
+ void app.start(); + guard(() => void app.start());
+ }); + });
+} +}
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
@@ -662,10 +708,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(" "); 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 diff --git a/packages/cli/src/screens.mjs b/packages/cli/src/screens.mjs
new file mode 100644 new file mode 100644
index 00000000..d6e09981 index 00000000..c0194cb2
--- /dev/null --- /dev/null
+++ b/packages/cli/src/screens.mjs +++ b/packages/cli/src/screens.mjs
@@ -0,0 +1,367 @@ @@ -0,0 +1,405 @@
+// The full-screen app's frames (src/app.mjs). Pure: draw(state, opts) takes +// 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 +// 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 +// once its escape codes are dropped. Nothing here reads the bus or the
@@ -685,17 +731,24 @@ index 00000000..d6e09981
+ +
+// Below this width the nav shows keys only and Inbox drops RAISED BY. +// Below this width the nav shows keys only and Inbox drops RAISED BY.
+export const NARROW_BELOW = 100; +export const NARROW_BELOW = 100;
+// Below this size the frame only says the terminal is too small.
+export const MIN_COLS = 40;
+export const MIN_ROWS = 10;
+ +
+const SGR = { action: ["34", "39"], accent: ["33", "39"], success: ["32", "39"], warning: ["93", "39"], danger: ["31", "39"], muted: ["90", "39"], bold: ["1", "22"] }; +const SGR = { action: ["34", "39"], accent: ["33", "39"], success: ["32", "39"], warning: ["93", "39"], danger: ["31", "39"], muted: ["90", "39"], bold: ["1", "22"] };
+ +
+const oneLine = (s) => visible(s ?? "").replace(/\r?\n/g, "^J"); +const oneLine = (s) => visible(s ?? "").replace(/\r?\n/g, "^J");
+ +
+// Terminal cells for one code point: 0 for combining marks, joiners and +// Terminal cells for one code point after `prev`: 0 for combining marks,
+// variation selectors, 2 for East Asian wide characters and emoji, else 1. +// joiners and variation selectors, 2 for East Asian wide characters and
+function cells(c) { +// emoji shown as emoji, else 1. U+FE0F after a one-cell emoji base (🅰️, ❤️)
+// asks for emoji presentation and adds the second cell.
+function cells(c, prev = "") {
+ const n = c.codePointAt(0); + const n = c.codePointAt(0);
+ if (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0f)) return 0; + if (n === 0xfe0f) return prev && /\p{Emoji}/u.test(prev) && cells(prev) === 1 ? 1 : 0;
+ if (/\p{M}/u.test(c) || n === 0x200d || n === 0x200c || (n >= 0xfe00 && n <= 0xfe0e)) return 0;
+ if ( + if (
+ /\p{Emoji_Presentation}/u.test(c) ||
+ (n >= 0x1100 && n <= 0x115f) || + (n >= 0x1100 && n <= 0x115f) ||
+ (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) || + (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) ||
+ (n >= 0xac00 && n <= 0xd7a3) || + (n >= 0xac00 && n <= 0xd7a3) ||
@@ -703,7 +756,7 @@ index 00000000..d6e09981
+ (n >= 0xfe30 && n <= 0xfe4f) || + (n >= 0xfe30 && n <= 0xfe4f) ||
+ (n >= 0xff00 && n <= 0xff60) || + (n >= 0xff00 && n <= 0xff60) ||
+ (n >= 0xffe0 && n <= 0xffe6) || + (n >= 0xffe0 && n <= 0xffe6) ||
+ (n >= 0x1f300 && n <= 0x1faff) || + (n >= 0x1f200 && n <= 0x1f2ff) ||
+ (n >= 0x20000 && n <= 0x3fffd) + (n >= 0x20000 && n <= 0x3fffd)
+ ) + )
+ return 2; + return 2;
@@ -712,7 +765,11 @@ index 00000000..d6e09981
+ +
+export function width(s) { +export function width(s) {
+ let w = 0; + let w = 0;
+ for (const c of s) w += cells(c); + let prev = "";
+ for (const c of s) {
+ w += cells(c, prev);
+ prev = c;
+ }
+ return w; + return w;
+} +}
+ +
@@ -722,11 +779,13 @@ index 00000000..d6e09981
+ if (width(s) <= w) return s + " ".repeat(w - width(s)); + if (width(s) <= w) return s + " ".repeat(w - width(s));
+ let out = ""; + let out = "";
+ let used = 0; + let used = 0;
+ let prev = "";
+ for (const c of s) { + for (const c of s) {
+ const n = cells(c); + const n = cells(c, prev);
+ if (used + n > w - 1) break; + if (used + n > w - 1) break;
+ out += c; + out += c;
+ used += n; + used += n;
+ prev = c;
+ } + }
+ return out + "…" + " ".repeat(w - 1 - used); + return out + "…" + " ".repeat(w - 1 - used);
+} +}
@@ -743,7 +802,7 @@ index 00000000..d6e09981
+ for (const word of s.split(/(?<= )/)) { + for (const word of s.split(/(?<= )/)) {
+ if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push(); + if (cur.trim() !== "" && width(cur + word.trimEnd()) > w) push();
+ for (const c of word) { + for (const c of word) {
+ if (width(cur) + cells(c) > w) push(); + if (width(cur + c) > w) push();
+ cur += c; + cur += c;
+ } + }
+ } + }
@@ -984,18 +1043,32 @@ index 00000000..d6e09981
+ const [text, style] = Array.isArray(l) ? l : [l, null]; + const [text, style] = Array.isArray(l) ? l : [l, null];
+ for (const t of wrap(oneLine(text), inner, " ")) out.push(line(["│ ", "accent"], [fit(t, inner), style], [" │", "accent"])); + for (const t of wrap(oneLine(text), inner, " ")) out.push(line(["│ ", "accent"], [fit(t, inner), style], [" │", "accent"]));
+ } + }
+ out.push(line([`└${"─".repeat(w - 2)}┘`, "accent"])); + out.push(line([`└${"─".repeat(Math.max(0, w - 2))}┘`, "accent"]));
+ return out; + return out;
+} +}
+ +
+function overlayBox(o, cols) { +const cutLine = (what, inner) => line(["│ ", "accent"], fit(`… (${what} cut to fit; the whole text is in mosaic inbox --json)`, inner), [" │", "accent"]);
+
+// `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)); + if (o.kind === "help") return box("help", HELP, Math.min(84, cols - 2));
+ const w = Math.min(84, cols - 2); + const w = Math.min(84, cols - 2);
+ if (o.kind === "decide") { + if (o.kind === "decide") {
+ const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t)); + const lines = o.lines.map((t) => (t.startsWith("your choice: ") ? [t, "bold"] : t));
+ if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · n, Esc or Enter cancels (the default is No)", "muted"]); + if (o.asking) lines.push("", [o.asking, "bold"], "", ["y resolves · any other key cancels (the default is No)", "muted"]);
+ else lines.push("", ["resolving…", "muted"]); + else lines.push("", ["resolving…", "muted"]);
+ return box("decide", lines, w); + const b = box("decide", lines, w);
+ 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;
+ 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;
+ } + }
+ if (o.kind === "unknown") { + 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); + 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);
@@ -1006,6 +1079,7 @@ index 00000000..d6e09981
+ +
+// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms). +// Draws the frame. opts: cols, rows, color (false under NO_COLOR), now (ms).
+export function draw(state, { cols, rows, color = true, now = Date.now() }) { +export function draw(state, { cols, rows, color = true, now = Date.now() }) {
+ if (cols < MIN_COLS || rows < MIN_ROWS) return tooSmall(cols, rows, color);
+ const room = rows - 5; + const room = rows - 5;
+ let body; + let body;
+ if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()]; + if (!state.business && state.view !== "talk") body = [line([` ${VIEWS.find((v) => v[2] === state.view)[1]}`, "bold"]), blank(), ...noBusiness()];
@@ -1017,13 +1091,15 @@ index 00000000..d6e09981
+ body = body.slice(0, room); + body = body.slice(0, room);
+ while (body.length < room) body.push(blank()); + while (body.length < room) body.push(blank());
+ if (state.overlay) { + if (state.overlay) {
+ let b = overlayBox(state.overlay, cols); + let b = overlayBox(state.overlay, cols, room);
+ // Too tall: cut the middle, never the choice, the prompt or the keys. + // 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.
+ if (b.length > room) { + if (b.length > room) {
+ const at = b.findIndex((l) => l.pieces.some(([t]) => t.startsWith("your choice: "))); + 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(at > 0 ? at : -2);
+ const inner = Math.min(84, cols - 2) - 4; + 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"})`, 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].slice(-room);
+ } + }
+ const left = Math.max(0, Math.floor((cols - width(b[0].pieces.map(([t]) => t).join(""))) / 2)); + 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)); + const top = Math.max(0, Math.min(2, room - b.length));
@@ -1033,21 +1109,31 @@ index 00000000..d6e09981
+ const frame = [nav(state, cols, now), rule(cols), ...body, rule(cols), status, line([cols < NARROW_BELOW ? HINT_NARROW : HINT_WIDE, "muted"])]; + const frame = [nav(state, cols, now), rule(cols), ...body, rule(cols), status, line([cols < NARROW_BELOW ? HINT_NARROW : HINT_WIDE, "muted"])];
+ return frame.map((l) => paint(l, cols, color)); + return frame.map((l) => paint(l, cols, color));
+} +}
+
+// 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));
+ 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 diff --git a/packages/cli/tests/app.test.mjs b/packages/cli/tests/app.test.mjs
new file mode 100644 new file mode 100644
index 00000000..b88b2230 index 00000000..b01abcb6
--- /dev/null --- /dev/null
+++ b/packages/cli/tests/app.test.mjs +++ b/packages/cli/tests/app.test.mjs
@@ -0,0 +1,427 @@ @@ -0,0 +1,719 @@
+import { test } from "node:test"; +import { test } from "node:test";
+import assert from "node:assert/strict"; +import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process";
+import { readFileSync } from "node:fs"; +import { EventEmitter } from "node:events";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path"; +import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url"; +import { fileURLToPath } from "node:url";
+import { App, parseKeys, quitLine } from "../src/app.mjs"; +import { App, parseKeys, quitLine, runApp } from "../src/app.mjs";
+import { main, SEAT_USAGE, USAGE } from "../src/cli.mjs"; +import { main, resolveDecision, SEAT_USAGE, USAGE } from "../src/cli.mjs";
+import { CliError } from "../src/errors.mjs"; +import { CliError } from "../src/errors.mjs";
+import { hostDir, hostFile, startTimeOf } from "../src/host.mjs";
+import { draw, strip, width } from "../src/screens.mjs"; +import { draw, strip, width } from "../src/screens.mjs";
+import { broker, humanEnv, io, tmp } from "./helpers.mjs"; +import { broker, humanEnv, io, tmp } from "./helpers.mjs";
+ +
@@ -1059,6 +1145,14 @@ index 00000000..b88b2230
+ [80, 24], + [80, 24],
+ [100, 30], + [100, 30],
+ [99, 24], + [99, 24],
+ [40, 10],
+];
+// Below 40x10 the frame says the terminal is too small.
+const TOO_SMALL = [
+ [39, 10],
+ [40, 9],
+ [3, 3],
+ [1, 1],
+]; +];
+ +
+const exitOf = async (p) => { +const exitOf = async (p) => {
@@ -1081,6 +1175,7 @@ index 00000000..b88b2230
+ write: (s) => (out.text = s), + write: (s) => (out.text = s),
+ now: () => NOW, + now: () => NOW,
+ refreshMs: 0, + refreshMs: 0,
+ askMs: 0,
+ onQuit: (r) => (out.quit = r), + onQuit: (r) => (out.quit = r),
+ ...opts, + ...opts,
+ }); + });
@@ -1113,10 +1208,15 @@ index 00000000..b88b2230
+ ...more, + ...more,
+ }); + });
+ const data = { + const data = {
+ inbox: [1, 2, 3].map((n) => decision(n)), + // Emoji and CJK that are two cells wide (P3b in the round 1 review).
+ inbox: [
+ decision(1),
+ decision(2, { action: "✅✅ ship", authorization: { action: "git.push.protected", target: "⚡ main", approvalChoice: "yes" } }),
+ decision(3, { authorization: { action: "git.push.protected", target: "🅰️ branch", approvalChoice: "yes" } }),
+ ],
+ tasks: [ + tasks: [
+ { seq: 1, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/7", updated: "x", digest: "d", fields: { title: HOSTILE, status: "in progress" }, source: "poll", via: "board", read_at: "2026-10-11T16:00:00.000Z" }, + { seq: 1, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/7", updated: "x", digest: "d", fields: { title: HOSTILE, status: "in progress" }, source: "poll", via: "board", read_at: "2026-10-11T16:00:00.000Z" },
+ { seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "Short", done: true }, source: "self", via: null, read_at: null }, + { seq: 2, at: "2026-10-11T16:00:00.000Z", business: "demo", task_ref: "vikunja:1/8", updated: "x", digest: "d", fields: { title: "発行 ✅", done: true }, source: "self", via: null, read_at: null },
+ ], + ],
+ agents: [{ role: "coder", holder_run: "coder-run-with-a-long-name-0123456789", harness: "pi", at: "2026-10-11T15:00:00.000Z" }], + agents: [{ role: "coder", holder_run: "coder-run-with-a-long-name-0123456789", harness: "pi", at: "2026-10-11T15:00:00.000Z" }],
+ trail: [ + trail: [
@@ -1169,7 +1269,7 @@ index 00000000..b88b2230
+ { kind: "error", title: "decide", message: `failed: ${HOSTILE}` }, + { kind: "error", title: "decide", message: `failed: ${HOSTILE}` },
+ ]) { + ]) {
+ a.state.overlay = o; + a.state.overlay = o;
+ shot(`overlay ${o.kind}`); + shot(`overlay ${o.kind}${o.lines?.length > 10 ? " long" : ""}`);
+ } + }
+ a.state.overlay = null; + a.state.overlay = null;
+ // Empty, failed-with-stale-data, refused and no-business states. + // Empty, failed-with-stale-data, refused and no-business states.
@@ -1181,6 +1281,7 @@ index 00000000..b88b2230
+ shot("inbox refused"); + shot("inbox refused");
+ a.state.business = null; + a.state.business = null;
+ shot("no business"); + shot("no business");
+ assert.equal(a.closed, false, `${cols}x${rows}: ${a.error?.stack}`);
+ a.quit("q"); + a.quit("q");
+ return shots; + return shots;
+} +}
@@ -1190,15 +1291,40 @@ index 00000000..b88b2230
+ for (const color of [true, false]) { + 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}`); + 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.
+ 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}`);
+ } + }
+ // Too small: every screen still fits, and says why.
+ for (const [cols, rows] of TOO_SMALL) {
+ for (const [label, frame] of await everyScreen(t, cols, rows, true)) fits(frame, cols, rows, `${cols}x${rows} ${label}`);
+ }
+ assert.match((await everyScreen(t, 39, 10, false))[0][1].join(""), /The terminal is 39x10; the app needs at least 40x10/);
+ // Designed at 120x36: the inbox shows the raiser; under 100 columns the nav shows keys only. + // Designed at 120x36: the inbox shows the raiser; under 100 columns the nav shows keys only.
+ const [wide] = await everyScreen(t, 120, 36, false); + const [wide] = await everyScreen(t, 120, 36, false);
+ assert.match(strip(wide[1][0]), /Inbox/); + assert.match(strip(wide[1][0]), /\[i Inbox 3\]/);
+ assert.match(wide[1].map(strip).join("\n"), /RAISED BY/); + assert.match(wide[1].map(strip).join("\n"), /RAISED BY/);
+ const [narrow] = await everyScreen(t, 99, 24, false); + const [narrow] = await everyScreen(t, 99, 24, false);
+ assert.match(strip(narrow[1][0]), /\[i 3\] t a /);
+ assert.doesNotMatch(narrow[1].map(strip).join("\n"), /RAISED BY/); + assert.doesNotMatch(narrow[1].map(strip).join("\n"), /RAISED BY/);
+}); +});
+ +
+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]]) {
+ assert.equal(width(s), cells, JSON.stringify(s));
+ }
+ // 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.
+ const frames = Object.fromEntries(await everyScreen(t, 80, 24, false));
+ const row = (label, text) => strip(frames[label].find((l) => l.includes(text)));
+ assert.equal(row("inbox", "✅✅ ship").length, 80 - 3, "two ✅ and one ⚡");
+ assert.equal(row("inbox", "🅰️ branch").length, 80 + 1, "🅰️");
+ assert.equal(row("tasks", "発行 ✅").length, 80 - 3, "発行 and ✅, not cut");
+});
+
+test("broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal", async (t) => { +test("broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal", async (t) => {
+ for (const [label, frame] of await everyScreen(t, 120, 36, true)) { + for (const [label, frame] of await everyScreen(t, 120, 36, true)) {
+ for (const l of frame) { + for (const l of frame) {
@@ -1222,8 +1348,12 @@ index 00000000..b88b2230
+ assert.doesNotMatch(a.render().join("\n"), /\x1b/, key); + assert.doesNotMatch(a.render().join("\n"), /\x1b/, key);
+ } + }
+ a.quit("q"); + a.quit("q");
+ // The selected row is reversed, with colour and without.
+ const [[, plain]] = await everyScreen(t, 120, 36, false);
+ assert.equal(plain.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
+ // The colour build does use colour. + // The colour build does use colour.
+ const [[, coloured]] = await everyScreen(t, 120, 36, true); + const [[, coloured]] = await everyScreen(t, 120, 36, true);
+ assert.equal(coloured.filter((l) => l.startsWith("\x1b[7m") && l.endsWith("\x1b[27m")).length, 1);
+ assert.match(coloured.join("\n"), /\x1b\[3[0-9]m/); + assert.match(coloured.join("\n"), /\x1b\[3[0-9]m/);
+}); +});
+ +
@@ -1237,7 +1367,7 @@ index 00000000..b88b2230
+ const box = s.screen(); + const box = s.screen();
+ assert.match(box, /your choice: yes \(Allow\); this authorizes the action/); + assert.match(box, /your choice: yes \(Allow\); this authorizes the action/);
+ assert.match(box, new RegExp(`resolve ${d.id.slice(0, 8)} with yes\\? \\[y/N\\]`)); + assert.match(box, new RegExp(`resolve ${d.id.slice(0, 8)} with yes\\? \\[y/N\\]`));
+ assert.match(box, /y resolves · n, Esc or Enter cancels \(the default is No\)/); + assert.match(box, /y resolves · any other key cancels \(the default is No\)/);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), "nothing is sent before y"); + assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), "nothing is sent before y");
+ await s.a.input("y"); + await s.a.input("y");
+ assert.deepEqual(s.calls.filter((c) => c.verb === "decision.resolve"), [{ business: "demo", verb: "decision.resolve", args: { id: d.id, choice: "yes" } }]); + assert.deepEqual(s.calls.filter((c) => c.verb === "decision.resolve"), [{ business: "demo", verb: "decision.resolve", args: { id: d.id, choice: "yes" } }]);
@@ -1273,23 +1403,132 @@ index 00000000..b88b2230
+ await s.a.input("n"); + await s.a.input("n");
+}); +});
+ +
+test("n, Esc, Enter and q at the question cancel; other keys wait; nothing is sent", async (t) => { +test("any key but y or Y at the question answers No; nothing is sent", async (t) => {
+ for (const key of ["n", "N", "\x1b", "\r", "q"]) { + for (const key of ["n", "N", "\x1b", "\r", "q", "x", " ", "2", "\x03", "\x1b[A"]) {
+ const s = app(t); + const s = app(t);
+ s.raise("git.push.protected", { target: "refactor" }); + s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start(); + await s.a.start();
+ await s.a.input("1"); + await s.a.input("1");
+ await s.a.input("x"); + assert.ok(s.a.state.overlay?.asking, JSON.stringify(key));
+ assert.ok(s.a.state.overlay?.asking, `${JSON.stringify(key)}: still asking after x`);
+ await s.a.input(key); + await s.a.input(key);
+ assert.equal(s.a.state.overlay, null, JSON.stringify(key)); + assert.equal(s.a.state.overlay, null, JSON.stringify(key));
+ assert.equal(s.a.state.status, "not resolved"); + assert.equal(s.a.state.status, "not resolved");
+ assert.equal(s.read("inbox").length, 1); + assert.equal(s.read("inbox").length, 1);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve")); + assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
+ assert.equal(s.out.quit, null, "q at the question cancels; it does not quit"); + assert.equal(s.out.quit, null, "q and Ctrl-C at the question answer No; they do not quit");
+ } + }
+}); +});
+ +
+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;
+ // P2a: "1 yes" pasted on the Inbox.
+ const a = app(t);
+ a.raise("git.push.protected", { target: "refactor" });
+ await a.a.start();
+ await a.a.input("1 yes");
+ assert.ok(asking(a), "P2a");
+ assert.equal(sent(a), 0, "P2a");
+ // P2b: "decide 1a2b yes" typed without the colon.
+ const b = app(t);
+ b.raise("git.push.protected", { target: "refactor" });
+ await b.a.start();
+ await b.a.input("decide 1a2b yes");
+ assert.ok(asking(b), "P2b");
+ assert.equal(sent(b), 0, "P2b");
+ // P2c: 1 and y as separate events, before the question is drawn.
+ const c = app(t);
+ c.raise("git.push.protected", { target: "refactor" });
+ await c.a.start();
+ await Promise.all([c.a.input("1"), c.a.input("y")]);
+ assert.ok(asking(c), "P2c");
+ assert.equal(sent(c), 0, "P2c");
+ // A y that arrives after the question is drawn but within askMs, as keys
+ // typed while a read blocked the screen do: one at once, one 10 ms later.
+ let early;
+ let d;
+ d = app(t, {
+ askMs: 300,
+ write: (text) => {
+ if (!early && text.includes("[y/N]")) {
+ void d.a.input("y");
+ early = new Promise((r) => setTimeout(() => d.a.input("y").then(r), 10));
+ }
+ },
+ });
+ d.raise("git.push.protected", { target: "refactor" });
+ await d.a.start();
+ await d.a.input("1");
+ await early;
+ assert.ok(asking(d), "within askMs");
+ assert.equal(sent(d), 0, "within askMs");
+ while (!d.a.answer) await new Promise((r) => setTimeout(r, 10));
+ // After that, y resolves.
+ for (const s of [a, b, c, d]) {
+ await s.a.input("y");
+ assert.equal(sent(s), 1);
+ assert.match(s.a.state.status, /^resolved /);
+ }
+});
+
+test("quitting at the question answers No and sends nothing, armed or not", async (t) => {
+ for (const askMs of [0, 60000]) {
+ const s = app(t, { askMs });
+ s.raise("git.push.protected", { target: "refactor" });
+ await s.a.start();
+ const pending = s.a.input("1");
+ while (!s.a.state.overlay?.asking) await new Promise((r) => setImmediate(r));
+ s.a.quit("SIGTERM");
+ await pending;
+ while (s.a.job) await new Promise((r) => setImmediate(r));
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"), `askMs ${askMs}`);
+ assert.deepEqual(s.out.quit, { how: "SIGTERM", resolved: 0, error: undefined });
+ }
+});
+
+test("a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status", async (t) => {
+ const s = app(t);
+ const d = s.raise("git.push.protected", { target: "refactor" });
+ s.raise("git.push.protected", { target: "next" });
+ await s.a.start();
+ const reads = () => s.calls.filter((c) => c.verb === "inbox").length;
+ const before = reads();
+ await s.a.input("?");
+ await s.a.input("\0refresh");
+ assert.equal(reads(), before, "help open");
+ await s.a.input("\x1b:");
+ await s.a.input("\0refresh");
+ assert.equal(reads(), before, "command line open");
+ await s.a.input("\x1b");
+ await s.a.input("\0refresh");
+ assert.equal(reads(), before + 1, "nothing open");
+ await s.a.input("1");
+ const asked = reads();
+ await s.a.input("\0refresh");
+ assert.ok(s.a.state.overlay?.asking, "a tick is not an answer");
+ assert.equal(reads(), asked);
+ await s.a.input("y");
+ assert.equal(s.a.state.status, `resolved ${d.id}: yes`);
+ await s.a.input("\0refresh");
+ assert.equal(s.a.state.status, `resolved ${d.id}: yes`, "N4");
+ assert.equal(s.a.state.data.inbox.list.length, 1);
+});
+
+test("a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1)", async (t) => {
+ const s = app(t, { cols: 80, rows: 24 });
+ const question = Array.from({ length: 90 }, (_, i) => `word${i}`).join(" ");
+ assert.ok(question.length > 530);
+ const d = s.raise("git.push.protected", { target: "refactor", blocking: true, task_ref: "vikunja:1/7", question });
+ await s.a.start();
+ await s.a.input("1");
+ const screen = s.screen();
+ for (const want of [`decision ${d.id}`, " question: word0 word1", "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", "question cut to fit; the whole text is in mosaic inbox --json"]) {
+ assert.ok(screen.includes(want), `${want}\n${screen}`);
+ }
+ assert.ok(!screen.includes("word89"), "the end of the question is what goes");
+ fits(s.a.frame, 80, 24, "long question");
+});
+
+test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => { +test("outcome unknown: the CLI's sentence, no retry, then the trail or a re-read", async (t) => {
+ let resolves = 0; + let resolves = 0;
+ const s = app(t, { + const s = app(t, {
@@ -1315,6 +1554,21 @@ index 00000000..b88b2230
+ assert.equal(s.a.state.view, "trail"); + assert.equal(s.a.state.view, "trail");
+ assert.equal(s.a.state.data.trail.subject, d.id); + assert.equal(s.a.state.data.trail.subject, d.id);
+ assert.ok(s.a.state.data.trail.list.length > 0); + assert.ok(s.a.state.data.trail.list.length > 0);
+ // The transport's own "did not finish" error has no code, only the
+ // hyphenated message; it opens the same box, and Esc closes it.
+ const hung = app(t, {
+ wrap: (call) => async (verb, args) => {
+ if (verb === "decision.resolve") throw new CliError("outcome-unknown: the bus transport did not finish", 1);
+ return call(verb, args);
+ },
+ });
+ hung.raise("git.push.protected", { target: "refactor" });
+ await hung.a.start();
+ await hung.a.input("1");
+ await hung.a.input("y");
+ assert.deepEqual([hung.a.state.overlay.kind, hung.a.state.overlay.message], ["unknown", "outcome-unknown: the bus transport did not finish"]);
+ await hung.a.input("\x1b");
+ assert.equal(hung.a.state.overlay, null);
+}); +});
+ +
+test("refused and failed reads keep the last list, marked stale; a refused decide says refused", async (t) => { +test("refused and failed reads keep the last list, marked stale; a refused decide says refused", async (t) => {
@@ -1347,7 +1601,8 @@ index 00000000..b88b2230
+ } }); + } });
+ decide.raise("git.push.protected", { target: "refactor" }); + decide.raise("git.push.protected", { target: "refactor" });
+ await decide.a.start(); + await decide.a.start();
+ await decide.a.input("1y"); + await decide.a.input("1");
+ await decide.a.input("y");
+ assert.deepEqual([decide.a.state.overlay.kind, decide.a.state.overlay.message], ["error", "refused: unauthenticated"]); + assert.deepEqual([decide.a.state.overlay.kind, decide.a.state.overlay.message], ["error", "refused: unauthenticated"]);
+}); +});
+ +
@@ -1385,6 +1640,48 @@ index 00000000..b88b2230
+ await h.a.input("\x03"); + await h.a.input("\x03");
+ assert.equal(h.out.quit.how, "Ctrl-C"); + assert.equal(h.out.quit.how, "Ctrl-C");
+ assert.deepEqual(parseKeys("a\x1b[A\x1b[5~\x1bOB\x1b[1;5C\r\x1b"), ["a", "up", "pgup", "down", "enter", "esc"]); + assert.deepEqual(parseKeys("a\x1b[A\x1b[5~\x1bOB\x1b[1;5C\r\x1b"), ["a", "up", "pgup", "down", "enter", "esc"]);
+ // Arrows on the command line move nothing and type nothing.
+ await h.a.start();
+ const c = app(t);
+ await c.a.start();
+ await c.a.input(":ab\x1b[A\x1b[Bc");
+ assert.equal(c.a.state.command, "abc");
+ // v on Tasks opens the trail of the task.
+ const f = app(t, { transport: fake() });
+ await f.a.start();
+ await f.a.input("t");
+ await f.a.input("v");
+ assert.equal(f.a.state.data.trail.subject, "vikunja:1/7");
+ assert.match(f.screen(), /Trail {2}vikunja:1\/7 · 2 row\(s\)/);
+ // :business switches: the old business's lists and selections go.
+ await f.a.input("t");
+ await f.a.input("j");
+ assert.equal(f.a.state.sel.tasks, 1);
+ const calls = [];
+ f.a.transport = (b) => async (verb) => (calls.push([b, verb]), []);
+ await f.a.input(":business other\r");
+ assert.equal(f.a.state.business, "other");
+ assert.equal(f.a.state.sel.tasks, 0);
+ assert.equal(f.a.state.data.trail, undefined);
+ assert.deepEqual(f.a.state.data.tasks.list, []);
+ assert.deepEqual(calls, [["other", "tasks"]]);
+});
+
+test("one option: the status line says Press 1, not 1-1", async (t) => {
+ const base = fake();
+ const s = app(t, {
+ transport: () => async (verb) => {
+ const rows = await base()(verb);
+ if (verb === "inbox") rows.forEach((d) => (d.options = d.options.slice(0, 1)));
+ return rows;
+ },
+ });
+ await s.a.start();
+ assert.match(s.screen(), /Press 1 to choose\./);
+ const two = app(t);
+ two.raise("git.push.protected", { target: "refactor" });
+ await two.a.start();
+ assert.match(two.screen(), /Press 1-2 to choose\./);
+}); +});
+ +
+test("no business: the app says so and :business picks one", async (t) => { +test("no business: the app says so and :business picks one", async (t) => {
@@ -1462,12 +1759,91 @@ index 00000000..b88b2230
+ } + }
+}); +});
+ +
+test("decide with --note still asks; a No sends nothing", async (t) => {
+ const s = broker(t);
+ const d = s.raise("git.push.protected", { target: "refactor" });
+ const call = s.transport("demo");
+ const asked = [];
+ const err = await resolveDecision({ call, ref: d.id, choice: "yes", note: "a reason", write: () => {}, ask: async (q) => (asked.push(q), false) }).catch((e) => e);
+ assert.equal(err.message, "not resolved");
+ assert.equal(asked.length, 1);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
+ // Off a terminal, --note without --yes refuses before anything is sent.
+ const x = io(humanEnv());
+ x.stdin.isTTY = false;
+ const off = await main(["decide", d.id, "yes", "--note", "a reason", "--business", "demo"], x, { system: { dataRoot: tmp(t) }, transport: s.transport }).catch((e) => e);
+ assert.equal(off.exitCode, 4);
+ assert.match(off.message, /stdin is not a terminal; pass --yes/);
+ assert.ok(!s.calls.some((c) => c.verb === "decision.resolve"));
+});
+
+test("the app's business comes from a live bus host only", async (t) => {
+ for (const [startTime, want] of [
+ ["not-this-process", null],
+ [startTimeOf(process.pid), "demo"],
+ ]) {
+ const dataRoot = tmp(t);
+ mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
+ writeFileSync(hostFile(dataRoot), JSON.stringify({ pid: process.pid, startTime, business: "demo" }), { mode: 0o600 });
+ const x = io(humanEnv());
+ x.stdin.isTTY = true;
+ x.stdout.isTTY = true;
+ let opened;
+ await main([], x, { system: { dataRoot }, transport: () => async () => [], runApp: async (o) => ((opened = o), { how: "q", resolved: 0 }) });
+ assert.equal(opened.business, want, startTime);
+ }
+});
+
+// A terminal for runApp: raw mode and listeners recorded, writes kept.
+function terminal({ throwOnResize = 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 });
+ let resized = false;
+ Object.defineProperty(stdout, "columns", {
+ get: () => {
+ if (resized && throwOnResize) throw new Error("columns failed");
+ return 80;
+ },
+ });
+ const signals = new EventEmitter();
+ const resize = () => ((resized = true), stdout.emit("resize"));
+ 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 () => {
+ const cases = [
+ ["q", (x) => x.stdin.emit("data", Buffer.from("q"))],
+ ["end of input", (x) => x.stdin.emit("end")],
+ ["SIGTERM", (x) => x.signals.emit("SIGTERM")],
+ ["SIGINT", (x) => x.signals.emit("SIGINT")],
+ ["SIGHUP", (x) => x.signals.emit("SIGHUP")],
+ ["an error", (x) => x.resize(), { throwOnResize: 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(
+ (r) => r,
+ (e) => ({ how: "an error", error: e }),
+ );
+ assert.equal(result.how, how);
+ if (how === "an error") assert.equal(result.error.message, "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);
+ for (const name of ["SIGTERM", "SIGINT", "SIGHUP"]) assert.equal(x.signals.listenerCount(name), 0, `${how} ${name}`);
+ assert.equal(x.stdin.listenerCount("data") + x.stdin.listenerCount("end") + x.stdout.listenerCount("resize"), 0, how);
+ }
+});
+
+test("draw is pure: same state, same frame", () => { +test("draw is pure: same state, same frame", () => {
+ const state = { business: "demo", view: "inbox", data: { inbox: { list: [], at: new Date(NOW).toISOString() } }, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "" }; + const state = { business: "demo", view: "inbox", data: { inbox: { list: [], at: new Date(NOW).toISOString() } }, sel: { inbox: 0, tasks: 0, agents: 0, trail: 0 }, overlay: null, command: null, status: "" };
+ assert.deepEqual(draw(state, { cols: 80, rows: 24, color: false, now: NOW }), draw(state, { cols: 80, rows: 24, color: false, now: NOW })); + assert.deepEqual(draw(state, { cols: 80, rows: 24, color: false, now: NOW }), draw(state, { cols: 80, rows: 24, color: false, now: NOW }));
+}); +});
diff --git a/packages/queue/tests/dispatch.test.mjs b/packages/queue/tests/dispatch.test.mjs diff --git a/packages/queue/tests/dispatch.test.mjs b/packages/queue/tests/dispatch.test.mjs
index aa425c0b..ddd341b8 100644 index aa425c0b..2280cdb2 100644
--- a/packages/queue/tests/dispatch.test.mjs --- a/packages/queue/tests/dispatch.test.mjs
+++ b/packages/queue/tests/dispatch.test.mjs +++ b/packages/queue/tests/dispatch.test.mjs
@@ -1,8 +1,9 @@ @@ -1,8 +1,9 @@
@@ -1506,7 +1882,7 @@ index aa425c0b..ddd341b8 100644
[""], [""],
["launch", "queue"], ["launch", "queue"],
["Queue", "list"], ["Queue", "list"],
@@ -73,6 +73,17 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) => @@ -73,6 +73,19 @@ test("every call but `queue` reaches the seat CLI exactly as before A2", (t) =>
} }
}); });
@@ -1516,9 +1892,11 @@ index aa425c0b..ddd341b8 100644
+ assert.deepEqual([got.status, got.out, got.err], [7, "fake out\n", "fake err\n"]); + assert.deepEqual([got.status, got.out, got.err], [7, "fake out\n", "fake err\n"]);
+ const c = JSON.parse(got.capture); + const c = JSON.parse(got.capture);
+ assert.deepEqual([c.cli, c.argv, c.cwd, c.stdin], ["/packages/cli/src/cli.mjs", [], "<ROOT>/work", "stdin line\nsecond\n"]); + assert.deepEqual([c.cli, c.argv, c.cwd, c.stdin], ["/packages/cli/src/cli.mjs", [], "<ROOT>/work", "stdin line\nsecond\n"]);
+ // Before row 55 the same call went to the seat CLI. + // Before row 55 the same call went to the seat CLI, with the same
+ // environment the app now gets.
+ const before = call(root(t, join(HERE, "fixtures", "mosaic-pre-a2.sh")), []); + const before = call(root(t, join(HERE, "fixtures", "mosaic-pre-a2.sh")), []);
+ assert.equal(JSON.parse(before.capture).cli, "/packages/seat/src/cli.mjs"); + assert.equal(JSON.parse(before.capture).cli, "/packages/seat/src/cli.mjs");
+ assert.deepEqual(c.env, JSON.parse(before.capture).env);
+}); +});
+ +
test("`queue` reaches the queue CLI with the rest of the arguments", (t) => { test("`queue` reaches the queue CLI with the rest of the arguments", (t) => {
@@ -1,9 +1,9 @@
fcc2791aa2f80d7e196b0ce18f02475f9fc6730c74cbb56d2db3f9aae333253e docs/TOOLS.md fcc2791aa2f80d7e196b0ce18f02475f9fc6730c74cbb56d2db3f9aae333253e docs/TOOLS.md
001ae6159638f783520fa300c3f9ce71c25701568058664a032760dc6d79062e packages/cli/README.md ac058d0aa7eaaf7a52a681f79fcedb3ea15051226697542029cb5fa15fc15600 packages/cli/README.md
534fdf2e70f7459789c32f31e467e78400a44d90df53dd319a774d8cf959506e packages/cli/src/app.mjs dfdeb9b91eb41f67ecec8e4835f631d6ec47b967897d349ed9b27d06045a9d93 packages/cli/src/app.mjs
c33d3fedb91be7977cf7b468c908719d8f07cd7a2dad1bb7d9c24d62bcdac432 packages/cli/src/cli.mjs c33d3fedb91be7977cf7b468c908719d8f07cd7a2dad1bb7d9c24d62bcdac432 packages/cli/src/cli.mjs
c2f6852226c7071dec132d422773d76bdfc356450f582ac28cb7a5077860b4c0 packages/cli/src/format.mjs c2f6852226c7071dec132d422773d76bdfc356450f582ac28cb7a5077860b4c0 packages/cli/src/format.mjs
681279edb4982ce103cdbb1a5fe3490721bca27f0877163f5a8c2f5ce6ace419 packages/cli/src/screens.mjs ff24eb0f78a64f98892eca372337ce141f30bc15169200fde37b6db34c6c8474 packages/cli/src/screens.mjs
17e4a3ccb8a8e896c0d9fb6d0781db9636ceff65ba3d48dd7bdff75934150e4d packages/cli/tests/app.test.mjs 10b15b25f607100f5a1a6e315334668c014d01452c92fbd1c04dd2bfba276d30 packages/cli/tests/app.test.mjs
5c892aea6a455f05aabb28d312f5fc4bb256584254357c6499319ac956f58109 packages/queue/tests/dispatch.test.mjs bc972d153603311b08a2173d5b912f5fa7e9277605d3ce60d87161bf28ea80ab packages/queue/tests/dispatch.test.mjs
ab246b3e39ea0c503d97e7af93ef8b958caedb3cf3006e387f14fa93b329856c scripts/mosaic ab246b3e39ea0c503d97e7af93ef8b958caedb3cf3006e387f14fa93b329856c scripts/mosaic
@@ -0,0 +1,14 @@
round 2 apply check, 2026-10-11T18:34:57Z
base for apply: c506c6ec12aac53df408645802111799d9aad63c
git apply --index build.patch: ok
9 files changed, 1741 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
candidate-manifest.sha256: 3f2d8f8ea3fea1cf7a002297ee9b23059f1522e8060c1d981fba19c85baeabd7
@@ -0,0 +1,116 @@
✔ every screen and overlay fits 120x36, 80x24 and the narrow layout, row for row (1388.082384ms)
✔ width counts terminal cells: wide CJK and emoji 2, U+FE0F after a one-cell base 1, marks 0 (67.593336ms)
✔ broker text is shown, never interpreted: no raw control character or bidi override reaches the terminal (143.85161ms)
✔ NO_COLOR: no escape codes, except reverse video on the selected row (226.278082ms)
✔ 1-9 shows the decide flow's own lines and question; only y resolves (190.559659ms)
✔ the app shows the same decide text as mosaic decide (109.597544ms)
✔ any key but y or Y at the question answers No; nothing is sent (874.466315ms)
✔ type-ahead never answers the question: a paste, keys typed before it is drawn, keys inside askMs (660.940214ms)
✔ quitting at the question answers No and sends nothing, armed or not (180.705251ms)
✔ a timer tick reads nothing under an overlay, never answers the question, and keeps a resolved status (219.873581ms)
✔ a long question is cut inside the question; the action, target, choice and prompt stay (Dewey B1) (264.814429ms)
✔ outcome unknown: the CLI's sentence, no retry, then the trail or a re-read (236.352318ms)
✔ refused and failed reads keep the last list, marked stale; a refused decide says refused (136.823699ms)
✔ keys: views, moving, trail of a selection, the command line, help and quit (374.348794ms)
✔ one option: the status line says Press 1, not 1-1 (123.773035ms)
✔ no business: the app says so and :business picks one (84.30717ms)
✔ mosaic with no argument: exit 3 in an agent run, exit 4 with the usage off a terminal, the app on one (4.148559ms)
✔ scripts/mosaic with no argument reaches the app entry; the seat usage it printed before has not drifted (275.068261ms)
✔ strings to keep exactly live once, in the decide flow and the transport; the app does not restate them (0.6017ms)
✔ decide with --note still asks; a No sends nothing (54.813604ms)
✔ the app's business comes from a live bus host only (1.680961ms)
✔ runApp leaves the terminal as it found it on q, end of input, SIGTERM, SIGINT, SIGHUP and an error (9.900172ms)
✔ draw is pure: same state, same frame (1.563689ms)
✔ inbox lists only decisions routed to the human, with what approving authorizes and how to decide (558.465697ms)
✔ decide resolves by id prefix with --yes, and the trail shows it in broker order with the task to follow (226.333914ms)
✔ decide refuses without a terminal or --yes, on an unknown option and on a short reference (124.030399ms)
✔ decide prints a declining choice as declining (123.202347ms)
✔ an unknown outcome is reported once and never resent (100.701958ms)
✔ a decision closed before the answer arrives exits 2 and points at its trail (106.502603ms)
✔ a prefix that matches two open decisions exits 2 and resolves neither (107.996238ms)
✔ without --business a command uses the live host's business, and a stale host.json is not a host (84.361031ms)
✔ every human command refuses inside an agent run before it touches the bus (47.368796ms)
✔ usage errors exit 4; no business and no host is a usage error (91.207182ms)
✔ agents and tasks print through the broker (82.943846ms)
✔ notify.json: missing, loose, malformed and extra keys refuse; a binding or null passes (1.528757ms)
✔ bootConfig builds the broker's boot message for one business, with no trackers key when no project names one (91.398158ms)
✔ trackers come from the tracker.* variables of the one project that names a tracker project (58.642973ms)
✔ with two projects, the one that sets tracker.project supplies the tracker and the other is no conflict (58.964757ms)
✔ two projects that each name a tracker project refuse, since the boot shape holds one (56.269289ms)
✔ a business without tracker.baseUrl gets no trackers entry (48.660207ms)
✔ an unknown business and a broken system config refuse with exit 3 (89.684246ms)
✔ empty views say so (1.526241ms)
✔ the trail keeps the broker's order and names a decision's task without its rows (1.600511ms)
✔ tasks print the tracker fields the snapshot carries (0.358094ms)
✔ the host boots the broker, binds a launch in process, and the notifier DMs a blocking decision exactly once (1475.971054ms)
✔ a notifier that dies takes the host down with exit 1, so the unit restarts the pair (301.319751ms)
✔ a second host for the same data root refuses with exit 3 while the first runs (229.25187ms)
✔ a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1 (1228.180989ms)
✔ a broker reply with another request's id, or none, breaks the channel and the host exits 1 (345.996602ms)
✔ a broker reply with no request waiting breaks the channel and the host exits 1 (189.581835ms)
✔ a notifier that refuses stops the broker and the host refuses with exit 3 (550.192871ms)
✔ a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker (218.85895ms)
✔ a notifier that dies before it replies, after the broker died, still refuses, without a send to the dead broker (179.181892ms)
✔ a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3 (298.228093ms)
✔ a close send that fails with EPIPE after the notifier dies unanswered still gives the notifier's error (187.817774ms)
✔ close() whose stop and close sends fail with EPIPE still finishes, with exit 1 (264.604371ms)
✔ watchChildren reports a child that died before it was called, and one that dies later (32.060309ms)
✔ bus stop refuses to signal a live pid that is not a bus host (203.20409ms)
✔ bus start refuses with exit 3 and the code when the broker refuses to boot; bus status names the lock (332.872065ms)
✔ bus start refuses with exit 3 without a notifier config (180.02518ms)
✔ bus start runs until bus stop; status reports it while it runs (939.74172ms)
✔ bus-service.sh renders the unit and installs it into a given directory (49.869693ms)
✔ bus start --pm: the PM runs under its own PID namespace, registered, with a manifest (1835.799228ms)
✔ the PM launches a coder through its launch tool; the coder answers; refusals name their code (791.19158ms)
✔ a runner that stops at once ends its launch with the runner's reason (248.693167ms)
✔ 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 (861.342895ms)
✔ 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 (4052.369698ms)
✔ a malformed sessions.json refuses the host start with exit 3 and stays as it was (162.656426ms)
✔ an over-long launch request is refused at once, not at the 10 s idle timeout (159.332156ms)
✔ a launch client that never closes its side doesn't hold the host's close (172.449402ms)
✔ a runner that ignores SIGTERM is killed when the host closes (1275.853097ms)
✔ zoned uses the IANA zone across DST (56.263686ms)
✔ each open blocking decision is DM'd once, across polls and a restart; non-blocking ones are not (628.011326ms)
✔ two blocking decisions get two DMs with different nonces (143.867589ms)
✔ the digest nonce differs per business and per day and fits Discord's 25 characters (0.421052ms)
✔ a failed DM is journaled, backs off, and is retried until it lands (129.80481ms)
✔ five definite refusals stop a DM: one gave-up line, one log line, and a restart keeps the count (110.686399ms)
✔ 429s, 5xx-style unknowns and refusals without a status never count toward the limit (122.930633ms)
✔ a crash between the fifth refusal and its gave-up line: the next poll appends it and sends nothing (114.02775ms)
✔ 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 (200.131735ms)
✔ a restart after the second refusal does not send before that refusal's 30 min are up (137.077578ms)
✔ the digest goes at 08:00 Chicago once a day, with blocking ones marked as DM'd (88.191016ms)
✔ a late start with no digest for the day sends one at once; an empty inbox gets one line (76.683336ms)
✔ an inbox read failure is logged and the next poll retries (1.291748ms)
✔ no Discord id reaches the journal or the log (108.749226ms)
✔ the journal: a torn tail is copied out and truncated, so an append after it reopens cleanly (29.499338ms)
✔ the journal: a crash between the copy and the truncate leaves a tail the next open repairs (65.404216ms)
✔ the journal: a whole file that is one torn line truncates to empty (22.595781ms)
✔ the journal: a malformed complete line refuses and leaves the file and any torn tail alone (4.271563ms)
✔ the journal: a loose file mode, a loose directory or a symlinked journal refuses (1.311849ms)
✔ the journal: a line with a wrong type refuses with exit 3 and names the field (10.18965ms)
✔ the journal: a symlinked directory refuses and says it is a link (0.546915ms)
✔ 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.930554ms)
✔ the journal: an append after the file was swapped for a symlink refuses and writes nothing through it (0.744786ms)
✔ the journal: a directory it cannot write or create refuses with exit 3 and names the path (0.925587ms)
✔ digest content stays within Discord's 2000 characters (0.55609ms)
✔ runLoop never overlaps ticks and stops after the one in flight (111.970759ms)
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 (893.910564ms)
✔ the transport writes {business, verb, args} to the child and reads its JSON (75.055927ms)
✔ a bus code on stderr becomes the exit code; garbage and timeouts are outcome problems (2278.818425ms)
✔ busExit and refuseInsideAgent (0.555669ms)
✔ talk sends a REQUEST, prints and reads everything that arrives, and stops at the reply (732.667829ms)
✔ talk --wait 0 only sends; no reply within --wait exits 1 and says where it will show (1152.250705ms)
✔ talk, stop and launches off/on refuse inside an agent run; bad arguments are usage errors (132.977272ms)
✔ launches off and on go to the broker and change the business's launch state (110.37747ms)
✔ launches list reads sessions.json and marks a stale entry; stop reports it and refuses a non-runner (95.505355ms)
ℹ tests 106
ℹ suites 0
ℹ pass 106
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 9738.674718
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
export TMPDIR=~/darkwing-scratch/r55/tmp
G=~/darkwing-scratch/r55/gate; O=~/darkwing-scratch/r55/out; 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,23 @@
On branch refactor
Your branch is up to date with 'origin/refactor'.
nothing to commit, working tree clean
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: 1455682 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,3 @@
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
survived 0
@@ -0,0 +1,31 @@
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
cells-no-fe0f: killed by width counts terminal cells: wide CJK and emoji 2, U+FE0F
overlay-cut-loses-prompt: killed by every screen and overlay fits 120x36, 80x24 and the narrow
b1-cut-keeps-end: killed by a long question is cut inside the question; the action, ta
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: SURVIVED
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
survived 1
@@ -0,0 +1,57 @@
// Round 2 mutants: each edits one source line, 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;", ""],
["overlay-cut-loses-prompt", "screens.mjs", "...tail].slice(-room);", "].slice(0, room);"],
["b1-cut-keeps-end", "screens.mjs", "return keep >= 1 ? [...head, ...question.slice(0, keep), cutLine(\"question\", w - 4), ...tail] : b;", "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");', 'this.answer = (k) => (k === "y" || k === "Y" ? answer(true) : ["n", "N", "esc", "enter", "q"].includes(k) ? answer(false) : null);'],
["no-capital-y", "app.mjs", 'answer(k === "y" || k === "Y")', 'answer(k === "y")'],
["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);", ""],
];
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}`);