Files
stack/packages/cli/README.md
T
jason.woltjeandClaude Opus 5.5 9cdb6d82e3 feat(cli): S4 follow-up, refusal backoff and tracker boot (row 45, #1527)
Rocko's round 2 candidate, packet agents/rocko/work/s4-follow-up/
(build.patch c8cec070, candidate manifest 5b067a9d, 8/8 OK).

- Definite DM refusals wait the full 30-minute cap, counted from the
  journal's last refusal, so five refusals span about two hours before
  gave-up (lead decision 73). Unknown outcomes keep doubling.
- Broker close sends at host.mjs:144/150/184/188 pass a callback, which
  closes the EPIPE window both reviewers found in round 1.
- README documents manual recovery for an open decision.
- trackers-boot test, X9, X14, and Darkwing's round 1 notes 1-4.
- The append type check stays out; Rocko's reason holds (both reviewers
  agree).

Reviews: Darkwing approve (comment 26884), Filbert approve (26886).
Landing gate on b13fef4c plus the patch: every node suite and every
scripts/test-*.sh green, test-task 98/0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 09:27:34 -05:00

247 lines
12 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. It marks each blocking decision with one of
three states: "DM sent", "DM pending" or "DM refused, not retried". 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. After an `unknown` outcome 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.
- A definite refusal is a DM refused with an HTTP 4xx other than 429. The
next attempt waits the full 30 min, counted from the refusal's `at` in
the journal, so a restart does not shorten it. After 5 of them for one
decision, at least 2 hours apart end to end, the notifier appends one
`gave-up` line, logs it once and never sends that DM again. The digest
marks the decision "DM refused, not retried". The count comes from the
journal, so a restart keeps it. An `unknown` outcome (network, 5xx or
429) retries without a limit (lead decisions 72 and 73).
- Recovery after a give-up is manual. Fixing the binding does not resend
the DM, which stays given up. The decision stays open in `mosaic inbox`,
the digest lists it, and the operator decides it with `mosaic decide`.
- 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. Each
line is type-checked: `at` an ISO timestamp, `kind` `dm` or `digest`,
`decision` a string (required for `dm`, null for `digest`), `day` a real
`YYYY-MM-DD` date (required for `digest`), `outcome` one of `confirmed`,
`refused`, `unknown` or `gave-up` (`gave-up` only for `dm`), `messageId`
a string (required when confirmed) or null, `status` an integer when
present.
- The journal refuses with exit 3 when its directory is looser than 0700,
not yours, a symlink (dangling or not) or not writable, or cannot be
created (for example, a parent is a file). It also refuses when
`sent.jsonl` is a symlink, a directory or not writable, or is not a
regular 0600 file you own. The message names the path.
- 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).