# Agentic HIL Agent Instructions

Agentic HIL is the hardware gate. It discovers the project's authoritative configuration outside the repository; `AGENTIC_HIL_CONFIG` may override that location with an absolute path. Mandatory `workspace_root` binds either file to the current project, and mandatory `state_root` pins trusted report and hardware-lease state outside the workspace.

For installation and first-time setup, follow [AI_AGENT_QUICKSTART.md](AI_AGENT_QUICKSTART.md): everything installs user-local without admin rights.

Setup has two scopes. User scope is per user, per machine: all of it under the invoking user's home, invisible to other OS users on the host.

| Command | Scope | Does |
|---|---|---|
| `agentic-hil agent-install --agent <agent>` | user, once per user and agent | agent skill + user-level MCP registration; needs no workspace or config, and finishes even where the config location is refused |
| `agentic-hil init [--agent <agent>]` | project, once per project | writes the workspace's authoritative config, then `doctor` |
| `agentic-hil setup --agent <agent>` | both, in order | first run in one command; later projects for this user need `init` alone |

Neither half rolls back the other: `setup` with `ok: false` but `scopes.user.ok` true left a working agent installed; read that field before reporting nothing was installed.

A project with no configuration answers every tool with `config_file_not_found`. `project_config_create` is the way out: it takes no arguments, generates the configuration for the workspace this server is bound to out of attached hardware, and writes every permission `true` (flashing, reset, resuming a halted target in a debug session, COM and CAN writes, and all three `permissions.allow_config_*` grants) except `allow_raw_debugger_commands` and `allow_mass_erase`, which it writes `false`. Those two are not capabilities you are missing: there is no tool here for either, and while either is true `flash_firmware` on that probe is refused, so a file that granted them was a bench that could not flash. The bench is workable from that file as it stands, flashing included, without anybody editing YAML. Report where it is and what it granted (the result's `permissions` is the list, and `narrowed_permissions` names anything narrowed *beyond* those defaults); say `allow_mass_erase` in particular wherever it is true because a mass erase cannot be taken back, and ask the operator which permissions this bench should not have. Never ask for those two to be turned on: that is the one change that stops flashing. Regenerating it later carries over the permissions of the configuration that server loaded at startup for the entries already in it, and anything the regeneration discovers for the first time arrives at those same defaults. A server does not reload, so a permission you narrowed with `project_config_set` in this session is not in what it loaded and a regeneration now writes that permission back open: one more reason regeneration is not yours to reach for. Never delete or move a configuration to get a different one: regenerating is the operator's call, not yours.

Changing an existing configuration goes the same way: over MCP, never with your own file tools, which `setup` denies on that path for exactly this reason. Two permissions gate it, and the split matters: `permissions.allow_config_description_write` opens what the bench **is** (`target.*`, `debuggers.<n>.probe_id` / `executable` / `interface_cfg` / `target_cfg`, `com_ports.<n>.device` / `baudrate` / `serial_number`, every `can_buses.<n>` field but its permissions), and `permissions.allow_config_permissions_write` opens every permission key (each `permissions:` block, plus the two grants that sit directly on a section, `artifacts.allow_upload` and `debug.allow_all_symbols`). The first can be left open without handing over the second.

**Permissions move one way through `project_config_set`.** You may write `false` into a permission. You may never send any other value for one: not `true` for a permission you never touched, and not `true` for one you set to `false` yourself a moment earlier, which would change nothing and is refused all the same. Such a call is refused as `permission_widening_denied`, whatever key it named: the value is checked, and the permissions present in the document before and after the write are compared as well. Only a person reopens a configuration, and there is no tool that does it; report that and stop.

**What you report is the command, and there are two.** `agentic-hil grant <key>` in the project root opens one named permission and leaves the rest of the file untouched: `agentic-hil grant can_buses.dut.allow_write`, several keys in one command if several are needed, and `agentic-hil revoke <key>` to close one again. `agentic-hil init --force` is the other and is far larger: it regenerates the whole file from attached hardware. Name the narrow one and the exact key when a permission is what is missing; ask for the wide one only when the bench itself has to be rebuilt. Neither is yours to run (you have no shell on the operator's terms here) and neither appears in any tool list, so there is nothing to call and no point looking.

Setting `permissions.allow_config_permissions_write: false` is therefore the last permission change **that call** can make: after it, `project_config_set` cannot move any permission in the file again. It says so in its own result: `permissions_frozen` lists what stands frozen, that you cannot undo it, and the commands a person reopens it with. Do not make it in passing; make it when the operator asked for exactly that.

Regenerating is not covered by that rule and is not your way around it. `project_config_create` is creation, `agentic-hil init --force` and `agentic-hil grant` are the operator's own commands, and all three may write open permissions. Ask for one; do not engineer one.

| Call | Answers |
|---|---|
| `project_config_describe` | which keys **this** caller may change in **this** state, which not, which permission would open a locked one, each key's current value and its shape from the schema. Needs no permission; reading is free |
| `project_config_set` | `{"changes": [{"key": "debuggers.dut.probe_id", "value": "…"}]}`: named keys, scalar values, all applied or none |
| `project_config_adopt_hardware` | reads the attached probe and fills in the identity keys that are still unset: probe id, the backend's executable, the detected controller, the probe's own COM device. Returns the plan; writes only with `{"apply": true}`, through `project_config_set` |
| `project_config_reload_description` | makes a changed **description** the one this running server answers out of, without a restart. No arguments, no grant (it writes nothing) and no permission re-read |

The value is a scalar and the key comes from a closed set, so there is no way to send a document or a subtree; that is what keeps the file something you did not author. A write is refused while a run or a session holds hardware (`config_write_in_open_run`; close it with `bench_run_stop`), the changed document is validated before it replaces the working one, and `provenance` records what moved, when, and through which call. The server keeps serving the configuration it loaded, so a write reports `reload_required`: call `project_config_reload_description` when what moved is a device, and ask the operator to restart the server when it was a permission.

A configuration written before the board was plugged in holds placeholders: `probe_id: null`, `executable: null`, `controller: "unknown-controller"`. Do not print those values for a person to retype: call `project_config_adopt_hardware`. It supplies no value of its own and its arguments only select (`probe_id` among several attached boards, `debugger_id` / `com_port_id` among configured entries). It fills a key only when the document has nothing there (absent, null, empty, or the placeholder the skeleton writes) and reports a key that already holds somebody's value under `kept` instead of replacing it. More than one attached probe, no probe, no COM port that carries the probe's serial: each is an answer that names what to do next, never a silent choice. An entry that already names a probe and a different probe attached is `hardware_mismatch` and no plan at all: the identity keys describe one board between them. Reading the probe is a hardware call like any other, so it takes the same machine-wide lock, is recorded in the same audit trail, and answers `device_busy` for a board somebody else is holding. Without `allow_config_description_write` the call is refused and still returns the exact keys and values, so what you hand the operator is one command rather than a serial to transcribe. The operator's own path is `agentic-hil adopt-hardware`.

`config_stale: true` on any result means the same thing arrived the other way: the authoritative file is no longer the one this server is enforcing, so every answer (the backend it names, the devices it knows, the permissions it enforces) comes from an older document. `config_status.description_source` says which: `startup` for all of it, which is the normal case; `description_reload` when a `project_config_reload_description` has since moved the devices and the backend onto a newer document (`description_reloaded_at`, and `loaded_digest` is that document's) while the permissions stayed the startup ones (`loaded_at`). The `config_status` block says which file (`path`) and which of three states it is in, and the remedy is not the same for all three: on `changed`, `loaded_digest` and `current_digest` differ, which is the whole of the claim; it says nothing about what the file now holds and does not promise the restart succeeds, so ask for the restart and, if the server does not come back, report the startup refusal, which names what is wrong with the file (`agentic-hil doctor` produces the same refusal without stopping anything); on `missing`, the file is gone and `current_digest` is `null`, so it has to be restored *before* a restart can load anything, and `project_config_describe` meanwhile answers out of the loaded policy (`permissions_in_force`, `document_source: loaded_policy`); on `unreadable`, it is there and will not open, `current_digest` is `null` and `backend_error` names what the read failed on, so it has to be made readable before it can be compared at all. `unknown` is not stale: nothing was compared, so nothing is claimed and `reload_required` is `false`. `debugger_info` and `project_config_describe` carry the block whether or not anything has moved, including when they refuse; `agentic-hil doctor` reads the file fresh every time, so a disagreement between it and `debugger_info` is this and nothing else. A revocation that binds before a restart is exactly three grants and two calls: `project_config_describe` and `project_config_set` take the narrower of what this server loaded and what the file now says for `allow_config_write`, `allow_config_description_write` and `allow_config_permissions_write`, so revoking one of those closes that surface on the next call, and adding one does nothing until a restart. Every other permission is the startup object and moves for nobody: `allow_flash`, `allow_reset`, `allow_debug_execution`, `allow_write`, `allow_mass_erase`, `allow_raw_debugger_commands` and every per-device grant, plus `debug.allow_all_symbols` and `artifacts.allow_upload`, keep enforcing what was loaded until the MCP server is restarted; a description reload re-reads no permission in either direction. Do not narrow one of those in the file and then call the operation: ask for the restart.

On `changed` there are exactly two ways forward and they cover different halves of the file. If what moved is the bench's description (`target`, a `debuggers` / `com_ports` / `can_buses` entry, a probe id, a COM device, a baudrate), call `project_config_reload_description`. It takes no arguments, re-reads those four sections minus every `permissions:` block, and clears the staleness for them without a restart. It re-reads **no permission at all**, in either direction, and no flag changes that: a device this server has never seen arrives with nothing, so it can be probed and read (from `version: 2` on, reading needs no grant) and cannot be flashed, reset, mass-erased or written to until an operator restarts the server; a renamed entry is the same case. Everything else (every permission, `version`, `workspace_root`, `state_root`, `debug`, `artifacts`, `validation`, `recovery`, `reports`, `logs`) is adopted by a restart and by nothing else, and the result names them under `not_reloaded_sections` and `permission_differences`. The reload is refused while a run or session holds the bench (`config_reload_in_open_run`), while an incident is unresolved (`resource_quarantined`), and on a file that is missing, unreadable or will not load; report the refusal and stop. For everything outside those four sections, ask the operator to restart the MCP server **once**, report what the answers currently say, and do not try to make the server pick the file up by editing it again or by calling a configuration write tool. After a reload, `config_status` reports the description now in force, and carries `permissions_source` whenever the grants in force came from the startup document rather than from the file.

Names: the Python distribution/install target, CLI command, repository URL, and MCP server name use `agentic-hil`. Python imports, pytest plugin names, fixtures, and Python examples use `agentic_hil`.

Use Agentic HIL MCP tools for hardware actions. Do not bypass them with raw OpenOCD commands, arbitrary debugger shells, direct serial-device access, or direct CAN-adapter access when an Agentic HIL tool is available.

If an Agentic HIL tool returns `permission_denied`, stop and ask the operator to review the authoritative config. Do not bypass it or replace the operator-controlled environment or MCP host registration. Reading a device needs no permission, so a `permission_denied` is always about writing or changing state, unless the configuration has no `version:` key, in which case it is read under version 1 where reading still needs `allow_probe` / `allow_read`.

A `device_busy` refusal is not a fault: another run holds that physical device for its whole duration, and the result names the holder. Wait for it or ask its owner; never delete a lock under `~/.agentic-hil/device-locks`. An `undeclared_device` refusal means the run reached for a device its test plan does not name; add it to the plan and rerun.

A sequence of hardware calls is one run, and it has to say so. `bench_run_start` takes every device you name (`{"kind": "debugger"|"uart"|"can", "id": "<config entry>"}`, the DUT is not a device) and holds them until `bench_run_stop`; without it each call holds its device only for its own duration and the board is free between two steps. Inside a run only the declared devices may be touched, a device already held fails the start immediately naming its holder, and nothing is left half-acquired. Two entries describing one physical unit collapse to one lock. Nothing times a run out: it ends when you close it, when the client disconnects, or when the server process dies. `bench_run_status` says whether one is open; `agentic-hil://reference/lease-lifecycle` has the rest.

Read facts about the server from its MCP resources, not from its source or its installed package. A failing result already carries `remediation` and `do_not` when a fix is known; `resources/list` carries the rest.

| Resource | Answers |
|---|---|
| `agentic-hil://reference/debugger-backends` | which `debuggers.<name>` field each of `openocd`, `stlink`, `pyocd` requires, discovers, or ignores; when `probe_id` and `flash_address` become mandatory |
| `agentic-hil://reference/target-support` | which field names the target per backend, known-good values, where pyOCD `target_type` values come from, how to find and install the CMSIS pack that provides one, and what `doctor`'s `target_support` statuses mean |
| `agentic-hil://reference/errors` | every `error_type` with its meaning, ordered fix, and the wrong fix; one entry at `.../errors/<error_type>[:<field-or-backend>]` |
| `agentic-hil://reference/platform-paths` | where the authoritative configuration, `state_root` and the machine-wide device locks live on Windows and POSIX, which command touches which of them, what is and is not checked about a configured path, why `AGENTIC_HIL_CONFIG` and a chosen `state_root` are the supported way to bind a project to another location |
| `agentic-hil://reference/lease-lifecycle` | which device a run locks and for how long, `device_busy` and `undeclared_device`, why a crashed run needs no recovery; lease states, the continue predicate, the recovery path |
| `agentic-hil://reference/config-schema` | the authoritative configuration JSON Schema |
| `agentic-hil://reference/config-shape` | which sections a configuration has and what each is for, which are required, a worked Nucleo-F446RE example; how to change one field-wise over MCP, which permission opens which key, and what deliberately cannot be done |
| `agentic-hil://reference/test-plan` | where a test plan lives and how `test_config_path` and `workspace_root` resolve its path; which format version admits which step; every step with the entry it routes to and its required and optional keys; the comparator families with their rules; two plans that run. Read this before writing a plan, never the shipped schema file or the installed package |
| `agentic-hil://reference/test-plan-schema` | the test plan JSON Schema, which the document above is generated from |

Continue after a result only when `ok` is true, `target_ok`, `audit_ok`, and `cleanup_ok` are not false, `cleanup_required` and `quarantined` are not true, `lease_state` is one of `null`, `active`, or `released` (any other value, including `stale`, blocks success), `side_effect_status` is not `unknown` or `partial`, and `hardware_state` is not `unknown`. The public `overall_success()` helper in `agentic_hil.report` encodes exactly this predicate.

Not every failure quarantines. A failure that proves it never reached the hardware, whether a toolchain executable that does not exist, an OpenOCD script or adapter that failed before `init` completed, a pyOCD probe or `target_type` refused before any connect, an absent ST-Link, an unopenable COM port whose handle is verifiably closed, a CAN adapter that never initialized on SocketCAN, or a read (`probe_target` / `debugger_probes_list`) whose backend reports that no target answered, refuses with a named `error_type`, `target_contacted: false` and `retry_safe: true`, writes no quarantine record, and leaves the bench in service. Report it, fix the named cause (attach the probe, fix the path, free the port) and retry; do not treat such a refusal as a hardware incident and do not send the operator to the bench for it. The proof is the backend's claim, not the fact that the call was a read: a read killed at its deadline, or one whose backend exited without a complete confirmation, quarantines like anything else (partial marker evidence is still no abort point); an SWD attach halts the core, and neither result says whether the call got that far. A PCAN `CanInitializationError` quarantines for the same reason: python-can raises it from calls that run after the channel is already on the bus.

For hardware that *is* quarantined (the failure could not prove its abort point), stop effects and read `cleanup_reasons`, `quarantine_guidance`, and `auto_recoverable` from `hardware_lease_status`. `auto_recoverable: true` means the running owner clears the incident itself on the next hardware call, so retry that call once: it reaps leftover debugger processes, verifies the safe state under the bench's `auto_recover_policy`, and proceeds. A refusal carrying `auto_recovery_attempted: true` means that already ran and did not confirm the safe state; do not retry it again. An incident does not outlive the call that raised it unless its evidence chain is what broke: what the recovery action did not settle stands down at the end of the call with a `no_standing_state` line in the recovery ledger, so `hardware_recover` on such a bench answers `nothing_to_recover: true` and there is nothing to clear. What stands is `resource_quarantined`, the audit-broken family, and that is yours to clear with `hardware_recover`. A reason that names no hardware contact (a lease release that could not persist its record, an owner process that died before it touched the board) clears with no argument. A reason that needs somebody to look at the board clears only by relaying an `operator_statement`: ask the operator in chat what state the bench is in and pass their answer back verbatim; never invent one, and never strengthen it on the way through. Reserve the operator's own `agentic-hil recover --confirm-safe-state --quarantine-id <id>` line, after they inspect `agentic-hil lease-status`, for the audit-broken family, for a bench whose `permissions.allow_recover` is false, and for any incident with nobody to ask. Each `quarantine_guidance` entry names, per reason, what was attempted, what is confirmed, what remains unknown, and the `physical_check` the operator performs on the board before signing; relay those four fields, whether you clear the incident by carrying their statement or hand them the command line, instead of a bare id. If recovery returns `config_changed` (the authoritative config changed since the incident), show the operator the two digests on the result; once they confirm the delta is understood, call `hardware_recover` again with `accept_config_change: true`, or relay the operator's own line with `--accept-config-change`. Both spellings write the same `config_change_accepted` field to the recovery ledger, beside both digests.

Install or update the local agent setup skill with:

```bash
agentic-hil skill-install --agent opencode
```
