# Hook controls — request

audience: AI coding agents first

**Goal:** Ship `/hooks` top-row control that enables/disables `bg-gate.sh` through reusable, durable overdeck hook-control infrastructure; install live, verify without Claude processes, land, push, and deploy.

**Canonical design:** `docs/specs/2026-08-09-hook-controls-design.md`. Its contracts remain binding; this request names implementation seams and acceptance.

## Context

- Live hook: `~/.claude/hooks/bg-gate.sh` resolves into overdeck deploy clone.
- Existing hook source: `modules/workstation/claude/hooks/bg-gate.sh`; registration stays in `modules/workstation/claude/settings.json`.
- Existing page: `apps/web/src/components/hooks/HooksContent.tsx` reads collector `/hooks` inventory.
- Existing proxy: `apps/web/src/pages/api/collector/[...path].ts` defaults deny and separately allowlists mutations.
- Existing durable config root: `collector/src/paths.ts::configDir()` = `${OVERDECK_CONFIG_DIR:-$HOME/.config/overdeck}`.
- Existing UI control: `Checkbox` from `@overdeck/deck-ui`. MUST compose it; MUST NOT add generic deck-ui primitives.

## Files

Create:

- `modules/workstation/claude/hooks/lib/hook-control.sh` — sourceable runtime policy reader.
- `collector/src/hook-controls.ts` — registry, schemas, effective read, cross-process lock, atomic mutation, corruption repair.
- `collector/test/hook-controls.test.ts` — store, durability, repair, lock, permissions tests.
- `apps/web/src/components/hooks/HookControlToggle.tsx` — reusable hook-domain control composed from existing exports.
- `apps/web/src/components/hooks/HookControlToggle.test.tsx` — interaction, pending, error, issue/repair, accessibility tests.
- `apps/web/tests/hooks-controls.spec.ts` — fixture-backed browser acceptance for `/hooks` control.

Modify:

- `modules/workstation/claude/hooks/bg-gate.sh` — consult runtime control immediately after stdin capture, before parsing.
- `modules/workstation/claude/tests/bg-gate-matcher.test.mjs` — direct quota-free enabled/disabled/fail-closed/conformance tests.
- `collector/src/server.ts` — add injected config directory and authenticated hook-control routes.
- `collector/test/server.test.ts` — route/status/auth/body/method acceptance.
- `apps/web/src/lib/collector-types.ts` — browser hook-control contracts.
- `apps/web/src/lib/collector-client.ts` — GET, ID mutation, repair clients.
- `apps/web/src/lib/collector-queries.ts` — query key and pessimistic mutations.
- `apps/web/src/lib/collector-client.test.ts` — exact client route/body/error coverage.
- `apps/web/src/pages/api/collector/[...path].ts` — exact read/mutation allowlists only.
- `apps/web/src/lib/collector-proxy.test.ts` — same-origin, authentication forwarding, content type, body, method, path tests.
- `apps/web/src/components/hooks/HooksContent.tsx` — top-row control and issue repair surface.
- `apps/web/src/components/hooks/HooksContent.test.tsx` — integration states and placement.
- `apps/web/tests/fixture-collector.ts` — fixture hook-control GET/POST state.

Touch no deck-ui files unless existing `Checkbox` cannot satisfy a verified requirement. If that occurs, stop: repository rules require owner approval for a new primitive.

## Contract

### Persistent schema and registry

File: `${OVERDECK_CONFIG_DIR:-$HOME/.config/overdeck}/hook-controls.json`

```json
{
  "version": "hook-controls/v1",
  "hooks": {
    "background-jobs-blocker": true
  }
}
```

```ts
const HOOK_CONTROL_REGISTRY = {
  'background-jobs-blocker': { defaultEnabled: true },
} as const

type HookControlId = keyof typeof HOOK_CONTROL_REGISTRY
type PersistedHookControls = {
  version: 'hook-controls/v1'
  hooks: Record<string, boolean>
}
type EffectiveHookControls = {
  version: 'hook-controls/v1'
  hooks: Record<HookControlId, boolean>
}
type HookControlsResponse = {
  controls: EffectiveHookControls
  issue: null | { code: 'invalid-hook-controls' | 'wrong-hook-controls-version'; detail: string }
  persistence?: 'confirmed' | 'indeterminate'
  persistenceDetail?: string
}
```

