duediligence 0.3.1 → 0.5.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/bin/api.mjs CHANGED
@@ -17,6 +17,9 @@
17
17
  // promptPath(name) the absolute path of prompts/<name>.md; anything outside prompts/ refused
18
18
  // renderPrompt(name, values) prompts/<name>.md with each {{key}} filled from values, as the CLI fills repo/ briefs
19
19
  //
20
+ // prompts/sandbox/ holds briefs only a platform renders: hidden tests for a
21
+ // generated project whose cut modules are recording mocks (sandbox-factory).
22
+ //
20
23
  // Suite results are what a runner prints (bin/runners/shared.mjs); the
21
24
  // rules' inputs and outputs are documented in bin/rules.mjs, the hash's in
22
25
  // bin/hash.mjs.
package/bin/ci.mjs CHANGED
@@ -6,6 +6,8 @@
6
6
  // node bin/ci.mjs --seal <file|https-url> [--repo <checkout>] [--ref <rev>]
7
7
  // [--name <check name>] [--out <payload.json>] [--record <file>]
8
8
  // [--no-post] [--exit-zero]
9
+ // node bin/ci.mjs --platform [<url>] --version <id> --workspace <id> [--base <rev>]
10
+ // judged on the hosted platform instead (hosted.mjs)
9
11
  //
10
12
  // 1. A URL seal is fetched with DUEDILIGENCE_SEAL_AUTH as the
11
13
  // Authorization header, if set, into a temporary file.
@@ -37,6 +39,17 @@ import { fileURLToPath } from "node:url";
37
39
 
38
40
  const BIN = dirname(fileURLToPath(import.meta.url));
39
41
  const argv = process.argv.slice(2);
42
+
43
+ // --platform: judged on the hosted platform instead of from a seal
44
+ // (hosted.mjs `ciPlatform`). A bare --platform means the default platform.
45
+ if (argv.includes("--platform") || argv.some((arg) => arg.startsWith("--platform="))) {
46
+ const { run } = await import("./hosted.mjs");
47
+ const { version } = JSON.parse(readFileSync(join(BIN, "..", "package.json"), "utf8"));
48
+ const given = argv.map((arg, index) =>
49
+ arg === "--platform" && (argv[index + 1] === undefined || argv[index + 1].startsWith("--")) ? "--platform=" : arg,
50
+ );
51
+ process.exit(await run("ci", given, { client: "duediligence", version }));
52
+ }
40
53
  const options = {};
