Files
stack/packages/cli
jason.woltjeandClaude Opus 5.5 2f5303c1c7 feat(cli): the mosaic CLI, broker host and decision notifier (row 39, S4, rocko)
packages/cli adds mosaic inbox, decide, tasks, agents and trail over the
human-cli transport, and mosaic bus start, stop and status as the trusted
host (unit mosaic-bus@<business>, scripts/bus-service.sh). The host boots
packages/bus/src/process.mjs, passes config.trackers from the tracker.*
variables (lead decision 70), and runs a notifier child. The notifier DMs
each open blocking decision once and sends an 08:00 America/Chicago
digest, journaled in notify/<business>/sent.jsonl at 0600 with no Discord
ids. A torn journal tail is copied aside and truncated; a malformed line,
a directory looser than 0700 or a symlinked journal refuses (lead
decision 71). packages/discord gains dmRecipient, createDm and notify.mjs.

Candidate agents/rocko/work/slice1-s4, base b9b6cf00, build.patch
b52f7d68, manifest e858504e (29 files). Darkwing approved round 2 on
#1521 (comment 26855), Filbert approved round 2 (comment 26856). The
packet's mutant table lists M28 as killed; it survived, and BUILD-LOG
records the correction.

Integration gate in a worktree on 2557e29d with the patch applied:
bus 67, business 60, cli 49, control-board 124, discord 178, ledger 78,
mosaic 69, queue 148, seat 19, tasks 51 and webui 14, all with no
failures. Conversation is 149/3, the same K1, K3 and K10 cases that fail
on the base; S4 doesn't touch the package. Every scripts/test-*.sh is
green, with test-release 14/14 and test-task 98/98 on the existing gate2
compose network. A scratch test, not in this commit, booted the real
host with trackers against S3's fake Vikunja: the adapter went ready and
a task.close on a missing task answered task-not-found after a Vikunja
read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 07:49:38 -05:00
..

CLI

Jason's front door to the bus (slice 1 row S4, #1521): mosaic inbox, decide, tasks, agents and trail, and the bus host mosaic bus start|stop|status with its Discord notifier. Brief: docs/plans/2026-10-04_slice-1.md, row S4. Rulings: lead decision 70 in docs/plans/2026-09-26_lead-decisions.md.

Every command reads through the broker. None opens the SQLite file.

scripts/mosaic inbox
scripts/mosaic decide 3f2a9c1e yes --note "ship it"
scripts/mosaic trail 3f2a9c1e-…        # then: scripts/mosaic trail vikunja:1/7
scripts/mosaic bus status
node --test 'packages/cli/tests/*.test.mjs'

Human commands

mosaic inbox [--business <id>] [--json]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic agents [--business <id>] [--json]
mosaic trail <task|decision> [--business <id>] [--json]
  • The business is --business, or else the business of the bus host running on this data root. With neither, the command exits 4.
  • The transport is packages/bus/src/human-cli.mjs <socket>, run as a child. The command writes {business, verb, args} on its stdin and reads the JSON reply on its stdout. The child proves a human shell: it re-executes itself with a nonce, and the broker checks its process and ancestry for agent markers. The command carries no capability and the bus is unchanged.
  • Each command refuses with exit 3 before it touches the bus when an agent marker is in its environment (REQ-DEC-3). The broker would refuse anyway; this check only gives a clear message first. Agent seats never run these commands. The tests use a stand-in transport.
  • inbox lists the open decisions routed to the human, gated first, in broker order. For a gated decision it prints what approving authorizes: the action, its target, and the one choice that authorizes it.
  • decide takes the full id, or a unique prefix of at least 8 characters, from the inbox. It prints the decision and whether the choice authorizes or declines, then asks [y/N] on a terminal. Without a terminal it needs --yes, else it exits 4. On outcome-unknown it does not resend: it exits 1 and asks you to check mosaic inbox or mosaic trail <id> first.
  • trail prints rows in the broker's order (at, table, seq). A decision's trail ends with its task_ref and follow with: mosaic trail <task>. It does not pull in the task's rows.

Bus host

mosaic bus start <business>     the host; run by the unit, not by hand
mosaic bus stop                 SIGTERM to the recorded host, then wait
mosaic bus status [--json]      host state file, socket, writer.lock

bus start does the following:

  1. It reads the system config through scripts/mosaic-config.mjs validate, the business file and the role files, and builds the broker's {op: 'boot'} message (src/config.mjs). The message has launches: [] and readers: [<business>]. It gets config.trackers (below) when a tracker is set.
  2. It reads the notifier config (below).
  3. It forks packages/bus/src/process.mjs and waits up to 30 s for the boot reply. If the broker answers {ok: false, error}, the host exits 3 and prints broker refused to start: <code>. A timeout or an early exit gives exit 1.
  4. It forks src/notifier-process.mjs and hands it the reader capability over IPC in {op: 'start'}. A notifier refusal closes the broker, and the host exits 3.
  5. It writes <dataRoot>/bus-host/host.json (0600): the pid, the process start time, the business, the start time as text and the notifier binding. The file holds no capability.

No capability appears in argv, stdout, the environment or a file. startHost() in src/host.mjs is also the in-process API S6 uses: bindLaunch(record) binds a launched run's process identity (pid, startTime) and returns {business, run, cap}. Requests to the broker process go one at a time, because its replies carry no request id.

