# Handler: No-HITL (opt-in unattended single-concern build)

## Purpose

Opt-in unattended execution path for `/ai-build`. When `--no-hitl` is set on
an approved single-concern plan, the build runs without operator prompts:
`NEEDS_CONTEXT` promotes to `BLOCKED`, the single quality round STOPs on
the first blocker with exit `78`, the delivery cleanup runs without the
watch loop, and `no auto-retry` is the terminal contract (operator must
re-invoke `/ai-build` explicitly). Default `/ai-build` behavior (HITL) is
unchanged.

Anchors: spec-134 D-134-03 (`--no-hitl` is opt-in), CLAUDE.md §10.5 TDD
(handler authored against RED tests first), §10.4 DRY (re-uses existing
`emit_build_event` + `EXIT_STACK_DRIFT` instead of inventing new
emitters / exit codes).

## Prerequisites

| Condition | Source |
|-----------|--------|
| `--no-hitl` flag set | Caller (`/ai-build` SKILL.md Process step). |
| Approved plan exists | `.ai-engineering/specs/plan.md` is not the placeholder. |
| Single-concern plan | `execution_route.executor: build`; see `## Single-Concern Gate` below. |
| No autopilot run active | `.ai-engineering/runtime/autopilot/manifest.md` ABSENT. |

If any precondition fails, refuse with exit `78` and emit
`emit_build_event(mode="no_hitl_blocker")`. Refusal is loud; never fall
back to HITL silently.

## Single-Concern Gate

`/ai-build --no-hitl` is permitted ONLY for plans whose reviewed route
metadata says `execution_route.executor: build`. If the plan says
`executor: autopilot`, REFUSE and print `execution_route.safe_next_command`
or `/ai-autopilot`.

### Authoritative signal — execution_route

For new plans, parse `.ai-engineering/specs/plan.md` frontmatter first:

- `execution_route.executor == "build"`: ALLOW when all other
  prerequisites hold.
- `execution_route.executor == "autopilot"`: REFUSE; multi-concern work
  belongs to `/ai-autopilot`.
- Missing `execution_route`: use the legacy fallback below and mention
  that the plan should be regenerated by `/ai-plan`.

Plan `status` remains the only approval source of truth. Do NOT look for
`approved`, `approval`, or any duplicate approval field under
`execution_route`.

### Legacy fallback — heading-count

Parse `.ai-engineering/specs/plan.md`. Count the headings:

- `## Task Group` occurrences in the plan body.
- `### Phase` occurrences in the plan body.

The plan is single-concern iff `count(## Task Group) + count(### Phase)`
fits the canonical one-concern shape: zero `## Task Group` (no multi-group
header), and at most one `### Phase` chain whose phases all belong to the
same concern. In practice the simple rule is:

- If `## Task Group` count is `>= 2`, the plan is multi-concern. REFUSE.
- If `## Task Group` count is `1` AND it contains multiple sibling
  `### Phase` blocks unified under that one group, the plan is
  single-concern. ALLOW.
- If `## Task Group` count is `0`, the plan is the legacy single-concern
  `/ai-plan` shape (phases only). ALLOW.

### Signal 2 — autopilot manifest absence

Check the filesystem: `.ai-engineering/runtime/autopilot/manifest.md`.
If the file EXISTS, an autopilot run is active and the plan is
multi-concern by construction. REFUSE.

### Refusal envelope

When the gate refuses, emit the structured exit envelope under
`## Exit Envelope` below and exit `78`. The exact message is:

```
Reason: no-hitl-refused (multi-concern plan)
Detected: execution_route.executor=<executor> OR <N> `## Task Group` headings OR autopilot manifest present
Recovery: drop --no-hitl, or dispatch /ai-autopilot for multi-concern work
Then retry: /ai-autopilot
```

## Procedure

Three deterministic steps. No loops, no operator prompts.

### Step 1 -- Entry

1. Verify all prerequisites (flag set, plan present, gate passes).
2. Emit `emit_build_event(mode="no_hitl_entry")` with the spec id and the
   plan path. This is the durable audit signal that the no-HITL contract
   has started.
3. Pass `--no-watch` through to `handlers/deliver.md` Step 2.5 so the
   delivery cleanup does NOT enter the watch loop later. `--no-hitl`
   IMPLIES `--no-watch`; this is the contract.

### Step 2 -- Monitor

For each build task dispatched by the execution kernel:

1. If the task reports `NEEDS_CONTEXT`, do NOT pause for operator input.
   Promote the status deterministically to `BLOCKED` and proceed to
   Step 3 (Terminal) on the next iteration — the kernel's per-task wrap
   handles the status write to `plan.md`.
2. If the task reports `BLOCKED`, proceed to Step 3 (Terminal).
3. If the task reports `DONE` or `DONE_WITH_CONCERNS`, advance to the
   next task. Normal kernel flow continues.

Cross-handler note: `handlers/quality.md` Step 2d already emits
`quality_loop_blocked` and STOPs. Under `--no-hitl` that STOP is
augmented (not replaced) by `emit_build_event(mode="no_hitl_blocker")`
and the structured exit envelope from `## Exit Envelope` below. The
escalation report from `quality.md` is preserved.

