# Architecture

`pi-jev-checkpoints` is one npm package that registers three Pi extensions. Pi loads every extension file with its own module instance, so the package is organised as a thick shared layer plus thin per-checkpoint directories, and the extensions talk to each other only over Pi's shared event bus.

```
extensions/                       entry files registered in package.json (one line each)
  clarity-gate.ts
  confidence-flag.ts
  checkpoints.ts                  the /checkpoints console
src/
  shared/
    jev.ts          Jev question/answer types, response validation (noul / choice / score)
    transport.ts    backend strategies (TypeSafe, OpenRouter), key resolution, timeout, fail-safe client
    redact.ts       local secret redaction (data-driven rules)
    config.ts       metadata-driven, layered configuration
    evidence.ts     candidate IDs and citation validation
    evaluator.ts    the fixed evaluation pipeline every checkpoint runs through
    policy.ts       threshold bands and de-duplication
    session.ts      pure helpers over Pi session/agent messages
    run.ts          evidence view of a run: tool calls paired with results, final answer, current-turn slice
    host.ts         adapter over ExtensionAPI/ExtensionContext (config, transport, audit, telemetry)
    runtime.ts      per-checkpoint session state + bus protocol handling
    bus.ts          the inter-extension protocol (channels and payload guards)
    text.ts         clipping, fragmenting, safe JSON
    icons.ts        the shared icon vocabulary (single-width glyphs + theme tone per meaning)
  checkpoints/
    registry.ts     static descriptors of every checkpoint
    clarity-gate/   spec.ts (state/questions/decide) · messages.ts · render.ts · extension.ts
    confidence-flag/ spec.ts (answer vs evidence) · recall-spec.ts (no tools used) · messages.ts · render.ts · extension.ts
    hub/            commands.ts (parser) · store.ts (config file I/O) · extension.ts
tests/              node --test, one file per module, with a fake Pi host in tests/helpers
```

## The evaluation pipeline

Every checkpoint is a `CheckpointSpec` with three pure pieces:

```ts
interface CheckpointSpec<Input, State, Questions, Decision> {
  buildState(input): State | undefined;   // what Jev sees (undefined = nothing to judge)
  questions(state): Questions;            // noul + typed evidence questions
  decide(answers, state): Decision;       // thresholds → action, pure and unit-tested
}
```

`evaluate()` in `shared/evaluator.ts` runs the fixed skeleton around it:

```
buildState → redactDeep → resolve transport (per call) → ask Jev → validate answers → decide
```

Any failure at any step returns `{ kind: "skipped", reason }` instead of throwing. That is where the suite-wide fail-safe rule lives: a checkpoint cannot forget to handle a timeout, because the pipeline never hands it one.

## Design patterns and where they are used

| Pattern / principle | Where | Why |
|---|---|---|
| **Template Method** (functional) | `evaluator.ts` | Redaction, transport, validation and fail-safe are written once; checkpoints supply only the three varying steps. |
| **Strategy** | `transport.ts` `JevBackend`; `confidence-flag` `ANNOTATORS` | Backends share one request/response shape but differ in endpoint, headers, default model. Annotation styles (inline/message/notify) are interchangeable functions keyed by config. |
| **Facade** | `JevClient.ask()` | One call hides timeout, HTTP, body limits, JSON parsing and schema validation; returns a `JevResult` union, never throws. |
| **Null Object** | `unavailableTransport()` | When no key is found the pipeline still gets a transport; callers never branch on "is there a key?". |
| **Adapter** | `host.ts` | Narrows Pi's large `ExtensionAPI`/`ExtensionContext` to `HostServices`; tests inject a fake with a fixed config and a scripted transport. |
| **Mediator** | `hub/extension.ts` + `bus.ts` | The console cannot import the other extensions' state (separate module instances), so it broadcasts status requests and toggles on `pi.events`; each `CheckpointRuntime` answers for itself. |
| **Registry** | `checkpoints/registry.ts` | Static descriptors drive the console listing; adding a checkpoint is one descriptor + one directory + one line in `package.json`. |
| **Metadata-driven config** | `config.ts` `CONFIG_FIELDS` | Each key is declared once with type, range, default and description; parsing, merging, validation, `/checkpoints set|get|keys` and the docs table all derive from it (Open/Closed: add a row, nothing else changes). |
| **Data-driven rules** | `redact.ts` `SecretPattern[]` | New secret formats are data; `createRedactor()` accepts extra rules without editing the module. |
| **Dependency inversion** | `JevTransport`, `ConfigLoader`, `HostServices` interfaces | Checkpoints depend on interfaces; production wiring happens in `createHostServices`, tests replace it wholesale. |
| **Command object** | `hub/commands.ts` | `/checkpoints` input is parsed into a discriminated union first, then executed by a single `switch`; parsing is tested without any I/O. |
| **Pure decision functions** | `*/spec.ts`, `policy.ts` | Thresholds, bands, de-duplication and citation checks have no side effects, so every branch in the design tables has a direct unit test. |