41
54
  for (let i = 0; i < argv.length; i += 1) {
42
55
  if (!argv[i].startsWith("--")) {
package/bin/cli.mjs CHANGED
@@ -27,9 +27,10 @@ const COMMANDS = {
27
27
  retry: "one retry of a blind run, told its failing scenarios",
28
28
  "import-archive": "a calibrated spec from the experiment archive, marked not held out",
29
29
  seal: "one spec's suites in one file, for a judge that has no specs/ (CI)",
30
- ci: "judge a commit from a seal and report it as a check (GitHub Actions or anywhere)",
30
+ ci: "judge a commit from a seal and report it as a check; --platform judges it on the hosted platform (OIDC)",
31
31
  referee: "a blind referee of rejected runs' failing tests, on any backend (--backend)",
32
32
  "red-team-audit": "red-team passes (run --brief red-team) audited: literals, a second suite, a session",
33
+ push: "a confirmed spec as a bounty in the signed-in platform workspace",
33
34
  login: "sign in to the Lunox platform (device code in the browser); --org, --platform, --open",
34
35
  whoami: "the account, workspace and token expiry signed in with",
35
36
  logout: "delete the saved platform token",
package/bin/hosted.mjs ADDED
@@ -0,0 +1,248 @@
1
+ // The commands that hand work to the hosted platform (sandbox-factory)
2
+ // instead of running it here, over platform-client.mjs:
3
+ //
4
+ // push <slug> a spec confirmed here becomes a bounty in the signed-in
5
+ // workspace: POST /api/v1/orgs/:org/bounties with the
6
+ // ticket's title and the confirmed spec.md as its text.
7
+ // Recorded in specs/<slug>/platform.json, so pushing
8
+ // again names the bounty already made.
9
+ // ci --platform a pull request judged by the platform against a
10
+ // published version's hidden suite, which never reaches
11
+ // the runner: the PR's changed files go to
12
+ // POST /api/judge with the run's GitHub OIDC token (no
13
+ // secret on either side; the platform checks the token
14
+ // names the repository the version was sliced from),
15
+ // then GET /api/judge/:id?workspace= until a verdict. The job's own
16
+ // pass or fail is the check.
17
+ //
18
+ // Each takes its argv and the outside world as `deps` (account.mjs's, plus
19
+ // `git` and `readFile`), so test/hosted.mjs drives them against a fake
20
+ // platform.
21
+
22
+ import { execFileSync } from "node:child_process";
23
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
24
+ import { join, resolve } from "node:path";
25
+ import { parseArgs } from "node:util";
26
+
27
+ import { defaultDeps as accountDeps } from "./account.mjs";
28
+ import { confirmation } from "./confirm.mjs";
29
+ import { specDir } from "./lib.mjs";
30
+ import { credentialFor, credentialsPath, platformUrl, PlatformError, request } from "./platform-client.mjs";
31
+
32
+ /** The platform's limits on one submission (sandbox-factory SUBMISSION_LIMITS). */
33
+ const MAX_FILES = 200;
34
+ const MAX_FILE_BYTES = 512 * 1024;
35
+ const MAX_BYTES = 4_000_000;
36
+ /** How often a verdict is asked for, and for how long. */
37
+ const POLL_MS = 10_000;
38
+ const POLL_LIMIT_MS = 45 * 60_000;
39
+ const TITLE_MAX = 255;
40
+ const DESCRIPTION_MAX = 20_000;
41
+
42
+ export function defaultDeps() {
43
+ return {
44
+ ...accountDeps(),
45
+ git: (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }),
46
+ readFile: (path) => readFileSync(path),
47
+ };
48
+ }
49
+
50
+ function options(argv, spec, usage, positionals = false) {
51
+ try {
52
+ return parseArgs({ args: argv, options: { ...spec, help: { type: "boolean", short: "h" } }, allowPositionals: positionals });
53
+ } catch (error) {
54
+ throw new PlatformError(`${error.message}\n${usage}`);
55
+ }
56
+ }
57
+
58
+ /** The first `# ` heading of a markdown text, else its first line. */
59
+ export function titleOf(text) {
60
+ const heading = /^#\s+(.+)$/m.exec(text)?.[1];
61
+ const line = heading ?? text.split("\n").find((each) => each.trim() !== "") ?? "";
62
+ return line.trim().slice(0, TITLE_MAX);
63
+ }
64
+
65
+ /** `push <slug>`: a locally confirmed spec as a bounty in the signed-in workspace. */
66
+ export async function push(argv, { client, version }, deps) {
67
+ const usage = `usage: ${client} push <slug> [--platform <url>] [--again]`;
68
+ const { values, positionals } = options(argv, { platform: { type: "string" }, again: { type: "boolean" } }, usage, true);
69
+ if (values.help || positionals.length !== 1) {
70
+ deps.out(
71
+ `${usage}\n\nPosts specs/<slug>/spec.md, as confirmed (${client} confirm <slug> --by <name>), as a bounty in the workspace\n` +
72
+ `${client} login signed in to. The bounty's title is ticket.md's (else spec.md's) heading. Pushing again names\n` +
73
+ `the bounty already made (specs/<slug>/platform.json); --again posts a new one, as after editing the spec.\n`,
74
+ );
75
+ return values.help ? 0 : 1;
76
+ }
77
+ const [slug] = positionals;
78
+ const dir = specDir(slug);
79
+ const confirmed = confirmation(slug);
80
+ if (!confirmed.ok) throw new PlatformError(`specs/${slug}: ${confirmed.reason}. Confirm it first: ${client} confirm ${slug} --by <name>.`);
81
+ const base = platformUrl(values.platform, deps.env);
82
+ const saved = credentialFor(credentialsPath(deps.env), base, client);
83
+ if (saved === undefined) throw new PlatformError(`Not signed in to ${base}. Run \`${client} login\`.`);
84
+ const recordPath = join(dir, "platform.json");
85
+ const recorded = existsSync(recordPath) ? JSON.parse(readFileSync(recordPath, "utf8")) : null;
86
+ if (recorded !== null && !values.again && recorded.base === base && recorded.organizationId === saved.organizationId) {
87
+ const stale = recorded.specSha256 !== confirmed.hash;
88
+ deps.out(
89
+ `specs/${slug} is bounty ${recorded.bountyId} in @${saved.organizationHandle} (pushed ${recorded.pushedAt.slice(0, 10)}).\n` +
90
+ (stale ? "spec.md was confirmed again since; --again posts it as a new bounty.\n" : ""),
91
+ );
92
+ return 0;
93
+ }
94
+ const spec = readFileSync(join(dir, "spec.md"), "utf8");
95
+ const ticketPath = join(dir, "ticket.md");
96
+ const title = titleOf(existsSync(ticketPath) ? readFileSync(ticketPath, "utf8") : spec);
97
+ if (title === "") throw new PlatformError(`specs/${slug}: no title in ticket.md or spec.md.`);
98
+ if (spec.length > DESCRIPTION_MAX)
99
+ throw new PlatformError(`specs/${slug}/spec.md is ${spec.length} characters; a bounty takes ${DESCRIPTION_MAX}.`);
100
+ const made = await request({
101
+ base,
102
+ token: saved.token,
103
+ client,
104
+ version,
105
+ fetchImpl: deps.fetchImpl,
106
+ method: "POST",
107
+ path: `/api/v1/orgs/${encodeURIComponent(saved.organizationId)}/bounties`,
108
+ body: { title, description: spec },
109
+ });
110
+ const bountyId = made?.bounty?.id;
111
+ if (typeof bountyId !== "string") throw new PlatformError(`${base} answered without a bounty.`);
112
+ writeFileSync(
113
+ recordPath,
114
+ `${JSON.stringify(
115
+ {
116
+ base,
117
+ organizationId: saved.organizationId,
118
+ bountyId,
119
+ specSha256: confirmed.hash,
120
+ confirmedBy: confirmed.record.by,
121
+ pushedAt: new Date(deps.now()).toISOString(),
122
+ },
123
+ null,
124
+ 2,
125
+ )}\n`,
126
+ );
127
+ deps.out(
128
+ `Posted specs/${slug} as bounty ${bountyId} in @${saved.organizationHandle} on ${base}.\n` +
129
+ `Size it there, or with the MCP tool size_bounty; its decisions were confirmed here by ${confirmed.record.by}.\n`,
130
+ );
131
+ return 0;
132
+ }
133
+
134
+ /** A GitHub Actions OIDC token for `audience`; the job needs `permissions: id-token: write`. */
135
+ export async function oidcToken(audience, deps) {
136
+ const url = deps.env.ACTIONS_ID_TOKEN_REQUEST_URL;
137
+ const bearer = deps.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
138
+ if (!url || !bearer)
139
+ throw new PlatformError(
140
+ "No GitHub OIDC token: run in GitHub Actions with `permissions: id-token: write` (ACTIONS_ID_TOKEN_REQUEST_URL is unset).",
141
+ );
142
+ const response = await deps.fetchImpl(`${url}${url.includes("?") ? "&" : "?"}audience=${encodeURIComponent(audience)}`, {
143
+ headers: { Authorization: `Bearer ${bearer}`, Accept: "application/json" },
144
+ signal: AbortSignal.timeout(30_000),
145
+ });
146
+ const body = await response.json().catch(() => ({}));
147
+ if (!response.ok || typeof body.value !== "string") throw new PlatformError(`GitHub gave no OIDC token (HTTP ${response.status}).`);
148
+ return body.value;
149
+ }
150
+
151
+ /** The PR's added and modified files between `base` and HEAD, whole, as the checkout has them. */
152
+ export function changedFiles(repo, base, deps) {
153
+ const names = deps
154
+ .git(["diff", "--name-only", "--diff-filter=AM", "-z", `${base}...HEAD`], repo)
155
+ .split("\0")
156
+ .filter((name) => name !== "");
157
+ if (names.length > MAX_FILES) throw new PlatformError(`The change touches ${names.length} files; the platform judges up to ${MAX_FILES}.`);
158
+ const files = [];
159
+ const skipped = [];
160
+ for (const path of names) {
161
+ const bytes = deps.readFile(resolve(repo, path));
162
+ if (bytes.length > MAX_FILE_BYTES || bytes.includes(0)) skipped.push(path);
163
+ else files.push({ path, text: bytes.toString("utf8") });
164
+ }
165
+ const total = files.reduce((sum, file) => sum + Buffer.byteLength(file.text), 0);
166
+ if (total > MAX_BYTES) throw new PlatformError(`The change is ${total} bytes; the platform judges up to ${MAX_BYTES}.`);
167
+ return { files, skipped };
168
+ }
169
+
170
+ const EXIT = { passed: 0, overfit: 2 };
171
+
172
+ /** `ci --platform`: the change judged on the platform; answers the exit code. */
173
+ export async function ciPlatform(argv, { client, version }, deps) {
174
+ const usage = `usage: ${client} ci --platform [<url>] --version <sandbox version id> --workspace <workspace id> [--repo <checkout>] [--base <rev>] [--exit-zero]`;
175
+ const { values } = options(
176
+ argv,
177
+ {
178
+ platform: { type: "string" },
179
+ version: { type: "string" },
180
+ workspace: { type: "string" },
181
+ repo: { type: "string" },
182
+ base: { type: "string" },
183
+ "exit-zero": { type: "boolean" },
184
+ },
185
+ usage,
186
+ );
187
+ if (values.help || !values.version || !values.workspace) {
188
+ deps.out(
189
+ `${usage}\n\nJudges this pull request on the platform against a published sandbox version's hidden suite, which never\n` +
190
+ `reaches the runner. Sends the files the PR added or changed since --base (default origin/$GITHUB_BASE_REF), with\n` +
191
+ `the run's GitHub OIDC token (the job needs \`permissions: id-token: write\`); the platform accepts it only from the\n` +
192
+ `repository the version was sliced from. Prints the verdict, counts and failing scenario keys.\n` +
193
+ `Exit: 0 passed, 2 overfit, 1 otherwise (0 with --exit-zero).\n`,
194
+ );
195
+ return values.help ? 0 : 1;
196
+ }
197
+ const base = platformUrl(values.platform === "" ? undefined : values.platform, deps.env);
198
+ const repo = resolve(values.repo ?? ".");
199
+ const from = values.base ?? (deps.env.GITHUB_BASE_REF ? `origin/${deps.env.GITHUB_BASE_REF}` : "HEAD~1");
200
+ const { files, skipped } = changedFiles(repo, from, deps);
201
+ for (const path of skipped) deps.err(`skipped ${path}: binary or over ${MAX_FILE_BYTES / 1024} KiB\n`);
202
+ if (files.length === 0) {
203
+ deps.out(`No file changed since ${from}; nothing to judge.\n`);
204
+ return 0;
205
+ }
206
+ const call = async (options) =>
207
+ request({ base, token: await oidcToken(base, deps), client, version, fetchImpl: deps.fetchImpl, ...options });
208
+ let { submission } = await call({
209
+ method: "POST",
210
+ path: "/api/judge",
211
+ body: { workspace: values.workspace, versionId: values.version, files },
212
+ });
213
+ deps.out(`Submitted ${files.length} file${files.length === 1 ? "" : "s"} as ${submission.id}; waiting for the verdict...\n`);
214
+ const started = deps.now();
215
+ while (submission.status === "queued" || submission.status === "running") {
216
+ if (deps.now() - started > POLL_LIMIT_MS) throw new PlatformError(`No verdict for ${submission.id} after ${POLL_LIMIT_MS / 60_000} minutes.`);
217
+ await deps.sleep(POLL_MS);
218
+ ({ submission } = await call({
219
+ path: `/api/judge/${encodeURIComponent(submission.id)}?workspace=${encodeURIComponent(values.workspace)}`,
220
+ }));
221
+ }
222
+ const verdict = submission.status === "errored" ? "errored" : (submission.verdict ?? submission.status);
223
+ const counts =
224
+ submission.public && submission.hidden
225
+ ? `public ${submission.public.passed}/${submission.public.total}, hidden ${submission.hidden.passed}/${submission.hidden.total}`
226
+ : "no tests ran";
227
+ const lines = [
228
+ `duediligence (platform): ${verdict}, ${counts}`,
229
+ ...(submission.failedScenarios.length > 0 ? [`failing scenarios: ${submission.failedScenarios.join(", ")}`] : []),
230
+ ...submission.refused.map((refusal) => `refused ${refusal.path}: ${refusal.reason}`),
231
+ ];
232
+ deps.out(`${lines.join("\n")}\n`);
233
+ if (deps.env.GITHUB_STEP_SUMMARY)
234
+ appendFileSync(deps.env.GITHUB_STEP_SUMMARY, `### ${lines[0]}\n\n${lines.slice(1).map((line) => `- ${line}`).join("\n")}\n`);
235
+ if (values["exit-zero"]) return 0;
236
+ return EXIT[verdict] ?? 1;
237
+ }
238
+
239
+ /** Runs one, printing a PlatformError as one line on stderr. */
240
+ export async function run(command, argv, meta, deps = defaultDeps()) {
241
+ try {
242
+ return await (command === "push" ? push : ciPlatform)(argv, meta, deps);
243
+ } catch (error) {
244
+ if (!(error instanceof PlatformError)) throw error;
245
+ deps.err(`${meta.client} ${command}: ${error.message}\n`);
246
+ return 1;
247
+ }
248
+ }
package/bin/push.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ // `duediligence push <slug>`: see hosted.mjs.
3
+ import { readFileSync } from "node:fs";
4
+ import { run } from "./hosted.mjs";
5
+
6
+ const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
7
+ process.exitCode = await run("push", process.argv.slice(2), { client: "duediligence", version });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duediligence",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Due diligence for AI coding agents: independent, hidden test suites written from the spec. The agent works against a public suite; a hidden suite it never sees judges the result.",
5
5
  "keywords": [
6
6
  "due-diligence",
@@ -58,7 +58,7 @@
58
58
  "skills/"
59
59
  ],
60
60
  "scripts": {
61
- "test": "node scripts/skill.mjs check && node test/api.mjs && node test/platform.mjs && node test/smoke.mjs",
61
+ "test": "node scripts/skill.mjs check && node test/api.mjs && node test/platform.mjs && node test/hosted.mjs && node test/smoke.mjs",
62
62
  "version": "node scripts/skill.mjs sync && git add skills .claude-plugin",
63
63
  "skill:check": "node scripts/skill.mjs check",
64
64
  "skill:sync": "node scripts/skill.mjs sync",
@@ -0,0 +1,73 @@
1
+ Write the hidden tests for the bounty: the acceptance tests a developer's
2
+ submission is judged by, which the developer never sees. The project is a
3
+ standalone copy of the code the bounty changes, generated so that it
4
+ installs, builds, runs its walkthrough and passes its public tests. Modules
5
+ it does not copy are cut: each is a typed stub whose runtime values are
6
+ recording mocks. The public tests are what the developer iterates against;
7
+ these tests exist to fail a submission that passes them without doing what
8
+ the spec says.
9
+
10
+ Write each test under `{{hiddenDir}}/` as a `{{testPattern}}` file, and mark
11
+ it with the outcome it has on the project as it is now. "fail" is a test
12
+ that only a finished bounty passes: it checks the behaviour the bounty asks
13
+ for. "pass" is one that guards behaviour the code already has and a finished
14
+ bounty must keep. At least one must be "fail". A submission is accepted when
15
+ every hidden test passes, so test what the spec says through the code's own
16
+ interface, such that any correct implementation passes and an incomplete one
17
+ does not.
18
+
19
+ Cover:
20
+
21
+ - every scenario in the spec, and every scenario kind it uses (happy,
22
+ boundary, unhappy, and any others it tags);
23
+ - for each public test, at least one case it does not exercise: other
24
+ values, larger sizes, other orders, other combinations, more items. Name
25
+ the public test it extends in a comment above it;
26
+ - the case an implementation tuned to the public tests would get wrong. For
27
+ each public test, ask what shortcut passes the visible cases, and write
28
+ the case that shortcut fails.
29
+
30
+ Assert only what the spec states or what follows necessarily from it. Where
31
+ the spec, or a public test, leaves a name, message or structure open, do not
32
+ test it either way.
33
+
34
+ A "fail" test must fail by its assertions, not by breaking. Hidden tests are
35
+ compiled with the project by `{{build}}`, so they must type-check against the
36
+ code as it is now: a test that imports a function or passes a parameter the
37
+ bounty has yet to add breaks the build, and the whole baseline with it. When
38
+ the bounty adds something new, reach it at run time inside the test, for
39
+ example `const mod: Record<string, unknown> = await import("{{importExample}}")`,
40
+ assert that it exists, and call it through a type you declare in the test. A
41
+ test that throws while its file loads, or imports something that is not
42
+ there, is an error, never the failure it is expected to show. A "pass" is
43
+ read from the runner's report, not only its exit code: at least one test
44
+ must run to completion and none may be skipped, todo or cancelled, and a
45
+ file that ends early or declares no test is an error. Do not name a test
46
+ after a file.
47
+
48
+ Write them so that a submission can only pass by doing the work:
49
+
50
+ - Assert observable results first: what the code returns, throws or leaves
51
+ in the state it exposes. A test that a call fails checks that the error is
52
+ the one the spec describes, not just that something was thrown. Use
53
+ `recordedCalls()` only to confirm that a seam was reached, and with what,
54
+ never as the only evidence that the behaviour is there.
55
+ - Call `resetMocks()` at the start of each test, for example in
56
+ `beforeEach`, and install the behaviour each test needs inside it, so no
57
+ test depends on what another left behind.
58
+ - Include at least one case whose inputs differ from the fixtures' default
59
+ data and the walkthrough's: give the mocked calls other values with
60
+ `mockImplementation(path, fn)` inside that test, so an implementation that
61
+ hard-codes the public data fails. Never use the exact fixture values as a
62
+ test's only inputs.
63
+ - Each hidden test runs twice and must give the same result both times:
64
+ nothing may depend on timing, randomness, the clock or the order the tests
65
+ run in.
66
+
67
+ The toolchain is fixed: {{toolchain}} Import the project's code with
68
+ relative paths from the test's folder; from `{{hiddenDir}}/` a file at
69
+ `src/feature.ts` is `{{importExample}}`. Import
70
+ `{ mockImplementation, recordedCalls, resetMocks }` from `{{mockModule}}` to
71
+ give a mocked call behaviour or to check what the code asked of it. Packages
72
+ compile as `any`. Nothing may reach the network, a database or any outside
73
+ service.
@@ -10,7 +10,7 @@ description: >-
10
10
  spec, or wants to stop trusting an agent's own green test run.
11
11
  license: MIT
12
12
  compatibility: >-
13
- Node 20.19 or later. Runs the duediligence@0.3.1 CLI from npm, which starts
13
+ Node 20.19 or later. Runs the duediligence@0.5.0 CLI from npm, which starts
14
14
  Claude Code, Codex or Anthropic API sessions and installs its test toolchain
15
15
  under ~/.duediligence/ once (network).
16
16
  ---
@@ -20,10 +20,10 @@ compatibility: >-
20
20
  From one confirmed spec, generate a **public** suite the implementing agent
21
21
  works against and a **hidden** suite that judges the result.
22
22
 
23
- `duediligence <command>` below is the CLI, `duediligence@0.3.1`. When
23
+ `duediligence <command>` below is the CLI, `duediligence@0.5.0`. When
24
24
  `duediligence --version` prints another version or nothing, ask the person
25
- before installing it: `npm i -g duediligence@0.3.1`, or put
26
- `npx duediligence@0.3.1` in place of `duediligence` in each command (in a clone
25
+ before installing it: `npm i -g duediligence@0.5.0`, or put
26
+ `npx duediligence@0.5.0` in place of `duediligence` in each command (in a clone
27
27
  of the repository, `node bin/cli.mjs`). Run it from any folder. `specs/` and
28
28
  `runs/` below are in the home, `~/.duediligence/` (`DUEDILIGENCE_HOME`);
29
29
  `prompts/` and `template*/` are in the CLI's package. `duediligence where`
@@ -86,14 +86,29 @@ _Repository_ below are for that target.
86
86
  When the `lunox` MCP tools are available and signed in (the Claude Code plugin
87
87
  bundles the server; `/mcp` signs in), they work on the person's workspace on
88
88
  the Lunox platform. Reads: `list_workspaces`, `list_bounties`, `get_bounty`,
89
- `get_proposal`, `get_run`, `get_usage`. Writes, only when the person asks for
89
+ `get_proposal`, `get_run`, `get_usage`, `list_sandboxes`, `get_sandbox`. Writes, only when the person asks for
90
90
  that one: `post_bounty`, `import_github_issue`, `import_jira_issue` (a new
91
- bounty) and `size_bounty` (price it; this starts a paid model run). Decision
91
+ bounty) and `size_bounty` (price it; this starts a paid model run).
92
+ `get_decisions` reads what a priced spec decided where the ticket was silent
93
+ (its `Decided:` items) and whether a person confirmed them; `confirm_link`
94
+ gives the page where the person confirms them. Hand them that link: decision
92
95
  confirmations, approving a price and publishing happen in the platform's web
93
- app: point the person there and never act for them. Hidden tests are not
94
- prepared or judged through these tools yet; use local mode for that. Never ask
96
+ app, and you never act for them. Once the decisions are confirmed and the
97
+ person asks: `start_sandbox` and `prepare` (build a sandbox version; the
98
+ platform writes hidden tests from the confirmed spec and calibrates them
99
+ against a reference, as `prepare` does locally), then `get_handout` for the
100
+ public files and the hidden suite's commitment hash. You never see the hidden
101
+ tests in hosted mode: they stay on the platform. On a published version,
102
+ `judge` submits an implementation's changed files and `get_verdict` reads the
103
+ verdict (counts and failing scenario keys only); `retry` is a failed one's
104
+ single retry. From a terminal, `duediligence push <slug>` posts a spec
105
+ confirmed here as a bounty, and `duediligence ci --platform --version <id>
106
+ --workspace <id>` (or `action.yml` with `version` and `workspace`, job
107
+ permission `id-token: write`) judges a pull request on the platform. Never ask
95
108
  for a token in the chat: the server signs in through `/mcp`, the CLI through
96
- `duediligence login` in the person's own terminal. Local mode, the steps below,
109
+ `duediligence login` in the person's own terminal. A tool error with code
110
+ `insufficient_scope` means the sign-in predates a scope: ask the person to
111
+ sign in again through `/mcp`. Local mode, the steps below,
97
112
  is unchanged and the default, and needs no sign-in.
98
113
 
99
114
  ## 0. Fit (repository)