`HOOK_CONTROL_REGISTRY` is TypeScript source of IDs/defaults. Persisted map is sparse and preserves unknown boolean keys. API materializes registered defaults. Shell literal/default duplication MUST have conformance coverage.

### Runtime shell seam

```text
hook_control_is_enabled <hook-id> <default-enabled> -> exit 0 enabled; exit 1 disabled
```

- Resolve config through `OVERDECK_CONFIG_DIR`; default `$HOME/.config/overdeck`.
- Missing/unreadable/malformed/wrong-version/non-boolean value returns caller default.
- Emit no normal output; remain safe under `set -euo pipefail`.
- `bg-gate.sh` MUST source helper before input capture. Immediately after `INPUT=$(cat)`, before every `jq`/matcher parse, apply literal guard:

```bash
if ! hook_control_is_enabled background-jobs-blocker true; then exit 0; fi
```

Disabled mode MUST pass through with empty stdout/stderr even for malformed hook input. Enabled behavior, subagent exemption, QuietContext exemption, and deny reason remain unchanged.

### Store seams

```ts
readHookControls(configDirectory?: string): Promise<HookControlsResponse>
setHookControl(id: HookControlId, enabled: boolean, configDirectory?: string): Promise<HookControlsResponse>
repairHookControls(configDirectory?: string): Promise<HookControlsResponse>
```

- Missing file: effective defaults, `issue: null`.
- Corrupt/wrong-version file: safe defaults plus issue; ID mutation rejected.
- Repair requires exact confirmation at route, accepts invalid file only, backs bytes up to sibling mode-`0600` corruption file, then writes defaults.
- ID mutation preserves unknown persisted booleans and rejects unregistered route IDs.
- Mutations hold kernel `flock` from latest read through backup/write/rename/fsync. Lock file has no metadata; create `O_CREAT|O_EXCL|O_NOFOLLOW` mode `0600` or open existing `O_RDONLY|O_NOFOLLOW`; `fstat` requires regular file, current uid, `nlink=1`, mode `0600`; never write/truncate/chmod lock after open; every path releases flock/fd.
- Commit-point contract: failures before rename preserve target and return non-2xx; rename is commit and never rolled back; parent fsync success confirms; parent fsync failure returns HTTP 2xx observed `persistence: indeterminate`.
- GET fsyncs config directory (with retries) before `persistence: confirmed`; fsync failure returns observed indeterminate.
- Config parent mode `0700`; exclusive temp, target, and backup mode `0600`; fsync temp, rename, fsync parent; remove temp after failure.

### Collector and proxy routes

```text
GET  /config/hooks
POST /config/hooks/:id             body { "enabled": boolean }
POST /config/hooks/repair          body { "confirm": "replace-invalid-config" }
response HookControlsResponse
```

- Collector routes require existing bearer authentication.
- Browser mutation proxy requires exact same-origin `Origin`; missing, `null`, foreign rejected.
- Accept exact JSON shape and JSON content type only. Retain current bounded-body protection for declared and streamed sizes.
- Proxy allowlist only exact GET, registered ID POST path shape, and repair POST. Never widen generic read/write access.

Stable errors:

| Status | Code | Condition |
|---|---|---|
| 400 | `invalid-hook-control-request` | malformed JSON or wrong shape/type |
| 401 | `unauthorized` | collector auth failure |
| 403 | `forbidden-origin` | missing, `null`, foreign mutation Origin |
| 404 | `unknown-hook-control` | unregistered ID |
| 405 | `method-not-allowed` | unsupported known-route method |
| 409 | `hook-controls-invalid` | mutation against invalid file; repair against valid file |
| 413 | `payload-too-large` | body bound exceeded |
| 415 | `unsupported-media-type` | mutation not JSON |
| 503 | `hook-controls-locked` | bounded lock wait expired |
| 500 | `hook-controls-write-failed` | durable persistence failed |

### Browser data and UI seams

```ts
fetchHookControls(): Promise<HookControlsResponse>
setHookControl(id: HookControlId, enabled: boolean): Promise<HookControlsResponse>
repairHookControls(): Promise<HookControlsResponse>
useHookControls(): query result
useSetHookControl(): mutation result
useRepairHookControls(): mutation result
```

```text
HookControlToggle({ id, label, hint, enabled, pending, error, onEnabledChange }): JSX
```

