jan-cli 0.24.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
# Unifier ↔ Jan: the sister-project IPC contract

**Normative.** This is the contract between two independently releasable programs.
Companion docs: [Architecture](architecture.md) (Jan internals),
[unifier-jan-multi-agent.md](unifier-jan-multi-agent.md) (why the split exists),
[mas-os-next-steps.md](mas-os-next-steps.md) (roadmap).

## 1. Two kernels, one plane each

| | **Unifier** | **Jan** |
|---|---|---|
| Plane | **State** — what is true | **Execution** — what runs, and when |
| Owns | Key/value board, mailboxes, named events, ACID tick state machine, SQL/log/serve | YAML command tree, cron schedules, process spawn, concurrency limits, disable/enable |
| Durable artifact | `$UNIFIER_HOME` (default `~/.local/unifier`) file tree | Command tree YAML + `audit.db` |
| Peer daemon | `unifier daemon` | `jan cron` daemon |
| Knows about the other | **Nothing.** `rg jan unifier/src` returns zero hits | Socket paths + notice schema only (`src/unifier_events.rs`) |

Neither crate depends on the other in `Cargo.toml`, and neither links the
other's library. **All coupling is wire-level and one-directional in code
knowledge:** Jan hard-codes Unifier's socket paths and JSON schema; Unifier is
built and tested with no awareness that Jan exists. Unifier is fully usable with
no scheduler; Jan trees that never mention Unifier are pure schedulers.

This is deliberate. The blackboard must survive the scheduler crashing, the
scheduler must survive the blackboard being absent, and either can be replaced
by a different implementation that speaks the same three channels.

## 2. The three channels

```
  agents ──unifier CLI──► .daemon/unifier.sock ──► Unifier daemon ──► board (files)
                                                        ├─ notices ─► .daemon/events.sock ─► Jan cron daemon ─► spawn leaf
  Jan cron daemon ──tick lifecycle──► .daemon/tick.sock ┘
```

| Channel | Direction | Transport | Semantics | Who may open it |
|---|---|---|---|---|
| `.daemon/unifier.sock` | agents → Unifier | one request line, one response line, close | Request/response, **durable** side effects | Any process running `unifier …`; agents only via the CLI |
| `.daemon/events.sock` | Unifier → subscribers | server-push, line-delimited JSON, never read from | Broadcast **hint**, best-effort, no ack, no replay | **Jan cron daemon** (and `unifier daemon watch` for humans) |
| `.daemon/tick.sock` | Jan → Unifier | one request line, one response line, close | Request/response, restricted verb set | **Jan cron daemon** (and `unifier tick …` CLI for humans/tests) |

Socket root is `$UNIFIER_HOME/.daemon/`, so a swarm can be isolated purely by
exporting a different `UNIFIER_HOME` to both daemons.

Agents (dotfiles scripts) open **no sockets**. They subprocess `unifier …` and
read what Jan injected on argv/env. This keeps arms restartable, testable, and
free of reconnect logic.

## 3. Wire schemas

### 3.1 Notices (`events.sock`, Unifier → Jan)

Producer: `unifier/src/daemon/notify.rs` (`Notice`).
Consumer: `jan-cli/src/unifier_events.rs` (`Notice` → `Wakeup`).

```json
{"kind":"mailbox","id":"<uuid>","from":"ping-agent","to":"pong-agent"}
{"kind":"event","id":"<uuid>","name":"status-report"}
{"kind":"tick","tick":7,"phase":"sense","label":"turn"}
```

| kind | Jan's reaction |
|---|---|
| `mailbox` | Wakeup for leaf named by **`to`**; passes `--message-id <id>` and `JAN_UNIFIER_KIND/MESSAGE_ID/FROM/TO` |
| `event` | Wakeup for leaf named by **`name`**; passes `--event-id <id>` (+ `--event-name`) and `JAN_UNIFIER_*`. No `name` ⇒ no wakeup |
| `tick` | Counted in `tick_notices`, **never** a wakeup — tick lifecycle belongs to Jan's driver, not to leaf dispatch |

**The `to` / `name` string is the join key between the two systems.** It is a
Jan script-leaf name that Unifier neither validates nor knows. A typo degrades
to a no-op wakeup logged by Jan; it is never an error on the Unifier side.

Notices carry an **id, not a payload**. The receiver reads the actual content
back through the CLI (`unifier message <id>`), so the board stays the single
source of truth and notices stay small enough to never block the fan-out.

### 3.2 Tick control (`tick.sock`, Jan → Unifier)

Same line-delimited JSON as the main socket, gated by
`is_tick_socket_request` (`unifier/src/daemon/protocol.rs`): only `ping`,
`tick_start`, `tick_phase`, `tick_end`, `tick_status`, `tick_lock`,
`tick_unlock` are accepted. Anything else is rejected with an error response
rather than silently executed — the socket is a **capability boundary**, so a
compromised or buggy tick driver cannot mutate the board.

`tick_start` / `tick_phase` / `tick_end` also emit a `kind:"tick"` notice on
`events.sock`, which is how observers see turn progress without polling.

### 3.3 Compatibility rules

- Both enums are `#[serde(tag = ...)]`. **Adding a variant is backward
  compatible only because the reader tolerates unknown lines**: Jan counts an
  unparseable notice in `ignored` and continues. Never make an unknown `kind`
  fatal.
