@alizain/doublecheck 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile.guest +17 -0
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/cli.mjs +513 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +64 -0
- package/scripts/build-guest-image.sh +12 -0
package/Dockerfile.guest
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# The check-inspector guest image. Built locally and side-loaded into the
|
|
2
|
+
# microsandbox cache — never pushed to a registry:
|
|
3
|
+
#
|
|
4
|
+
# ./scripts/build-guest-image.sh
|
|
5
|
+
#
|
|
6
|
+
# Baking the claude CLI here (instead of `npm i -g` at every guest boot) pins
|
|
7
|
+
# its version and cuts ~30s off each check run. Rebuild to pick up a newer CLI.
|
|
8
|
+
# git is for check-authored scoping ("run `git diff main`" lives in check
|
|
9
|
+
# prose); ripgrep because inspectors grep; curl/wget because checks may fetch
|
|
10
|
+
# from the internet (node:*-slim ships none of these).
|
|
11
|
+
FROM node:24-bookworm-slim
|
|
12
|
+
|
|
13
|
+
RUN apt-get update \
|
|
14
|
+
&& apt-get install -y --no-install-recommends git ripgrep curl wget ca-certificates \
|
|
15
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
16
|
+
|
|
17
|
+
RUN npm install -g @anthropic-ai/claude-code
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the doublecheck authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# doublecheck
|
|
2
|
+
|
|
3
|
+
Runs self-authored LLM code-inspectors ("checks") against a project — one
|
|
4
|
+
sandboxed agent per check, in parallel — and writes one markdown report per
|
|
5
|
+
check.
|
|
6
|
+
|
|
7
|
+
**A check is a markdown file:** `$PROJECT/.agents/checks/<name>.md`. No
|
|
8
|
+
frontmatter, no schema — the body is the inspector's instructions, and scoping
|
|
9
|
+
(whole tree, `git diff main`, one package) is the check's own prose; the
|
|
10
|
+
harness computes no diffs and knows nothing about git. **A report is a
|
|
11
|
+
markdown file:** whatever the agent writes, no imposed structure.
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
Per check: a scratch workdir gets `prompt.txt` (environment preamble + check
|
|
16
|
+
body + report contract). A [microsandbox](https://github.com/superradcompany/microsandbox)
|
|
17
|
+
microVM boots from a locally built image with the project bind-mounted
|
|
18
|
+
**read-only at its real host path** and the scratch dir mounted rw as the
|
|
19
|
+
guest cwd. `claude -p` runs inside with the full inspector toolkit (Task,
|
|
20
|
+
Bash, Read, Write, Edit, Glob, Grep, WebSearch, WebFetch) and no permission
|
|
21
|
+
gates — the microVM is the safety boundary, the ro mount is the "don't touch
|
|
22
|
+
my repo" guarantee. The agent writes `report.md`; the host copies it to
|
|
23
|
+
`$OUTPUT/<run-timestamp>/<check>.md`.
|
|
24
|
+
|
|
25
|
+
The project is never copied — every guest shares the live host directory
|
|
26
|
+
(dirty files included) through the ro mount, so per-check cost is one temp dir
|
|
27
|
+
and ~2 GiB of guest memory.
|
|
28
|
+
|
|
29
|
+
Exit 0 only when every check produced a report. An agent failure (exit ≠ 0,
|
|
30
|
+
no report) writes a failure-record report and flips the run's exit code;
|
|
31
|
+
missing token / checks / image abort loudly up front.
|
|
32
|
+
|
|
33
|
+
## Setup
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g @alizain/doublecheck # installs the `doublecheck` command; or, from a clone: pnpm install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Guests boot from a locally built image. Build it once from a clone of this
|
|
40
|
+
repo (needs a running Docker daemon; rebuild to pick up a newer claude CLI):
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
./scripts/build-guest-image.sh
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
CLAUDE_CODE_OAUTH_TOKEN=... doublecheck check \
|
|
50
|
+
--project DIR # default: cwd
|
|
51
|
+
--model MODEL # default: haiku
|
|
52
|
+
--parallel N # default: 4 concurrent guests
|
|
53
|
+
--output DIR # default: $PROJECT/.doublecheck
|
|
54
|
+
--check NAME # repeatable; default: every check in .agents/checks/
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The token is required and injected into each guest; where it comes from is
|
|
58
|
+
the operator's business.
|
|
59
|
+
|
|
60
|
+
### Mining your Claude history into an observation catalog
|
|
61
|
+
|
|
62
|
+
`doublecheck mine` walks every Claude Code transcript on the machine and runs
|
|
63
|
+
one sandboxed agent per real conversation (≥ `--min-turns` genuine human
|
|
64
|
+
turns) to extract durable engineering preferences into
|
|
65
|
+
`~/.doublecheck/catalog`, mirroring the transcript tree — one
|
|
66
|
+
`<project>/<session>/observations.md` per conversation, frontmatter recording
|
|
67
|
+
the source hash so re-runs only mine new or grown sessions. Mining guests get
|
|
68
|
+
egress to `*.anthropic.com` only. Design: `docs/2026-07-05-mine-design.md`.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
CLAUDE_CODE_OAUTH_TOKEN=... doublecheck mine \
|
|
72
|
+
--projects DIR # default: ~/.claude/projects
|
|
73
|
+
--catalog DIR # default: ~/.doublecheck/catalog
|
|
74
|
+
--model MODEL # default: opus (a bad-model mine pollutes a durable asset)
|
|
75
|
+
--parallel N # default: 4
|
|
76
|
+
--min-turns N # default: 2
|
|
77
|
+
--limit N # mine at most N pending conversations
|
|
78
|
+
--dry-run # list what would be mined, boot nothing
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`fixtures/planted/` is a tiny target with two planted silent fallbacks (and
|
|
82
|
+
two legitimate defaults that must not be flagged) for exercising the tool
|
|
83
|
+
end-to-end:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
CLAUDE_CODE_OAUTH_TOKEN=... doublecheck check --project fixtures/planted --model haiku
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pnpm doublecheck # run the CLI from source (tsx)
|
|
93
|
+
pnpm test # vitest — pure logic, plus real-guest integration tests with a FAKE agent (never real claude)
|
|
94
|
+
pnpm typecheck # tsc --noEmit
|
|
95
|
+
pnpm check # biome
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Design record: `docs/2026-07-05-doublecheck-design.md`.
|
|
99
|
+
|
|
100
|
+
## Releasing
|
|
101
|
+
|
|
102
|
+
Manual, via the `release` GitHub Actions workflow (semantic-release over
|
|
103
|
+
Conventional Commits — a `feat:`/`fix:` commit is what makes the next run
|
|
104
|
+
release). Dispatch it with `dry_run` checked to preview the version + notes,
|
|
105
|
+
then re-run unchecked to publish to npm and cut the GitHub Release. Needs the
|
|
106
|
+
`NPM_TOKEN` repo secret.
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import PQueue from "p-queue";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
//#region src/contract.ts
|
|
10
|
+
const PROMPT_FILE = "prompt.txt";
|
|
11
|
+
const REPORT_FILE = "report.md";
|
|
12
|
+
function reportContract(activity, deliverable) {
|
|
13
|
+
return `## Report — the only output that counts
|
|
14
|
+
|
|
15
|
+
Your final chat reply is discarded. The only thing read back is the file \`./${REPORT_FILE}\` in your working directory — if it does not exist when you exit, this run FAILS.
|
|
16
|
+
|
|
17
|
+
So: create \`./${REPORT_FILE}\` with the Write tool BEFORE you start ${activity} (a title line is enough), and update it as you work. The final state of the file when you finish is ${deliverable}.`;
|
|
18
|
+
}
|
|
19
|
+
function composePrompt(checkBody, project) {
|
|
20
|
+
return `## Environment
|
|
21
|
+
|
|
22
|
+
You are a code inspector running inside a sandboxed microVM with full permissions and unrestricted internet access.
|
|
23
|
+
|
|
24
|
+
- The project under inspection is mounted read-only at \`${project}\`. You cannot modify it — inspect, don't fix.
|
|
25
|
+
- \`git\` and \`rg\` are installed.
|
|
26
|
+
- Your current working directory is a writable scratch workspace.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
${checkBody}
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
${reportContract("inspecting", "the check's report")}`;
|
|
35
|
+
}
|
|
36
|
+
function composeMinePrompt(digest) {
|
|
37
|
+
return `## Environment
|
|
38
|
+
|
|
39
|
+
You are running inside a sandboxed microVM with no network access. The machine's Claude Code transcripts are mounted read-only at their real paths; your current working directory is a writable scratch workspace.
|
|
40
|
+
|
|
41
|
+
## Task
|
|
42
|
+
|
|
43
|
+
You are mining ONE Claude Code conversation for the operator's durable preferences — how they want engineering done, in any conversation, not what they wanted built in this one.
|
|
44
|
+
|
|
45
|
+
Below is the conversation digest: every genuine human turn, numbered, in order. When a turn is a terse correction or interruption ("no", "stop", "don't", "why did you…", "[Request interrupted]"), recover its context: the "# source:" header names the full session transcript — Grep that turn's text in it to find its line, then Read the assistant turns IMMEDIATELY BEFORE it with offset/limit. The preference lives in the contrast between what the assistant did and what the operator demanded. NEVER read a transcript whole — some are tens of MB.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
${digest}
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## What counts as an observation (strict)
|
|
54
|
+
|
|
55
|
+
A durable preference, backed by a verbatim quote from the digest or transcript:
|
|
56
|
+
|
|
57
|
+
- **kind: code** — how code should be written/structured/shaped ("no silent fallbacks", "reuse the existing helper instead of hand-rolling").
|
|
58
|
+
- **kind: workflow** — how work should proceed ("verify before claiming done", "ask before making scope decisions").
|
|
59
|
+
- **kind: style** — how prose/output/docs should read.
|
|
60
|
+
|
|
61
|
+
Exclude task-of-the-moment directives with no durable signal ("add a tab to home.tsx", "kill the dev server"). Exclude anything you cannot quote. Be conservative: a weak or speculative observation is worse than none.
|
|
62
|
+
|
|
63
|
+
## Observation format
|
|
64
|
+
|
|
65
|
+
The report is observation blocks and NOTHING else — no title, no preamble, no summary or conclusion sections. One block per observation:
|
|
66
|
+
|
|
67
|
+
## <kebab-case-name>
|
|
68
|
+
- **observation:** <one sentence: the durable preference>
|
|
69
|
+
- **kind:** code | workflow | style
|
|
70
|
+
- **evidence:** "<verbatim quote>" — turn <N>
|
|
71
|
+
|
|
72
|
+
For **code** observations that an LLM reviewer could check against a diff or file tree (a concrete change could flip PASS↔FAIL), and ONLY for those — never on workflow or style blocks — ALSO add:
|
|
73
|
+
|
|
74
|
+
- **pass/fail:** PASS when <…>; FAIL when <…>
|
|
75
|
+
- **why-LLM:** <why a deterministic linter cannot enforce this>
|
|
76
|
+
- **scope:** diff-only | needs-tree
|
|
77
|
+
|
|
78
|
+
If the conversation carries no durable preference signal, the report is exactly these two lines and nothing more:
|
|
79
|
+
|
|
80
|
+
No durable preference signal.
|
|
81
|
+
<one sentence: what the conversation was about instead>
|
|
82
|
+
|
|
83
|
+
${reportContract("mining", "the mined observations")}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/claude.ts
|
|
88
|
+
const GUEST_HOME = "/root";
|
|
89
|
+
function claudeAgent(opts) {
|
|
90
|
+
return {
|
|
91
|
+
command: `claude -p --no-session-persistence --dangerously-skip-permissions --output-format stream-json --verbose --tools Task Bash Read Write Edit Glob Grep WebSearch WebFetch < ${PROMPT_FILE}`,
|
|
92
|
+
env: {
|
|
93
|
+
HOME: GUEST_HOME,
|
|
94
|
+
IS_SANDBOX: "1",
|
|
95
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
96
|
+
ANTHROPIC_MODEL: opts.model,
|
|
97
|
+
CLAUDE_CODE_OAUTH_TOKEN: opts.token
|
|
98
|
+
},
|
|
99
|
+
files: [{
|
|
100
|
+
path: `${GUEST_HOME}/.claude.json`,
|
|
101
|
+
content: `${JSON.stringify({ projects: { [opts.workdir]: { hasTrustDialogAccepted: true } } }, null, " ")}\n`
|
|
102
|
+
}]
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function describeStreamLine(line) {
|
|
106
|
+
let obj;
|
|
107
|
+
try {
|
|
108
|
+
obj = JSON.parse(line);
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const label = (obj.type ?? "") + (obj.subtype ? `:${obj.subtype}` : "");
|
|
113
|
+
if (label === "system:thinking_tokens") return null;
|
|
114
|
+
let detail = "";
|
|
115
|
+
if (obj.type === "assistant") {
|
|
116
|
+
const content = obj.message?.content ?? [];
|
|
117
|
+
const tools = content.filter((b) => b.type === "tool_use" && b.name).map((b) => b.name);
|
|
118
|
+
const chars = content.reduce((n, b) => n + (b.text?.length ?? 0), 0);
|
|
119
|
+
detail = tools.length ? ` ${tools.join(",")}` : ` ${chars} chars`;
|
|
120
|
+
} else if (obj.type === "result") detail = ` ${obj.subtype}, ${((obj.duration_ms ?? 0) / 1e3).toFixed(1)}s`;
|
|
121
|
+
return `[${label}]${detail}`;
|
|
122
|
+
}
|
|
123
|
+
function progressSink(label) {
|
|
124
|
+
return (kind, line) => {
|
|
125
|
+
if (kind === "stderr") {
|
|
126
|
+
process.stderr.write(`[${label}] ! ${line}\n`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const described = describeStreamLine(line);
|
|
130
|
+
if (described) process.stderr.write(`[${label}] ${described}\n`);
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/runner.ts
|
|
136
|
+
const GUEST_IMAGE = "doublecheck-guest:latest";
|
|
137
|
+
const GUEST_MEMORY_MIB = 2048;
|
|
138
|
+
function feedLines(carry, chunk) {
|
|
139
|
+
const parts = (carry + chunk).split("\n");
|
|
140
|
+
const rest = parts.pop() ?? "";
|
|
141
|
+
return {
|
|
142
|
+
lines: parts.filter((l) => l.trim()),
|
|
143
|
+
carry: rest
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function decideOutcome(exitCode, report, workdir) {
|
|
147
|
+
if (exitCode !== 0) return {
|
|
148
|
+
ok: false,
|
|
149
|
+
reason: `agent process exited ${exitCode}`,
|
|
150
|
+
partialReport: report
|
|
151
|
+
};
|
|
152
|
+
if (report === null) return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: `agent exited 0 but wrote no ${REPORT_FILE} (work dir: ${workdir})`,
|
|
155
|
+
partialReport: null
|
|
156
|
+
};
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
report
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function runAgent(opts) {
|
|
163
|
+
const microsandbox = await import("microsandbox");
|
|
164
|
+
const name = `doublecheck-${basename(opts.workdir)}`;
|
|
165
|
+
const policy = opts.network === "all" ? microsandbox.NetworkPolicy.allowAll() : microsandbox.NetworkPolicy.builder().defaultDeny().egress((rb) => rb.allow((d) => d.domainSuffix("anthropic.com"))).build();
|
|
166
|
+
let sandbox = null;
|
|
167
|
+
try {
|
|
168
|
+
try {
|
|
169
|
+
sandbox = await microsandbox.Sandbox.builder(name).image(GUEST_IMAGE).memory(GUEST_MEMORY_MIB).pullPolicy("never").replace().workdir(opts.workdir).envs(opts.spec.env).volume(opts.mount, (mb) => mb.bind(opts.mount).readonly()).volume(opts.workdir, (mb) => mb.bind(opts.workdir)).patch((pb) => {
|
|
170
|
+
let p = pb;
|
|
171
|
+
for (const f of opts.spec.files) p = p.text(f.path, f.content, {
|
|
172
|
+
mode: 384,
|
|
173
|
+
replace: true
|
|
174
|
+
});
|
|
175
|
+
return p;
|
|
176
|
+
}).network((nb) => nb.policy(policy)).create();
|
|
177
|
+
} catch (e) {
|
|
178
|
+
if (String(e).includes("not cached")) throw new Error(`guest image ${GUEST_IMAGE} is not in the microsandbox cache — run scripts/build-guest-image.sh (${String(e)})`);
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
const handle = await sandbox.execStreamWith("bash", (e) => e.args(["-c", opts.spec.command]));
|
|
182
|
+
const carry = {
|
|
183
|
+
stdout: "",
|
|
184
|
+
stderr: ""
|
|
185
|
+
};
|
|
186
|
+
let exitCode = null;
|
|
187
|
+
for (;;) {
|
|
188
|
+
const ev = await handle.recv();
|
|
189
|
+
if (ev === null) break;
|
|
190
|
+
if (ev.kind === "stdout" || ev.kind === "stderr") {
|
|
191
|
+
const fed = feedLines(carry[ev.kind], Buffer.from(ev.data).toString());
|
|
192
|
+
carry[ev.kind] = fed.carry;
|
|
193
|
+
for (const line of fed.lines) opts.onLine(ev.kind, line);
|
|
194
|
+
} else if (ev.kind === "exited") exitCode = ev.code;
|
|
195
|
+
}
|
|
196
|
+
for (const kind of ["stdout", "stderr"]) if (carry[kind].trim()) opts.onLine(kind, carry[kind]);
|
|
197
|
+
const reportPath = join(opts.workdir, REPORT_FILE);
|
|
198
|
+
const report = existsSync(reportPath) ? readFileSync(reportPath, "utf-8") : null;
|
|
199
|
+
return decideOutcome(exitCode, report, opts.workdir);
|
|
200
|
+
} finally {
|
|
201
|
+
if (sandbox) {
|
|
202
|
+
try {
|
|
203
|
+
await sandbox.stop();
|
|
204
|
+
} catch (e) {
|
|
205
|
+
opts.onLine("stderr", `sandbox stop failed: ${String(e)}`);
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
await microsandbox.Sandbox.remove(name);
|
|
209
|
+
} catch (e) {
|
|
210
|
+
opts.onLine("stderr", `sandbox remove failed: ${String(e)}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/check.ts
|
|
218
|
+
function selectChecks(all, only, dir) {
|
|
219
|
+
if (all.length === 0) throw new Error(`no checks (*.md) in ${dir}`);
|
|
220
|
+
if (only.length === 0) return all;
|
|
221
|
+
const byName = new Map(all.map((c) => [c.name, c]));
|
|
222
|
+
return only.map((name) => {
|
|
223
|
+
const check = byName.get(name);
|
|
224
|
+
if (!check) {
|
|
225
|
+
const have = all.map((c) => c.name).join(", ");
|
|
226
|
+
throw new Error(`no check named "${name}" in ${dir} (have: ${have})`);
|
|
227
|
+
}
|
|
228
|
+
return check;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function discoverChecks(project, only) {
|
|
232
|
+
const dir = join(project, ".agents", "checks");
|
|
233
|
+
let entries;
|
|
234
|
+
try {
|
|
235
|
+
entries = readdirSync(dir);
|
|
236
|
+
} catch {
|
|
237
|
+
throw new Error(`no checks directory at ${dir}`);
|
|
238
|
+
}
|
|
239
|
+
return selectChecks(entries.filter((f) => f.endsWith(".md")).sort().map((f) => ({
|
|
240
|
+
name: f.slice(0, -3),
|
|
241
|
+
body: readFileSync(join(dir, f), "utf-8")
|
|
242
|
+
})), only, dir);
|
|
243
|
+
}
|
|
244
|
+
function runTimestamp(d) {
|
|
245
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
246
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
247
|
+
}
|
|
248
|
+
function failureReport(reason, partialReport) {
|
|
249
|
+
return `# CHECK FAILED\n\n${reason}\n${partialReport ? `\n---\n\nPartial ${REPORT_FILE} left by the agent:\n\n${partialReport}` : ""}`;
|
|
250
|
+
}
|
|
251
|
+
async function runOneCheck(check, opts, token, outDir) {
|
|
252
|
+
const workdir = mkdtempSync(join(tmpdir(), `doublecheck-${check.name}-`));
|
|
253
|
+
writeFileSync(join(workdir, PROMPT_FILE), composePrompt(check.body, opts.project));
|
|
254
|
+
const spec = claudeAgent({
|
|
255
|
+
token,
|
|
256
|
+
model: opts.model,
|
|
257
|
+
workdir
|
|
258
|
+
});
|
|
259
|
+
const outcome = await runAgent({
|
|
260
|
+
mount: opts.project,
|
|
261
|
+
workdir,
|
|
262
|
+
spec,
|
|
263
|
+
network: "all",
|
|
264
|
+
onLine: progressSink(check.name)
|
|
265
|
+
});
|
|
266
|
+
const reportPath = join(outDir, `${check.name}.md`);
|
|
267
|
+
if (outcome.ok) {
|
|
268
|
+
writeFileSync(reportPath, outcome.report);
|
|
269
|
+
process.stderr.write(`[${check.name}] report: ${reportPath}\n`);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
writeFileSync(reportPath, failureReport(outcome.reason, outcome.partialReport));
|
|
273
|
+
process.stderr.write(`[${check.name}] FAILED (${outcome.reason}): ${reportPath}\n`);
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
async function runChecks(opts) {
|
|
277
|
+
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
278
|
+
if (!token) throw new Error("CLAUDE_CODE_OAUTH_TOKEN is required in the environment (it is injected into each check's sandbox)");
|
|
279
|
+
const checks = discoverChecks(opts.project, opts.only);
|
|
280
|
+
const outDir = join(opts.output, runTimestamp(/* @__PURE__ */ new Date()));
|
|
281
|
+
mkdirSync(outDir, { recursive: true });
|
|
282
|
+
process.stderr.write(`${checks.length} check(s) against ${opts.project} (model ${opts.model}, parallel ${opts.parallel})\n`);
|
|
283
|
+
const queue = new PQueue({ concurrency: opts.parallel });
|
|
284
|
+
return (await Promise.all(checks.map((check) => queue.add(() => runOneCheck(check, opts, token, outDir))))).every((ok) => ok === true);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/transcript.ts
|
|
289
|
+
const TURN_CHAR_CAP = 2e3;
|
|
290
|
+
const NON_HUMAN_TEXT = /^\s*<command-name>|<local-command-stdout>|^\s*<local-command|^\s*<system-reminder>|Caveat: The messages below/;
|
|
291
|
+
function humanTurns(jsonl) {
|
|
292
|
+
const turns = [];
|
|
293
|
+
for (const line of jsonl.split("\n")) {
|
|
294
|
+
if (!line.trim()) continue;
|
|
295
|
+
const raw = JSON.parse(line);
|
|
296
|
+
if (raw.type !== "user" || raw.isMeta || raw.isSidechain) continue;
|
|
297
|
+
const content = raw.message?.content;
|
|
298
|
+
const text = typeof content === "string" ? content : Array.isArray(content) ? content.filter((item) => typeof item === "object" && item !== null && item.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n") : "";
|
|
299
|
+
if (!text) continue;
|
|
300
|
+
if (NON_HUMAN_TEXT.test(text)) continue;
|
|
301
|
+
turns.push(text.replace(/[\n\r]+/g, " ⏎ ").slice(0, TURN_CHAR_CAP));
|
|
302
|
+
}
|
|
303
|
+
return turns;
|
|
304
|
+
}
|
|
305
|
+
function renderDigest(meta, turns) {
|
|
306
|
+
const numbered = turns.map((t, i) => `${String(i + 1).padStart(3, " ")} | ${t}`).join("\n");
|
|
307
|
+
return `# source: ${meta.source}
|
|
308
|
+
# project: ${meta.project}
|
|
309
|
+
# session: ${meta.session}
|
|
310
|
+
# human_turns: ${turns.length}
|
|
311
|
+
# To understand WHY a terse turn or interruption happened, grep its text in the
|
|
312
|
+
# source file above and read the assistant turn(s) immediately before it.
|
|
313
|
+
|
|
314
|
+
${numbered}
|
|
315
|
+
`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
//#endregion
|
|
319
|
+
//#region src/catalog.ts
|
|
320
|
+
function enumerateUnits(projectsDir) {
|
|
321
|
+
let projects;
|
|
322
|
+
try {
|
|
323
|
+
projects = readdirSync(projectsDir).sort();
|
|
324
|
+
} catch {
|
|
325
|
+
throw new Error(`no transcripts directory at ${projectsDir}`);
|
|
326
|
+
}
|
|
327
|
+
const units = [];
|
|
328
|
+
for (const project of projects) {
|
|
329
|
+
const dir = join(projectsDir, project);
|
|
330
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
331
|
+
for (const f of readdirSync(dir).sort()) {
|
|
332
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
333
|
+
units.push({
|
|
334
|
+
project,
|
|
335
|
+
session: basename(f, ".jsonl"),
|
|
336
|
+
jsonlPath: join(dir, f)
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return units;
|
|
341
|
+
}
|
|
342
|
+
function observationsPath(catalogDir, unit) {
|
|
343
|
+
return join(catalogDir, unit.project, unit.session, "observations.md");
|
|
344
|
+
}
|
|
345
|
+
function decideUnitStatus(input) {
|
|
346
|
+
const sourceSha256 = createHash("sha256").update(input.jsonl).digest("hex");
|
|
347
|
+
if (input.recordedSha256 === sourceSha256) return { kind: "mined" };
|
|
348
|
+
let turns;
|
|
349
|
+
try {
|
|
350
|
+
turns = humanTurns(input.jsonl.toString("utf-8"));
|
|
351
|
+
} catch (e) {
|
|
352
|
+
return {
|
|
353
|
+
kind: "unreadable",
|
|
354
|
+
error: String(e)
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (turns.length < input.minTurns) return {
|
|
358
|
+
kind: "below-threshold",
|
|
359
|
+
turns: turns.length
|
|
360
|
+
};
|
|
361
|
+
return {
|
|
362
|
+
kind: "pending",
|
|
363
|
+
reason: input.recordedSha256 === null ? "new" : "changed",
|
|
364
|
+
turns: turns.length,
|
|
365
|
+
digest: renderDigest({
|
|
366
|
+
source: input.unit.jsonlPath,
|
|
367
|
+
project: input.unit.project,
|
|
368
|
+
session: input.unit.session
|
|
369
|
+
}, turns),
|
|
370
|
+
sourceSha256
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function unitStatus(unit, catalogDir, minTurns) {
|
|
374
|
+
const obsPath = observationsPath(catalogDir, unit);
|
|
375
|
+
return decideUnitStatus({
|
|
376
|
+
unit,
|
|
377
|
+
jsonl: readFileSync(unit.jsonlPath),
|
|
378
|
+
recordedSha256: existsSync(obsPath) ? readSourceSha256(readFileSync(obsPath, "utf-8")) : null,
|
|
379
|
+
minTurns
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function composeObservationsFile(meta, body) {
|
|
383
|
+
return `---
|
|
384
|
+
source: ${meta.source}
|
|
385
|
+
source_sha256: ${meta.sourceSha256}
|
|
386
|
+
mined_at: ${meta.minedAt}
|
|
387
|
+
model: ${meta.model}
|
|
388
|
+
human_turns: ${meta.humanTurns}
|
|
389
|
+
---
|
|
390
|
+
|
|
391
|
+
${body.trimEnd()}
|
|
392
|
+
`;
|
|
393
|
+
}
|
|
394
|
+
function readSourceSha256(observationsMd) {
|
|
395
|
+
return observationsMd.match(/^source_sha256: ([0-9a-f]{64})$/m)?.[1] ?? null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/mine.ts
|
|
400
|
+
function planMine(entries, limit) {
|
|
401
|
+
const pending = entries.filter((e) => e.status.kind === "pending");
|
|
402
|
+
return {
|
|
403
|
+
todo: limit ? pending.slice(0, limit) : pending,
|
|
404
|
+
pendingTotal: pending.length,
|
|
405
|
+
mined: entries.filter((e) => e.status.kind === "mined").length,
|
|
406
|
+
belowThreshold: entries.filter((e) => e.status.kind === "below-threshold").length,
|
|
407
|
+
unreadable: entries.flatMap((e) => e.status.kind === "unreadable" ? [{
|
|
408
|
+
unit: e.unit,
|
|
409
|
+
error: e.status.error
|
|
410
|
+
}] : [])
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
function summarizeMinePlan(plan, minTurns) {
|
|
414
|
+
const total = plan.pendingTotal + plan.mined + plan.belowThreshold + plan.unreadable.length;
|
|
415
|
+
const limited = plan.todo.length !== plan.pendingTotal ? ` (mining ${plan.todo.length})` : "";
|
|
416
|
+
return `${total} transcripts: ${plan.pendingTotal} pending${limited}, ${plan.mined} mined, ${plan.belowThreshold} below ${minTurns} turns, ${plan.unreadable.length} unreadable`;
|
|
417
|
+
}
|
|
418
|
+
function dryRunRow({ unit, status }) {
|
|
419
|
+
return `${status.reason.padEnd(7)} ${status.turns.toString().padStart(4)} turns ${unit.project}/${unit.session}`;
|
|
420
|
+
}
|
|
421
|
+
async function mineOneUnit({ unit, status }, opts, token) {
|
|
422
|
+
const tag = unit.session.slice(0, 8);
|
|
423
|
+
const workdir = mkdtempSync(join(tmpdir(), `doublecheck-mine-${tag}-`));
|
|
424
|
+
writeFileSync(join(workdir, PROMPT_FILE), composeMinePrompt(status.digest));
|
|
425
|
+
const spec = claudeAgent({
|
|
426
|
+
token,
|
|
427
|
+
model: opts.model,
|
|
428
|
+
workdir
|
|
429
|
+
});
|
|
430
|
+
process.stderr.write(`[${tag}] mining ${unit.project}/${unit.session} (${status.turns} turns, ${status.reason})\n`);
|
|
431
|
+
const outcome = await runAgent({
|
|
432
|
+
mount: opts.projects,
|
|
433
|
+
workdir,
|
|
434
|
+
spec,
|
|
435
|
+
network: "anthropic-only",
|
|
436
|
+
onLine: progressSink(tag)
|
|
437
|
+
});
|
|
438
|
+
if (!outcome.ok) {
|
|
439
|
+
process.stderr.write(`[${tag}] FAILED (${outcome.reason})\n`);
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
const obsPath = observationsPath(opts.catalog, unit);
|
|
443
|
+
mkdirSync(dirname(obsPath), { recursive: true });
|
|
444
|
+
writeFileSync(obsPath, composeObservationsFile({
|
|
445
|
+
source: unit.jsonlPath,
|
|
446
|
+
sourceSha256: status.sourceSha256,
|
|
447
|
+
minedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
448
|
+
model: opts.model,
|
|
449
|
+
humanTurns: status.turns
|
|
450
|
+
}, outcome.report));
|
|
451
|
+
process.stderr.write(`[${tag}] observations: ${obsPath}\n`);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
async function runMine(opts) {
|
|
455
|
+
const plan = planMine(enumerateUnits(opts.projects).map((unit) => ({
|
|
456
|
+
unit,
|
|
457
|
+
status: unitStatus(unit, opts.catalog, opts.minTurns)
|
|
458
|
+
})), opts.limit);
|
|
459
|
+
for (const { unit, error } of plan.unreadable) process.stderr.write(`UNREADABLE ${unit.project}/${unit.session}: ${error}\n`);
|
|
460
|
+
process.stderr.write(`${summarizeMinePlan(plan, opts.minTurns)}\n`);
|
|
461
|
+
if (opts.dryRun) {
|
|
462
|
+
for (const pending of plan.todo) console.log(dryRunRow(pending));
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
if (plan.todo.length === 0) return true;
|
|
466
|
+
const token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
467
|
+
if (!token) throw new Error("CLAUDE_CODE_OAUTH_TOKEN is required in the environment (it is injected into each miner's sandbox)");
|
|
468
|
+
const queue = new PQueue({ concurrency: opts.parallel });
|
|
469
|
+
const results = await Promise.all(plan.todo.map((p) => queue.add(() => mineOneUnit(p, opts, token))));
|
|
470
|
+
const failed = results.filter((ok) => ok !== true).length;
|
|
471
|
+
process.stderr.write(`mined ${results.length - failed}/${results.length}${failed ? `, ${failed} FAILED` : ""}\n`);
|
|
472
|
+
return failed === 0;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/cli.ts
|
|
477
|
+
function parsePositiveInt(flag) {
|
|
478
|
+
return (v) => {
|
|
479
|
+
const n = Number.parseInt(v, 10);
|
|
480
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`${flag} must be a positive integer, got "${v}"`);
|
|
481
|
+
return n;
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const program = new Command().name("doublecheck").description("Run self-authored LLM code-inspectors (.agents/checks/*.md) against a project, one sandboxed agent per check");
|
|
485
|
+
program.command("check").option("--project <dir>", "project to inspect", process.cwd()).option("--model <model>", "model for the inspector agents", "haiku").option("--parallel <n>", "max concurrent checks", parsePositiveInt("--parallel"), 4).option("--output <dir>", "reports root (default: $PROJECT/.doublecheck)").option("--check <name>", "run only this check (repeatable)", (v, prev) => [...prev, v], []).action(async (opts) => {
|
|
486
|
+
const project = resolve(opts.project);
|
|
487
|
+
if (!await runChecks({
|
|
488
|
+
project,
|
|
489
|
+
model: opts.model,
|
|
490
|
+
parallel: opts.parallel,
|
|
491
|
+
output: opts.output ? resolve(opts.output) : join(project, ".doublecheck"),
|
|
492
|
+
only: opts.check
|
|
493
|
+
})) process.exitCode = 1;
|
|
494
|
+
});
|
|
495
|
+
program.command("mine").option("--projects <dir>", "Claude Code transcripts root", join(homedir(), ".claude", "projects")).option("--catalog <dir>", "observation catalog root", join(homedir(), ".doublecheck", "catalog")).option("--model <model>", "model for the mining agents", "opus").option("--parallel <n>", "max concurrent miners", parsePositiveInt("--parallel"), 4).option("--min-turns <n>", "min genuine human turns for a real conversation", parsePositiveInt("--min-turns"), 2).option("--limit <n>", "mine at most N pending units", parsePositiveInt("--limit")).option("--dry-run", "list what would be mined without booting anything").action(async (opts) => {
|
|
496
|
+
if (!await runMine({
|
|
497
|
+
projects: resolve(opts.projects),
|
|
498
|
+
catalog: resolve(opts.catalog),
|
|
499
|
+
model: opts.model,
|
|
500
|
+
parallel: opts.parallel,
|
|
501
|
+
minTurns: opts.minTurns,
|
|
502
|
+
limit: opts.limit,
|
|
503
|
+
dryRun: !!opts.dryRun
|
|
504
|
+
})) process.exitCode = 1;
|
|
505
|
+
});
|
|
506
|
+
program.parseAsync().catch((e) => {
|
|
507
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
508
|
+
process.exit(1);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
//#endregion
|
|
512
|
+
export { };
|
|
513
|
+
//# sourceMappingURL=cli.mjs.map
|
package/dist/cli.mjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.mjs","names":[],"sources":["../src/contract.ts","../src/claude.ts","../src/runner.ts","../src/check.ts","../src/transcript.ts","../src/catalog.ts","../src/mine.ts","../src/cli.ts"],"sourcesContent":["// The agent contract: what the harness stages for the agent and what the\n// agent must leave behind. Any adapter (claude today, codex someday) runs\n// under this same contract, which is what keeps the harness CLI-agnostic.\n\n// Staged into the scratch workdir before boot; the rw bind mount delivers it.\nexport const PROMPT_FILE = \"prompt.txt\"\n// The agent writes its findings here (relative to its cwd); the host reads it\n// back after exit. Missing report = failure.\nexport const REPORT_FILE = \"report.md\"\n\n// Weak models reliably skip a report contract stated once at the prompt's\n// tail unless it leads with the stakes and orders the file created first\n// (measured: haiku went from ~50% to 3/3 with this wording).\nfunction reportContract(activity: string, deliverable: string): string {\n\treturn `## Report — the only output that counts\n\nYour final chat reply is discarded. The only thing read back is the file \\`./${REPORT_FILE}\\` in your working directory — if it does not exist when you exit, this run FAILS.\n\nSo: create \\`./${REPORT_FILE}\\` with the Write tool BEFORE you start ${activity} (a title line is enough), and update it as you work. The final state of the file when you finish is ${deliverable}.`\n}\n\n// Environment preamble + check body + report contract. Scoping (diff vs tree)\n// is the check's own prose — the harness knows nothing about git.\nexport function composePrompt(checkBody: string, project: string): string {\n\treturn `## Environment\n\nYou are a code inspector running inside a sandboxed microVM with full permissions and unrestricted internet access.\n\n- The project under inspection is mounted read-only at \\`${project}\\`. You cannot modify it — inspect, don't fix.\n- \\`git\\` and \\`rg\\` are installed.\n- Your current working directory is a writable scratch workspace.\n\n---\n\n${checkBody}\n\n---\n\n${reportContract(\"inspecting\", \"the check's report\")}`\n}\n\n// The mining prompt: digest of one conversation + what counts as a durable\n// preference observation + the block format the catalog accumulates.\nexport function composeMinePrompt(digest: string): string {\n\treturn `## Environment\n\nYou are running inside a sandboxed microVM with no network access. The machine's Claude Code transcripts are mounted read-only at their real paths; your current working directory is a writable scratch workspace.\n\n## Task\n\nYou are mining ONE Claude Code conversation for the operator's durable preferences — how they want engineering done, in any conversation, not what they wanted built in this one.\n\nBelow is the conversation digest: every genuine human turn, numbered, in order. When a turn is a terse correction or interruption (\"no\", \"stop\", \"don't\", \"why did you…\", \"[Request interrupted]\"), recover its context: the \"# source:\" header names the full session transcript — Grep that turn's text in it to find its line, then Read the assistant turns IMMEDIATELY BEFORE it with offset/limit. The preference lives in the contrast between what the assistant did and what the operator demanded. NEVER read a transcript whole — some are tens of MB.\n\n---\n\n${digest}\n\n---\n\n## What counts as an observation (strict)\n\nA durable preference, backed by a verbatim quote from the digest or transcript:\n\n- **kind: code** — how code should be written/structured/shaped (\"no silent fallbacks\", \"reuse the existing helper instead of hand-rolling\").\n- **kind: workflow** — how work should proceed (\"verify before claiming done\", \"ask before making scope decisions\").\n- **kind: style** — how prose/output/docs should read.\n\nExclude task-of-the-moment directives with no durable signal (\"add a tab to home.tsx\", \"kill the dev server\"). Exclude anything you cannot quote. Be conservative: a weak or speculative observation is worse than none.\n\n## Observation format\n\nThe report is observation blocks and NOTHING else — no title, no preamble, no summary or conclusion sections. One block per observation:\n\n## <kebab-case-name>\n- **observation:** <one sentence: the durable preference>\n- **kind:** code | workflow | style\n- **evidence:** \"<verbatim quote>\" — turn <N>\n\nFor **code** observations that an LLM reviewer could check against a diff or file tree (a concrete change could flip PASS↔FAIL), and ONLY for those — never on workflow or style blocks — ALSO add:\n\n- **pass/fail:** PASS when <…>; FAIL when <…>\n- **why-LLM:** <why a deterministic linter cannot enforce this>\n- **scope:** diff-only | needs-tree\n\nIf the conversation carries no durable preference signal, the report is exactly these two lines and nothing more:\n\nNo durable preference signal.\n<one sentence: what the conversation was about instead>\n\n${reportContract(\"mining\", \"the mined observations\")}`\n}\n","import { PROMPT_FILE } from \"./contract.ts\"\nimport type { AgentSpec } from \"./runner.ts\"\n\nconst GUEST_HOME = \"/root\"\n\n// The claude adapter: produce the AgentSpec that runs `claude -p` in the guest.\nexport function claudeAgent(opts: {\n\ttoken: string\n\tmodel: string\n\tworkdir: string\n}): AgentSpec {\n\treturn {\n\t\t// Headless claude -p cannot answer permission prompts, so in \"default\"\n\t\t// mode every tool is auto-denied and the agent produces nothing. The\n\t\t// microVM is the safety boundary here, so bypass the per-tool gate;\n\t\t// IS_SANDBOX=1 is what lets that run as root without claude's refusal.\n\t\tcommand:\n\t\t\t\"claude -p --no-session-persistence \" +\n\t\t\t\"--dangerously-skip-permissions \" +\n\t\t\t// --verbose is required by claude -p with stream-json output; it only\n\t\t\t// widens what lands on stdout, which describeStreamLine already labels.\n\t\t\t\"--output-format stream-json --verbose \" +\n\t\t\t// Not a permission gate — a contract guard. The default headless tool\n\t\t\t// set omits Glob/Grep and includes harness tools (ReportFindings,\n\t\t\t// TaskCreate, …) that hijack the report: haiku \"reports findings\"\n\t\t\t// through them and never writes report.md (verified live). This pins\n\t\t\t// the inspector's full toolkit and nothing else.\n\t\t\t`--tools Task Bash Read Write Edit Glob Grep WebSearch WebFetch < ${PROMPT_FILE}`,\n\t\tenv: {\n\t\t\tHOME: GUEST_HOME,\n\t\t\tIS_SANDBOX: \"1\",\n\t\t\tGIT_OPTIONAL_LOCKS: \"0\",\n\t\t\tANTHROPIC_MODEL: opts.model,\n\t\t\tCLAUDE_CODE_OAUTH_TOKEN: opts.token,\n\t\t},\n\t\t// Minimal trust-accepted entry keyed to the guest cwd so headless claude\n\t\t// skips the trust prompt. Deliberately NOT a copy of any host .claude.json.\n\t\tfiles: [\n\t\t\t{\n\t\t\t\tpath: `${GUEST_HOME}/.claude.json`,\n\t\t\t\tcontent: `${JSON.stringify(\n\t\t\t\t\t{ projects: { [opts.workdir]: { hasTrustDialogAccepted: true } } },\n\t\t\t\t\tnull,\n\t\t\t\t\t\"\\t\",\n\t\t\t\t)}\\n`,\n\t\t\t},\n\t\t],\n\t}\n}\n\ninterface StreamLine {\n\ttype?: string\n\tsubtype?: string\n\tmessage?: { content?: Array<{ type?: string; text?: string; name?: string }> }\n\tduration_ms?: number\n}\n\n// One human-readable label per stream-json stdout line; null for lines that\n// aren't claude's JSON (dropped from the progress stream).\nexport function describeStreamLine(line: string): string | null {\n\tlet obj: StreamLine\n\ttry {\n\t\tobj = JSON.parse(line) as StreamLine\n\t} catch {\n\t\treturn null\n\t}\n\tconst label = (obj.type ?? \"\") + (obj.subtype ? `:${obj.subtype}` : \"\")\n\t// A once-per-second heartbeat with no content — pure noise in the stream.\n\tif (label === \"system:thinking_tokens\") return null\n\tlet detail = \"\"\n\tif (obj.type === \"assistant\") {\n\t\tconst content = obj.message?.content ?? []\n\t\tconst tools = content\n\t\t\t.filter((b) => b.type === \"tool_use\" && b.name)\n\t\t\t.map((b) => b.name)\n\t\tconst chars = content.reduce((n, b) => n + (b.text?.length ?? 0), 0)\n\t\tdetail = tools.length ? ` ${tools.join(\",\")}` : ` ${chars} chars`\n\t} else if (obj.type === \"result\") {\n\t\tdetail = ` ${obj.subtype}, ${((obj.duration_ms ?? 0) / 1000).toFixed(1)}s`\n\t}\n\treturn `[${label}]${detail}`\n}\n\n// The per-unit progress sink both workflow shells hang on runAgent: guest\n// stderr passes through as `[label] ! line`, stdout is claude's stream-json,\n// labelled by describeStreamLine (non-JSON and heartbeat lines dropped).\nexport function progressSink(\n\tlabel: string,\n): (kind: \"stdout\" | \"stderr\", line: string) => void {\n\treturn (kind, line) => {\n\t\tif (kind === \"stderr\") {\n\t\t\tprocess.stderr.write(`[${label}] ! ${line}\\n`)\n\t\t\treturn\n\t\t}\n\t\tconst described = describeStreamLine(line)\n\t\tif (described) process.stderr.write(`[${label}] ${described}\\n`)\n\t}\n}\n","import { existsSync, readFileSync } from \"node:fs\"\nimport { basename, join } from \"node:path\"\nimport type { ExecEvent } from \"microsandbox\"\nimport { REPORT_FILE } from \"./contract.ts\"\n\n// microsandbox exports its fluent builders as value-only consts; recover their\n// instance types via a type-only import (erased at build) so the native module\n// only loads behind the `await import` in runAgent.\ntype MountBuilder = InstanceType<typeof import(\"microsandbox\").MountBuilder>\ntype PatchBuilder = InstanceType<typeof import(\"microsandbox\").PatchBuilder>\ntype ExecOptionsBuilder = InstanceType<typeof import(\"microsandbox\").ExecOptionsBuilder>\n// The SDK types `.network(cb)` as `(b: any) => any`, and we call the JS-shim\n// `.policy(...)`, absent from the native builder's types.\n// biome-ignore lint/suspicious/noExplicitAny: SDK types this builder callback as any\ntype NetworkBuilder = any\n\n// Locally built (Dockerfile.guest): node 24 slim + git/ripgrep/curl/wget + the\n// claude CLI baked in. Side-loaded into the microsandbox cache by\n// ./scripts/build-guest-image.sh — it exists nowhere else, hence pullPolicy\n// \"never\": a cache miss means \"run the build script\", not \"try Docker Hub\".\nconst GUEST_IMAGE = \"doublecheck-guest:latest\"\nconst GUEST_MEMORY_MIB = 2048\n\n// Staged into the guest before boot (e.g. claude's trust-accepted config).\nexport interface GuestFile {\n\tpath: string\n\tcontent: string\n}\n\n// What it takes to run one agent in a booted guest: a bash command executed in\n// the scratch cwd that must leave REPORT_FILE there, the env it needs, and\n// files staged into the guest. Plain data — adapters produce it, this runner\n// consumes it, tests fake it.\nexport interface AgentSpec {\n\tcommand: string\n\tenv: Record<string, string>\n\tfiles: GuestFile[]\n}\n\nexport type AgentOutcome =\n\t| { ok: true; report: string }\n\t| { ok: false; reason: string; partialReport: string | null }\n\n// Pure line buffering: fold a chunk into the carry, emit complete non-blank\n// lines, keep the unterminated tail.\nexport function feedLines(\n\tcarry: string,\n\tchunk: string,\n): { lines: string[]; carry: string } {\n\tconst parts = (carry + chunk).split(\"\\n\")\n\tconst rest = parts.pop() ?? \"\"\n\treturn { lines: parts.filter((l) => l.trim()), carry: rest }\n}\n\n// Pure outcome decision: what the exec left behind → what it means.\nexport function decideOutcome(\n\texitCode: number | null,\n\treport: string | null,\n\tworkdir: string,\n): AgentOutcome {\n\tif (exitCode !== 0) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: `agent process exited ${exitCode}`,\n\t\t\tpartialReport: report,\n\t\t}\n\t}\n\tif (report === null) {\n\t\treturn {\n\t\t\tok: false,\n\t\t\treason: `agent exited 0 but wrote no ${REPORT_FILE} (work dir: ${workdir})`,\n\t\t\tpartialReport: null,\n\t\t}\n\t}\n\treturn { ok: true, report }\n}\n\nexport interface RunAgentOpts {\n\t// Host dir the agent inspects, bind-mounted READ-ONLY at its real host\n\t// path: the project for `check`, the transcripts corpus for `mine`.\n\tmount: string\n\t// Scratch dir with PROMPT_FILE already inside; bind-mounted rw as the guest\n\t// cwd at its identical host path, so the agent's report lands back on the host.\n\tworkdir: string\n\tspec: AgentSpec\n\t// \"all\" for checks (inspectors may research); \"anthropic-only\" for miners:\n\t// the personal corpus is mounted, so the only reachable destination is the\n\t// API the agent already sends its context to. NetworkPolicy.none() is not\n\t// an option here — it kills DNS entirely and the adapter can't reach its\n\t// own model API (measured: claude retries ~180s then exits 1).\n\tnetwork: \"all\" | \"anthropic-only\"\n\tonLine: (kind: \"stdout\" | \"stderr\", line: string) => void\n}\n\n// Boot a guest (ro mount + scratch rw as cwd), exec the agent command, stream\n// its output line-by-line, read the report back off the scratch dir, tear\n// down. Agent-level failures (exit ≠ 0, no report) return ok:false;\n// harness-level failures (image missing, boot error) throw.\nexport async function runAgent(opts: RunAgentOpts): Promise<AgentOutcome> {\n\tconst microsandbox = await import(\"microsandbox\")\n\tconst name = `doublecheck-${basename(opts.workdir)}`\n\tconst policy =\n\t\topts.network === \"all\"\n\t\t\t? microsandbox.NetworkPolicy.allowAll()\n\t\t\t: microsandbox.NetworkPolicy.builder()\n\t\t\t\t\t.defaultDeny()\n\t\t\t\t\t.egress((rb) => rb.allow((d) => d.domainSuffix(\"anthropic.com\")))\n\t\t\t\t\t.build()\n\tlet sandbox: InstanceType<typeof microsandbox.Sandbox> | null = null\n\ttry {\n\t\ttry {\n\t\t\tsandbox = await microsandbox.Sandbox.builder(name)\n\t\t\t\t.image(GUEST_IMAGE)\n\t\t\t\t.memory(GUEST_MEMORY_MIB)\n\t\t\t\t.pullPolicy(\"never\")\n\t\t\t\t.replace()\n\t\t\t\t.workdir(opts.workdir)\n\t\t\t\t.envs(opts.spec.env)\n\t\t\t\t.volume(opts.mount, (mb: MountBuilder) => mb.bind(opts.mount).readonly())\n\t\t\t\t.volume(opts.workdir, (mb: MountBuilder) => mb.bind(opts.workdir))\n\t\t\t\t.patch((pb: PatchBuilder) => {\n\t\t\t\t\tlet p = pb\n\t\t\t\t\tfor (const f of opts.spec.files) {\n\t\t\t\t\t\tp = p.text(f.path, f.content, { mode: 0o600, replace: true })\n\t\t\t\t\t}\n\t\t\t\t\treturn p\n\t\t\t\t})\n\t\t\t\t.network((nb: NetworkBuilder) => nb.policy(policy))\n\t\t\t\t.create()\n\t\t} catch (e) {\n\t\t\tif (String(e).includes(\"not cached\")) {\n\t\t\t\tthrow new Error(\n\t\t\t\t\t`guest image ${GUEST_IMAGE} is not in the microsandbox cache — run scripts/build-guest-image.sh (${String(e)})`,\n\t\t\t\t)\n\t\t\t}\n\t\t\tthrow e\n\t\t}\n\n\t\tconst handle = await sandbox.execStreamWith(\"bash\", (e: ExecOptionsBuilder) =>\n\t\t\te.args([\"-c\", opts.spec.command]),\n\t\t)\n\n\t\tconst carry: Record<\"stdout\" | \"stderr\", string> = { stdout: \"\", stderr: \"\" }\n\t\tlet exitCode: number | null = null\n\t\tfor (;;) {\n\t\t\tconst ev: ExecEvent | null = await handle.recv()\n\t\t\tif (ev === null) break\n\t\t\tif (ev.kind === \"stdout\" || ev.kind === \"stderr\") {\n\t\t\t\tconst fed = feedLines(carry[ev.kind], Buffer.from(ev.data).toString())\n\t\t\t\tcarry[ev.kind] = fed.carry\n\t\t\t\tfor (const line of fed.lines) opts.onLine(ev.kind, line)\n\t\t\t} else if (ev.kind === \"exited\") exitCode = ev.code\n\t\t}\n\t\tfor (const kind of [\"stdout\", \"stderr\"] as const) {\n\t\t\tif (carry[kind].trim()) opts.onLine(kind, carry[kind])\n\t\t}\n\n\t\tconst reportPath = join(opts.workdir, REPORT_FILE)\n\t\tconst report = existsSync(reportPath) ? readFileSync(reportPath, \"utf-8\") : null\n\t\treturn decideOutcome(exitCode, report, opts.workdir)\n\t} finally {\n\t\tif (sandbox) {\n\t\t\ttry {\n\t\t\t\tawait sandbox.stop()\n\t\t\t} catch (e) {\n\t\t\t\topts.onLine(\"stderr\", `sandbox stop failed: ${String(e)}`)\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tawait microsandbox.Sandbox.remove(name)\n\t\t\t} catch (e) {\n\t\t\t\topts.onLine(\"stderr\", `sandbox remove failed: ${String(e)}`)\n\t\t\t}\n\t\t}\n\t}\n}\n","import { mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from \"node:fs\"\nimport { tmpdir } from \"node:os\"\nimport { join } from \"node:path\"\nimport PQueue from \"p-queue\"\nimport { claudeAgent, progressSink } from \"./claude.ts\"\nimport { composePrompt, PROMPT_FILE, REPORT_FILE } from \"./contract.ts\"\nimport { runAgent } from \"./runner.ts\"\n\n// A check is a plain markdown file in $PROJECT/.agents/checks — no\n// frontmatter, no schema. The body is the inspector's instructions; the name\n// is the filename minus .md.\nexport interface Check {\n\tname: string\n\tbody: string\n}\n\n// Pure selection: `only` (from repeated --check flags) filters, in the order\n// named; naming a check that doesn't exist is an error, not an empty run.\nexport function selectChecks(all: Check[], only: string[], dir: string): Check[] {\n\tif (all.length === 0) throw new Error(`no checks (*.md) in ${dir}`)\n\tif (only.length === 0) return all\n\tconst byName = new Map(all.map((c) => [c.name, c]))\n\treturn only.map((name) => {\n\t\tconst check = byName.get(name)\n\t\tif (!check) {\n\t\t\tconst have = all.map((c) => c.name).join(\", \")\n\t\t\tthrow new Error(`no check named \"${name}\" in ${dir} (have: ${have})`)\n\t\t}\n\t\treturn check\n\t})\n}\n\n// Shell: read every check file, then select purely.\nexport function discoverChecks(project: string, only: string[]): Check[] {\n\tconst dir = join(project, \".agents\", \"checks\")\n\tlet entries: string[]\n\ttry {\n\t\tentries = readdirSync(dir)\n\t} catch {\n\t\tthrow new Error(`no checks directory at ${dir}`)\n\t}\n\tconst all = entries\n\t\t.filter((f) => f.endsWith(\".md\"))\n\t\t.sort()\n\t\t.map((f) => ({\n\t\t\tname: f.slice(0, -\".md\".length),\n\t\t\tbody: readFileSync(join(dir, f), \"utf-8\"),\n\t\t}))\n\treturn selectChecks(all, only, dir)\n}\n\nexport interface CheckWorkflowOpts {\n\tproject: string\n\tmodel: string\n\tparallel: number\n\toutput: string\n\tonly: string[]\n}\n\n// fs-safe local timestamp, one dir per run shared by all its checks:\n// 2026-07-05-143000\nexport function runTimestamp(d: Date): string {\n\tconst p = (n: number) => String(n).padStart(2, \"0\")\n\treturn `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`\n}\n\n// The report written when the agent failed: the failure is the report, plus\n// whatever partial report the agent managed to leave.\nexport function failureReport(reason: string, partialReport: string | null): string {\n\tconst partial = partialReport\n\t\t? `\\n---\\n\\nPartial ${REPORT_FILE} left by the agent:\\n\\n${partialReport}`\n\t\t: \"\"\n\treturn `# CHECK FAILED\\n\\n${reason}\\n${partial}`\n}\n\nasync function runOneCheck(\n\tcheck: Check,\n\topts: CheckWorkflowOpts,\n\ttoken: string,\n\toutDir: string,\n): Promise<boolean> {\n\tconst workdir = mkdtempSync(join(tmpdir(), `doublecheck-${check.name}-`))\n\twriteFileSync(join(workdir, PROMPT_FILE), composePrompt(check.body, opts.project))\n\tconst spec = claudeAgent({ token, model: opts.model, workdir })\n\tconst outcome = await runAgent({\n\t\tmount: opts.project,\n\t\tworkdir,\n\t\tspec,\n\t\tnetwork: \"all\",\n\t\tonLine: progressSink(check.name),\n\t})\n\tconst reportPath = join(outDir, `${check.name}.md`)\n\tif (outcome.ok) {\n\t\twriteFileSync(reportPath, outcome.report)\n\t\tprocess.stderr.write(`[${check.name}] report: ${reportPath}\\n`)\n\t\treturn true\n\t}\n\twriteFileSync(reportPath, failureReport(outcome.reason, outcome.partialReport))\n\tprocess.stderr.write(`[${check.name}] FAILED (${outcome.reason}): ${reportPath}\\n`)\n\treturn false\n}\n\n// The check workflow: one sandboxed agent per check, at most `parallel`\n// guests at once. Returns true only when every check produced a report; agent\n// failures still write a failure-record report and flip the run to false.\nexport async function runChecks(opts: CheckWorkflowOpts): Promise<boolean> {\n\tconst token = process.env.CLAUDE_CODE_OAUTH_TOKEN\n\tif (!token) {\n\t\tthrow new Error(\n\t\t\t\"CLAUDE_CODE_OAUTH_TOKEN is required in the environment (it is injected into each check's sandbox)\",\n\t\t)\n\t}\n\tconst checks = discoverChecks(opts.project, opts.only)\n\tconst outDir = join(opts.output, runTimestamp(new Date()))\n\tmkdirSync(outDir, { recursive: true })\n\tprocess.stderr.write(\n\t\t`${checks.length} check(s) against ${opts.project} (model ${opts.model}, parallel ${opts.parallel})\\n`,\n\t)\n\tconst queue = new PQueue({ concurrency: opts.parallel })\n\tconst results = await Promise.all(\n\t\tchecks.map((check) => queue.add(() => runOneCheck(check, opts, token, outDir))),\n\t)\n\treturn results.every((ok) => ok === true)\n}\n","// Claude Code transcript (.jsonl) → the genuine human turns → a flat digest.\n// Port of the June-2026 jq extraction, verified against the same corpus: a\n// \"genuine human turn\" is type:user, not meta, not sidechain, text content\n// only — which excludes the three things that are also type:user in the JSONL\n// (tool_results, slash-command stdout wrappers, injected system-reminders).\n\n// Per-turn char cap in the digest; the mining agent greps the source jsonl\n// for full text when it needs context around a turn.\nconst TURN_CHAR_CAP = 2000\n\ninterface RawLine {\n\ttype?: string\n\tisMeta?: boolean\n\tisSidechain?: boolean\n\tmessage?: { content?: unknown }\n}\n\nconst NON_HUMAN_TEXT =\n\t/^\\s*<command-name>|<local-command-stdout>|^\\s*<local-command|^\\s*<system-reminder>|Caveat: The messages below/\n\n// Throws on an unparseable line — the caller decides what an unreadable\n// transcript means for its run (mine counts and reports them, visibly).\nexport function humanTurns(jsonl: string): string[] {\n\tconst turns: string[] = []\n\tfor (const line of jsonl.split(\"\\n\")) {\n\t\tif (!line.trim()) continue\n\t\tconst raw = JSON.parse(line) as RawLine\n\t\tif (raw.type !== \"user\" || raw.isMeta || raw.isSidechain) continue\n\t\tconst content = raw.message?.content\n\t\tconst text =\n\t\t\ttypeof content === \"string\"\n\t\t\t\t? content\n\t\t\t\t: Array.isArray(content)\n\t\t\t\t\t? content\n\t\t\t\t\t\t\t.filter(\n\t\t\t\t\t\t\t\t(item): item is { type: \"text\"; text: string } =>\n\t\t\t\t\t\t\t\t\ttypeof item === \"object\" &&\n\t\t\t\t\t\t\t\t\titem !== null &&\n\t\t\t\t\t\t\t\t\t(item as { type?: string }).type === \"text\" &&\n\t\t\t\t\t\t\t\t\ttypeof (item as { text?: unknown }).text === \"string\",\n\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t.map((item) => item.text)\n\t\t\t\t\t\t\t.join(\"\\n\")\n\t\t\t\t\t: \"\"\n\t\tif (!text) continue\n\t\tif (NON_HUMAN_TEXT.test(text)) continue\n\t\tturns.push(text.replace(/[\\n\\r]+/g, \" ⏎ \").slice(0, TURN_CHAR_CAP))\n\t}\n\treturn turns\n}\n\nexport interface DigestMeta {\n\tsource: string\n\tproject: string\n\tsession: string\n}\n\nexport function renderDigest(meta: DigestMeta, turns: string[]): string {\n\tconst numbered = turns\n\t\t.map((t, i) => `${String(i + 1).padStart(3, \" \")} | ${t}`)\n\t\t.join(\"\\n\")\n\treturn `# source: ${meta.source}\n# project: ${meta.project}\n# session: ${meta.session}\n# human_turns: ${turns.length}\n# To understand WHY a terse turn or interruption happened, grep its text in the\n# source file above and read the assistant turn(s) immediately before it.\n\n${numbered}\n`\n}\n","import { createHash } from \"node:crypto\"\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\"\nimport { basename, join } from \"node:path\"\nimport { humanTurns, renderDigest } from \"./transcript.ts\"\n\n// One minable unit = one top-level transcript $PROJECTS/<project>/<session>.jsonl.\n// Its catalog home mirrors that path: $CATALOG/<project>/<session>/observations.md.\nexport interface Unit {\n\tproject: string\n\tsession: string\n\tjsonlPath: string\n}\n\nexport function enumerateUnits(projectsDir: string): Unit[] {\n\tlet projects: string[]\n\ttry {\n\t\tprojects = readdirSync(projectsDir).sort()\n\t} catch {\n\t\tthrow new Error(`no transcripts directory at ${projectsDir}`)\n\t}\n\tconst units: Unit[] = []\n\tfor (const project of projects) {\n\t\tconst dir = join(projectsDir, project)\n\t\tif (!statSync(dir).isDirectory()) continue\n\t\tfor (const f of readdirSync(dir).sort()) {\n\t\t\tif (!f.endsWith(\".jsonl\")) continue\n\t\t\tunits.push({\n\t\t\t\tproject,\n\t\t\t\tsession: basename(f, \".jsonl\"),\n\t\t\t\tjsonlPath: join(dir, f),\n\t\t\t})\n\t\t}\n\t}\n\treturn units\n}\n\nexport function observationsPath(catalogDir: string, unit: Unit): string {\n\treturn join(catalogDir, unit.project, unit.session, \"observations.md\")\n}\n\nexport type UnitStatus =\n\t// hash matches the recorded source_sha256 — skipped without parsing\n\t| { kind: \"mined\" }\n\t| { kind: \"below-threshold\"; turns: number }\n\t| {\n\t\t\tkind: \"pending\"\n\t\t\treason: \"new\" | \"changed\"\n\t\t\tturns: number\n\t\t\tdigest: string\n\t\t\tsourceSha256: string\n\t }\n\t// transcript has an unparseable line; reported visibly, never mined\n\t| { kind: \"unreadable\"; error: string }\n\n// Pure decision core: everything already read, nothing left but judgment.\nexport function decideUnitStatus(input: {\n\tunit: Unit\n\tjsonl: Buffer\n\trecordedSha256: string | null\n\tminTurns: number\n}): UnitStatus {\n\tconst sourceSha256 = createHash(\"sha256\").update(input.jsonl).digest(\"hex\")\n\tif (input.recordedSha256 === sourceSha256) return { kind: \"mined\" }\n\tlet turns: string[]\n\ttry {\n\t\tturns = humanTurns(input.jsonl.toString(\"utf-8\"))\n\t} catch (e) {\n\t\treturn { kind: \"unreadable\", error: String(e) }\n\t}\n\tif (turns.length < input.minTurns)\n\t\treturn { kind: \"below-threshold\", turns: turns.length }\n\treturn {\n\t\tkind: \"pending\",\n\t\treason: input.recordedSha256 === null ? \"new\" : \"changed\",\n\t\tturns: turns.length,\n\t\tdigest: renderDigest(\n\t\t\t{\n\t\t\t\tsource: input.unit.jsonlPath,\n\t\t\t\tproject: input.unit.project,\n\t\t\t\tsession: input.unit.session,\n\t\t\t},\n\t\t\tturns,\n\t\t),\n\t\tsourceSha256,\n\t}\n}\n\n// Shell: read the unit's inputs, then decide purely.\nexport function unitStatus(unit: Unit, catalogDir: string, minTurns: number): UnitStatus {\n\tconst obsPath = observationsPath(catalogDir, unit)\n\treturn decideUnitStatus({\n\t\tunit,\n\t\tjsonl: readFileSync(unit.jsonlPath),\n\t\trecordedSha256: existsSync(obsPath)\n\t\t\t? readSourceSha256(readFileSync(obsPath, \"utf-8\"))\n\t\t\t: null,\n\t\tminTurns,\n\t})\n}\n\nexport interface ObservationsMeta {\n\tsource: string\n\tsourceSha256: string\n\tminedAt: string\n\tmodel: string\n\thumanTurns: number\n}\n\n// The host composes the final file: frontmatter it alone can vouch for, then\n// the agent's report verbatim.\nexport function composeObservationsFile(meta: ObservationsMeta, body: string): string {\n\treturn `---\nsource: ${meta.source}\nsource_sha256: ${meta.sourceSha256}\nmined_at: ${meta.minedAt}\nmodel: ${meta.model}\nhuman_turns: ${meta.humanTurns}\n---\n\n${body.trimEnd()}\n`\n}\n\nexport function readSourceSha256(observationsMd: string): string | null {\n\treturn observationsMd.match(/^source_sha256: ([0-9a-f]{64})$/m)?.[1] ?? null\n}\n","import { mkdirSync, mkdtempSync, writeFileSync } from \"node:fs\"\nimport { tmpdir } from \"node:os\"\nimport { dirname, join } from \"node:path\"\nimport PQueue from \"p-queue\"\nimport {\n\tcomposeObservationsFile,\n\tenumerateUnits,\n\tobservationsPath,\n\ttype Unit,\n\ttype UnitStatus,\n\tunitStatus,\n} from \"./catalog.ts\"\nimport { claudeAgent, progressSink } from \"./claude.ts\"\nimport { composeMinePrompt, PROMPT_FILE } from \"./contract.ts\"\nimport { runAgent } from \"./runner.ts\"\n\nexport interface MineOpts {\n\tprojects: string\n\tcatalog: string\n\tmodel: string\n\tparallel: number\n\tminTurns: number\n\tlimit?: number\n\tdryRun: boolean\n}\n\nexport interface Pending {\n\tunit: Unit\n\tstatus: Extract<UnitStatus, { kind: \"pending\" }>\n}\n\n// What one mine invocation will and won't do, decided purely from the\n// per-unit statuses.\nexport interface MinePlan {\n\ttodo: Pending[]\n\tpendingTotal: number\n\tmined: number\n\tbelowThreshold: number\n\tunreadable: { unit: Unit; error: string }[]\n}\n\nexport function planMine(\n\tentries: { unit: Unit; status: UnitStatus }[],\n\tlimit?: number,\n): MinePlan {\n\tconst pending = entries.filter((e): e is Pending => e.status.kind === \"pending\")\n\treturn {\n\t\ttodo: limit ? pending.slice(0, limit) : pending,\n\t\tpendingTotal: pending.length,\n\t\tmined: entries.filter((e) => e.status.kind === \"mined\").length,\n\t\tbelowThreshold: entries.filter((e) => e.status.kind === \"below-threshold\").length,\n\t\tunreadable: entries.flatMap((e) =>\n\t\t\te.status.kind === \"unreadable\"\n\t\t\t\t? [{ unit: e.unit, error: e.status.error }]\n\t\t\t\t: [],\n\t\t),\n\t}\n}\n\nexport function summarizeMinePlan(plan: MinePlan, minTurns: number): string {\n\tconst total =\n\t\tplan.pendingTotal + plan.mined + plan.belowThreshold + plan.unreadable.length\n\tconst limited =\n\t\tplan.todo.length !== plan.pendingTotal ? ` (mining ${plan.todo.length})` : \"\"\n\treturn `${total} transcripts: ${plan.pendingTotal} pending${limited}, ${plan.mined} mined, ${plan.belowThreshold} below ${minTurns} turns, ${plan.unreadable.length} unreadable`\n}\n\nexport function dryRunRow({ unit, status }: Pending): string {\n\treturn `${status.reason.padEnd(7)} ${status.turns.toString().padStart(4)} turns ${unit.project}/${unit.session}`\n}\n\nasync function mineOneUnit(\n\t{ unit, status }: Pending,\n\topts: MineOpts,\n\ttoken: string,\n): Promise<boolean> {\n\tconst tag = unit.session.slice(0, 8)\n\tconst workdir = mkdtempSync(join(tmpdir(), `doublecheck-mine-${tag}-`))\n\twriteFileSync(join(workdir, PROMPT_FILE), composeMinePrompt(status.digest))\n\tconst spec = claudeAgent({ token, model: opts.model, workdir })\n\tprocess.stderr.write(\n\t\t`[${tag}] mining ${unit.project}/${unit.session} (${status.turns} turns, ${status.reason})\\n`,\n\t)\n\tconst outcome = await runAgent({\n\t\tmount: opts.projects,\n\t\tworkdir,\n\t\tspec,\n\t\tnetwork: \"anthropic-only\",\n\t\tonLine: progressSink(tag),\n\t})\n\t// A failed mine writes NOTHING — the catalog is a durable asset; the next\n\t// run retries this unit because no hash gets recorded.\n\tif (!outcome.ok) {\n\t\tprocess.stderr.write(`[${tag}] FAILED (${outcome.reason})\\n`)\n\t\treturn false\n\t}\n\tconst obsPath = observationsPath(opts.catalog, unit)\n\tmkdirSync(dirname(obsPath), { recursive: true })\n\twriteFileSync(\n\t\tobsPath,\n\t\tcomposeObservationsFile(\n\t\t\t{\n\t\t\t\tsource: unit.jsonlPath,\n\t\t\t\tsourceSha256: status.sourceSha256,\n\t\t\t\tminedAt: new Date().toISOString(),\n\t\t\t\tmodel: opts.model,\n\t\t\t\thumanTurns: status.turns,\n\t\t\t},\n\t\t\toutcome.report,\n\t\t),\n\t)\n\tprocess.stderr.write(`[${tag}] observations: ${obsPath}\\n`)\n\treturn true\n}\n\n// The mine workflow shell: gather statuses (I/O), plan purely, then either\n// print the plan (dry-run) or run one sandboxed agent per pending unit.\n// Returns true when nothing it attempted failed.\nexport async function runMine(opts: MineOpts): Promise<boolean> {\n\tconst entries = enumerateUnits(opts.projects).map((unit) => ({\n\t\tunit,\n\t\tstatus: unitStatus(unit, opts.catalog, opts.minTurns),\n\t}))\n\tconst plan = planMine(entries, opts.limit)\n\tfor (const { unit, error } of plan.unreadable) {\n\t\tprocess.stderr.write(`UNREADABLE ${unit.project}/${unit.session}: ${error}\\n`)\n\t}\n\tprocess.stderr.write(`${summarizeMinePlan(plan, opts.minTurns)}\\n`)\n\n\tif (opts.dryRun) {\n\t\tfor (const pending of plan.todo) console.log(dryRunRow(pending))\n\t\treturn true\n\t}\n\tif (plan.todo.length === 0) return true\n\n\tconst token = process.env.CLAUDE_CODE_OAUTH_TOKEN\n\tif (!token) {\n\t\tthrow new Error(\n\t\t\t\"CLAUDE_CODE_OAUTH_TOKEN is required in the environment (it is injected into each miner's sandbox)\",\n\t\t)\n\t}\n\tconst queue = new PQueue({ concurrency: opts.parallel })\n\tconst results = await Promise.all(\n\t\tplan.todo.map((p) => queue.add(() => mineOneUnit(p, opts, token))),\n\t)\n\tconst failed = results.filter((ok) => ok !== true).length\n\tprocess.stderr.write(\n\t\t`mined ${results.length - failed}/${results.length}${failed ? `, ${failed} FAILED` : \"\"}\\n`,\n\t)\n\treturn failed === 0\n}\n","#!/usr/bin/env node\nimport { homedir } from \"node:os\"\nimport { join, resolve } from \"node:path\"\nimport { Command } from \"commander\"\nimport { runChecks } from \"./check.ts\"\nimport { runMine } from \"./mine.ts\"\n\nfunction parsePositiveInt(flag: string) {\n\treturn (v: string): number => {\n\t\tconst n = Number.parseInt(v, 10)\n\t\tif (!Number.isInteger(n) || n < 1)\n\t\t\tthrow new Error(`${flag} must be a positive integer, got \"${v}\"`)\n\t\treturn n\n\t}\n}\n\nconst program = new Command()\n\t.name(\"doublecheck\")\n\t.description(\n\t\t\"Run self-authored LLM code-inspectors (.agents/checks/*.md) against a project, one sandboxed agent per check\",\n\t)\n\nprogram\n\t.command(\"check\")\n\t.option(\"--project <dir>\", \"project to inspect\", process.cwd())\n\t.option(\"--model <model>\", \"model for the inspector agents\", \"haiku\")\n\t.option(\"--parallel <n>\", \"max concurrent checks\", parsePositiveInt(\"--parallel\"), 4)\n\t.option(\"--output <dir>\", \"reports root (default: $PROJECT/.doublecheck)\")\n\t.option(\n\t\t\"--check <name>\",\n\t\t\"run only this check (repeatable)\",\n\t\t(v: string, prev: string[]) => [...prev, v],\n\t\t[] as string[],\n\t)\n\t.action(async (opts) => {\n\t\tconst project = resolve(opts.project)\n\t\tconst ok = await runChecks({\n\t\t\tproject,\n\t\t\tmodel: opts.model,\n\t\t\tparallel: opts.parallel,\n\t\t\toutput: opts.output ? resolve(opts.output) : join(project, \".doublecheck\"),\n\t\t\tonly: opts.check,\n\t\t})\n\t\tif (!ok) process.exitCode = 1\n\t})\n\nprogram\n\t.command(\"mine\")\n\t.option(\n\t\t\"--projects <dir>\",\n\t\t\"Claude Code transcripts root\",\n\t\tjoin(homedir(), \".claude\", \"projects\"),\n\t)\n\t.option(\n\t\t\"--catalog <dir>\",\n\t\t\"observation catalog root\",\n\t\tjoin(homedir(), \".doublecheck\", \"catalog\"),\n\t)\n\t.option(\"--model <model>\", \"model for the mining agents\", \"opus\")\n\t.option(\"--parallel <n>\", \"max concurrent miners\", parsePositiveInt(\"--parallel\"), 4)\n\t.option(\n\t\t\"--min-turns <n>\",\n\t\t\"min genuine human turns for a real conversation\",\n\t\tparsePositiveInt(\"--min-turns\"),\n\t\t2,\n\t)\n\t.option(\"--limit <n>\", \"mine at most N pending units\", parsePositiveInt(\"--limit\"))\n\t.option(\"--dry-run\", \"list what would be mined without booting anything\")\n\t.action(async (opts) => {\n\t\tconst ok = await runMine({\n\t\t\tprojects: resolve(opts.projects),\n\t\t\tcatalog: resolve(opts.catalog),\n\t\t\tmodel: opts.model,\n\t\t\tparallel: opts.parallel,\n\t\t\tminTurns: opts.minTurns,\n\t\t\tlimit: opts.limit,\n\t\t\tdryRun: !!opts.dryRun,\n\t\t})\n\t\tif (!ok) process.exitCode = 1\n\t})\n\nprogram.parseAsync().catch((e: unknown) => {\n\tconsole.error(e instanceof Error ? e.message : String(e))\n\tprocess.exit(1)\n})\n"],"mappings":";;;;;;;;;AAKA,MAAa,cAAc;AAG3B,MAAa,cAAc;AAK3B,SAAS,eAAe,UAAkB,aAA6B;CACtE,OAAO;;+EAEuE,YAAY;;iBAE1E,YAAY,0CAA0C,SAAS,uGAAuG,YAAY;AACnM;AAIA,SAAgB,cAAc,WAAmB,SAAyB;CACzE,OAAO;;;;2DAImD,QAAQ;;;;;;EAMjE,UAAU;;;;EAIV,eAAe,cAAc,oBAAoB;AACnD;AAIA,SAAgB,kBAAkB,QAAwB;CACzD,OAAO;;;;;;;;;;;;EAYN,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkCP,eAAe,UAAU,wBAAwB;AACnD;;;;ACxFA,MAAM,aAAa;AAGnB,SAAgB,YAAY,MAId;CACb,OAAO;EAKN,SACC,4KAUoE;EACrE,KAAK;GACJ,MAAM;GACN,YAAY;GACZ,oBAAoB;GACpB,iBAAiB,KAAK;GACtB,yBAAyB,KAAK;EAC/B;EAGA,OAAO,CACN;GACC,MAAM,GAAG,WAAW;GACpB,SAAS,GAAG,KAAK,UAChB,EAAE,UAAU,GAAG,KAAK,UAAU,EAAE,wBAAwB,KAAK,EAAE,EAAE,GACjE,MACA,GACD,EAAE;EACH,CACD;CACD;AACD;AAWA,SAAgB,mBAAmB,MAA6B;CAC/D,IAAI;CACJ,IAAI;EACH,MAAM,KAAK,MAAM,IAAI;CACtB,QAAQ;EACP,OAAO;CACR;CACA,MAAM,SAAS,IAAI,QAAQ,OAAO,IAAI,UAAU,IAAI,IAAI,YAAY;CAEpE,IAAI,UAAU,0BAA0B,OAAO;CAC/C,IAAI,SAAS;CACb,IAAI,IAAI,SAAS,aAAa;EAC7B,MAAM,UAAU,IAAI,SAAS,WAAW,CAAC;EACzC,MAAM,QAAQ,QACZ,QAAQ,MAAM,EAAE,SAAS,cAAc,EAAE,IAAI,CAAC,CAC9C,KAAK,MAAM,EAAE,IAAI;EACnB,MAAM,QAAQ,QAAQ,QAAQ,GAAG,MAAM,KAAK,EAAE,MAAM,UAAU,IAAI,CAAC;EACnE,SAAS,MAAM,SAAS,IAAI,MAAM,KAAK,GAAG,MAAM,IAAI,MAAM;CAC3D,OAAO,IAAI,IAAI,SAAS,UACvB,SAAS,IAAI,IAAI,QAAQ,MAAM,IAAI,eAAe,KAAK,IAAI,CAAE,QAAQ,CAAC,EAAE;CAEzE,OAAO,IAAI,MAAM,GAAG;AACrB;AAKA,SAAgB,aACf,OACoD;CACpD,QAAQ,MAAM,SAAS;EACtB,IAAI,SAAS,UAAU;GACtB,QAAQ,OAAO,MAAM,IAAI,MAAM,MAAM,KAAK,GAAG;GAC7C;EACD;EACA,MAAM,YAAY,mBAAmB,IAAI;EACzC,IAAI,WAAW,QAAQ,OAAO,MAAM,IAAI,MAAM,IAAI,UAAU,GAAG;CAChE;AACD;;;;AC7EA,MAAM,cAAc;AACpB,MAAM,mBAAmB;AAwBzB,SAAgB,UACf,OACA,OACqC;CACrC,MAAM,SAAS,QAAQ,MAAK,CAAE,MAAM,IAAI;CACxC,MAAM,OAAO,MAAM,IAAI,KAAK;CAC5B,OAAO;EAAE,OAAO,MAAM,QAAQ,MAAM,EAAE,KAAK,CAAC;EAAG,OAAO;CAAK;AAC5D;AAGA,SAAgB,cACf,UACA,QACA,SACe;CACf,IAAI,aAAa,GAChB,OAAO;EACN,IAAI;EACJ,QAAQ,wBAAwB;EAChC,eAAe;CAChB;CAED,IAAI,WAAW,MACd,OAAO;EACN,IAAI;EACJ,QAAQ,+BAA+B,YAAY,cAAc,QAAQ;EACzE,eAAe;CAChB;CAED,OAAO;EAAE,IAAI;EAAM;CAAO;AAC3B;AAuBA,eAAsB,SAAS,MAA2C;CACzE,MAAM,eAAe,MAAM,OAAO;CAClC,MAAM,OAAO,eAAe,SAAS,KAAK,OAAO;CACjD,MAAM,SACL,KAAK,YAAY,QACd,aAAa,cAAc,SAAS,IACpC,aAAa,cAAc,QAAQ,CAAC,CACnC,YAAY,CAAC,CACb,QAAQ,OAAO,GAAG,OAAO,MAAM,EAAE,aAAa,eAAe,CAAC,CAAC,CAAC,CAChE,MAAM;CACX,IAAI,UAA4D;CAChE,IAAI;EACH,IAAI;GACH,UAAU,MAAM,aAAa,QAAQ,QAAQ,IAAI,CAAC,CAChD,MAAM,WAAW,CAAC,CAClB,OAAO,gBAAgB,CAAC,CACxB,WAAW,OAAO,CAAC,CACnB,QAAQ,CAAC,CACT,QAAQ,KAAK,OAAO,CAAC,CACrB,KAAK,KAAK,KAAK,GAAG,CAAC,CACnB,OAAO,KAAK,QAAQ,OAAqB,GAAG,KAAK,KAAK,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC,CACxE,OAAO,KAAK,UAAU,OAAqB,GAAG,KAAK,KAAK,OAAO,CAAC,CAAC,CACjE,OAAO,OAAqB;IAC5B,IAAI,IAAI;IACR,KAAK,MAAM,KAAK,KAAK,KAAK,OACzB,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS;KAAE,MAAM;KAAO,SAAS;IAAK,CAAC;IAE7D,OAAO;GACR,CAAC,CAAC,CACD,SAAS,OAAuB,GAAG,OAAO,MAAM,CAAC,CAAC,CAClD,OAAO;EACV,SAAS,GAAG;GACX,IAAI,OAAO,CAAC,CAAC,CAAC,SAAS,YAAY,GAClC,MAAM,IAAI,MACT,eAAe,YAAY,wEAAwE,OAAO,CAAC,EAAE,EAC9G;GAED,MAAM;EACP;EAEA,MAAM,SAAS,MAAM,QAAQ,eAAe,SAAS,MACpD,EAAE,KAAK,CAAC,MAAM,KAAK,KAAK,OAAO,CAAC,CACjC;EAEA,MAAM,QAA6C;GAAE,QAAQ;GAAI,QAAQ;EAAG;EAC5E,IAAI,WAA0B;EAC9B,SAAS;GACR,MAAM,KAAuB,MAAM,OAAO,KAAK;GAC/C,IAAI,OAAO,MAAM;GACjB,IAAI,GAAG,SAAS,YAAY,GAAG,SAAS,UAAU;IACjD,MAAM,MAAM,UAAU,MAAM,GAAG,OAAO,OAAO,KAAK,GAAG,IAAI,CAAC,CAAC,SAAS,CAAC;IACrE,MAAM,GAAG,QAAQ,IAAI;IACrB,KAAK,MAAM,QAAQ,IAAI,OAAO,KAAK,OAAO,GAAG,MAAM,IAAI;GACxD,OAAO,IAAI,GAAG,SAAS,UAAU,WAAW,GAAG;EAChD;EACA,KAAK,MAAM,QAAQ,CAAC,UAAU,QAAQ,GACrC,IAAI,MAAM,KAAK,CAAC,KAAK,GAAG,KAAK,OAAO,MAAM,MAAM,KAAK;EAGtD,MAAM,aAAa,KAAK,KAAK,SAAS,WAAW;EACjD,MAAM,SAAS,WAAW,UAAU,IAAI,aAAa,YAAY,OAAO,IAAI;EAC5E,OAAO,cAAc,UAAU,QAAQ,KAAK,OAAO;CACpD,UAAU;EACT,IAAI,SAAS;GACZ,IAAI;IACH,MAAM,QAAQ,KAAK;GACpB,SAAS,GAAG;IACX,KAAK,OAAO,UAAU,wBAAwB,OAAO,CAAC,GAAG;GAC1D;GACA,IAAI;IACH,MAAM,aAAa,QAAQ,OAAO,IAAI;GACvC,SAAS,GAAG;IACX,KAAK,OAAO,UAAU,0BAA0B,OAAO,CAAC,GAAG;GAC5D;EACD;CACD;AACD;;;;AC5JA,SAAgB,aAAa,KAAc,MAAgB,KAAsB;CAChF,IAAI,IAAI,WAAW,GAAG,MAAM,IAAI,MAAM,uBAAuB,KAAK;CAClE,IAAI,KAAK,WAAW,GAAG,OAAO;CAC9B,MAAM,SAAS,IAAI,IAAI,IAAI,KAAK,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;CAClD,OAAO,KAAK,KAAK,SAAS;EACzB,MAAM,QAAQ,OAAO,IAAI,IAAI;EAC7B,IAAI,CAAC,OAAO;GACX,MAAM,OAAO,IAAI,KAAK,MAAM,EAAE,IAAI,CAAC,CAAC,KAAK,IAAI;GAC7C,MAAM,IAAI,MAAM,mBAAmB,KAAK,OAAO,IAAI,UAAU,KAAK,EAAE;EACrE;EACA,OAAO;CACR,CAAC;AACF;AAGA,SAAgB,eAAe,SAAiB,MAAyB;CACxE,MAAM,MAAM,KAAK,SAAS,WAAW,QAAQ;CAC7C,IAAI;CACJ,IAAI;EACH,UAAU,YAAY,GAAG;CAC1B,QAAQ;EACP,MAAM,IAAI,MAAM,0BAA0B,KAAK;CAChD;CAQA,OAAO,aAPK,QACV,QAAQ,MAAM,EAAE,SAAS,KAAK,CAAC,CAAC,CAChC,KAAK,CAAC,CACN,KAAK,OAAO;EACZ,MAAM,EAAE,MAAM,GAAG,EAAa;EAC9B,MAAM,aAAa,KAAK,KAAK,CAAC,GAAG,OAAO;CACzC,EACqB,GAAG,MAAM,GAAG;AACnC;AAYA,SAAgB,aAAa,GAAiB;CAC7C,MAAM,KAAK,MAAc,OAAO,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG;CAClD,OAAO,GAAG,EAAE,YAAY,EAAE,GAAG,EAAE,EAAE,SAAS,IAAI,CAAC,EAAE,GAAG,EAAE,EAAE,QAAQ,CAAC,EAAE,GAAG,EAAE,EAAE,SAAS,CAAC,IAAI,EAAE,EAAE,WAAW,CAAC,IAAI,EAAE,EAAE,WAAW,CAAC;AAC7H;AAIA,SAAgB,cAAc,QAAgB,eAAsC;CAInF,OAAO,qBAAqB,OAAO,IAHnB,gBACb,oBAAoB,YAAY,yBAAyB,kBACzD;AAEJ;AAEA,eAAe,YACd,OACA,MACA,OACA,QACmB;CACnB,MAAM,UAAU,YAAY,KAAK,OAAO,GAAG,eAAe,MAAM,KAAK,EAAE,CAAC;CACxE,cAAc,KAAK,SAAS,WAAW,GAAG,cAAc,MAAM,MAAM,KAAK,OAAO,CAAC;CACjF,MAAM,OAAO,YAAY;EAAE;EAAO,OAAO,KAAK;EAAO;CAAQ,CAAC;CAC9D,MAAM,UAAU,MAAM,SAAS;EAC9B,OAAO,KAAK;EACZ;EACA;EACA,SAAS;EACT,QAAQ,aAAa,MAAM,IAAI;CAChC,CAAC;CACD,MAAM,aAAa,KAAK,QAAQ,GAAG,MAAM,KAAK,IAAI;CAClD,IAAI,QAAQ,IAAI;EACf,cAAc,YAAY,QAAQ,MAAM;EACxC,QAAQ,OAAO,MAAM,IAAI,MAAM,KAAK,YAAY,WAAW,GAAG;EAC9D,OAAO;CACR;CACA,cAAc,YAAY,cAAc,QAAQ,QAAQ,QAAQ,aAAa,CAAC;CAC9E,QAAQ,OAAO,MAAM,IAAI,MAAM,KAAK,YAAY,QAAQ,OAAO,KAAK,WAAW,GAAG;CAClF,OAAO;AACR;AAKA,eAAsB,UAAU,MAA2C;CAC1E,MAAM,QAAQ,QAAQ,IAAI;CAC1B,IAAI,CAAC,OACJ,MAAM,IAAI,MACT,mGACD;CAED,MAAM,SAAS,eAAe,KAAK,SAAS,KAAK,IAAI;CACrD,MAAM,SAAS,KAAK,KAAK,QAAQ,6BAAa,IAAI,KAAK,CAAC,CAAC;CACzD,UAAU,QAAQ,EAAE,WAAW,KAAK,CAAC;CACrC,QAAQ,OAAO,MACd,GAAG,OAAO,OAAO,oBAAoB,KAAK,QAAQ,UAAU,KAAK,MAAM,aAAa,KAAK,SAAS,IACnG;CACA,MAAM,QAAQ,IAAI,OAAO,EAAE,aAAa,KAAK,SAAS,CAAC;CAIvD,QAAO,MAHe,QAAQ,IAC7B,OAAO,KAAK,UAAU,MAAM,UAAU,YAAY,OAAO,MAAM,OAAO,MAAM,CAAC,CAAC,CAC/E,EACc,CAAC,OAAO,OAAO,OAAO,IAAI;AACzC;;;;ACnHA,MAAM,gBAAgB;AAStB,MAAM,iBACL;AAID,SAAgB,WAAW,OAAyB;CACnD,MAAM,QAAkB,CAAC;CACzB,KAAK,MAAM,QAAQ,MAAM,MAAM,IAAI,GAAG;EACrC,IAAI,CAAC,KAAK,KAAK,GAAG;EAClB,MAAM,MAAM,KAAK,MAAM,IAAI;EAC3B,IAAI,IAAI,SAAS,UAAU,IAAI,UAAU,IAAI,aAAa;EAC1D,MAAM,UAAU,IAAI,SAAS;EAC7B,MAAM,OACL,OAAO,YAAY,WAChB,UACA,MAAM,QAAQ,OAAO,IACpB,QACC,QACC,SACA,OAAO,SAAS,YAChB,SAAS,QACR,KAA2B,SAAS,UACrC,OAAQ,KAA4B,SAAS,QAC/C,CAAC,CACA,KAAK,SAAS,KAAK,IAAI,CAAC,CACxB,KAAK,IAAI,IACV;EACL,IAAI,CAAC,MAAM;EACX,IAAI,eAAe,KAAK,IAAI,GAAG;EAC/B,MAAM,KAAK,KAAK,QAAQ,YAAY,KAAK,CAAC,CAAC,MAAM,GAAG,aAAa,CAAC;CACnE;CACA,OAAO;AACR;AAQA,SAAgB,aAAa,MAAkB,OAAyB;CACvE,MAAM,WAAW,MACf,KAAK,GAAG,MAAM,GAAG,OAAO,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,GAAG,EAAE,MAAM,GAAG,CAAC,CAC1D,KAAK,IAAI;CACX,OAAO,cAAc,KAAK,OAAO;aACrB,KAAK,QAAQ;aACb,KAAK,QAAQ;iBACT,MAAM,OAAO;;;;EAI5B,SAAS;;AAEX;;;;ACzDA,SAAgB,eAAe,aAA6B;CAC3D,IAAI;CACJ,IAAI;EACH,WAAW,YAAY,WAAW,CAAC,CAAC,KAAK;CAC1C,QAAQ;EACP,MAAM,IAAI,MAAM,+BAA+B,aAAa;CAC7D;CACA,MAAM,QAAgB,CAAC;CACvB,KAAK,MAAM,WAAW,UAAU;EAC/B,MAAM,MAAM,KAAK,aAAa,OAAO;EACrC,IAAI,CAAC,SAAS,GAAG,CAAC,CAAC,YAAY,GAAG;EAClC,KAAK,MAAM,KAAK,YAAY,GAAG,CAAC,CAAC,KAAK,GAAG;GACxC,IAAI,CAAC,EAAE,SAAS,QAAQ,GAAG;GAC3B,MAAM,KAAK;IACV;IACA,SAAS,SAAS,GAAG,QAAQ;IAC7B,WAAW,KAAK,KAAK,CAAC;GACvB,CAAC;EACF;CACD;CACA,OAAO;AACR;AAEA,SAAgB,iBAAiB,YAAoB,MAAoB;CACxE,OAAO,KAAK,YAAY,KAAK,SAAS,KAAK,SAAS,iBAAiB;AACtE;AAiBA,SAAgB,iBAAiB,OAKlB;CACd,MAAM,eAAe,WAAW,QAAQ,CAAC,CAAC,OAAO,MAAM,KAAK,CAAC,CAAC,OAAO,KAAK;CAC1E,IAAI,MAAM,mBAAmB,cAAc,OAAO,EAAE,MAAM,QAAQ;CAClE,IAAI;CACJ,IAAI;EACH,QAAQ,WAAW,MAAM,MAAM,SAAS,OAAO,CAAC;CACjD,SAAS,GAAG;EACX,OAAO;GAAE,MAAM;GAAc,OAAO,OAAO,CAAC;EAAE;CAC/C;CACA,IAAI,MAAM,SAAS,MAAM,UACxB,OAAO;EAAE,MAAM;EAAmB,OAAO,MAAM;CAAO;CACvD,OAAO;EACN,MAAM;EACN,QAAQ,MAAM,mBAAmB,OAAO,QAAQ;EAChD,OAAO,MAAM;EACb,QAAQ,aACP;GACC,QAAQ,MAAM,KAAK;GACnB,SAAS,MAAM,KAAK;GACpB,SAAS,MAAM,KAAK;EACrB,GACA,KACD;EACA;CACD;AACD;AAGA,SAAgB,WAAW,MAAY,YAAoB,UAA8B;CACxF,MAAM,UAAU,iBAAiB,YAAY,IAAI;CACjD,OAAO,iBAAiB;EACvB;EACA,OAAO,aAAa,KAAK,SAAS;EAClC,gBAAgB,WAAW,OAAO,IAC/B,iBAAiB,aAAa,SAAS,OAAO,CAAC,IAC/C;EACH;CACD,CAAC;AACF;AAYA,SAAgB,wBAAwB,MAAwB,MAAsB;CACrF,OAAO;UACE,KAAK,OAAO;iBACL,KAAK,aAAa;YACvB,KAAK,QAAQ;SAChB,KAAK,MAAM;eACL,KAAK,WAAW;;;EAG7B,KAAK,QAAQ,EAAE;;AAEjB;AAEA,SAAgB,iBAAiB,gBAAuC;CACvE,OAAO,eAAe,MAAM,kCAAkC,CAAC,GAAG,MAAM;AACzE;;;;ACpFA,SAAgB,SACf,SACA,OACW;CACX,MAAM,UAAU,QAAQ,QAAQ,MAAoB,EAAE,OAAO,SAAS,SAAS;CAC/E,OAAO;EACN,MAAM,QAAQ,QAAQ,MAAM,GAAG,KAAK,IAAI;EACxC,cAAc,QAAQ;EACtB,OAAO,QAAQ,QAAQ,MAAM,EAAE,OAAO,SAAS,OAAO,CAAC,CAAC;EACxD,gBAAgB,QAAQ,QAAQ,MAAM,EAAE,OAAO,SAAS,iBAAiB,CAAC,CAAC;EAC3E,YAAY,QAAQ,SAAS,MAC5B,EAAE,OAAO,SAAS,eACf,CAAC;GAAE,MAAM,EAAE;GAAM,OAAO,EAAE,OAAO;EAAM,CAAC,IACxC,CAAC,CACL;CACD;AACD;AAEA,SAAgB,kBAAkB,MAAgB,UAA0B;CAC3E,MAAM,QACL,KAAK,eAAe,KAAK,QAAQ,KAAK,iBAAiB,KAAK,WAAW;CACxE,MAAM,UACL,KAAK,KAAK,WAAW,KAAK,eAAe,YAAY,KAAK,KAAK,OAAO,KAAK;CAC5E,OAAO,GAAG,MAAM,gBAAgB,KAAK,aAAa,UAAU,QAAQ,IAAI,KAAK,MAAM,UAAU,KAAK,eAAe,SAAS,SAAS,UAAU,KAAK,WAAW,OAAO;AACrK;AAEA,SAAgB,UAAU,EAAE,MAAM,UAA2B;CAC5D,OAAO,GAAG,OAAO,OAAO,OAAO,CAAC,EAAE,GAAG,OAAO,MAAM,SAAS,CAAC,CAAC,SAAS,CAAC,EAAE,UAAU,KAAK,QAAQ,GAAG,KAAK;AACzG;AAEA,eAAe,YACd,EAAE,MAAM,UACR,MACA,OACmB;CACnB,MAAM,MAAM,KAAK,QAAQ,MAAM,GAAG,CAAC;CACnC,MAAM,UAAU,YAAY,KAAK,OAAO,GAAG,oBAAoB,IAAI,EAAE,CAAC;CACtE,cAAc,KAAK,SAAS,WAAW,GAAG,kBAAkB,OAAO,MAAM,CAAC;CAC1E,MAAM,OAAO,YAAY;EAAE;EAAO,OAAO,KAAK;EAAO;CAAQ,CAAC;CAC9D,QAAQ,OAAO,MACd,IAAI,IAAI,WAAW,KAAK,QAAQ,GAAG,KAAK,QAAQ,IAAI,OAAO,MAAM,UAAU,OAAO,OAAO,IAC1F;CACA,MAAM,UAAU,MAAM,SAAS;EAC9B,OAAO,KAAK;EACZ;EACA;EACA,SAAS;EACT,QAAQ,aAAa,GAAG;CACzB,CAAC;CAGD,IAAI,CAAC,QAAQ,IAAI;EAChB,QAAQ,OAAO,MAAM,IAAI,IAAI,YAAY,QAAQ,OAAO,IAAI;EAC5D,OAAO;CACR;CACA,MAAM,UAAU,iBAAiB,KAAK,SAAS,IAAI;CACnD,UAAU,QAAQ,OAAO,GAAG,EAAE,WAAW,KAAK,CAAC;CAC/C,cACC,SACA,wBACC;EACC,QAAQ,KAAK;EACb,cAAc,OAAO;EACrB,0BAAS,IAAI,KAAK,EAAC,CAAC,YAAY;EAChC,OAAO,KAAK;EACZ,YAAY,OAAO;CACpB,GACA,QAAQ,MACT,CACD;CACA,QAAQ,OAAO,MAAM,IAAI,IAAI,kBAAkB,QAAQ,GAAG;CAC1D,OAAO;AACR;AAKA,eAAsB,QAAQ,MAAkC;CAK/D,MAAM,OAAO,SAJG,eAAe,KAAK,QAAQ,CAAC,CAAC,KAAK,UAAU;EAC5D;EACA,QAAQ,WAAW,MAAM,KAAK,SAAS,KAAK,QAAQ;CACrD,EAC4B,GAAG,KAAK,KAAK;CACzC,KAAK,MAAM,EAAE,MAAM,WAAW,KAAK,YAClC,QAAQ,OAAO,MAAM,cAAc,KAAK,QAAQ,GAAG,KAAK,QAAQ,IAAI,MAAM,GAAG;CAE9E,QAAQ,OAAO,MAAM,GAAG,kBAAkB,MAAM,KAAK,QAAQ,EAAE,GAAG;CAElE,IAAI,KAAK,QAAQ;EAChB,KAAK,MAAM,WAAW,KAAK,MAAM,QAAQ,IAAI,UAAU,OAAO,CAAC;EAC/D,OAAO;CACR;CACA,IAAI,KAAK,KAAK,WAAW,GAAG,OAAO;CAEnC,MAAM,QAAQ,QAAQ,IAAI;CAC1B,IAAI,CAAC,OACJ,MAAM,IAAI,MACT,mGACD;CAED,MAAM,QAAQ,IAAI,OAAO,EAAE,aAAa,KAAK,SAAS,CAAC;CACvD,MAAM,UAAU,MAAM,QAAQ,IAC7B,KAAK,KAAK,KAAK,MAAM,MAAM,UAAU,YAAY,GAAG,MAAM,KAAK,CAAC,CAAC,CAClE;CACA,MAAM,SAAS,QAAQ,QAAQ,OAAO,OAAO,IAAI,CAAC,CAAC;CACnD,QAAQ,OAAO,MACd,SAAS,QAAQ,SAAS,OAAO,GAAG,QAAQ,SAAS,SAAS,KAAK,OAAO,WAAW,GAAG,GACzF;CACA,OAAO,WAAW;AACnB;;;;AC/IA,SAAS,iBAAiB,MAAc;CACvC,QAAQ,MAAsB;EAC7B,MAAM,IAAI,OAAO,SAAS,GAAG,EAAE;EAC/B,IAAI,CAAC,OAAO,UAAU,CAAC,KAAK,IAAI,GAC/B,MAAM,IAAI,MAAM,GAAG,KAAK,oCAAoC,EAAE,EAAE;EACjE,OAAO;CACR;AACD;AAEA,MAAM,UAAU,IAAI,QAAQ,CAAC,CAC3B,KAAK,aAAa,CAAC,CACnB,YACA,8GACD;AAED,QACE,QAAQ,OAAO,CAAC,CAChB,OAAO,mBAAmB,sBAAsB,QAAQ,IAAI,CAAC,CAAC,CAC9D,OAAO,mBAAmB,kCAAkC,OAAO,CAAC,CACpE,OAAO,kBAAkB,yBAAyB,iBAAiB,YAAY,GAAG,CAAC,CAAC,CACpF,OAAO,kBAAkB,+CAA+C,CAAC,CACzE,OACA,kBACA,qCACC,GAAW,SAAmB,CAAC,GAAG,MAAM,CAAC,GAC1C,CAAC,CACF,CAAC,CACA,OAAO,OAAO,SAAS;CACvB,MAAM,UAAU,QAAQ,KAAK,OAAO;CAQpC,IAAI,CAAC,MAPY,UAAU;EAC1B;EACA,OAAO,KAAK;EACZ,UAAU,KAAK;EACf,QAAQ,KAAK,SAAS,QAAQ,KAAK,MAAM,IAAI,KAAK,SAAS,cAAc;EACzE,MAAM,KAAK;CACZ,CAAC,GACQ,QAAQ,WAAW;AAC7B,CAAC;AAEF,QACE,QAAQ,MAAM,CAAC,CACf,OACA,oBACA,gCACA,KAAK,QAAQ,GAAG,WAAW,UAAU,CACtC,CAAC,CACA,OACA,mBACA,4BACA,KAAK,QAAQ,GAAG,gBAAgB,SAAS,CAC1C,CAAC,CACA,OAAO,mBAAmB,+BAA+B,MAAM,CAAC,CAChE,OAAO,kBAAkB,yBAAyB,iBAAiB,YAAY,GAAG,CAAC,CAAC,CACpF,OACA,mBACA,mDACA,iBAAiB,aAAa,GAC9B,CACD,CAAC,CACA,OAAO,eAAe,gCAAgC,iBAAiB,SAAS,CAAC,CAAC,CAClF,OAAO,aAAa,mDAAmD,CAAC,CACxE,OAAO,OAAO,SAAS;CAUvB,IAAI,CAAC,MATY,QAAQ;EACxB,UAAU,QAAQ,KAAK,QAAQ;EAC/B,SAAS,QAAQ,KAAK,OAAO;EAC7B,OAAO,KAAK;EACZ,UAAU,KAAK;EACf,UAAU,KAAK;EACf,OAAO,KAAK;EACZ,QAAQ,CAAC,CAAC,KAAK;CAChB,CAAC,GACQ,QAAQ,WAAW;AAC7B,CAAC;AAEF,QAAQ,WAAW,CAAC,CAAC,OAAO,MAAe;CAC1C,QAAQ,MAAM,aAAa,QAAQ,EAAE,UAAU,OAAO,CAAC,CAAC;CACxD,QAAQ,KAAK,CAAC;AACf,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alizain/doublecheck",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Run self-authored LLM code-inspectors against a project — one sandboxed microVM agent per check, one markdown report each",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@11.5.1",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/alizain/doublecheck.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/alizain/doublecheck#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/alizain/doublecheck/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude",
|
|
18
|
+
"code-review",
|
|
19
|
+
"llm",
|
|
20
|
+
"agent",
|
|
21
|
+
"microvm",
|
|
22
|
+
"sandbox",
|
|
23
|
+
"checks"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.0.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"doublecheck": "./dist/cli.mjs"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"Dockerfile.guest",
|
|
37
|
+
"scripts/build-guest-image.sh"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"doublecheck": "tsx src/cli.ts",
|
|
41
|
+
"build": "tsdown",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:unit": "vitest run --exclude \"**/*.integration.test.ts\"",
|
|
44
|
+
"test:watch": "vitest",
|
|
45
|
+
"check": "biome check .",
|
|
46
|
+
"format": "biome format --write .",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"release": "semantic-release"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"commander": "^14.0.3",
|
|
52
|
+
"microsandbox": "^0.6.4",
|
|
53
|
+
"p-queue": "^9.0.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@biomejs/biome": "^2.0.0",
|
|
57
|
+
"@types/node": "^24.0.0",
|
|
58
|
+
"semantic-release": "^25.0.5",
|
|
59
|
+
"tsdown": "^0.22.3",
|
|
60
|
+
"tsx": "^4.22.4",
|
|
61
|
+
"typescript": "^5.6.0",
|
|
62
|
+
"vitest": "^2.1.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Build the guest image and side-load it into the microsandbox cache.
|
|
3
|
+
# Requires a running Docker daemon (OrbStack/Docker Desktop). No registry:
|
|
4
|
+
# `msb load` imports the docker-save tarball directly.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
cd "$(dirname "$0")/.."
|
|
7
|
+
|
|
8
|
+
IMAGE="doublecheck-guest:latest"
|
|
9
|
+
|
|
10
|
+
docker build -f Dockerfile.guest -t "$IMAGE" .
|
|
11
|
+
docker save "$IMAGE" | pnpm exec msb load --tag "$IMAGE"
|
|
12
|
+
pnpm exec msb image list
|