- Pessimistic state only: retain server-confirmed value pending; update cache after success without invalidating indeterminate; retain value on failure; indeterminate adopts observed state and offers `Reconfirm hook controls`.
- Disable before initial GET and during mutation.
- Checked means blocker enabled and agent background jobs forced foreground.
- Label identifies background-job blocker. Persistent hint states disabling permits agent-initiated background commands.
- Announce errors and connect hint/error through `aria-describedby`.
- Non-null `issue`: disable toggle, show honest issue, render existing `Button` action `Repair hook controls`; send repair confirmation only from that action.
- `persistence: indeterminate`: adopt observed checkbox state, announce `persistenceDetail`, disable direct toggling, render `Reconfirm hook controls` rewriting the same observed value.
- Place control in existing `/hooks` top row before counts/generated timestamp. Preserve responsive wrapping and keyboard access.

## Behavior

- Next hook invocation observes atomic rename; no Claude restart or daemon reload.
- Toggle changes runtime behavior, not hook registration.
- Missing or invalid config never silently disables safety blocker.
- Mutable state never lives in git checkout/deploy clone and survives deploy.
- UI/collector unavailable leaves last durable policy effective.

## Out of scope

- Other hook adopters.
- Claude hook registration changes.
- Claude core auto-background behavior.
- Audit-history UI.
- General policy expressions.
- Unrelated UI refactors or new primitives.

## Acceptance

All commands MUST run through repository-required load wrappers where applicable. Any warning/error/notice is fixed or explicitly justified; no new `.warnignore` suppression.

1. `node --test modules/workstation/claude/tests/bg-gate-matcher.test.mjs`
   - PASS: missing/invalid/true denies; false passes malformed background input before parsing; existing exemptions pass; shell ID/default matches registry.
2. `cd collector && bun test && bun run typecheck`
   - PASS: store durability/permissions/repair/cross-process locking and route/auth/status contracts.
3. `pnpm --filter web test -- src/components/hooks/HookControlToggle.test.tsx src/components/hooks/HooksContent.test.tsx src/lib/collector-client.test.ts src/lib/collector-proxy.test.ts`
   - PASS: query/mutation/UI/proxy contracts, including pessimistic state and repair.
4. `pnpm --filter web build && pnpm --filter web typecheck`
   - PASS with zero new warnings.
5. Fixture browser E2E MUST run remotely, never browser+server locally:

```bash
~/.claude/bin/e2e-remote \
  --server "pnpm --filter @overdeck/web exec astro dev --host 127.0.0.1 --port 4321 --force" \
  --wait-port 4321 \
  --env COLLECTOR_URL=http://127.0.0.1:14980 \
  --env COLLECTOR_TOKEN=overdeck-dev \
  -- pnpm exec playwright test apps/web/tests/hooks-controls.spec.ts --config=playwright.config.ts
```

   - PASS: fetched state, toggle round trip, pending/error/repair surface, top-row placement.
6. Install before landing: deploy the tested candidate commit into the clean deploy clone as a detached commit, build/install/restart with the same service set as `packaging/deploy-local.sh`, and keep clone clean. MUST NOT run `packaging/deploy-local.sh` yet: it intentionally checks out landed `origin/main` and would erase the candidate. Assert services healthy and installed `~/.claude/hooks/bg-gate.sh` resolves into candidate deploy source.
7. Live quota-free behavior:
   - clear incidental `OVERDECK_CONFIG_DIR`; assert collector and hook use `$HOME/.config/overdeck/hook-controls.json`;
   - capture original file existence/bytes and effective value;
   - install cleanup trap before disabling; trap restores original value or missing-file state on every exit and verifies restoration;
   - GET/POST via `http://127.0.0.1:31337/api/collector/config/hooks` with same-origin header;
   - disabled installed hook direct stdin invocation returns empty passthrough, including malformed input;
   - enabled installed hook direct stdin invocation returns deny JSON;
   - cleanup restores original state; `/hooks` and production assets return success.
8. NEVER execute `claude`, `claudex`, or `cld` for any test or verification.
9. Land/push through project-approved mechanism, run final `packaging/deploy-local.sh`, then re-assert service health, config persistence/restoration, installed target, GET response, `/hooks`, and installed direct-hook behavior matching restored state.