SIGTERM or SIGINT stops the notifier after its poll in flight, then closes the broker and removes host.json. If either child dies on its own, the host goes down with exit 1. The unit then restarts both. The host watches the children once both have started, and it checks each child's exit state at that point too, so a broker that dies while the notifier starts still takes the host down.

bus stop signals only a pid that still runs with the recorded start time and whose command line is …/packages/cli/src/cli.mjs bus start. bus status never removes a lock. A writer.lock whose pid is gone means a broker was killed hard. Check, then remove the lock by hand (packages/bus/README.md).

Trackers

config.trackers[<business>] is {baseUrl, project, pollSeconds, reconcileMinutes}, taken from the tracker.* variables. tracker.project is a project-layer variable, so the entry comes from the one declared project whose .mosaic/project.json sets it. The resulting entry depends on the variables:

Variables Result
No tracker.baseUrl No entry and no task verbs
tracker.baseUrl set, no project setting tracker.project A warning on stderr and no entry
Two projects setting tracker.project Refusal with exit 3, because the boot shape holds one tracker project per business

Unit

scripts/bus-service.sh render|install|uninstall|status installs the template systemd/mosaic-bus.service.in as the systemd user unit [email protected], one instance per business (mosaic-bus@<business>). The template's file name has no @ because queue candidate manifests refuse it. It is modelled on scripts/discord-service.sh:

  • install [--dir DIR] [--no-reload] writes the unit through a temp file and a rename, then runs systemctl --user daemon-reload.
  • uninstall refuses while an instance is active.

The unit settings:

  • ExecStart=@REPO@/scripts/mosaic bus start %i
  • Restart=on-failure
  • RestartPreventExitStatus=2 3 4, so refusals are never retried
  • KillSignal=SIGTERM
  • TimeoutStopSec=60

The unit has no network-online.target ordering, since a user unit cannot order on that system target. The notifier needs no network at start: a send that fails is journaled and retried.

Notifier

The notifier is a child of the host. Each poll (every 30 s) it reads the inbox through the reader capability and does two things:

  • DMs. It DMs each open blocking decision once. The DM holds the question, the options, the recommendation, what approving authorizes, the task, and mosaic decide <short id> <option>.
  • Digest. It sends one digest a day at 08:00 America/Chicago. The hour comes from the IANA zone, so daylight saving time is handled. If the host starts after 08:00 and the day has no digest yet, the digest goes at once. The digest lists the inbox and marks each blocking decision as DM sent or DM pending. An empty inbox gets one line. Each message stays within Discord's 2000 characters.

The notifier only reads the bus. Its memory is the journal <dataRoot>/notify/<business>/sent.jsonl (directory 0700, file 0600), with one line per send attempt:

{"at": "…", "kind": "dm", "decision": "<id>", "outcome": "confirmed", "messageId": "…"}
{"at": "…", "kind": "digest", "decision": null, "day": "2026-10-08", "outcome": "unknown", "messageId": null}
  • A decision, or a day's digest, counts as sent once it has a confirmed line.
  • A refused or unknown send is retried. The wait starts at 30 s and doubles up to 30 min. A duplicate costs less than a miss. Every retry carries the same Discord nonce, so a retry inside Discord's dedupe window returns the first message. A DM's nonce comes from the decision id. A digest's comes from the business and the day.
  • On open, a final line without its newline is a torn write. The notifier copies those bytes to torn-<UTC stamp>.bin in the same directory (0600, a new file, synced), then truncates sent.jsonl to its last newline and syncs it. It logs both steps. A crash between the two leaves the torn tail in place, and the next open repairs it with a second copy. The next append therefore starts on a line of its own.
  • A malformed complete line refuses with exit 3 and changes nothing.
  • The journal refuses with exit 3 when its directory is looser than 0700 or not yours, when sent.jsonl is a symlink, or when the file is not a regular 0600 file you own.
  • No Discord channel or user id goes in the journal, a log line or an error.

The Discord side is packages/discord/src/notify.mjs. It uses the connector's REST client and the binding's tokenFile, and sends to the binding's fixed dmRecipient, who must be one of the binding's users.

Notifier config

<dataRoot>/notify/<business>/notify.json, mode 0600, owned by you. The journal shares the directory, so create it 0700 first (mkdir -m 0700 -p <dataRoot>/notify/<business>). A host with a binding refuses with exit 3 on a looser directory.

{"notifyVersion": 1, "binding": "sage-seat"}

"binding": null runs the host without DMs. A missing file refuses with exit 3, so a host never starts until someone decides whether it DMs.

Exit codes

Code Meaning
0 ok
1 failed, or outcome unknown: check before retrying
2 invalid input: unknown option, no such open decision, decision already closed
3 refused or config problem: inside an agent run, human proof failed, unknown business, bad config, broker or notifier refused to start
4 usage

Limits

  • One broker per data root. The bus store takes <dataRoot>/bus/writer.lock. The unit is a template per business, but only one instance can run on one data root.
  • digest.sent is unused. The bus schema has this event kind, but decision 70 puts the notifier's memory in the journal, and a reader capability cannot write events.
  • The real human transport is untested here. No test runs the real human-cli.mjs, because its proof needs a human shell. The live run covers it.
  • Tracker boot is tested only without trackers. The trackers boot case is tested once S3's broker change lands.

Not in this piece

mosaic talk (S6) and the WebUI (S5).