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, baseb9b6cf00, 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 on2557e29dwith 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]>
226 lines
10 KiB
Markdown
226 lines
10 KiB
Markdown
# 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.
|
|
|
|
```sh
|
|
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:
|
|
|
|
```json
|
|
{"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.
|
|
|
|
```json
|
|
{"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).
|