# Task 1820 — `cpu-triage` as a two-call admin diagnostic

Design agreed 2026-07-20. Implements `.tasks/1820-expose-cpu-triage-to-the-admin-assistant-as-a-two-call-device-diagnostic.md`.

## Problem restated

`platform/scripts/cpu-triage.sh` answers "which cores are busy and what is on them" and is reachable only by an engineer with SSH. The measurement needs at least one full 120 s reconcile cycle to be trustworthy, and no admin tool anywhere exceeds a 12 s envelope. So the diagnostic has to be split across two calls: one that starts a run and returns immediately, one that reads a result.

## Shape

Two tools in the admin plugin, one new wrapper script, one JSON-contract fix in the measurement script.

### `cpu-triage.sh` — JSON contract only

The `--control` comparison is currently computed only inside the text-report branch (lines 275–285); the JSON branch ends at line 251 and emits no control field. Passing `--control` together with `--json` is therefore inert today, which would silently drop the single most valuable input to this measurement.

Add to the JSON object:

```json
"control": { "brand": "<name>", "meanPct": <number>, "found": true }
```

`null` when `--control` was not passed. `{ "brand": "<name>", "found": false }` when the named brand is not among the running services. The measurement design, sampling loop, exit codes and text report are untouched.

### `cpu-triage-run.sh` — new wrapper

```
cpu-triage-run.sh --run-id ID --out-dir DIR --log LOGFILE -- <cpu-triage args…>
```

Runs `cpu-triage.sh --json <args>`, streaming stdout to `DIR/cpu-triage-<ID>.json`. **After** that file is closed it writes `DIR/cpu-triage-<ID>.status`, then appends one completion line to `LOGFILE`.

The ordering is load-bearing. The result file exists from the first byte of output, so its existence is not a readiness signal. `.status` is the only readiness signal, and it is written strictly after the result. A reader that keys on the `.json` existing reports a half-written run as complete — which is the exact failure the task's mutation check exists to catch.

The wrapper exists rather than an inline `sh -c` string because the ordering guarantee should be reviewable in a file with exit-code gates, per the deterministic doctrine.

### Per-run files, under `<configDir>/logs/`

| File | Written by | Carries |
|---|---|---|
| `cpu-triage-<id>.meta.json` | `cpu-triage-start` | runId, startedAt, expectedCompleteAt, windowSec, args |
| `cpu-triage-<id>.json` | wrapper, streaming | the report — **not** a readiness signal |
| `cpu-triage-<id>.status` | wrapper, last | `exit=<n>` on line one, stderr on the lines below |

Start-time metadata and end-time status cannot share one file, which is why the layout is three files rather than the task's "sibling status marker" phrasing implies.

Run ids are timestamp-prefixed — `20260720T143000-a1b2` — so "most recent" resolves by filename sort rather than a stat walk.

### `cpu-triage-start { windowSec?, intervalSec?, thresholdPct?, control? }`

Returns `{ runId, startedAt, expectedCompleteAt, windowSec, resultPath }`. Spawns the wrapper detached with `stdio: "ignore"` and `unref()`, so the MCP process does not hold the run open. Logs `[cpu-triage] op=start runId=<id> windowSec=<n>`. Prunes to the 20 most recent runs.

`windowSec` is validated at the tool boundary with a 120 s floor. The script already refuses a shorter window with exit 2, but detached that refusal arrives one full window late — the refusal has to be synchronous.

A missing `cpu-triage.sh` returns a typed refusal naming the resolved path with `isError: true`, never an empty report.

### `cpu-triage-read { runId? }`

Defaults to the most recent run. Decides state from `.status` alone.

| On disk | Returns |
|---|---|
| no runs at all | typed "no runs started" — not a failure |
| `.meta.json` present, no `.status` | `running`, carrying `expectedCompleteAt` so an overdue run is visible |
| `.status` exit 0 or 1 | `complete` plus the parsed report. Exit 1 is a finding, not a failure. |
| `.status` any other code | `failed` plus exitCode and stderr |
| `.status` exit 0 or 1, JSON unparseable | `failed`, reason naming the parse error |

The exit-code contract survives to the agent: 0 clean, 1 sustained hot core, 2 precondition failure.

## Registration

`platform/plugins/admin/mcp/src/index.ts` (via `eagerTool`) and `platform/plugins/admin/PLUGIN.md` move in one commit. Both tools take `publicAllowlist: false`, `adminAllowlist: true` — the operator's "why is this box busy" question should not cost a permission round trip during an incident. `ADMIN_CORE_TOOLS` is a dead name and needs no edit. `npm run gen:canonical-tools` regenerates the byte-match artifact the build gate compares against.

`platform/scripts/` is copied wholesale into the installer payload by `packages/create-maxy-code/scripts/bundle.js`, and both scripts are mode 755 in git, so they land executable by the same path `logs-read.sh` already uses.

## Observability

`[cpu-triage] op=start runId=<id> windowSec=<n>` from the start tool, `[cpu-triage] op=complete runId=<id> exit=<code> sustainedHotCores=<n>` from the wrapper. The completion line is written after the result file exists — a post-condition, not an intent. A run that starts and dies is visible as a start with no completion rather than as silence.

## Tests

Ephemeral, discarded after the sprint.

1. `.meta.json` present, no `.status` → `running`
2. `.status` `exit=0` plus valid JSON → `complete`, `perCore` populated, `sustainedHotCores` present
3. `.status` `exit=2` → `failed`, exitCode 2, distinct from exit 0
4. script absent → typed refusal naming the path
5. wrapper against a stub exiting 2 → `.status` written and the log line appended, both after the result file

Tests 1 and 2 are a pair. An implementation that always reports `complete` passes 2 and fails 1; neither alone pins the behaviour.

## Scope boundaries

**In scope:** the two tools, their registration, the wrapper, the JSON control field, result persistence and retention, shipping to devices.

**Out of scope:** the measurement design, which is fixed and documented in the script header; the activity view (1810); escalation or alerting on a finding (1351); any remediation a report implies; remote targeting — this measures the box it runs on.
