Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docs/src/content/docs/guide/inspect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.*

<Aside type="caution" title="Pre-release">
This is a trial release. Get the newest `inspect-v*` release from the [releases page](https://github.com/stablekernel/SHRINE/releases). The steps below use `inspect-v0.1.3`. Trial testers also follow [TRIAL.md](https://github.com/stablekernel/SHRINE/blob/main/inspect/TRIAL.md).
This is a trial release. Get the newest `inspect-v*` release from the [releases page](https://github.com/stablekernel/SHRINE/releases). The steps below use `inspect-v0.1.4`. Trial testers also follow [TRIAL.md](https://github.com/stablekernel/SHRINE/blob/main/inspect/TRIAL.md).
</Aside>

- The first release reads one harness: Claude Code; other harnesses come later
Expand Down Expand Up @@ -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"
```
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion inspect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run> A <no-history|history>`
- 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
Expand Down Expand Up @@ -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 <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 <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 [<run>] [--json]`: `running` (with judging progress), `gate` (with the exact `approve` line to type), `done` (report paths), `died` (no worker, no report), or `stopped`
- `approve <run> <gate> [choice] --background`: you answer in your terminal, then a worker does the rest
Expand Down
25 changes: 17 additions & 8 deletions inspect/TRIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```
Expand All @@ -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.
Expand All @@ -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:

Expand All @@ -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`.
Expand All @@ -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:

```
Expand Down
4 changes: 2 additions & 2 deletions inspect/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion inspect/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion inspect/skill/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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" },
Expand Down
10 changes: 5 additions & 5 deletions inspect/skill/skills/shrine-inspect/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -31,23 +31,23 @@ 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.

4. Watch the run. Run this about every 15 seconds while the state is `running`:

```bash
shrine-inspect status <run_id> --skill-version 0.1.3 --json
shrine-inspect status <run_id> --skill-version 0.1.4 --json
```

- `running`: give the user one short line with the phase. Check again.
Expand Down
17 changes: 13 additions & 4 deletions inspect/src/cli/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>] [--json]
shrine-inspect run [--tier T0|T1] [--no-history] [--session-id <id>] [--json]
shrine-inspect start --tier T0|T1 [--no-history] [--session-id <id>] [--json]
shrine-inspect status [<run>] [--json]
shrine-inspect approve <run> <A|B|C> [choice] [--background]
Expand All @@ -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 <v>: a mismatch with this CLI stops before any read.`;

// Every command, for the skill test: the skill names only these.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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') {
Expand Down
5 changes: 3 additions & 2 deletions inspect/src/cli/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
41 changes: 41 additions & 0 deletions inspect/src/cli/tier.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
53 changes: 47 additions & 6 deletions inspect/src/io/tty.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run> <gate>` 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 <run> <gate>` 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.
Expand All @@ -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();
}
}
2 changes: 1 addition & 1 deletion inspect/src/scorer/score.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
4 changes: 3 additions & 1 deletion inspect/test/cli/check-share.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
Loading
Loading