# AUTOPILOT-120 — what still fires when autopilot is OFF

## STEP 1 — the ACTUAL prior behaviour (read from the code, not inferred)

The off switch is `enabled()` — `isAutopilotOn(stateDir, sessionId)`
(`src/config.ts`), wired from each host. Before this change it was consulted at
exactly four tick seams, and EVERY one was a **blanket early-return**:

| Seam | File | Effect while OFF |
| --- | --- | --- |
| `sweep()` — the whole reconcile pass | `src/framework/runner.ts` | every sweep-emitted tick silenced |
| `onCompletion()` — completion routing | `src/framework/runner.ts` | no verdict routing, no flips, no completion ticks |
| `runRecoveryPass()` | `src/framework/runner.ts` | no recovery, no recovery ticks |
| decision panel `apply()` | `src/hosts/pi-panel.ts` | panel decision tick not delivered (AUTOPILOT-67) |

So OFF silenced the tick channel **WHOLESALE** — auto-dispatch / auto-review /
auto re-dispatch / recovery did not run, AND `decision` / `review` / `failure` /
`budget` / `ship` / `config` / `main-write` / `harness` ticks never fired
either. That matches the observed "received NO `[orch-tick]` messages at all".

**Correction to the observation-based guess**: the behaviour was
blanket-SUPPRESSION, **not inverted**. The INTAKE tick is produced by the same
gated `sweep()`, so it was silenced too — it did not deliberately keep firing.
The one way an intake/dispatch-shaped tick could still surface while OFF was the
**ungated deferral flush**: `onSettled()`/`onTimer()` call `flushDeferred()` /
`flushHarness()` with no `enabled()` check, and `refreshDeferredFacts` can
re-derive a held dispatch tick into an **intake** tick at flush time. A tick
generated while ON, held through a busy window, then flushed after the switch
turned OFF could therefore leak an intake/dispatch tick. That leak is fixed
here (below).

## STEP 2 — the tick-class rule (the visible artefact)

ONE table — `src/framework/tick-class.ts` — answers the operator's question:
**"Does DELIVERING this tick cause work to happen, or does it only report?"**

| Reason | Class | Why |
| --- | --- | --- |
| `dispatch` | AUTOMATIC-ACTION | delivering it TELLS the orchestrator to dispatch ready work |
| `intake` | AUTOMATIC-ACTION | delivering it TELLS the orchestrator to run an intake scan |
| `recover` | AUTOMATIC-ACTION | the proactive auto-recovery lane's voice |
| `review` | INFORMATIONAL | reports a reviewer completion / verdict / stuck review |
| `failure` | INFORMATIONAL | reports a failed run |
| `budget` | INFORMATIONAL | warns a live run is near its wall-clock cap |
| `decision` | INFORMATIONAL | reports a status move that already happened |
| `harness` | INFORMATIONAL | reports what the harness did / fleet degradation / sweep failure |
| `ship` | INFORMATIONAL | reports the merge-finisher lane's result / a policy inquiry |
| `main-write` | INFORMATIONAL | reports a MAIN-IMMUTABILITY violation |
| `config` | INFORMATIONAL | reports a config change |

Unknown reasons classify as INFORMATIONAL — losing an event is the worse
failure; a new work-causing reason must be added to the table deliberately.

## STEP 3 — the applied split (PROACTIVE vs REACTIVE; supervisor decision)

The supervisor ruled the boundary is **PROACTIVE (suppressed while OFF) versus
REACTIVE (kept)**:

- **Suppressed while OFF** — the sweep's PROACTIVE lane: auto-dispatch,
  auto-review, auto re-dispatch, auto-recovery, the zombie/dead-run safety-net
  flips, the shipping merge, and the collection of preservation writes the sweep
  does on its own initiative. Plus the AUTOMATIC-ACTION tick classes
  (`dispatch`, `intake`, `recover`).
- **Kept while OFF** — the INFORMATIONAL ticks (`review`, `failure`, `budget`,
  `decision`, `harness`, `ship`, `main-write`, `config`) and the
  completion-driven REACTIVE transitions that make those ticks truthful (a
  `review` tick that said "it is in human-review" while the flip was suppressed
  would lie). This is Option 1: `handleAsyncComplete`'s return value is NOT
  decoupled — the tick and the REACTIVE flip stay together, as they must for the
  tick to be honest.

### Implementation (a scoping fix, no re-design)

- `src/framework/tick-class.ts` (new) — the table + `tickClassOfReason`,
  `tickClassOfMessage`, `deliverableWhileOff`.
- `src/framework/runner.ts` — the ONE choke point: the router is wrapped so
  `send` drops an AUTOMATIC-ACTION message while OFF and delivers the rest. Every
  runner tick funnels through it (`sendTick`/`sendTickText`/`harnessTick`/the
  deferred flush), so the flush leak is closed at the same place. An
  undeterminable gate (a host whose `enabled()` throws) never licences an
  automatic-action tick but never swallows an informational one either. The
  sweep/onCompletion blanket returns became class-scoped: the PROACTIVE sections
  are guarded by `on`, the INFORMATIONAL emissions run unconditionally.
- `src/hosts/pi-panel.ts` — the panel's decision tick is INFORMATIONAL, so it is
  delivered while OFF too (the decision itself always applied); the SAME
  predicate gates it, so an automatic-action panel tick would still be
  suppressed.
- `src/framework/shipping.ts` — `runShippingPass` gained a `notifyOnly` mode.
  The shipping MERGE is PROACTIVE (suppressed while OFF), but the lane's notices
  are INFORMATIONAL, so while OFF the pass still DETECTS + ANNOUNCES — the
  one-time policy inquiry, `NO TIP`, and a "ready to ship" notice for a
  resolvable tip — and NEVER runs the finisher (no merge, no push, no shippedAt
  write). The runner passes `notifyOnly: !on`; the `[orch-tick: ship]` class
  stays informational, so each notice is delivered.
- `src/config.ts` — `autopilotModeMessage("off")` states the new rule to the
  orchestrator (and the host notify strings + the opencode tool description).

Delivery/dedupe mechanics are untouched: cooldown, one-notice-per-state-change,
and the delivery-time premise validation all behave exactly as before.

## Tests

`test/framework/off-tick-scope.test.ts` (new) — every case DISCRIMINATES on the
off/on dimension:

- the class table + message parsing;
- (b) a reviewer completing delivers its `[orch-tick: review]` while OFF **and**
  ON (and the REACTIVE flip to `human-review` happens in both);
- a worker failure delivers its `[orch-tick: failure]` while OFF and ON;
- (c) OFF starts NO auto-dispatch (approved stays approved, no spawn) and NO
  auto-recovery; ON does both;
- (d) OFF fires NO intake tick; ON fires it for the same store;
- the LEAK FIX — a dispatch tick held while ON is NOT delivered by the settle
  flush after the switch flips OFF, with the ON counterpart delivered.

Updated: `test/framework/runner.test.ts` (the two blanket-gate tests now assert
the PROACTIVE stop + REACTIVE keep), `test/hosts/pi-panel.test.ts` (the
AUTOPILOT-67 off test now asserts the INFORMATIONAL decision tick is delivered),
`test/hosts/pi-extension.test.ts` (the OFF toggle message contract), and
`test/framework/shipping.test.ts` (the OFF/ON shipping discriminator: while OFF a
`done` item produces its `[orch-tick: ship]` ready-to-ship notice **and** the
base is untouched; while ON the same fixture merges — both halves, so the test
pins the suppression as well as the notice).