### Step 3 -- Terminal

The run is terminal in exactly one of three shapes:

1. **Clean completion** — all tasks reached `DONE` and the quality round
   PASSed. Emit `emit_build_event(mode="no_hitl_complete")` and proceed
   to `handlers/deliver.md` Step 3 (Cleanup) immediately (no watch loop,
   per Step 1.3 above). Exit code 0.

2. **Quality blocker** — `handlers/quality.md` Step 2c found a blocker
   and STOPped. Emit `emit_build_event(mode="no_hitl_blocker")`, render
   the `## Exit Envelope`, exit `78`. The quality_loop_blocked event
   from quality.md is already on the audit chain; do NOT suppress it.

3. **Build BLOCKED** — a build task reported BLOCKED (whether directly
   or via `NEEDS_CONTEXT` promotion from Step 2.1). Emit
   `emit_build_event(mode="no_hitl_blocker")`, render the
   `## Exit Envelope`, exit `78`.

No fourth shape exists. See `## Behavioral Negatives` for the
prohibition on operator prompts and auto-recovery loops.

## Exit Envelope

The blocker / refusal terminal shape MUST print this structured envelope
to stdout AND emit it as the `quality_loop_blocked` event payload (when
applicable). Re-uses `EXIT_STACK_DRIFT` (78) semantically per
`src/ai_engineering/cli_commands/_exit_codes.py` (see D-134-03 overload
note in that module's docstring).

```
Reason: <no-hitl-blocker | no-hitl-refused>
Detected: <finding summary, file:line OR refusal cause>
Recovery: <concrete operator action — e.g., "run /ai-debug then re-invoke /ai-build">
Then retry: <command — e.g., "/ai-build" or "/ai-build --no-hitl" or "/ai-autopilot">
```

Exit code: `78` (the `EXIT_STACK_DRIFT` constant). The exit code is
semantically overloaded: 78 means "structured machine-readable contract
for the AI cognitive recovery loop" (per spec-133 D-133-23/24). The
`Reason:` field disambiguates the two callers (stack drift vs. no-HITL
blocker).

## Behavioral Negatives

The following are prohibited in `--no-hitl` mode:

- **Do NOT** prompt the operator at any point. No `read`, no `input`, no
  pause. If the workflow needs human judgment, it is BLOCKED.
- **Do NOT** auto-retry on blocker. Single round is terminal. The
  operator re-invokes `/ai-build` to re-attempt.
- **Do NOT** silently fall back to HITL when the gate refuses. Refusal
  is loud (exit 78 + structured envelope).
- **Do NOT** suppress `quality_loop_blocked` from `handlers/quality.md`.
  The no-HITL blocker event is additive, not a replacement.
- **Do NOT** invent new exit codes. 78 (`EXIT_STACK_DRIFT`) covers the
  structured-recovery contract per `_exit_codes.py`.
- **Do NOT** weaken the single-concern gate to admit a multi-concern
  plan. Multi-concern plans go through `/ai-autopilot`.

## Failure Modes

| Condition | Action |
|-----------|--------|
| Multi-concern plan detected at gate | REFUSE with exit 78. Emit `emit_build_event(mode="no_hitl_blocker")`. Recovery hint: `/ai-autopilot`. |
| Autopilot manifest present | REFUSE with exit 78. Recovery hint: drop `--no-hitl`. |
| Build task reports BLOCKED | STOP. Emit blocker envelope + exit 78. No auto-retry. |
| Build task reports NEEDS_CONTEXT | Promote to BLOCKED deterministically. Emit blocker envelope + exit 78. |
| Quality round flags a blocker | STOP. Emit `emit_build_event(mode="no_hitl_blocker")` alongside the existing `quality_loop_blocked` event. Exit 78. |
| Deliver cleanup partial failure | Fail-open as today, BUT emit `emit_build_event(mode="no_hitl_cleanup_failed", outcome="failure")` so the partial state is on the audit chain for later operator review. Do NOT exit 78 for cleanup failure — the PR is already delivered. |

## Gate

**Entry condition**: `--no-hitl` flag set, approved plan present,
single-concern gate passes, no autopilot manifest.

**Exit conditions** (one of three):

1. Clean — `emit_build_event(mode="no_hitl_complete")`, exit 0.
2. Blocker — `emit_build_event(mode="no_hitl_blocker")` + structured
   envelope, exit `78` (`EXIT_STACK_DRIFT`).
3. Refusal at gate — exit `78` + refusal envelope.

No other terminal shapes exist for `--no-hitl`.
