duediligence 0.3.0 → 0.4.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/README.md +58 -11
- package/bin/account.mjs +262 -0
- package/bin/api.mjs +73 -0
- package/bin/calibrate.mjs +11 -38
- package/bin/cli.mjs +5 -0
- package/bin/commitment.mjs +7 -50
- package/bin/hash.mjs +111 -0
- package/bin/judge.mjs +22 -29
- package/bin/lib.mjs +5 -41
- package/bin/login.mjs +7 -0
- package/bin/logout.mjs +7 -0
- package/bin/platform-client.mjs +175 -0
- package/bin/rules.mjs +178 -0
- package/bin/whoami.mjs +7 -0
- package/package.json +15 -4
- package/{SKILL.md → skills/duediligence/SKILL.md} +87 -53
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).
|
|
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
|
|
304
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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
|
|
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>
|
package/bin/account.mjs
ADDED
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
|
188
|
-
reason
|
|
160
|
+
status,
|
|
161
|
+
reason,
|
|
189
162
|
publicIds,
|
|
190
163
|
publicOnSkeleton: count(publicOnSkeleton, publicIds),
|
|
191
164
|
publicFailingOnReference: publicFailing,
|
|
192
165
|
...(repo ? { referenceRegressions: regressions } : {}),
|
|
193
|
-
hiddenWritten
|
|
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}/${
|
|
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);
|
package/bin/commitment.mjs
CHANGED
|
@@ -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,
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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 {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
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
|
-
|
|
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
|