diff --git a/docs/src/content/docs/guide/inspect.mdx b/docs/src/content/docs/guide/inspect.mdx
index 3078b370..d227965b 100644
--- a/docs/src/content/docs/guide/inspect.mdx
+++ b/docs/src/content/docs/guide/inspect.mdx
@@ -8,7 +8,7 @@ import { Aside, Steps } from '@astrojs/starlight/components';
*SHRINE Inspect is a local, read-only command-line tool. It measures your AI coding setup and session history against SHRINE and reports what to change first. It changes nothing itself.*
- The first release reads one harness: Claude Code; other harnesses come later
@@ -54,9 +54,9 @@ It checks that it changed nothing it read. If anything changed, the report shows
2. Download the three release files into a new folder
```sh
- mkdir -p ~/shrine-inspect-0.1.3 && cd ~/shrine-inspect-0.1.3
- base=https://github.com/stablekernel/SHRINE/releases/download/inspect-v0.1.3
- curl -fLO "$base/shrine-inspect-0.1.3.tgz"
+ mkdir -p ~/shrine-inspect-0.1.4 && cd ~/shrine-inspect-0.1.4
+ base=https://github.com/stablekernel/SHRINE/releases/download/inspect-v0.1.4
+ curl -fLO "$base/shrine-inspect-0.1.4.tgz"
curl -fLO "$base/install.sh"
curl -fLO "$base/SHA256SUMS"
```
@@ -106,6 +106,12 @@ It checks that it changed nothing it read. If anything changed, the report shows
shrine-inspect run --tier T0 --no-history
```
+- Not sure which tier? Leave out `--tier`; in an interactive terminal it asks `Type T0 or T1:`. Picking T1 there is not consent: Gate B still asks for a typed `yes`. Elsewhere, and always for `start`, it stops (exit 2) and names `--tier T0|T1`
+
+ ```sh
+ shrine-inspect run
+ ```
+
- T1 run: type `history` at Gate A, then `yes` at Gate B after reading its estimate
```sh
diff --git a/inspect/README.md b/inspect/README.md
index 850c5bdf..48c1c6d7 100644
--- a/inspect/README.md
+++ b/inspect/README.md
@@ -15,6 +15,10 @@ Status: pre-release trial (background runs, a thin skill, a checksummed release)
## Run
- `node bin/shrine-inspect.mjs run --tier T0 --no-history`: setup and repos only
+- `run` with no `--tier` prints a T0/T1 menu and asks `Type T0 or T1:` (case-insensitive) in an interactive terminal; there is no default
+ - 3 wrong or empty answers, or Ctrl-D, stop with `E_TIER_NOT_CHOSEN` (exit 2) and create no run
+ - No terminal (piped stdin, a skill, a worker): `E_TIER_REQUIRED` (exit 2), naming `--tier T0|T1`; nothing is read from stdin
+ - A T1 pick is not Gate B consent; Gate A and Gate B ask as with `--tier T1`
- Without `--no-history`, the run pauses at Gate A (exit 3) until you answer in your own terminal: `node bin/shrine-inspect.mjs approve A `
- Gate A lists projects, session files with the dates they last changed, size on disk, and a size-based time estimate; every run asks again
- The report counts sessions, not files: the appendix says how many files were forks with nothing new, this run's own session, unreadable, or housekeeping
@@ -59,7 +63,7 @@ Status: pre-release trial (background runs, a thin skill, a checksummed release)
## Background runs
-- `shrine-inspect start --tier T0|T1 [--session-id ]`: creates the run and continues it in a detached worker, so closing the terminal does not stop it; prints the run id, pid, and log
+- `shrine-inspect start --tier T0|T1 [--session-id ]`: creates the run and continues it in a detached worker, so closing the terminal does not stop it; prints the run id, pid, and log; `--tier` is required (`E_TIER_REQUIRED`, exit 2), since `start` never asks
- The worker has no terminal, so it pauses at every gate; it never answers one
- `shrine-inspect status [] [--json]`: `running` (with judging progress), `gate` (with the exact `approve` line to type), `done` (report paths), `died` (no worker, no report), or `stopped`
- `approve [choice] --background`: you answer in your terminal, then a worker does the rest
diff --git a/inspect/TRIAL.md b/inspect/TRIAL.md
index a7818fb0..9bf3bb59 100644
--- a/inspect/TRIAL.md
+++ b/inspect/TRIAL.md
@@ -13,12 +13,12 @@ A local health check of your Claude Code setup and session history, measured aga
node --version
```
-2. Download the 3 release files into a new folder. With the GitHub CLI, use `gh release download inspect-v0.1.3 -R stablekernel/SHRINE` in place of the `curl` lines.
+2. Download the 3 release files into a new folder. With the GitHub CLI, use `gh release download inspect-v0.1.4 -R stablekernel/SHRINE` in place of the `curl` lines.
```sh
- mkdir -p ~/shrine-inspect-0.1.3 && cd ~/shrine-inspect-0.1.3
- base=https://github.com/stablekernel/SHRINE/releases/download/inspect-v0.1.3
- curl -fLO "$base/shrine-inspect-0.1.3.tgz"
+ mkdir -p ~/shrine-inspect-0.1.4 && cd ~/shrine-inspect-0.1.4
+ base=https://github.com/stablekernel/SHRINE/releases/download/inspect-v0.1.4
+ curl -fLO "$base/shrine-inspect-0.1.4.tgz"
curl -fLO "$base/install.sh"
curl -fLO "$base/SHA256SUMS"
```
@@ -33,7 +33,7 @@ A local health check of your Claude Code setup and session history, measured aga
```
install.sh: OK
- shrine-inspect-0.1.3.tgz: OK
+ shrine-inspect-0.1.4.tgz: OK
```
4. Install. Run it from the folder that holds the files.
@@ -43,9 +43,9 @@ A local health check of your Claude Code setup and session history, measured aga
```
- Usage: `sh install.sh [--skill] [path/to/tgz]`.
- - With no path, it uses `shrine-inspect-0.1.3.tgz` next to `install.sh`, not in your current folder.
+ - With no path, it uses `shrine-inspect-0.1.4.tgz` next to `install.sh`, not in your current folder.
- It checks Node, then checks the tarball against the SHA-256 written in `install.sh`, then extracts it. A mismatch installs nothing.
- - The CLI goes to `~/.local/share/shrine-inspect/0.1.3/`, linked as `~/.local/bin/shrine-inspect`.
+ - The CLI goes to `~/.local/share/shrine-inspect/0.1.4/`, linked as `~/.local/bin/shrine-inspect`.
- `--skill` copies the Claude Code skill to `~/.claude/skills/shrine-inspect/`. Leave it out to use the CLI only.
- To install somewhere else, set the install folder and the link folder first:
@@ -68,7 +68,7 @@ A local health check of your Claude Code setup and session history, measured aga
```
```
- shrine-inspect 0.1.3
+ shrine-inspect 0.1.4
```
- All commands: `shrine-inspect --help`.
@@ -79,6 +79,15 @@ A local health check of your Claude Code setup and session history, measured aga
shrine-inspect run --tier T0
```
+- Without `--tier`, `run` asks which tier, then goes on as if you had passed it:
+
+ ```
+ Type T0 or T1:
+ ```
+
+ - It asks only in an interactive terminal. Anywhere else, and always for `start`, it stops (exit 2, `E_TIER_REQUIRED`) and names `--tier T0|T1`.
+ - Picking T1 there is not consent. Gate B still asks for a typed `yes`.
+
- It stops at Gate A and asks whether to read your session history too:
```
diff --git a/inspect/package-lock.json b/inspect/package-lock.json
index a7aadb67..14b673c3 100644
--- a/inspect/package-lock.json
+++ b/inspect/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "shrine-inspect",
- "version": "0.1.3",
+ "version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shrine-inspect",
- "version": "0.1.3",
+ "version": "0.1.4",
"license": "Apache-2.0",
"bin": {
"shrine-inspect": "bin/shrine-inspect.mjs"
diff --git a/inspect/package.json b/inspect/package.json
index b7650135..ffecb056 100644
--- a/inspect/package.json
+++ b/inspect/package.json
@@ -1,6 +1,6 @@
{
"name": "shrine-inspect",
- "version": "0.1.3",
+ "version": "0.1.4",
"description": "Health check of an AI coding setup against the SHRINE docs. Local, read-only, no network.",
"license": "Apache-2.0",
"author": "Stable Kernel",
diff --git a/inspect/skill/.claude-plugin/plugin.json b/inspect/skill/.claude-plugin/plugin.json
index c70462c3..d69e9b80 100644
--- a/inspect/skill/.claude-plugin/plugin.json
+++ b/inspect/skill/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "shrine-inspect",
- "version": "0.1.3",
+ "version": "0.1.4",
"description": "Thin skill that starts and watches the shrine-inspect CLI. Holds no logic; install the CLI from the same release.",
"license": "Apache-2.0",
"author": { "name": "Stable Kernel" },
diff --git a/inspect/skill/skills/shrine-inspect/SKILL.md b/inspect/skill/skills/shrine-inspect/SKILL.md
index e44c923e..59c213e5 100644
--- a/inspect/skill/skills/shrine-inspect/SKILL.md
+++ b/inspect/skill/skills/shrine-inspect/SKILL.md
@@ -6,7 +6,7 @@ allowed-tools: Bash(node --version), Bash(shrine-inspect version:*), Bash(shrine
# SHRINE Inspect
-Skill version: 0.1.3
+Skill version: 0.1.4
This skill only starts the `shrine-inspect` CLI and watches it. The CLI does every read, every gate, and every judge call.
@@ -31,15 +31,15 @@ This skill only starts the `shrine-inspect` CLI and watches it. The CLI does eve
2. Check the CLI. Run:
```bash
- shrine-inspect version --skill-version 0.1.3
+ shrine-inspect version --skill-version 0.1.4
```
If the command is not found, or it stops with `E_VERSION`, stop. Tell the user to install the CLI and this skill from the same release (see TRIAL.md in the release).
-3. Start a run in the background. Use `--tier T1` only when the user asked for judging ("Pulse"); else use `--tier T0`. Session id: `${CLAUDE_SESSION_ID}`. If that id is empty or still shows as a placeholder, leave out `--session-id` and its value. Never leave `--session-id` with nothing after it.
+3. Start a run in the background. Use `--tier T1` only when the user asked for judging ("Pulse"); else use `--tier T0`. Always pass `--tier`: without it, `start` stops with `E_TIER_REQUIRED`. Session id: `${CLAUDE_SESSION_ID}`. If that id is empty or still shows as a placeholder, leave out `--session-id` and its value. Never leave `--session-id` with nothing after it.
```bash
- shrine-inspect start --tier T0 --session-id ${CLAUDE_SESSION_ID} --skill-version 0.1.3 --json
+ shrine-inspect start --tier T0 --session-id ${CLAUDE_SESSION_ID} --skill-version 0.1.4 --json
```
Keep the `run_id` from the output.
@@ -47,7 +47,7 @@ This skill only starts the `shrine-inspect` CLI and watches it. The CLI does eve
4. Watch the run. Run this about every 15 seconds while the state is `running`:
```bash
- shrine-inspect status --skill-version 0.1.3 --json
+ shrine-inspect status --skill-version 0.1.4 --json
```
- `running`: give the user one short line with the phase. Check again.
diff --git a/inspect/src/cli/main.mjs b/inspect/src/cli/main.mjs
index 51783a71..005e1cb0 100644
--- a/inspect/src/cli/main.mjs
+++ b/inspect/src/cli/main.mjs
@@ -6,9 +6,10 @@ import { purgeCommand } from './purge.mjs';
import { checkShareCommand } from './checkshare.mjs';
import { GRANT_ENV, resumeCommand, startCommand, statusCommand } from './background.mjs';
import { TOOL_VERSION } from '../scorer/score.mjs';
+import { askTier, tierRequired } from './tier.mjs';
const USAGE = `usage:
- shrine-inspect run --tier T0|T1 [--no-history] [--session-id ] [--json]
+ shrine-inspect run [--tier T0|T1] [--no-history] [--session-id ] [--json]
shrine-inspect start --tier T0|T1 [--no-history] [--session-id ] [--json]
shrine-inspect status [] [--json]
shrine-inspect approve [choice] [--background]
@@ -17,6 +18,7 @@ const USAGE = `usage:
shrine-inspect purge [--json]
shrine-inspect version
shrine-inspect --help
+run with no --tier asks for one in an interactive terminal; start always needs --tier.
Any command takes --skill-version : a mismatch with this CLI stops before any read.`;
// Every command, for the skill test: the skill names only these.
@@ -45,7 +47,7 @@ export function parseArgs(argv) {
out.sessionId = a.includes('=') ? a.slice(13) : argv[++i];
if (!out.sessionId || !/^[A-Za-z0-9][\w-]*$/.test(out.sessionId)) throw new InspectError('E_USAGE', 'bad --session-id', { hint: USAGE, exit: EXIT.USAGE });
}
- else if (a === '--tier') out.tier = argv[++i];
+ else if (a === '--tier') out.tier = value(++i, '--tier');
else if (a.startsWith('--tier=')) out.tier = a.slice(7);
else if (a.startsWith('--')) throw new InspectError('E_USAGE', `unknown option ${a}`, { hint: USAGE, exit: EXIT.USAGE });
else out.positional.push(a);
@@ -81,8 +83,15 @@ export async function main(argv, io) {
io.stdout.write(json ? `${JSON.stringify({ version: TOOL_VERSION })}\n` : `shrine-inspect ${TOOL_VERSION}\n`);
return EXIT.OK;
}
- if (args.cmd === 'run') return (await runCommand(args, cio)).code;
- if (args.cmd === 'start') return startCommand(args, cio, { createRun }).code;
+ if (args.cmd === 'run') {
+ // No --tier: a person picks one in their terminal; the pick is not Gate B consent.
+ const opts = args.tier === null ? { ...args, tier: await askTier(cio), tierBy: 'prompt' } : args;
+ return (await runCommand(opts, cio)).code;
+ }
+ if (args.cmd === 'start') {
+ if (args.tier === null) throw tierRequired('start');
+ return startCommand(args, cio, { createRun }).code;
+ }
if (args.cmd === 'status') return statusCommand({ runId: args.positional[0] }, cio, deps).code;
if (args.cmd === 'resume') return (await resumeCommand({ runId: args.positional[0], worker: args.worker, grant: args.worker ? grant ?? null : null }, cio, deps)).code;
if (args.cmd === 'approve') {
diff --git a/inspect/src/cli/run.mjs b/inspect/src/cli/run.mjs
index 4af1087d..9c4dcffa 100644
--- a/inspect/src/cli/run.mjs
+++ b/inspect/src/cli/run.mjs
@@ -85,8 +85,9 @@ export function createRun(opts, io) {
// The sample seed, drawn once per run (T1 only) and printed in the report footer.
// io.seed is an in-process test seam (the bin never sets it).
seed: opts.tier === 'T1' ? (Number.isInteger(io.seed) ? io.seed : newSeed()) : null,
- // Gate B: --tier T0 sets it; --tier T1 asks after the scan, T0 pre-selected.
- gates: { A: opts.noHistory ? { choice: 'no-history', by: 'flag', at } : null, B: opts.tier === 'T0' ? { choice: 'T0', by: 'flag', at } : null },
+ // Gate B: T0 (by --tier, or picked at the tier prompt) sets it; T1 asks
+ // after the scan, T0 pre-selected. A T1 pick never sets it.
+ gates: { A: opts.noHistory ? { choice: 'no-history', by: 'flag', at } : null, B: opts.tier === 'T0' ? { choice: 'T0', by: opts.tierBy ?? 'flag', at } : null },
};
folder.writeJson('state.json', state);
return { folder, ctx, state };
diff --git a/inspect/src/cli/tier.mjs b/inspect/src/cli/tier.mjs
new file mode 100644
index 00000000..55e1ffdf
--- /dev/null
+++ b/inspect/src/cli/tier.mjs
@@ -0,0 +1,41 @@
+// `run` with no --tier: asks which tier, only in an interactive terminal (the
+// same check as the gates). The answer picks the tier only; it is never Gate B
+// consent, so a T1 run still stops at Gate A and Gate B as with --tier T1.
+// `start`, piped stdin, and skills get a usage error that names the flag: no
+// silent default, no hang.
+import { askUntil, isTerminal } from '../io/tty.mjs';
+import { InspectError, EXIT } from './errors.mjs';
+
+export const TIER_MENU = [
+ 'Pick a tier for this run:',
+ ' T0: scan only. No model calls, nothing leaves your machine.',
+ ' T1: T0 plus up to 12 judged episodes, sent with your own Claude Code login. Gate B shows the estimate before anything is sent.',
+ 'Picking T1 here is not consent: Gate B still asks for a typed yes.',
+];
+const QUESTION = 'Type T0 or T1: ';
+const ATTEMPTS = 3;
+
+export const tierRequired = (cmd) =>
+ new InspectError('E_TIER_REQUIRED', `${cmd} needs --tier T0|T1 here`, {
+ hint: cmd === 'start'
+ ? 'start runs in the background, so it never asks: shrine-inspect start --tier T0'
+ : 'the tier is asked only in an interactive terminal; else pass it: shrine-inspect run --tier T0',
+ exit: EXIT.USAGE,
+ });
+
+// Returns 'T0' or 'T1'. Throws E_TIER_REQUIRED with no terminal, and
+// E_TIER_NOT_CHOSEN on Ctrl-D or after ATTEMPTS wrong answers.
+export async function askTier(io) {
+ if (!isTerminal(io.stdin)) throw tierRequired('run');
+ io.stderr.write(`${TIER_MENU.join('\n')}\n`);
+ const tier = await askUntil(QUESTION, (a) => (/^t[01]$/i.test(a) ? a.toUpperCase() : null), {
+ input: io.stdin,
+ output: io.stderr,
+ attempts: ATTEMPTS,
+ again: (a) => (a ? `not ${a}.` : 'no answer.'),
+ });
+ if (!tier) {
+ throw new InspectError('E_TIER_NOT_CHOSEN', 'no tier chosen; no run was created', { hint: 'run again and type T0 or T1, or pass --tier T0|T1', exit: EXIT.USAGE });
+ }
+ return tier;
+}
diff --git a/inspect/src/io/tty.mjs b/inspect/src/io/tty.mjs
index 868d49d8..6c84bf71 100644
--- a/inspect/src/io/tty.mjs
+++ b/inspect/src/io/tty.mjs
@@ -3,13 +3,17 @@
import { createInterface } from 'node:readline/promises';
import { InspectError, EXIT } from '../cli/errors.mjs';
+// The interactive-terminal check, the same `stdin.isTTY` test the gates use.
+export const isTerminal = (input) => Boolean(input?.isTTY);
+
+const notTty = () =>
+ new InspectError('E_NOT_TTY', 'gates are answered only in your own terminal', {
+ hint: 'run `shrine-inspect approve ` in an interactive terminal',
+ exit: EXIT.GATE_WAITING,
+ });
+
export async function prompt(question, { input = process.stdin, output = process.stderr } = {}) {
- if (!input.isTTY) {
- throw new InspectError('E_NOT_TTY', 'gates are answered only in your own terminal', {
- hint: 'run `shrine-inspect approve ` in an interactive terminal',
- exit: EXIT.GATE_WAITING,
- });
- }
+ if (!isTerminal(input)) throw notTty();
// Silence is no answer: input that already ended, or ends (Ctrl-D) before a
// line, resolves to '' so the caller leaves the gate paused. A pending
// question on an ended stream would otherwise never settle.
@@ -28,3 +32,40 @@ export async function prompt(question, { input = process.stdin, output = process
rl.close();
}
}
+
+// Asks until accept(answer) returns a value, at most `attempts` times, on one
+// readline so no typed line is lost between asks. Returns null when input ends
+// (Ctrl-D) or no answer was accepted. Same terminal rule as prompt.
+export async function askUntil(question, accept, { input = process.stdin, output = process.stderr, attempts = 3, again = (a) => a } = {}) {
+ if (!isTerminal(input)) throw notTty();
+ if (input.readableEnded || input.destroyed) {
+ output.write(`${question}\n`);
+ return null;
+ }
+ const rl = createInterface({ input, output });
+ const queued = [];
+ let waiting = null;
+ let ended = false;
+ rl.on('line', (l) => (waiting ? waiting(l.trim()) : queued.push(l.trim())));
+ rl.once('close', () => {
+ ended = true;
+ if (waiting) waiting(null);
+ });
+ const next = () => (queued.length ? queued.shift() : ended ? null : new Promise((r) => (waiting = (v) => ((waiting = null), r(v)))));
+ try {
+ for (let i = 0; i < attempts; i++) {
+ output.write(question);
+ const answer = await next();
+ if (answer === null) {
+ output.write('\n');
+ return null;
+ }
+ const v = accept(answer);
+ if (v) return v;
+ output.write(`${again(answer)}\n`);
+ }
+ return null;
+ } finally {
+ rl.close();
+ }
+}
diff --git a/inspect/src/scorer/score.mjs b/inspect/src/scorer/score.mjs
index c58d8aad..4827448c 100644
--- a/inspect/src/scorer/score.mjs
+++ b/inspect/src/scorer/score.mjs
@@ -7,7 +7,7 @@ import { underHome } from '../scrub/mask.mjs';
import { THRESHOLDS } from '../probes/config.mjs';
import { controlTest, usageConfidence, usageSeverity } from './stats.mjs';
-export const TOOL_VERSION = '0.1.3';
+export const TOOL_VERSION = '0.1.4';
const SEV_RANK = { high: 3, medium: 2, low: 1 };
const KIND_ORDER = ['blanket_permissions', 'stale_instructions', 'oversized_instructions', 'done_without_test', 'no_owner_or_review', 'stack_gap', 'short_retention'];
diff --git a/inspect/test/cli/check-share.test.mjs b/inspect/test/cli/check-share.test.mjs
index f56a4f44..3bfad234 100644
--- a/inspect/test/cli/check-share.test.mjs
+++ b/inspect/test/cli/check-share.test.mjs
@@ -63,7 +63,9 @@ test('bin --help exits 0 as a child process', () => {
const state = mkdtempSync(join(tmpdir(), 'si-state-'));
const r = spawnSync(process.execPath, [BIN, '--help'], { env: env(h, state), encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
- assert.match(r.stdout, /shrine-inspect run --tier/);
+ assert.match(r.stdout, /shrine-inspect run \[--tier T0\|T1\]/);
+ assert.match(r.stdout, /shrine-inspect start --tier T0\|T1/);
+ assert.match(r.stdout, /no --tier asks for one in an interactive terminal/);
h.cleanup();
});
diff --git a/inspect/test/cli/tier-prompt.test.mjs b/inspect/test/cli/tier-prompt.test.mjs
new file mode 100644
index 00000000..39be280b
--- /dev/null
+++ b/inspect/test/cli/tier-prompt.test.mjs
@@ -0,0 +1,122 @@
+// `run` with no --tier asks for one in an interactive terminal, and only there.
+// Anywhere else (piped stdin, `start`, a skill) it stops with a usage error that
+// names the flag: never a silent default, never a hang. Picking T1 here is not
+// Gate B consent.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { existsSync, mkdtempSync, readdirSync, readFileSync } from 'node:fs';
+import { spawnSync } from 'node:child_process';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { Readable } from 'node:stream';
+import { fileURLToPath } from 'node:url';
+import { main, parseArgs } from '../../src/cli/main.mjs';
+import { makeHome, FIXED_NOW } from '../synth/home.mjs';
+
+const BIN = fileURLToPath(new URL('../../bin/shrine-inspect.mjs', import.meta.url));
+
+function sink() {
+ const chunks = [];
+ return { write: (s) => chunks.push(String(s)), text: () => chunks.join('') };
+}
+// A terminal that types these lines, then ends (Ctrl-D).
+function tty(...lines) {
+ const s = Readable.from(lines.map((l) => `${l}\n`));
+ s.isTTY = true;
+ return s;
+}
+function setup() {
+ const h = makeHome('good');
+ const state = mkdtempSync(join(tmpdir(), 'si-state-'));
+ const env = { ...process.env, SHRINE_INSPECT_STATE_DIR: join(state, 'state'), SHRINE_INSPECT_CACHE_DIR: join(state, 'cache'), SHRINE_INSPECT_NOW: FIXED_NOW, SHRINE_INSPECT_TEMP_PATTERNS: JSON.stringify(['^/nowhere/']), HOME: h.home };
+ return { h, state, env };
+}
+async function inproc(argv, { h, env }, stdin) {
+ const stdout = sink();
+ const stderr = sink();
+ const code = await main(argv, { env, home: h.home, stdin, stdout, stderr });
+ return { code, stdout: stdout.text(), stderr: stderr.text() };
+}
+const runs = (state) => (existsSync(join(state, 'state', 'runs')) ? readdirSync(join(state, 'state', 'runs')) : []);
+const stateOf = (state) => JSON.parse(readFileSync(join(state, 'state', 'runs', runs(state)[0], 'state.json'), 'utf8'));
+
+test('run with no --tier and no terminal: usage error naming --tier, no run created', async () => {
+ const s = setup();
+ const r = await inproc(['run', '--json'], s, { isTTY: false });
+ assert.equal(r.code, 2);
+ const err = JSON.parse(r.stderr).error;
+ assert.equal(err.code, 'E_TIER_REQUIRED');
+ assert.match(`${err.message} ${err.hint}`, /--tier T0\|T1/);
+ assert.deepEqual(runs(s.state), []);
+ s.h.cleanup();
+});
+
+test('start with no --tier is refused even in a terminal: no worker, no run', async () => {
+ const s = setup();
+ const r = await inproc(['start'], s, tty('T0'));
+ assert.equal(r.code, 2);
+ assert.match(r.stderr, /--tier T0\|T1/);
+ assert.doesNotMatch(r.stderr, /Pick a tier/);
+ assert.deepEqual(runs(s.state), []);
+ s.h.cleanup();
+});
+
+test('piped stdin to the bin is refused at once, never read as a tier', () => {
+ const s = setup();
+ const r = spawnSync(process.execPath, [BIN, 'run', '--no-history'], { env: s.env, input: 'T0\n', encoding: 'utf8', timeout: 20_000 });
+ assert.equal(r.status, 2);
+ assert.match(r.stderr, /--tier T0\|T1/);
+ assert.deepEqual(runs(s.state), []);
+ s.h.cleanup();
+});
+
+test('terminal: the menu names both tiers; t0 (any case) runs the scan only', async () => {
+ const s = setup();
+ const r = await inproc(['run', '--no-history', '--json'], s, tty('t0'));
+ assert.equal(r.code, 0, r.stderr);
+ assert.match(r.stderr, /T0: scan only/);
+ assert.match(r.stderr, /T1: /);
+ assert.match(r.stderr, /Gate B/);
+ const st = stateOf(s.state);
+ assert.equal(st.tier, 'T0');
+ assert.equal(st.gates.B.choice, 'T0');
+ assert.equal(st.gates.B.by, 'prompt');
+ s.h.cleanup();
+});
+
+test('terminal: a wrong answer asks again; T1 is not Gate B consent', async () => {
+ const s = setup();
+ // T1, then the Gate A question gets no answer (input ends): the run pauses.
+ const r = await inproc(['run'], s, tty('T2', 'T1'));
+ assert.equal(r.code, 3, r.stderr);
+ assert.match(r.stderr, /not T2|T0 or T1/);
+ assert.equal((r.stderr.match(/Type T0 or T1/g) ?? []).length, 2);
+ const st = stateOf(s.state);
+ assert.equal(st.tier, 'T1');
+ assert.equal(st.gates.B, null);
+ s.h.cleanup();
+});
+
+test('terminal: three wrong or empty answers stop with no run created', async () => {
+ const s = setup();
+ const r = await inproc(['run', '--json'], s, tty('', 'yes', 'T2', 'T1'));
+ assert.equal(r.code, 2);
+ assert.equal(JSON.parse(r.stderr.trim().split('\n').at(-1)).error.code, 'E_TIER_NOT_CHOSEN');
+ assert.deepEqual(runs(s.state), []);
+ s.h.cleanup();
+});
+
+test('terminal: input that ends (Ctrl-D) stops at once with no run created', async () => {
+ const s = setup();
+ const r = await inproc(['run'], s, tty());
+ assert.equal(r.code, 2);
+ assert.match(r.stderr, /no tier chosen/);
+ assert.equal((r.stderr.match(/Type T0 or T1/g) ?? []).length, 1);
+ assert.deepEqual(runs(s.state), []);
+ s.h.cleanup();
+});
+
+test('--tier with no value is a usage error, not a prompt', () => {
+ assert.throws(() => parseArgs(['run', '--tier']), (e) => e.code === 'E_USAGE');
+ assert.throws(() => parseArgs(['run', '--tier', '--json']), (e) => e.code === 'E_USAGE');
+});
diff --git a/inspect/test/golden/bad-setup/findings.json b/inspect/test/golden/bad-setup/findings.json
index 0056bc55..28f459b5 100644
--- a/inspect/test/golden/bad-setup/findings.json
+++ b/inspect/test/golden/bad-setup/findings.json
@@ -3,7 +3,7 @@
"run": {
"id": "",
"started_at": "2026-11-04T12:00:00.000Z",
- "tool_version": "0.1.3",
+ "tool_version": "0.1.4",
"harness": {
"name": "Claude Code",
"version": null
diff --git a/inspect/test/golden/zero-history-good/findings.json b/inspect/test/golden/zero-history-good/findings.json
index e80da3ac..0065f87b 100644
--- a/inspect/test/golden/zero-history-good/findings.json
+++ b/inspect/test/golden/zero-history-good/findings.json
@@ -3,7 +3,7 @@
"run": {
"id": "",
"started_at": "2026-11-04T12:00:00.000Z",
- "tool_version": "0.1.3",
+ "tool_version": "0.1.4",
"harness": {
"name": "Claude Code",
"version": null