duediligence 0.3.0 → 0.3.1

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/README.md CHANGED
@@ -7,12 +7,13 @@
7
7
  <p align="center">
8
8
  <a href="https://www.npmjs.com/package/duediligence"><img src="https://img.shields.io/npm/v/duediligence?style=flat-square&label=npm" alt="npm version"></a>
9
9
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT license"></a>
10
- <a href="SKILL.md"><img src="https://img.shields.io/badge/Claude_Code-skill-D97757?style=flat-square" alt="Claude Code skill"></a>
10
+ <a href="skills/duediligence/SKILL.md"><img src="https://img.shields.io/badge/Claude_Code-skill-D97757?style=flat-square" alt="Claude Code skill"></a>
11
+ <a href="https://skills.sh/lunox-work/duediligence"><img src="https://img.shields.io/badge/skills.sh-npx_skills_add_lunox--work%2Fduediligence-111?style=flat-square" alt="skills.sh"></a>
11
12
  <a href="#install"><img src="https://img.shields.io/badge/judges-any_coding_agent-555?style=flat-square" alt="Judges any coding agent"></a>
12
13
  </p>
13
14
 
14
15
  <p align="center">
15
- <img src="assets/hero.png" width="100%" alt="Coding agents that report a task complete are wrong 71% of the time (75 of 105). Independent tests cut that to 9% (9 of 105).">
16
+ <img src="assets/hero.png" width="100%" alt="Coding agents that report a task complete are wrong 71% of the time (75 of 105). duediligence cuts that to 9% (9 of 105).">
16
17
  </p>
17
18
 
18
19
  <table>
@@ -295,18 +296,29 @@ workdir (`bin/backends.mjs`). Windows has no such sandbox: there the file tools'
295
296
  deny rules still hold, but a session's shell is held only by the transcript
296
297
  audit, which reports any attempt to read the hidden suite. Run sessions under
297
298
  WSL2 for an enforced sandbox; judging and handouts need none.
298
- [SKILL.md](SKILL.md) has the steps as the skill runs them.
299
+ [SKILL.md](skills/duediligence/SKILL.md) has the steps as the skill runs them.
299
300
 
300
301
  ## Install in full
301
302
 
302
303
  ```sh
303
- npx skills add lunox-work/duediligence -g # the skill, for Claude Code and other agents
304
- git clone https://github.com/lunox-work/duediligence ~/.claude/skills/duediligence # or the whole repo
305
- npx -y duediligence --help # the CLI; nothing to clone
304
+ npx skills add lunox-work/duediligence -g # the skill, for Claude Code, Codex, Cursor and 20+ agents
305
+ npm i -g duediligence # the CLI the skill runs
306
306
  ```
307
307
 
