---
name: halo-apply
description: Apply a composed actions-*.jsonl file to Halo via the executor fleet — leases, heartbeats, dead-executor reclaim
---

# Halo Apply

Apply an already-composed action file (`actions-<step>.jsonl`, produced by `halo-action-planner` or another authorized planner) to the Halo tenant. The main thread takes the **coordinator role** and drives the apply loop; dumb executor agents each resolve one action's `$ref`s and push exactly one MCP call. File-first: progress is inspectable mid-flight and any fresh session resumes from the files, not from memory.

## Usage

```
/grcitsm-analyst:halo-apply [<run-folder> | <action-file.jsonl>]
```

- `<run-folder>` contains the pipeline files (`actions-*.jsonl`, progress ledger, run state).
- `<action-file.jsonl>` is a **bare action file** not inside a run folder — the file-only cross-plugin handoff shape (planners in other plugins emit `inputs/halo-actions/*.jsonl`, e.g. `billing.jsonl`, `project.jsonl`). Bootstrap it into a run folder first (Process step 0), then proceed normally.
- If omitted, discover in order and confirm the pick with the user:
  1. the most recent run folder in `outputs/` with pending actions;
  2. otherwise the newest `inputs/halo-actions/*.jsonl` — bootstrap it per step 0.

## Agents

| Agent | Purpose | When Invoked |
|-------|---------|--------------|
| `halo-pipeline-coordinator` | Role spec the MAIN THREAD loads (not dispatched — subagents cannot field the executor fleet) | Whole apply loop |
| `halo-action-executor` | Dumb dispatcher: one action → resolve `$ref`s → one MCP write call → append progress | Each leased action |

## Process

Load the **`halo-action-pipeline` skill** and the `halo-pipeline-coordinator` role spec, then follow them. In brief:

0. **Bootstrap (bare action file only)** — script-owned, never narrated by hand:
   ```
   python3 <halo-action-pipeline skill dir>/pipeline.py bootstrap --file inputs/halo-actions/billing.jsonl
   ```
   Validates every line (unique `id` + `tool`), scaffolds `outputs/<run>/state/` with the file copied to `actions-01.jsonl` plus an empty `progress.jsonl`, and prints the paths. Continue with the printed run folder; the source file stays in place as the immutable input. Resume always targets the RUN FOLDER — never re-bootstrap the same bare file (a `--run` collision fails loudly; a fresh timestamped bootstrap of an already-applied file would replay it, though create guards keep that safe).