- New fields must be `#[serde(default)]` and `skip_serializing_if`.
- Renaming or repurposing `to` / `name` / `id` is a breaking change for the
  whole MAS and requires a coordinated release of both crates.

## 4. Delivery semantics (read this before trusting an event)

The two channels have **different reliability classes**, and the design leans
on that difference:

| | Board + mailboxes (poll) | Notices (push) |
|---|---|---|
| Durability | Files on disk, survive both daemons restarting | In-memory fan-out only |
| Delivery | Exactly the state that was written | **At most once** |
| Ordering | Per-mailbox by file/uuid | Per-connection FIFO, no cross-connection guarantee |
| Replay | `unifier poll <recipient>` any time | None |

Loss is real and has three named causes:

1. **Slow subscriber.** `EventHub::broadcast` writes with a 250 ms timeout and
   drops any subscriber whose write fails (`subs.retain_mut`). A stalled Jan is
   disconnected, not buffered.
2. **Disconnected window.** Notices produced while Jan is reconnecting are gone;
   there is no since-cursor on `events.sock`.
3. **Queue overflow.** Jan's wakeup queue is bounded at `MAX_WAKEUPS = 256` and
   **drops oldest**, counted in `jan cron status` as `drops=`.

**Therefore: an event is a latency optimization, never a correctness
mechanism.** Any agent whose work must not be missed needs a cron leaf that
polls its mailbox (`unifier poll <self>`) as the reconciling clock, with the
event path only shortening the median wake latency from "next cron tick" to
"~100 ms". A watcher that only fires on events is a data-loss bug waiting for
the first slow disk.

Corollary for operators: `drops`, `tick_notices`, `reconnects`, and
`events=connected` in `jan cron status` are the health signals for this plane. A
climbing `reconnects` with steady `drops` means Jan is being fan-out-dropped and
the polling backstop is carrying the system.

## 5. Interaction modes

**Polling (pull).** Agent on a `cron:` schedule → `unifier get` / `poll` /
`sql` → compute → `unifier put` / `message`. Complete, idempotent, and immune to
notice loss. This is the default and the fallback for everything else.

**Events (push).** Agent A runs `unifier message B …` → Unifier persists the
mail **then** broadcasts → Jan wakes leaf `B` within one 100 ms tick → `B` reads
the mail by id and `ack`s it. Persist-then-notify ordering means a lost notice
degrades to "B sees it on its next poll", never to lost work.

**Ticks (barrier).** Jan's tick driver opens `tick.sock`, runs
start → phase(s) → end, and Unifier stages writes and commits atomically at
`end`. Phases give the swarm a shared, ordered notion of "turn" — the thing you
need before any sense/decide/act loop can be reasoned about. Unifier owns the
state machine; **Jan owns when phases advance**; agents observe the tick only
through injected argv/env.

## 6. Invariants

1. Neither crate takes a code dependency on the other. The contract is this
   document plus the two serde enums.
2. Unifier never learns Jan concepts (leaf names, YAML, chains, cron).
3. Jan never implements board semantics — no key store, no mailbox, no tick
   state machine of its own.
4. Only daemons hold socket connections. Agents are CLI-only, short-lived, and
   crash-safe by being restartable.
5. Persist before notify. Every notice refers to state that is already durable.
6. `tick.sock` accepts tick verbs only; the general socket is not a tick socket.
7. Unknown notice kinds and unknown fields are ignored, never fatal.

## 7. Failure modes

| Failure | Observable | Recovery |
|---|---|---|
| Unifier daemon down | Jan `events=disconnected`, `reconnects` frozen; agent `unifier` calls fall back to direct file access or error | Jan retries `events.sock` every 1 s; cron leaves keep running |
| Jan cron daemon down | Nothing wakes on mail; board keeps accepting writes | Restart; mail is still queued in files, so poll-based leaves catch up |
| Jan too slow to drain | `drops=` climbs | Raise `JAN_CRON_MAX_CONCURRENT`, or move the agent to a poll schedule |
| Leaf named in `to` does not exist | Jan logs "no script leaf named …"; mail stays unacked | Fix the leaf name or the sender; mail is not lost |
| Tick driver dies mid-tick | Tick stays active; writes stay staged, uncommitted | `unifier tick status`, then `tick end` or daemon restart discards staging |
| `UNIFIER_HOME` mismatch between daemons | Jan watches a socket that never exists | Export the same `UNIFIER_HOME` to both units |

## 8. Where this lives in code

| Concern | Unifier | Jan |
|---|---|---|
| Socket paths | `src/daemon/paths.rs` | `src/unifier_events.rs::events_socket_path` |
| Notice schema | `src/daemon/notify.rs` | `src/unifier_events.rs::Notice` |
| Request/response schema | `src/daemon/protocol.rs` | — (tick driver: planned) |
| Fan-out / accept loop | `src/daemon/server.rs`, `EventHub` | `src/cron_daemon.rs` |
| Wakeup → spawn || `src/cron_daemon.rs::drain_unifier_wakeups`, `wakeup_spawn_args` |
| Tick state machine | `src/tick.rs`, `src/store.rs` | — (drives it only) |