308
- Either install puts the skill (SKILL.md) in place; it calls the same CLI,
309
- through `npx` when only SKILL.md was copied. Needs Node 20.19 or later on macOS,
308
+ In Claude Code, the repository is also a plugin marketplace:
309
+
310
+ ```
311
+ /plugin marketplace add lunox-work/duediligence
312
+ /plugin install duediligence@duediligence
313
+ ```
314
+
315
+ The plugin also bundles the `lunox` MCP server, Lunox's hosted platform
316
+ ([below](#sign-in-to-the-lunox-platform-optional)): Claude Code asks you to
317
+ sign in on first use (`/mcp`), and signed out the skill runs locally as before.
318
+
319
+ The skill is the folder [`skills/duediligence/`](skills/duediligence/), which
320
+ also installs by copying it into an agent's skills folder. It runs the CLI at
321
+ the version it names, and asks before installing it when it is missing. Needs Node 20.19 or later on macOS,
310
322
  Linux or Windows, and one of: Claude Code signed in, a Codex login, or
311
323
  `ANTHROPIC_API_KEY`. The first command that needs the toolchain installs
312
324
  `template/`'s dependencies with npm 10.9.2 under `~/.duediligence/`, once.
@@ -319,6 +331,29 @@ Specs, runs and results live in `~/.duediligence/`
319
331
  in the package. Every writer and agent session is denied that folder: it holds
320
332
  the hidden suites at rest.
321
333
 
334
+ ## Sign in to the Lunox platform (optional)
335
+
336
+ Everything above runs on your machine and needs no account. The CLI can also
337
+ sign in to Lunox's hosted platform, where a workspace keeps bounties,
338
+ repositories, sandboxes and runs:
339
+
340
+ ```sh
341
+ duediligence login # prints a code; approve it in the browser (--open opens it)
342
+ duediligence whoami # the account, workspace and token expiry
343
+ duediligence logout # deletes the saved token
344
+ ```
345
+
346
+ `login` uses the device flow: it prints a code, waits while you approve it at
347
+ platform.lunox.work/device, and saves a 90-day workspace token in
348
+ `~/.lunox/credentials` (mode 600; `LUNOX_CREDENTIALS` moves it), a file
349
+ carveout shares. Only a workspace's owners and admins can sign a CLI in to it;
350
+ `--org <handle>` picks the workspace when you have several, and
351
+ `--platform <url>` or `LUNOX_PLATFORM_URL` another deployment. `logout`
352
+ deletes the token here only: the platform has no route for a token to revoke
353
+ itself, so revoke it in the platform's web app to stop it before it expires.
354
+ No other command talks to the platform. In Claude Code, the plugin's `lunox`
355
+ MCP server (`.mcp.json`) signs in on its own, through `/mcp`.
356
+
322
357
  ## Try it in five minutes
323
358
 
324
359
  `duration-parse` is a prepared example, copied into
@@ -458,11 +493,21 @@ reads the runner's disk and sends it over the network is not stopped.
458
493
  <summary>Every file and folder, in the repository and in <code>~/.duediligence/</code></summary>
459
494
 
460
495
  ```
461
- SKILL.md the steps the skill runs
496
+ skills/ the skill, duediligence/SKILL.md: the steps it runs
497
+ .claude-plugin/ the Claude Code plugin and its one-plugin marketplace
498
+ .mcp.json the plugin's MCP server: lunox, the hosted platform (https://platform.lunox.work/mcp/duediligence)
499
+ scripts/skill.mjs check the skill, sync its version pins, pack it as a zip
462
500
  AGENTS.md this README's section for coding agents, verbatim
463
501
  RESULTS.md what the method was measured to do
464
- package.json the npm package: `duediligence` → bin/cli.mjs
502
+ package.json the npm package: `duediligence` → bin/cli.mjs; `duediligence/api` → bin/api.mjs
503
+ bin/api.mjs the library entry: decisionItems, scenarios, diffDecisions, hiddenManifest, hiddenSha256,
504
+ calibrate({ skeleton, reference }), verdict(outcomes), promptPath(name); no process, home or
505
+ network on import or call (test/api.mjs)
506
+ bin/rules.mjs the judge's verdict and calibration's filter over suite results, pure
507
+ bin/hash.mjs the commitment hash of a spec folder or a file map, reading nothing else
465
508
  bin/cli.mjs `duediligence <command>`: runs bin/<command>.mjs; `where`
509
+ bin/platform-client.mjs the hosted platform: its URL, ~/.lunox/credentials, one fetch-based request with the CLI version
510
+ bin/account.mjs login (device flow), whoami, logout; bin/login.mjs, whoami.mjs and logout.mjs run them
466
511
  evidence/ the experiment's write-ups behind RESULTS.md, vendored
467
512
  bin/fit.mjs whether a repository's package can be a target
468
513
  bin/repo.mjs point a spec at a package: seam, allowed paths, base tree, baseline
@@ -515,6 +560,8 @@ evidence/languages.mjs Go and Rust modules end to end; commands and services in
515
560
  results.jsonl one line per graded run
516
561
  template/<hash>/ template/ with its dependencies, installed on first use
517
562
  toolchains/ python-<hash>/ (with .venv), go-<hash>/, rust-<hash>/, built on first use
563
+
564
+ ~/.lunox/credentials platform tokens after `login`, per platform URL and CLI (shared with carveout; LUNOX_CREDENTIALS)
518
565
  ```
519
566
 
520
567
  A development checkout keeps its own `specs/`, `runs/` and `results.jsonl` with
@@ -530,7 +577,7 @@ A development checkout keeps its own `specs/`, `runs/` and `results.jsonl` with
530
577
  <sub>
531
578
  <a href="RESULTS.md">RESULTS.md</a> ·
532
579
  <a href="AGENTS.md">AGENTS.md</a> ·
533
- <a href="SKILL.md">SKILL.md</a> ·
580
+ <a href="skills/duediligence/SKILL.md">SKILL.md</a> ·
534
581
  <a href="https://github.com/lunox-work/duediligence/issues">Issues</a>
535
582
  </sub>
536
583
  </p>
@@ -0,0 +1,262 @@
1
+ // `login`, `whoami` and `logout` against the hosted platform
2
+ // (platform-client.mjs). Each takes its argv and the outside world as
3
+ // `deps`, so test/platform.mjs drives them against a fake platform with no
4
+ // real waiting; bin/login.mjs, whoami.mjs and logout.mjs run them.
5
+ //
6
+ // login is RFC 8628's device flow on the platform's Better Auth:
7
+ // POST /api/auth/device/code {client_id} → device_code, user_code, verification_uri(_complete), expires_in, interval
8
+ // POST /api/auth/device/token {grant_type, device_code, client_id} → access_token (a session that may only mint this client's token)
9
+ // GET /api/v1/me, GET /api/v1/me/orgs (with that session)
10
+ // POST /api/v1/orgs/:orgId/tokens/cli {client, label} → { token, secret }: a 90-day lnx_ token; the session ends
11
+ // The token is saved, never printed.
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { hostname as osHostname } from "node:os";
15
+ import { createInterface } from "node:readline/promises";
16
+ import { parseArgs } from "node:util";
17
+
18
+ import { credentialFor, credentialsPath, platformUrl, PlatformError, request, saveCredential } from "./platform-client.mjs";
19
+
20
+ const GRANT = "urn:ietf:params:oauth:grant-type:device_code";
21
+ const ADMIN_ROLES = new Set(["owner", "admin"]);
22
+
23
+ /** The process's own world; tests pass their own. */
24
+ export function defaultDeps() {
25
+ return {
26
+ env: process.env,
27
+ out: (text) => process.stdout.write(text),
28
+ err: (text) => process.stderr.write(text),
29
+ isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
30
+ ask: async (question) => {
31
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
32
+ try {
33
+ return await rl.question(question);
34
+ } finally {
35
+ rl.close();
36
+ }
37
+ },
38
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
39
+ now: () => Date.now(),
40
+ fetchImpl: fetch,
41
+ openUrl,
42
+ hostname: osHostname,
43
+ };
44
+ }
45
+
46
+ /** The OS's browser opener, detached; a failure is only a missed convenience. */
47
+ function openUrl(url) {
48
+ const [command, args] =
49
+ process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
50
+ try {
51
+ spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
52
+ } catch {
53
+ // The URL is printed either way.
54
+ }
55
+ }
56
+
57
+ /** Whether a held role (Better Auth joins several with commas) may mint a token. */
58
+ const isAdmin = (role) => String(role ?? "").split(",").some((one) => ADMIN_ROLES.has(one.trim()));
59
+ const describe = (org) => `${org.slug} (${org.name}, ${org.role}) ${org.id}`;
60
+
61
+ function options(argv, spec, usage) {
62
+ try {
63
+ return parseArgs({ args: argv, options: { ...spec, help: { type: "boolean", short: "h" } }, allowPositionals: false }).values;
64
+ } catch (error) {
65
+ throw new PlatformError(`${error.message}\n${usage}`);
66
+ }
67
+ }
68
+
69
+ /** Runs one of the three, printing a PlatformError as one line on stderr. Answers the exit code. */
70
+ export async function run(command, argv, { client, version }, deps = defaultDeps()) {
71
+ const commands = { login, whoami, logout };
72
+ try {
73
+ return await commands[command](argv, { client, version }, deps);
74
+ } catch (error) {
75
+ if (!(error instanceof PlatformError)) throw error;
76
+ deps.err(`${client} ${command}: ${error.message}\n`);
77
+ return 1;
78
+ }
79
+ }
80
+
81
+ /** Picks the workspace to mint in: --org, the only one an owner or admin holds, or a choice on a TTY. */
82
+ async function chooseOrg(orgs, wanted, { client, deps }) {
83
+ const eligible = orgs.filter((org) => isAdmin(org.role));
84
+ const listing = (list) => list.map((org) => ` ${describe(org)}`).join("\n");
85
+ if (wanted !== undefined) {
86
+ const key = wanted.replace(/^@/, "").toLowerCase();
87
+ const found = orgs.find((org) => org.id === wanted || org.slug.toLowerCase() === key);
88
+ if (found === undefined) throw new PlatformError(`No workspace ${wanted} for this account. It has:\n${listing(orgs)}`);
89
+ if (!isAdmin(found.role)) {
90
+ throw new PlatformError(`You are ${found.role} in ${found.slug}; only its owners and admins can give ${client} a token.`);
91
+ }
92
+ return found;
93
+ }
94
+ if (eligible.length === 1) return eligible[0];
95
+ if (eligible.length === 0) {
96
+ throw new PlatformError(
97
+ orgs.length === 0
98
+ ? "This account has no workspace yet. Create one in the platform's web app, then run login again."
99
+ : `You are not an owner or admin of any workspace, and only they can give ${client} a token. This account has:\n${listing(orgs)}`,
100
+ );
101
+ }
102
+ if (!deps.isTTY) {
103
+ throw new PlatformError(`This account can sign in to ${eligible.length} workspaces; name one with --org <handle|id>:\n${listing(eligible)}`);
104
+ }
105
+ deps.out(`\nWorkspaces you can sign ${client} in to:\n${eligible.map((org, i) => ` ${i + 1}. ${describe(org)}`).join("\n")}\n`);
106
+ for (let tries = 0; tries < 3; tries += 1) {
107
+ const answer = (await deps.ask(`Workspace [1-${eligible.length}]: `)).trim();
108
+ const index = Number(answer);
109
+ if (Number.isInteger(index) && index >= 1 && index <= eligible.length) return eligible[index - 1];
110
+ const named = eligible.find((org) => org.slug.toLowerCase() === answer.replace(/^@/, "").toLowerCase() || org.id === answer);
111
+ if (named) return named;
112
+ }
113
+ throw new PlatformError("No workspace chosen.");
114
+ }
115
+
116
+ /** Polls the token endpoint at the server's interval, five seconds slower after each `slow_down`, until approval, denial or expiry. */
117
+ async function pollForSession(base, code, { client, version, deps }) {
118
+ let interval = Math.max(1, Number(code.interval) || 5) * 1000;
119
+ const deadline = deps.now() + Math.max(1, Number(code.expires_in) || 900) * 1000;
120
+ for (;;) {
121
+ await deps.sleep(interval);
122
+ if (deps.now() >= deadline) throw new PlatformError("The code expired before it was approved. Run login again.");
123
+ try {
124
+ const granted = await request({
125
+ base,
126
+ method: "POST",
127
+ path: "/api/auth/device/token",
128
+ body: { grant_type: GRANT, device_code: code.device_code, client_id: client },
129
+ client,
130
+ version,
131
+ fetchImpl: deps.fetchImpl,
132
+ });
133
+ if (typeof granted.access_token !== "string") throw new PlatformError("The platform approved the sign-in but sent no session.");
134
+ return granted.access_token;
135
+ } catch (error) {
136
+ if (!(error instanceof PlatformError)) throw error;
137
+ if (error.code === "authorization_pending") continue;
138
+ if (error.code === "slow_down") {
139
+ interval += 5000;
140
+ continue;
141
+ }
142
+ if (error.code === "expired_token") throw new PlatformError("The code expired before it was approved. Run login again.");
143
+ if (error.code === "access_denied") throw new PlatformError("The sign-in was denied in the browser. Nothing was saved.");
144
+ if (error.code === "invalid_grant") throw new PlatformError("The code is no longer valid (already used, or unknown). Run login again.");
145
+ throw error;
146
+ }
147
+ }
148
+ }
149
+
150
+ export async function login(argv, { client, version }, deps) {
151
+ const usage = `usage: ${client} login [--platform <url>] [--org <handle|id>] [--open]`;
152
+ const values = options(argv, { platform: { type: "string" }, org: { type: "string" }, open: { type: "boolean" } }, usage);
153
+ if (values.help) {
154
+ deps.out(
155
+ `${usage}\n\nSigns ${client} in to the Lunox platform (default ${platformUrl(undefined, {})}; LUNOX_PLATFORM_URL) with a code\n` +
156
+ `you approve in the browser, and saves a workspace token in ${credentialsPath(deps.env)} (LUNOX_CREDENTIALS).\n` +
157
+ `--org picks the workspace; --open opens the approval page.\n`,
158
+ );
159
+ return 0;
160
+ }
161
+ const base = platformUrl(values.platform, deps.env);
162
+ const call = (options) => request({ base, client, version, fetchImpl: deps.fetchImpl, ...options });
163
+ const code = await call({ method: "POST", path: "/api/auth/device/code", body: { client_id: client } });
164
+ if (typeof code.device_code !== "string" || typeof code.user_code !== "string") {
165
+ throw new PlatformError(`${base} answered the sign-in request without a code.`);
166
+ }
167
+ const page = code.verification_uri_complete ?? code.verification_uri ?? `${base}/device`;
168
+ const minutes = Math.round((Number(code.expires_in) || 900) / 60);
169
+ deps.out(
170
+ `To sign ${client} in to ${base}, open this page and approve the code:\n\n ${page}\n\n` +
171
+ ` Code: ${code.user_code} (expires in ${minutes} minute${minutes === 1 ? "" : "s"})\n\n`,
172
+ );
173
+ if (values.open) deps.openUrl(page);
174
+ deps.out("Waiting for approval...\n");
175
+ const session = await pollForSession(base, code, { client, version, deps });
176
+ const asSession = (options) => call({ token: session, session: true, ...options });
177
+ let minted;
178
+ let me;
179
+ let org;
180
+ try {
181
+ me = (await asSession({ path: "/api/v1/me" })).user;
182
+ const orgs = (await asSession({ path: "/api/v1/me/orgs" })).organizations ?? [];
183
+ org = await chooseOrg(orgs, values.org, { client, deps });
184
+ const label = String(deps.hostname() ?? "").trim().slice(0, 60);
185
+ minted = await asSession({
186
+ method: "POST",
187
+ path: `/api/v1/orgs/${encodeURIComponent(org.id)}/tokens/cli`,
188
+ body: label === "" ? { client } : { client, label },
189
+ });
190
+ } catch (error) {
191
+ // Minting ends the session; a sign-in that stopped short ends it here, best effort.
192
+ await call({ token: session, session: true, method: "POST", path: "/api/auth/sign-out", body: {} }).catch(() => {});
193
+ throw error;
194
+ }
195
+ if (typeof minted?.secret !== "string" || !minted.secret.startsWith("lnx_")) {
196
+ throw new PlatformError(`${base} answered without a workspace token.`);
197
+ }
198
+ const path = credentialsPath(deps.env);
199
+ saveCredential(path, base, client, {
200
+ token: minted.secret,
201
+ organizationId: org.id,
202
+ organizationHandle: org.slug,
203
+ client,
204
+ createdAt: minted.token?.createdAt ?? new Date(deps.now()).toISOString(),
205
+ expiresAt: minted.token?.expiresAt ?? null,
206
+ });
207
+ const who = me ? `${me.name} <${me.email}>` : "you";
208
+ const expiry = minted.token?.expiresAt ? `; it expires ${minted.token.expiresAt.slice(0, 10)}` : "";
209
+ deps.out(`Signed in as ${who}, workspace ${org.name} (@${org.slug}) on ${base}.\nToken saved to ${path}${expiry}.\n`);
210
+ return 0;
211
+ }
212
+
213
+ export async function whoami(argv, { client, version }, deps) {
214
+ const usage = `usage: ${client} whoami [--platform <url>]`;
215
+ const values = options(argv, { platform: { type: "string" } }, usage);
216
+ if (values.help) {
217
+ deps.out(`${usage}\n\nThe account, workspace and token expiry ${client} is signed in with.\n`);
218
+ return 0;
219
+ }
220
+ const base = platformUrl(values.platform, deps.env);
221
+ const saved = credentialFor(credentialsPath(deps.env), base, client);
222
+ if (saved === undefined) throw new PlatformError(`Not signed in to ${base}. Run \`${client} login\`.`);
223
+ const call = (path) => request({ base, token: saved.token, path, client, version, fetchImpl: deps.fetchImpl });
224
+ const { user } = await call("/api/v1/me");
225
+ const { organization, role } = await call(`/api/v1/orgs/${encodeURIComponent(saved.organizationId)}`);
226
+ const expires = saved.expiresAt ? `expires ${saved.expiresAt.slice(0, 10)}` : "does not expire";
227
+ deps.out(
228
+ [
229
+ `user ${user.name} <${user.email}>${user.username ? ` (@${user.username})` : ""}`,
230
+ `workspace ${organization.name} (@${organization.slug}), ${role}`,
231
+ `platform ${base}`,
232
+ `token ${client}, ${expires}`,
233
+ "",
234
+ ].join("\n"),
235
+ );
236
+ return 0;
237
+ }
238
+
239
+ export async function logout(argv, { client }, deps) {
240
+ const usage = `usage: ${client} logout [--platform <url>]`;
241
+ const values = options(argv, { platform: { type: "string" } }, usage);
242
+ if (values.help) {
243
+ deps.out(`${usage}\n\nDeletes ${client}'s saved token for the platform.\n`);
244
+ return 0;
245
+ }
246
+ const base = platformUrl(values.platform, deps.env);
247
+ const path = credentialsPath(deps.env);
248
+ const saved = credentialFor(path, base, client);
249
+ if (saved === undefined) {
250
+ deps.out(`Not signed in to ${base}; nothing to do.\n`);
251
+ return 0;
252
+ }
253
+ saveCredential(path, base, client, undefined);
254
+ // The platform lets no API token revoke itself (token routes take a person's session), so this is local.
255
+ const until = saved.expiresAt ? `until ${saved.expiresAt.slice(0, 10)}` : "until it is revoked";
256
+ deps.out(
257
+ `Signed out of ${base}: the token is deleted from ${path}.\n` +
258
+ `The platform has no route for a token to revoke itself, so it stays valid ${until} unless an owner or\n` +
259
+ `admin of @${saved.organizationHandle} revokes it in the platform's web app (the workspace's API tokens).\n`,
260
+ );
261
+ return 0;
262
+ }
package/bin/api.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // The library entry, `duediligence/api`: the rules and prompts without the
2
+ // session runner, for a platform that runs its own agent loop and evaluator.
3
+ // Every export is a pure function of its arguments, apart from the hash and
4
+ // promptPath reading the files they are pointed at (a spec folder, the
5
+ // package's prompts/). Importing it spawns no process, reads no home folder
6
+ // (DUEDILIGENCE_HOME, HOME) and touches no network: it imports nothing that
7
+ // does (bin/lib.mjs, whose top level seeds a home, least of all).
8
+ // test/api.mjs holds it to that.
9
+ //
10
+ // decisionItems(specText) the `Decided:` items with their Ticket/Instead/Scenarios fields
11
+ // scenarios(specText) the Gherkin scenarios keyed `kind-n`: { key, title, body }
12
+ // diffDecisions(before, after) { kept, edited, added, removed } between two lists of decision texts
13
+ // hiddenManifest(dirOrFiles, kept?) the commitment manifest of a spec folder or a file map
14
+ // hiddenSha256(dirOrFiles, kept?) its sha256: what prepared.json, handouts and seals record
15
+ // calibrate({ skeleton, reference }) the hidden-test filter: { status, reason, hiddenWritten, kept, dropped }
16
+ // verdict(outcomes) passed | overfit | incomplete | regressed | refused, with counts and failing scenario keys
17
+ // promptPath(name) the absolute path of prompts/<name>.md; anything outside prompts/ refused
18
+ // renderPrompt(name, values) prompts/<name>.md with each {{key}} filled from values, as the CLI fills repo/ briefs
19
+ //
20
+ // Suite results are what a runner prints (bin/runners/shared.mjs); the
21
+ // rules' inputs and outputs are documented in bin/rules.mjs, the hash's in
22
+ // bin/hash.mjs.
23
+
24
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
25
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ export { decisionItems, diffDecisions, scenarios } from "./decisions.mjs";
29
+ export { hiddenManifest, hiddenSha256 } from "./hash.mjs";
30
+ export { calibration as calibrate, judgement as verdict } from "./rules.mjs";
31
+
32
+ const PROMPTS = join(dirname(fileURLToPath(import.meta.url)), "..", "prompts");
33
+
34
+ /**
35
+ * The absolute path of a brief under prompts/: `name` is relative with `/`,
36
+ * with or without `.md` ("spec-from-ticket", "skeleton.md", "repo/hidden-tests").
37
+ * Throws on an absolute path, `.` or `..` segments, a backslash, a path that
38
+ * resolves outside prompts/ (through a symlink too) or a file that is not there.
39
+ */
40
+ export function promptPath(name) {
41
+ const refuse = (why) => {
42
+ throw new Error(`promptPath(${JSON.stringify(name)}): ${why}`);
43
+ };
44
+ if (typeof name !== "string" || name === "") refuse("a prompt name is a non-empty string");
45
+ if (name.includes("\0") || name.includes("\\") || isAbsolute(name) || /^[a-zA-Z]:/.test(name))
46
+ refuse("not a relative path under prompts/");
47
+ const parts = name.split("/");
48
+ if (parts.some((part) => part === "" || part === "." || part === "..")) refuse("not a relative path under prompts/");
49
+ const path = join(PROMPTS, ...parts.slice(0, -1), parts.at(-1).endsWith(".md") ? parts.at(-1) : `${parts.at(-1)}.md`);
50
+ if (!existsSync(path) || !statSync(path).isFile()) refuse("no such prompt");
51
+ const within = relative(realpathSync(PROMPTS), realpathSync(path));
52
+ if (within === "" || within.startsWith(`..${sep}`) || within === ".." || isAbsolute(within)) refuse("outside prompts/");
53
+ return path;
54
+ }
55
+
56
+ /**
57
+ * The brief at prompts/`name` (as `promptPath` takes it) with each
58
+ * `{{key}}` replaced by `values[key]`, the way the CLI fills the repository
59
+ * briefs from a spec's toolchain.json (bin/lib.mjs `render`), for a
60
+ * platform that fills them from its own project. Throws when a placeholder
61
+ * has no value or a value is not a string, so a brief that gains a
62
+ * placeholder fails loudly at the version that adds it rather than reaching
63
+ * a model with `{{key}}` in it.
64
+ */
65
+ export function renderPrompt(name, values) {
66
+ const path = promptPath(name);
67
+ if (values === null || typeof values !== "object") throw new Error(`renderPrompt(${JSON.stringify(name)}): values is an object`);
68
+ return readFileSync(path, "utf8").replace(/\{\{(\w+)\}\}/g, (_, key) => {
69
+ if (!Object.hasOwn(values, key)) throw new Error(`renderPrompt(${JSON.stringify(name)}): no value for {{${key}}}`);
70
+ if (typeof values[key] !== "string") throw new Error(`renderPrompt(${JSON.stringify(name)}): {{${key}}} is not a string`);
71
+ return values[key];
72
+ });
73
+ }
package/bin/calibrate.mjs CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  workdir,
51
51
  writeJson,
52
52
  } from "./lib.mjs";
53
+ import { calibration } from "./rules.mjs";
53
54
 
54
55
  const failing = (result) => [
55
56
  ...result.files.filter((file) => file.crashed).map((file) => `${file.file} :: (file crashed)`),
@@ -72,9 +73,8 @@ async function calibrate(slug) {
72
73
  throw new Error(`${slug}: baseline.json is not the run on base plus stub; run generate first.`);
73
74
  for (const part of ["tests/public", "tests/private"])
74
75
  if (!existsSync(join(out, part))) throw new Error(`${slug}: no ${part}; run generate first.`);
75
- const dropped = [];
76
76
 
77
- // The skeleton: every hidden test fails, and fails inside its test.
77
+ // The skeleton: every hidden test fails, and fails inside its test (the rule: rules.mjs `calibration`).
78
78
  const skeletonDir = stage(workdir(`${slug}-calibrate`), slug, {
79
79
  skeleton: true,
80
80
  publicTests: true,
@@ -83,19 +83,10 @@ async function calibrate(slug) {
83
83
  const onSkeleton = runSuite(skeletonDir, "private", chain);
84
84
  const publicOnSkeleton = runSuite(skeletonDir, "public", chain);
85
85
  discard(skeletonDir);
86
- if (!onSkeleton.compiled)
87
- return {
88
- slug,
89
- status: "dropped",
90
- reason: "The hidden suite does not compile against the skeleton.",
91
- compileOutput: onSkeleton.compileOutput,
92
- };
93
- for (const file of onSkeleton.files)
94
- if (file.crashed)
95
- dropped.push({ id: `${file.file} :: *`, reason: "The file crashes on the skeleton instead of failing a test." });
96
- for (const test of testsOf(onSkeleton))
97
- if (test.ok || test.skipped)
98
- dropped.push({ id: test.id, reason: test.skipped ? "Skipped." : "Passes on the skeleton." });
86
+ if (!onSkeleton.compiled) {
87
+ const { status, reason, compileOutput } = calibration({ skeleton: onSkeleton });
88
+ return { slug, status, reason, compileOutput };
89
+ }
99
90
 
100
91
  // The reference: a fourth session that sees both suites.
101
92
  const dir = stage(workdir(`${slug}-reference`), slug, {
@@ -160,37 +151,19 @@ async function calibrate(slug) {
160
151
  discard(snapshot);
161
152
  discard(dir);
162
153
 
163
- const droppedIds = new Set(dropped.map((item) => item.id));
164
- const droppedFiles = new Set(
165
- dropped.filter((item) => item.id.endsWith(" :: *")).map((item) => item.id.slice(0, -5)),
166
- );
167
- const referenceTests = new Map(testsOf(judged.hidden).map((test) => [test.id, test]));
168
- const hiddenIds = testsOf(onSkeleton).map((test) => test.id);
169
- for (const id of hiddenIds) {
170
- if (droppedIds.has(id)) continue;
171
- const test = referenceTests.get(id);
172
- if (test === undefined || !test.ok)
173
- dropped.push({
174
- id,
175
- reason: `The reference cannot satisfy it: ${test?.error?.message.split("\n")[0] ?? "it did not run"}`,
176
- });
177
- }
178
- const finalDropped = new Set(dropped.map((item) => item.id));
179
- const kept = hiddenIds.filter(
180
- (id) => !finalDropped.has(id) && !droppedFiles.has(id.split(" :: ")[0]),
181
- );
154
+ const { status, reason, hiddenWritten, kept, dropped } = calibration({ skeleton: onSkeleton, reference: judged.hidden });
182
155
  const publicIds = testsOf(judged.public).map((test) => test.id);
183
156
  const publicFailing = failing(judged.public);
184
157
  const notes = sessions.at(-1).finalText.trim().split("\n").slice(-20).join("\n");
185
158
  const record = {
186
159
  slug,
187
- status: kept.length > 0 ? "ok" : "dropped",
188
- reason: kept.length > 0 ? null : "No hidden test survived calibration.",
160
+ status,
161
+ reason,
189
162
  publicIds,
190
163
  publicOnSkeleton: count(publicOnSkeleton, publicIds),
191
164
  publicFailingOnReference: publicFailing,
192
165
  ...(repo ? { referenceRegressions: regressions } : {}),
193
- hiddenWritten: hiddenIds.length,
166
+ hiddenWritten,
194
167
  kept,
195
168
  dropped,
196
169
  reference: {
@@ -205,7 +178,7 @@ async function calibrate(slug) {
205
178
  };
206
179
  log(
207
180
  slug,
208
- `kept ${kept.length}/${hiddenIds.length} hidden, dropped ${dropped.length}; reference public ${record.reference.public.passed}/${record.reference.public.total}`,
181
+ `kept ${kept.length}/${hiddenWritten} hidden, dropped ${dropped.length}; reference public ${record.reference.public.passed}/${record.reference.public.total}`,
209
182
  publicFailing.length > 0 ? `, ${publicFailing.length} PUBLIC FAILING ON REFERENCE` : "",
210
183
  regressions.length > 0 ? `, ${regressions.length} REFERENCE REGRESSION(S)` : "",
211
184
  );
package/bin/cli.mjs CHANGED
@@ -30,6 +30,9 @@ const COMMANDS = {
30
30
  ci: "judge a commit from a seal and report it as a check (GitHub Actions or anywhere)",
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
+ login: "sign in to the Lunox platform (device code in the browser); --org, --platform, --open",
34
+ whoami: "the account, workspace and token expiry signed in with",
35
+ logout: "delete the saved platform token",
33
36
  };
34
37
 
35
38
  const [command, ...rest] = process.argv.slice(2);
@@ -53,6 +56,8 @@ if (!Object.hasOwn(COMMANDS, command ?? "")) {
53
56
  "",
54
57
  "Each command prints its own usage when called without arguments.",
55
58
  "Data: DUEDILIGENCE_HOME (default ~/.duediligence/).",
59
+ "Platform sign-in (optional; every other command is local): ~/.lunox/credentials (LUNOX_CREDENTIALS),",
60
+ "against https://platform.lunox.work (LUNOX_PLATFORM_URL).",
56
61
  ].join("\n"),
57
62
  );
58
63
  process.exit(command === undefined || command === "help" || command === "--help" ? 0 : 1);
@@ -5,28 +5,9 @@
5
5
  // refuses on a mismatch, bin/promote.mjs prints it at reveal, and a seal
6
6
  // (bin/seal.mjs) carries it in seal.json.
7
7
  //
8
- // What is hashed, from a folder laid out as specs/<slug>/ (or an unpacked
9
- // seal): a manifest of lines in `sha256sum` format, `<hex> <path>\n`,
10
- // - one per file under tests/private/, every depth, `.DS_Store` skipped,
11
- // sorted by the UTF-8 bytes of the path: the hex the sha256 of its
12
- // bytes, the path `tests/private/<rel>` with `/` separators;
13
- // - then one line for the kept test ids: the hex the sha256 of
14
- // JSON.stringify(calibration.kept sorted) (UTF-8), the path
15
- // `calibration.kept`;
16
- // - only for a spec at a black-box seam, a last line for what the suites
17
- // call (toolchain.json `boundary`: the command, or the service's start,
18
- // healthcheck and build), since changing it changes what the hidden
19
- // suite tests: the hex the sha256 of its JSON with keys sorted at every
20
- // level, the path `toolchain.boundary`. A spec without one hashes as
21
- // before.
22
- // hiddenSha256 is the sha256 of the manifest's UTF-8 bytes, lowercase hex.
23
- // By hand:
24
- // (cd specs/<slug> && find tests/private -type f ! -name .DS_Store | LC_ALL=C sort | xargs shasum -a 256
25
- // && printf '%s calibration.kept\n' "$(jq -cj '.kept | sort' calibration.json | shasum -a 256 | cut -c1-64)"
26
- // && printf '%s toolchain.boundary\n' "$(jq -cjS '.boundary' toolchain.json | shasum -a 256 | cut -c1-64)") | shasum -a 256
27
- // (the last printf only when toolchain.json has a boundary)
28
- // (jq sorts by code point and JS by UTF-16 unit; the two agree unless an id
29
- // holds characters beyond the BMP.)
8
+ // What is hashed, and how to compute it by hand: bin/hash.mjs, which
9
+ // computes it from a folder or from files in memory and reads nothing else
10
+ // (the library entry, bin/api.mjs, exports it).
30
11
  //
31
12
  // Specs prepared before the hash existed have no commitment in
32
13
  // prepared.json. Their first handout records one there (`hiddenRecordedBy:
@@ -35,35 +16,11 @@
35
16
  // without checking it. A repository handout's own handout.json is checked
36
17
  // too, so a change after the handout is refused even then.
37
18
 
38
- import { createHash } from "node:crypto";
39
- import { readFileSync } from "node:fs";
40
- import { join, sep } from "node:path";
41
- import { listFiles, readJson, specDir, writeJson } from "./lib.mjs";
19
+ import { join } from "node:path";
20
+ import { hiddenSha256 } from "./hash.mjs";
21
+ import { readJson, specDir, writeJson } from "./lib.mjs";
42
22
 
43
- const sha = (data) => createHash("sha256").update(data).digest("hex");
44
- const byBytes = (a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path));
45
- /** A JSON value with every object's keys sorted, as `jq -S` writes it. */
46
- const sorted = (value) =>
47
- Array.isArray(value)
48
- ? value.map(sorted)
49
- : value !== null && typeof value === "object"
50
- ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, sorted(value[key])]))
51
- : value;
52
-
53
- /** The manifest hiddenSha256 is the hash of (see above). */
54
- export function hiddenManifest(dir, kept = readJson(join(dir, "calibration.json"), {}).kept ?? []) {
55
- const hidden = join(dir, "tests", "private");
56
- const lines = listFiles(hidden, [])
57
- .filter((rel) => rel.split(sep).at(-1) !== ".DS_Store")
58
- .map((rel) => ({ path: `tests/private/${rel.split(sep).join("/")}`, hex: sha(readFileSync(join(hidden, rel))) }));
59
- lines.sort(byBytes).push({ path: "calibration.kept", hex: sha(JSON.stringify([...kept].sort())) });
60
- const boundary = readJson(join(dir, "toolchain.json"), {}).boundary;
61
- if (boundary !== undefined) lines.push({ path: "toolchain.boundary", hex: sha(JSON.stringify(sorted(boundary))) });
62
- return lines.map((line) => `${line.hex} ${line.path}\n`).join("");
63
- }
64
-
65
- /** The commitment hash of the hidden suite in `dir` (specs/<slug>/ or an unpacked seal). */
66
- export const hiddenSha256 = (dir, kept) => sha(hiddenManifest(dir, kept));
23
+ export { hiddenManifest, hiddenSha256 } from "./hash.mjs";
67
24
 
68
25
  /**
69
26
  * The spec's recorded commitment against its hidden suite now: `ok` false