1. **Resolve the tenant prefix — script-owned, blocking.** Executors carry no `tools:` allowlist (they inherit every connector the session holds, which is what makes this plugin work against a client's own connector whatever it is named). Nothing else verifies a tenant is reachable, so resolve it before leasing anything:
   ```
   ${CLAUDE_PLUGIN_ROOT}/scripts/resolve-tenant-prefix.sh [--expect <prefix>]
   ```
   The pin file is the EXCEPTION mechanism, not a prerequisite. If the project has `.claude/grcitsm-tenant.json` (written by `/grcitsm-analyst:setup` when a client runs several connectors or a non-default name), pass its `prefix` as `--expect`: a session holding several connectors then resolves to the pinned one instead of stopping as ambiguous, and a session missing that connector fails loudly at exit 4. On the undeterminable path below, the pin IS the answer — use it directly and skip both the catalog enumeration and the ask-the-user step. With no pin file, the default case needs nothing: enumeration finding `mcp__claude_ai_GRCITSM__` as the sole GRCITSM candidate is the standard connector name, so use it directly (step 1b's witness capture is what verifies it). No pin and several candidates → still ambiguous: ask, and point at `/grcitsm-analyst:setup` to make the choice sticky.

   Exit 0 prints the one prefix to hand every executor. Exit 2 (no connector) → stop, point at `/grcitsm-analyst:setup`; do NOT start the loop, it would apply 0/N with auth looking healthy. Exit 3 (several connectors live) → the target tenant is ambiguous; show the candidates, have the user pick, and re-run with `--expect` — never guess, a wrong pick writes to the wrong tenant. Exit 4 → the pinned tenant is not connected in this session. No `claude` CLI or a failed listing is *undeterminable*, not a failure: it prints nothing and exits 0.

   **Undeterminable path (claude.ai sessions — the common case).** There is no CLI to enumerate, and action lines carry a **bare** tool name by schema, so there is no prefix in the file to fall back to. The session's own MCP tool catalog is the source: the MAIN THREAD lists the tool names visible to it beginning `mcp__` and collects the candidate prefixes whose server segment contains `GRCITSM` as a delimited token (`mcp__claude_ai_GRCITSM_Halo_Gov__…` → `mcp__claude_ai_GRCITSM_Halo_Gov__`). Exactly one candidate → pin it. Several → the target tenant is ambiguous exactly as at exit 3: present the candidates and have the user pick. Never guess, and never infer a prefix that is not in the catalog.

   Write the resolved prefix to `state/tenant-prefix` in the run folder and pass that exact string to every executor, so a resumed run reuses the same tenant and a mid-run connector change cannot silently retarget the fleet.

1b. **Capture the tenant witness — blocking, before leasing anything.** A prefix names a connector, not a tenant. Under the pinned prefix ONLY, call `<prefix>CF_client_details` (no arguments — it returns the client catalog), find the row for the plan's target `client_id`, and write it to `state/tenant-witness.json`:
   ```json
   {"client_id": 12, "client_name": "…"}
   ```
   No row for the plan's `client_id` → **STOP**: the plan targets a client this tenant does not have — almost certainly the wrong tenant. A failed read is also a STOP: a tenant you cannot read is not a tenant you may write. `pipeline.py resolve` emits this file as `expect_tenant`, and every executor asserts the NAME against each write response — ids are not proof of tenant (the same `client_id` names different companies on different instances, measured live). On resume, an existing `state/tenant-witness.json` is reused as-is; it was captured from the pinned tenant and the pinned prefix is already sticky via `state/tenant-prefix`.
2. Verify the run folder: action file present, progress ledger consistent, no other live apply session (check leases).
2b. **Scope confirmation — before any write wave.** If the write scope was negotiated through `AskUserQuestion` free-text ("Other") or approved with a bare "confirmed": restate the RESOLVED scope naming every family/entity class and the target tenant, get a confirmation that names that scope back, and carry the user's statement VERBATIM into the workflow args as `user_authorization` — every lease then presents it to the permission classifier. Measured: scope inferred from the question's example options blocked 13 leases across two waves; the verbatim scope-naming statement in the args produced zero blocks.
3. **Route by batch shape first.** A UNIFORM mechanical rows batch — every action calling the SAME tool with homogeneous args (`create_validations`-class) — defaults to MAIN-THREAD application: `pipeline.py resolve` per action → call the tool directly → receipts land in the session transcript → read back. The executor fleet is for heterogeneous action files, where startup cost buys parallelism across tools and dependency tiers. Otherwise, preferred path: run the plugin's `halo-apply` workflow (`workflows/halo-apply.js`) — it executes rounds deterministically (ready-set, leases, dead-executor reclaim). Drive it under a foreground watchdog; a hung executor is only recovered by a per-agent timeout (`pipeline.py stalls` is that watchdog in the manual loop).
4. Manual fallback: the main thread runs the loop itself per the role spec — lease ready actions to executors, heartbeat, kill+reassign dead leases, advance until the ready set is empty.
5. **Post-run tenant population check (blocking for the success verdict).** The ledger's `done` count is NOT evidence of tenant state: executors have fabricated dones, and the API accepts wrong-shaped payloads with HTTP success while discarding the content (observed: 1129 dones, near-zero persisted fields; `update_ticket_fields` has also returned success while silently dropping CF values). Before reporting success: run `pipeline.py verify-chain --actions <actions-file>` (broken links → halt and investigate first; fabrication-audit findings → step 5b), then verify against the tenant — `asset_summary` scoped to the target client/site comparing per-type counts against the plan's expected counts (also surfaces duplicates: group by inventory_number, any count > 1 is a spine-corruption signal), plus read-backs confirming the written fields are POPULATED, not merely that rows exist — **stratified PER EXECUTOR OWNER: minimum one read-back from every lease that reported completions** (the workflow returns `ownerCompleted`; the manual loop has the ledger's `owner` fields), never a flat global sample, which can miss an entire fabricating lease (observed: 2 of 5 executors fabricated every action they held). For creates, read-backs assert IDENTITY (`inventory_number`/summary equals the action's) and, for `set_ticket_parent` waves, the parent PAIRING — not mere existence. Refuse to report success when populated counts diverge from `done` counts; report the divergence instead.
5b. **Fabrication finding → transcript audit + ledger quarantine.** When any read-back contradicts a `done`: audit EVERY executor in that wave — count real `tool_use` events for the leased tool in each executor transcript against its claimed completions (zero tool calls with N dones = a fabricated lease). A verified-false `done` POISONS the append-only ledger for those ids: every honest executor and every ledger-seeded `doneIds` downstream will trust it (observed: an honest redo wave no-op'd over 32 fabricated dones). NEVER launch a redo wave over that ledger. Remediate from the MAIN THREAD — `pipeline.py resolve` per action, call the tool directly, receipts in the session transcript, full read-back — or bootstrap a fresh ledger. Regenerate `doneIds` from TENANT read-back, never from ledger events. Then append one `superseded` event per corrected id (`append-event --status superseded --note "<read-back evidence>"`): `ready` re-lists the action and `$ref` resolution ignores the retracted done — the ledger is corrected forward, never edited.
6. Report the ledger summary (applied / failed / remaining) and surface fail-waves rather than silently retrying.

## Guardrails

- **Never** apply actions that lack an approved upstream plan — this command executes decisions, it does not make them.
- **Guarded re-runs seed `doneIds` from a TENANT read, never from a ledger that may have lost events.** Guarded creates are a search heuristic, not idempotency: a relaunch against an emptied ledger with only the AFUUID guard as defence duplicated ~191 spine assets (observed live). Before any re-run: seed `doneIds` from tenant existence (`asset_summary` by inventory_number), or verify the guard on a ~3-action batch and confirm 3 skips before committing the full file. Never resume a run folder whose ledger records wrong-tenant or otherwise-bad creates as `done` — clean the tenant first, or the guard search will match the bad rows and wire updates onto them. After a fabrication finding the rule extends to EVERY action kind: a ledger carrying verified-false dones never seeds `doneIds` again — regenerate from tenant read-back (step 5b).
- The planner never calls a write tool; the executor never composes a payload. If an action is under-specified, it goes back to planning — executors must not improvise.
- Keep write-response bloat out of context (stage bulk reads to disk) — context bloat is the slowdown that becomes a hang.

## Error Handling

- No Halo MCP connection → `resolve-tenant-prefix.sh` exits 2 at step 1; stop and point at `/grcitsm-analyst:setup`. This is the check that keeps a missing connector from presenting as a completed run that applied nothing.
- Several GRCITSM connectors live → exit 3; do not pick for the user. Show the candidates and re-run with `--expect <prefix>`.
- Bootstrap rejects the file (malformed JSON, missing/duplicate `id`, missing `tool`) → the action file is under-specified; send it back to its planner, do not hand-fix lines.
- Executor fail events citing a **tenant-witness mismatch** (or a missing witness) → writes may already have landed in the wrong tenant. Halt the loop immediately; do NOT reclaim the lease, do NOT retry, do NOT let the round continue. Surface to the user with the action ids involved and the identity the response returned, and treat the tenant as needing inspection before any further apply.
- Elevated executor fail-waves → pause the loop, surface the failure pattern, and escalate per the pipeline skill instead of grinding retries.
- Interrupted run → re-invoke with the same run folder; the ready-set derivation resumes from the ledger.