## Citation validation

Jev never generates a quote. Both checkpoints turn text into candidates (`p_0…`, `a_0…`), send them as `choice` criteria with a leading `none`, and accept an answer only if the chosen ID exists in the submitted list (`evidence.ts`). The same rule applies to enum answers such as ambiguity type or evidence level: an unknown value is treated as "no evidence" and the checkpoint stays quiet.

## Hooks used

- `clarity-gate`
  - `input` (only when `blockOnStrongAmbiguity` is on): the one hook that can stop a prompt before any model call. Evaluates the raw prompt, shows a dialog on a strong verdict, and either hands the text back to the editor (`{ action: "handled" }`) or caches the verdict.
  - `before_agent_start`: sees the expanded prompt for every new turn. With `trigger: "turn"` it evaluates here and returns `{ message }` to inject guidance; with `trigger: "first-mutation"` (default) it only remembers the prompt for this turn.
  - `tool_call` (`first-mutation` only): the first call to a tool in `mutatingTools` evaluates once, with the turn's tool activity so far (`shared/run.ts` `currentRunMessages` + `toolActivityOf` over the session branch) and the pending call as `pending_action`. Strong verdict → `{ block: true, reason }`; soft → the call proceeds and a steer message (`sendMessage(..., { deliverAs: "steer" })`) carries the guidance. Read-only turns never reach Jev. When `pi.getActiveTools()` includes `ask_user_question`, the guidance names that tool.
- `confidence-flag`
  - `agent_start` / `agent_end`: track the run number and keep the run's new messages.
  - `agent_settled`: evaluate asynchronously and discard the verdict if a new run started or the session is no longer idle. Runs with tool calls use `spec.ts` (answer vs evidence); runs without use `recall-spec.ts` (unverified workspace claims), or are skipped when `recallMode` is `off`. Both decisions are normalised into one `Flag` shape before de-duplication and annotation.
- `checkpoints`
  - `registerCommand("checkpoints")` only.

## Adding a checkpoint

1. Add a descriptor to `checkpoints/registry.ts` and a config section to `CONFIG_FIELDS` in `shared/config.ts`.
2. Create `checkpoints/<name>/spec.ts` implementing `CheckpointSpec`, plus an `extension.ts` that wires hooks through `evaluate()` and `CheckpointRuntime`.
3. Add `extensions/<name>.ts` (one re-export line) and list it in `package.json` → `pi.extensions`.

The shared layer does not change.

## Answers to the design doc's open questions

The original design (`docs/pi-jev-plugins-design.md`) listed items to confirm before building. Verified against `@earendil-works/pi-coding-agent` 0.86.1 and the TypeSafe API reference:

- **Request pre-processing hook exists.** Pi emits `input` (before skill/template expansion, can return `handled`/`transform`) and then `before_agent_start` (can inject a message). `clarity-gate` uses both; no fallback to "first agent step" was needed.
- **Jev question schema.** `noul` → `{ noul }`; `choice` → `{ choice, probabilities, confidence }` with `criteria` as a `Record<string, string | null>` (≤ 255 options); `score` → `{ score, legend, probabilities, confidence }` with `criteria` as an ordered array (2–10 levels). Request body is `{ model, state, questions }`; both TypeSafe (`/v1/systemone`) and OpenRouter (`/api/alpha/decisions`) accept the same shape. Evidence citation is done by us: candidates become choice options with IDs.
- **Scaffold source.** Config layering and fail-safe rules follow pi-follow-through and @alexlikevibe/pi-jev; key resolution (env first, then Pi's saved `/login` credential for OpenRouter) follows specpi-jev-guard.
- **Multiple toggleable extensions in one package.** `package.json` → `pi.extensions` lists one file per checkpoint; users disable a file with `pi config` or a `!extensions/<name>.ts` filter in the package entry. `/checkpoints on|off` adds session-level and config-level toggles on top.
- **Name availability.** `npm view pi-jev-checkpoints` returned 404 on 2026-09-20; the name was free.
