# research

`ai.stravica.knowledge.research` — deep-research **session store CLI**. Creates,
validates, inspects and prunes research sessions on disk, and owns the camelCase
schemas that every downstream research workflow reads and writes.

This is the state layer under the `/research` engine (build-spec `deep-research`,
§3). It is **not** the engine: `run` creates the session shell and prints the plan;
it does not orchestrate searchers, verifiers, or synthesis. Actual fan-out lands in
the Workflow scripts (w-017+) which read and write the session dir this tool defines.

Built to the platform **tooling standard** (`@stravica-ai/digital-operator-pa` →
`engineering-pa-core/docs/standards/tooling.md`): zero runtime dependencies,
hand-rolled validation, a single write chokepoint, `node:test` only, camelCase JSON
throughout.

---

## Status

**v1 — session store.** Implements `run`, `list`, `show`, `gates`, `prune`,
`mark done`, `--help`. Owns all five schemas (`manifest`, `plan`, `finding`,
`sources`, `gates`).

- `run` creates the session (dir + manifest + skeleton plan), validates inputs,
  prints the plan, and returns. It never spawns workers.
- `gates --retry` marks eligible gates as `retrying` and prints them; the actual
  re-execution mechanism is workflow-side (**w-018**, out of scope here).
- `mark done` on a **normal**/**deep** session promotes the answer into the operator
  knowledge library (**w-021**; light never promotes). See
  [Knowledge-library promotion](#knowledge-library-promotion-on-mark-done).

Deferred (not this tool): the `/research` skill (w-016), the workflow scripts
(w-017), gate escalation / worker park-resume (w-018), judge scoring and presets
(v2/v3). Findings are **written by workers**; this tool only reads and validates them.

## Install / invoke

Ships as part of `@stravica-ai/digital-operator-pa`. From an operator repo:

```sh
pnpm exec research <command>
# or through the pa umbrella:
pnpm exec pa research <command>
```

Path resolution: the CLI walks upward from CWD (or reads `$DAVE_REPO_ROOT` /
`$PA_REPO_ROOT`) to find the operator repo root, and lands sessions under
`<repoRoot>/.cache/research/sessions/`. Promoted answers land in
`<repoRoot>/operator/knowledge/research/`. The four workflow-facing paths
(sessionsDir, briefsDir, workflowsDir, schemaDir) are emitted by
`research paths --json` — the `/research` skill uses that to wire Workflow
dispatches without hard-coding install locations.

Zero runtime dependencies; Node 24+ (Node standard library only:
`node:util`, `node:fs/promises`, `node:path`, `node:crypto`, `node:test`).

## Commands

### `run` — create a session

```
research run --mode <light|normal|deep> --question "..."
             [--gate <operatorPresent|background|worker>]
             [--intent "..."] [--output <shortAnswer|article|structured>]
             [--dry-run]
```

Creates `sessions/<sessionId>/` with `manifest.json`, a skeleton `plan.json`
(mode-derived `deepeningRounds`/`verificationTier`, empty `subQuestions` — the
workflow fills those), plus empty `sources.json` and `gates.json`. Validates every
input, prints the manifest + plan, returns.

- `--mode` (required): `light` (0 rounds, no verify), `normal` (1 round,
  central-3vote), `deep` (3 rounds, diverse-lens-3vote).
- `--gate` (default `worker`): human-gate posture recorded in the manifest (§6).
- `--intent` (optional): clarified intent, recorded on the manifest.
- `--output` (default `shortAnswer`): synthesis render target.
- `--dry-run`: validate and print **without persisting** — the only branch that
  does not write to disk.

```sh
research run --mode normal --question "Compare Postgres vs SQLite for embedded use"
research run --mode light --question "What is a ULID?" --dry-run
```

Exit: `0` created (or validated, for `--dry-run`); `1` bad/missing `--mode`,
`--question`, `--gate`, or `--output`; `3` disk write failure.

### `list` — list sessions

```
research list [--status <draft|in-progress|done|aborted>] [--all] [--json]
```

Sessions newest-first (the `sessionId` sorts by creation time). Default: 20 most
recent. `--all` lists every session; `--status` filters; `--json` emits manifests.
Malformed session dirs are surfaced (not silently dropped) with a `MALFORMED` marker.

```sh
research list
research list --status done --all
research list --json
```

Exit: `0` always on success (empty list prints `no sessions`); `1` bad `--status`;
`3` disk read failure.

### `show` — dump one session

```
research show <sessionId> [--json]
```

Prints the manifest, plan, findings summary (per sub-question: confidence,
`UNSOURCED` flag, verification votes), source count, and gate summary. `--json`
emits the full assembled `{ manifest, plan, sources, gates, findings }` object.

```sh
research show 01KWYG… 
research show 01KWYG… --json
```

Exit: `0` shown; `1` missing/invalid `<sessionId>`; `2` session not found or a
session file is malformed; `3` disk read failure.

### `gates` — inspect / mark the human-gate queue

```
research gates --list [<sessionId>] [--json]
research gates --retry <sessionId> [--json]
research gates --append <sessionId> [--record '<json>'] [--json]
```

- `--list`: aggregate the gate queue across all sessions, or one session if a
  `<sessionId>` is given. Prints session, gate id, status, type, url, what-is-needed.
- `--retry <sessionId>`: mark eligible gates (`pending` or `retrying`) as `retrying`
  and print them. This only **marks** — actual re-fetching is workflow-side (a v2
  resume mechanism; for v1 `retrying` means "queued for an operator to hand-run").
  Nothing eligible is a clean no-op.
- `--append <sessionId>`: append gate records to the session's `gates.json`. Records
  arrive as a **JSON array on stdin**, or a single object via `--record '<json>'`
  (when `--record` is given, stdin is not read). This is the write path the
  `research-normal.js` workflow's Finalise phase uses to persist each searcher's
  `gatedDropped[]` records (background-posture gates), so a later operator-present
  session can sweep them.

  Each record supplies the **semantic** fields — `url`, `gateType`, `whatIsNeeded`
  (all required), and an optional `subQuestionId`. The verb owns the rest: it assigns
  a stable id (`g-N`, continuing from the last existing id), sets `blockedAt` from the
  clock, and defaults `status` to `pending`. A record may override `status` (validated
  against `pending|retrying|resolved|failed`). Unknown fields, a bad status enum, or a
  missing required field are usage errors (exit 1). The write goes through the store
  chokepoint (`store.writeGates`), which validates the whole file before persist.

```sh
research gates --list
research gates --list 01KWYG…
research gates --retry 01KWYG…
echo '[{"url":"https://x","gateType":"cloudflare","whatIsNeeded":"clear the challenge","subQuestionId":"sq-2"}]' \
  | research gates --append 01KWYG…
research gates --append 01KWYG… --record '{"url":"https://y","gateType":"login","whatIsNeeded":"sign in"}'
```

Exit: `0` on success (incl. empty queue / nothing eligible); `1` if not exactly one
of `--list`/`--retry`/`--append` is given, an invalid `<sessionId>`, bad JSON, or an
invalid record; `2` if an explicitly named session does not exist, or `--append` was
handed zero records; `3` disk read/write failure.

### `sources`: merge into the source registry

```
research sources --merge <sessionId> [--json]
```

Reads a source registry on stdin (`{ "items": [...] }` or a bare array of items) and
merges it into the session's `sources.json` (w-2026-10-19-dave-009). The registry
never loses items: every existing item is kept in place, an item with a new `id` is
appended, and an item whose `id` is already present only fills fields the existing
one lacks. Merging the same set twice adds nothing, so every workflow writer (the
registry checkpoint, the finaliser, deep's finaliser) sends the full set through this
verb instead of overwriting the file. The write goes through the store chokepoint
(`store.writeSources` -> `validateSources`).

Exit: `0` merged (prints `merged N new source(s) into <id> (M total)`, or
`{ added, total }` with `--json`); `1` no `--merge`, a missing or invalid
`<sessionId>`, bad JSON, or an item that fails the sources schema (nothing written);
`2` the session does not exist; `3` disk read/write failure.

### `prune` — delete old / matching sessions

```
research prune [--older-than <days>] [--status <status>] [--dry-run]
```

Deletes sessions older than N days **and/or** matching a status (filters AND
together). **At least one filter is required** — a bare `prune` is a usage error, so
a fat-fingered command can never wipe everything. `--dry-run` previews the deletion.
Malformed sessions (no readable manifest) are never matched.

```sh
research prune --older-than 30
research prune --status aborted --dry-run
research prune --older-than 7 --status done
```

Exit: `0` deleted (or previewed); `1` no filter given, or bad `--older-than` /
`--status`; `2` the explicit filter matched no sessions; `3` disk delete failure.

### `mark` — set a terminal status

```
research mark <done|aborted> <sessionId> [--no-promote | --promote]
```

Sets `manifest.status` to a terminal value and bumps `updatedAt`:

- **`done`** — the session finalised successfully. A completed research run (light
  recipe or the `research-normal.js` workflow) calls it to move the session out of
  `draft`/`in-progress` so `list --status done` becomes meaningful.
- **`aborted`**: the run stopped short of a finished answer. The
  `research-normal.js` workflow stamps `aborted` itself on every abort path, so the
  session reads correctly in `list`/`show` instead of lingering as `draft`
  (w-2026-10-19-dave-009):
  - **Early unsourced guard** (after Search+Extract, before Rank, Verify, Deepen,
    Synthesize and Finalise): more than 25 percent of the extracted assertions on
    sourced-class sub-questions could not be grounded in a quote. The registry
    checkpoint has already merged every searcher source into `sources.json` and
    appended every `gatedDropped` record to `gates.json`; the same agent runs
    `mark aborted`. No synthesis ran, so there is no `answer.md`.
  - **Late abort** (after synthesis): the synth receipts show more than 10 percent
    unsourced claims on sourced-class sub-questions (build-spec §8), or a planned
    sub-question has no finding (coverage gap). The finaliser still merges the
    registry, writes a partial `answer.md` (headed `# Partial answer (run aborted)`,
    unsourced assertions tagged `[UNSOURCED]`, a `## Unsourced claims` section),
    round-trips `show --json`, and runs `mark aborted`. The work is never discarded.

  Sub-questions the planner classed `claimClass: "analysis"` (allocation shapes,
  trade-offs, recommendations) are left out of both guards.

This is the one verb that mutates an existing session's status. Both transitions are
**one-way**: a session already `done` or `aborted` is refused (exit `2`), the same
posture as a `prune` filter that matches nothing. The write goes through the store
chokepoint (`store.updateManifestStatus`), which reads, guards the terminal
transition, validates, and persists.

```sh
research mark done 01KWYG…
research mark aborted 01KWYG…
```

#### Knowledge-library promotion (on `mark done`)

`mark done` on a **normal** or **deep** session promotes the session's verbatim
`answer.md` into the operator knowledge library at
`operator/knowledge/research/<sessionId>-<slug>.md`, so `pa knowledge` surfaces it
as a first-class queryable doc instead of leaving it in this tool's local session
store. The write goes through one dedicated function (`src/promote.mjs`) — the only
place that writes to `operator/knowledge/`.

- **light** — **never** promoted (operator directive; quick lookups don't warrant a
  library entry). The skip is silent. Passing `--promote` on a light session is a
  usage error (exit `1`).
- **`--no-promote`** (normal/deep) — skip promotion with a one-line note; the status
  still moves to `done`. For sensitive-topic sessions.
- **`aborted`** — never promoted (incomplete evidence).
- **idempotent** — the target filename is deterministic; re-promotion is a no-op, and
  `mark done` on an already-`done` session is refused (exit `2`) before promotion runs.

Promotion never fails the `mark`: the status is `done` on disk before the library is
touched, and a missing `answer.md` (a session finalised without one) is a soft note.
The graph is **not** auto-rebuilt — after promotion, run the knowledge-reindex (the
6-hourly cron, or the graph/index builders) so the doc becomes queryable; `mark done`
prints this hint. Front-matter carries `name`, `title`, `description`, `domain:
research`, `type: research-session`, `mode`, `sessionId`, `date`, and derived counts
(`sourceCount`, `verifiedCount`, `refutedCount`, `unverifiedCount`, `gatedCount`); the
graph builder itself keys off the `#` H1 (the question) and the `research/` path.

Exit: `0` marked (promotion outcome printed as a note); `1` missing/invalid
`<sessionId>`, bad/missing sub-verb, or `--promote` on light; `2` session not found,
or already `done`/`aborted`; `3` disk read/write failure.

### `--help` / `-h`

Prints usage and exits `0`.

## Exit codes

Per tooling standard §6. Translation happens **only** in `src/cli.mjs`; domain code
throws `Error` objects carrying `.exitCode`, and commands return a number.

| Code | Meaning |
|---|---|
| `0` | OK (incl. `--help`, `--dry-run`, and legitimate no-ops that are not an explicit empty prune) |
| `1` | Usage error — bad/missing args, unknown verb, bad enum value, missing or invalid `<sessionId>` |
| `2` | Operational failure — session not found, malformed session file, explicit `prune` filter matched nothing, `mark` on an already-terminal session, `gates --append` handed zero records |
| `3` | Transport/IO failure — disk read, write, mkdir, or delete error |

## Operational discipline rules

- **Single write chokepoint (§7).** Every filesystem mutation for session state —
  `mkdir`, `writeFile`, `rm` — lives in `src/store/index.mjs` and nowhere else.
  There is deliberately no "write arbitrary path" API: callers write whole, validated
  session artifacts. Validators (`src/store/*.mjs`) are pure and never touch disk.
  Reads may fan out (commands read directly), but writes may not.
- **Validate before persist.** Every artifact is schema-checked before it reaches
  disk; a validation failure leaves the tree untouched.
- **camelCase everywhere (§11).** Schemas, session files, and `--json` output all use
  camelCase property names. No exceptions.
- **`sessions/` is gitignored (§16).** Runtime session data is operational and never
  git-tracked. The directory is kept in git via `sessions/.gitkeep`; its contents are
  ignored by the tool-root `.gitignore`.
- **`run` never orchestrates.** It creates the session shell and prints the plan.
  Any code that spawns research workers belongs in a workflow, not here.

## sessionId scheme

ULID-like: a 26-character Crockford base32 string = a 10-char millisecond timestamp
(48 bits, **lexicographically sortable by creation time**) followed by 16 chars of
`crypto.randomBytes` entropy (80 bits, **collision-safe**). Uppercase, excludes
`I L O U`. Sortable ids mean `list` and `prune` can order and age sessions from the
id alone, and the entropy makes same-millisecond collisions vanishingly unlikely.

## Schemas

Machine-readable JSON Schemas (documentation-grade) live in `src/schema/*.schema.json`;
the authoritative, enforced validators are the hand-rolled ones in `src/store/*.mjs`.
All camelCase.

| File | Location | Written by | Purpose |
|---|---|---|---|
| `manifest.json` | session root | this tool (`run`) | session identity, mode, gate policy, status, budget, assumptions |
| `plan.json` | session root | `run` (skeleton) → workflow (subQuestions) | dependency-classified decomposition (each sub-question may carry `claimClass`: `sourced`, the default, or `analysis`), rounds, verification tier |
| `findings/sq-<id>.json` | `findings/` | **workers** | one finding per sub-question: summary, quotes, sources, confidence, votes |
| `sources.json` | session root | `run` (empty) → `sources --merge` (workflow registry checkpoint after Search, after a deepen round, and the finaliser) | aggregated source registry referenced by findings; merged, never overwritten |
| `gates.json` | session root | `gates --append` / `gates --retry` | human-gate retry queue |

Enum values follow the build-spec's own spelling: `gatePolicy` is
`operatorPresent | background | worker` (build-spec §6), source `kind` is
`web | playwright | kb | codebase | prior-session`, verification tiers are
`none | central-3vote | diverse-lens-3vote`.

## Project layout

Ships inside `@stravica-ai/engineering-pa-cli` (bundled into `@stravica-ai/digital-operator-pa`).
Sources at `packages/cli/src/research/`, tests at `packages/cli/test/research/`,
bin at `packages/cli/bin/research` (also exposed as `packages/engineering-pa/bin/research`
in the shipped tarball).

```
packages/cli/src/research/
  cli.mjs                    # arg parsing, verb routing, the ONLY exit-code translation
  format.mjs                 # pure string renderers (tables, summaries) — no I/O
  promote.mjs                # knowledge-library promotion (mark done → operator/knowledge/research)
  store/
    index.mjs                # WRITE CHOKEPOINT: session-dir CRUD, id gen, path resolution, reads
    validate.mjs             # shared hand-rolled validation primitives (zero-dep)
    manifest.mjs plan.mjs findings.mjs sources.mjs gates.mjs
  commands/
    run.mjs list.mjs show.mjs gates.mjs sources.mjs prune.mjs mark.mjs paths.mjs
  schema/
    *.schema.json            # documentation-grade JSON Schemas (camelCase)
  briefs/                    # dispatch briefs the /research skill workflow feeds to subagents
  workflows/                 # workflow scripts (research-normal.js et al.)
  docs/                      # operator-facing docs (operator-present-flow.md, …)
```

Runtime data lives under `<repoRoot>/.cache/research/sessions/` — resolved via
`getRepoRoot()` (env-var override `DAVE_REPO_ROOT` / `PA_REPO_ROOT`, else walks
upward for a `.git` entry). Promoted answers land in `<repoRoot>/operator/knowledge/research/`.
The `paths` verb exposes the four workflow-facing paths as JSON.

`validate.mjs` and `format.mjs` are internal helpers outside the store/commands
split — pure, no I/O, added to keep the five validators and the renderers DRY.

## Tests

```sh
node --test          # from the tool root — auto-discovers test/*.test.mjs
```

(`node --test test/` — the dir-argument form — is broken under Node 24; use bare
`node --test` or an explicit glob `node --test test/*.test.mjs`.)

Dependency injection over mocking (§8): commands take an optional `{ store, now,
stdout, stderr }` so tests bind a temp-dir store, a fixed clock, and capturing
streams. Validators and renderers are pure and tested input-to-output. The write
chokepoint is exercised against a real temp directory.

## The tooling standard

This tool is governed by the platform tooling standard. Sections that bind it most
directly: §3 (zero dependencies), §4 (project structure), §6 (exit codes), §7
(chokepoint pattern — the store module), §9 (this README as contract), §11 (camelCase
JSON), §16 (gitignored operational artefacts). See the standard for the full contract.
