@decidio/sdk 0.1.8 → 0.1.9

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/dist/cli.js CHANGED
@@ -1,13 +1,32 @@
1
1
  #!/usr/bin/env node
2
+ // @decidio/sdk CLI — the on-ramp. Run via `npx @decidio/sdk <cmd>` (or tsx in-repo).
3
+ // init [agentId] sign in, register the agent, mint its API token, write .env
4
+ // doctor check config + connectivity + durability readiness
5
+ // approvals list pending agent approvals; `approvals approve|reject <id> [reason]`
6
+ // receipt <id> download a sealed decision's Authority Receipt to a .json file
7
+ // dev [--target U] signal-only relay: forwards Decidio's webhook to your local agent URL
8
+ //
9
+ // TWO TOKEN KINDS, deliberately (self-serve design 2026-08-28): commands that ADMINISTER a
10
+ // workspace (init's register+mint, approvals, receipt) need the owner's SESSION token — acquired
11
+ // interactively per run or from DECIDIO_SESSION_TOKEN, held in memory, NEVER written to disk.
12
+ // What lands in .env as DECIDIO_API_TOKEN is the floor-limited AGENT token the runtime SDK
13
+ // needs (it can call /agent/* and nothing else). A session-capable secret must never live in
14
+ // an agent host's .env — that was the pre-publish design's hole.
15
+ //
16
+ // MESSAGE DISCIPLINE (founder bar 2026-08-29, from the Codex acceptance run): every failure
17
+ // tells the developer WHAT happened, WHY, and the EXACT next command. Every success says what
18
+ // changed, where, and what to do next. No stack traces for expected failures; no green ✔ for a
19
+ // no-op (the replay-approve bug: the server's idempotent alreadyResolved was reported as a
20
+ // fresh success — an authority CLI may never dress a no-op as an action).
2
21
  import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync } from "node:fs";
3
- import { randomBytes, createHash } from "node:crypto";
22
+ import { randomBytes } from "node:crypto";
4
23
  import { createServer } from "node:http";
5
24
  import { execFileSync } from "node:child_process";
6
25
  import { createInterface } from "node:readline";
7
26
  import { createRequire } from "node:module";
8
27
  import { generateKeypair } from "./signing.js";
9
28
  import { withApproval, DecidioRateLimitError } from "./index.js";
10
- import { verifyReceipt } from "@decidio/verify";
29
+ import { verifyReceipt, runVerify } from "@decidio/verify";
11
30
  const VERSION = (() => {
12
31
  try {
13
32
  return createRequire(import.meta.url)("../package.json").version;
@@ -16,33 +35,69 @@ const VERSION = (() => {
16
35
  return "unknown";
17
36
  }
18
37
  })();
38
+ // ---- environment: load the CWD .env FIRST (Codex finding: init writes .env, but the next
39
+ // command ignored it and pointed at localhost). Process env always wins — .env only fills gaps.
40
+ // NEVER a crash: an unreadable .env (EPERM, EISDIR, a mangled volume) is that file's problem,
41
+ // not this command's — warn on stderr and continue (round-9 catch: it was an unhandled Node
42
+ // stack trace).
43
+ let envWarned = false;
19
44
  function readEnvFile() {
20
45
  const out = {};
21
- if (existsSync(".env"))
22
- for (const line of readFileSync(".env", "utf8").split(/\r?\n/)) {
23
- const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
24
- if (m)
25
- out[m[1]] = m[2];
46
+ try {
47
+ if (existsSync(".env"))
48
+ for (const line of readFileSync(".env", "utf8").split(/\r?\n/)) {
49
+ const m = line.match(/^\s*([A-Z_]+)=(.*)$/);
50
+ if (m)
51
+ out[m[1]] = m[2];
52
+ }
53
+ }
54
+ catch (e) {
55
+ if (!envWarned) {
56
+ envWarned = true;
57
+ console.error(`⚠ could not read .env (${e?.code ?? e?.message ?? e}) — continuing without it. Fix its permissions if you meant it to load.`);
26
58
  }
27
- return out;
28
- }
29
- for (const [k, v] of Object.entries(readEnvFile())) {
30
- if (k === "DECIDIO_SESSION_TOKEN") {
31
- console.error("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
32
- continue;
33
59
  }
34
- if (process.env[k] === undefined)
35
- process.env[k] = v;
60
+ return out;
36
61
  }
62
+ // OFFLINE commands never touch .env at all (round-9 catch: `verify` is cryptographically
63
+ // offline and needs no credentials, yet the autoload coupled it to the directory's .env —
64
+ // and the help text's "every command talks to the hosted sandbox" was wrong for it).
65
+ const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", "--help", "-h", "help", undefined].includes(process.argv[2]);
66
+ if (!OFFLINE_CMD)
67
+ for (const [k, v] of Object.entries(readEnvFile())) {
68
+ // NEVER autoload the session token from disk: the whole design splits session (memory-only)
69
+ // from the floor-limited agent token (.env). Silently honoring a parked session token would
70
+ // reward exactly the credential-at-rest the split exists to prevent (guard catch 2026-08-29).
71
+ if (k === "DECIDIO_SESSION_TOKEN") {
72
+ console.error("⚠ ignoring DECIDIO_SESSION_TOKEN found in .env — session tokens belong in your shell or CI secret store, never on disk. Remove that line; export it in the shell instead.");
73
+ continue;
74
+ }
75
+ if (process.env[k] === undefined)
76
+ process.env[k] = v;
77
+ }
78
+ // SANDBOX-FIRST (blind-review catch, founder-ruled): every command defaults to the hosted sandbox,
79
+ // because that is the product people actually receive — the old localhost default was our own dev
80
+ // environment leaking into the package, and it broke the tour's own follow-up commands the moment
81
+ // cleanup removed .env. Explicit config (env or .env) always wins; the target host is announced on
82
+ // stderr so no command ever talks to a server silently.
37
83
  const API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
84
+ // Token is read AT CALL TIME, not captured at import: init mints a fresh agent token mid-run and
85
+ // its closing doctor must probe with THAT, not the pre-init env (round-3 Codex catch — the stale
86
+ // capture made init's own doctor print "token: REJECTED" seconds after a successful mint).
38
87
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
39
88
  const ENV_PATH = ".env";
40
89
  const log = (...a) => console.log(...a);
90
+ // Errors and usage guidance go to STDERR (blind-review catch: error text on stdout corrupts every
91
+ // `--json | jq` pipeline — and any consumer of a CLI's stdout). Verdict output (verify's VALID/
92
+ // INVALID lines) is the product and stays on stdout.
41
93
  const err = (...a) => console.error(...a);
94
+ /** Fetch wrapper that turns transport failures into guidance instead of a stack trace. */
42
95
  let hostAnnounced = false;
43
96
  async function api(path, init = {}, bearer) {
44
97
  if (!hostAnnounced) {
45
98
  hostAnnounced = true;
99
+ // Once per process, on stderr: WHICH server this command talks to (sandbox-first means a
100
+ // missing config no longer fails loud, so the target must never be silent instead).
46
101
  err(`· decidio → ${API}${process.env.DECIDIO_API_URL ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
47
102
  }
48
103
  const auth = bearer ?? TOKEN();
@@ -72,13 +127,19 @@ async function api(path, init = {}, bearer) {
72
127
  }
73
128
  return { status: res.status, body };
74
129
  }
130
+ /** Write a secrets file OWNER-ONLY (0600). `mode` applies on creation; an existing file keeps its
131
+ * old permissions, so chmod as well. Best-effort: Windows ignores POSIX modes (its ACLs are
132
+ * inherited from the directory), and a chmod failure must never lose the write. */
75
133
  function writeSecretFile(path, body) {
76
134
  writeFileSync(path, body, { mode: 0o600 });
77
135
  try {
78
136
  chmodSync(path, 0o600);
79
137
  }
80
- catch { }
138
+ catch { /* restricted FS — the write itself stands */ }
81
139
  if (process.platform === "win32") {
140
+ // POSIX modes are a no-op on NTFS (round-8 verified the file kept inherited Modify ACLs).
141
+ // Do it the Windows way: strip inheritance, grant only the current user. Best-effort — a
142
+ // failure never loses the write, but it is SAID, not swallowed.
82
143
  try {
83
144
  execFileSync("icacls", [path, "/inheritance:r", "/grant:r", `${process.env.USERNAME ?? process.env.USER ?? ""}:F`], { stdio: "ignore" });
84
145
  }
@@ -88,12 +149,42 @@ function writeSecretFile(path, body) {
88
149
  }
89
150
  }
90
151
  function upsertEnv(updates) {
91
- const cur = readEnvFile();
92
- const merged = { ...cur, ...updates };
93
- const body = Object.entries(merged).map(([k, v]) => `${k}=${v}`).join("\n") + "\n";
94
- writeSecretFile(ENV_PATH, body);
152
+ // Update IN PLACE, never rewrite (round-2 blind catch: the merge-and-rewrite dropped every
153
+ // comment and blank line — "# Production database — do not touch" vanished unrecoverably,
154
+ // since this file is gitignored). A user's line only changes when its KEY is being updated;
155
+ // everything else survives byte-for-byte; new keys append.
156
+ const remaining = { ...updates };
157
+ const updatedKeys = new Set();
158
+ const lines = [];
159
+ try {
160
+ if (existsSync(ENV_PATH))
161
+ for (const line of readFileSync(ENV_PATH, "utf8").split(/\r?\n/)) {
162
+ const m = line.match(/^\s*([A-Z_]+)=/);
163
+ // A hand-edited duplicate of an updated key is DROPPED, not kept (guard note: last-wins on
164
+ // the next read would resurrect the stale value — e.g. an old token — over the fresh one).
165
+ if (m && updatedKeys.has(m[1]))
166
+ continue;
167
+ if (m && m[1] in remaining) {
168
+ lines.push(`${m[1]}=${remaining[m[1]]}`);
169
+ updatedKeys.add(m[1]);
170
+ delete remaining[m[1]];
171
+ }
172
+ else
173
+ lines.push(line);
174
+ }
175
+ }
176
+ catch { /* unreadable existing file: start fresh below — the write still lands */ }
177
+ while (lines.length && lines[lines.length - 1] === "")
178
+ lines.pop();
179
+ for (const [k, v] of Object.entries(remaining))
180
+ lines.push(`${k}=${v}`);
181
+ writeSecretFile(ENV_PATH, lines.join("\n") + "\n");
95
182
  }
183
+ /** Keep secrets out of git without nagging: append the two entries to .gitignore when they're
184
+ * not already covered. Creating the file in a non-git directory is harmless. */
96
185
  function ensureGitignore() {
186
+ // Every file this CLI may write that can hold a private key — including the `--force` backup
187
+ // (.env.<agent>.bak), which git's plain `.env` pattern does NOT match.
97
188
  const entries = [".env", ".env.quickstart", ".env.*.bak", ".decidio-pending/"];
98
189
  const existing = existsSync(".gitignore") ? readFileSync(".gitignore", "utf8") : "";
99
190
  const missing = entries.filter((e) => !existing.split(/\r?\n/).some((l) => l.trim() === e || l.trim() === e.replace(/\/$/, "")));
@@ -102,10 +193,12 @@ function ensureGitignore() {
102
193
  appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n");
103
194
  log(`✔ added ${missing.join(" + ")} to .gitignore (your agent key and parked actions stay out of git)`);
104
195
  }
196
+ // ---- interactive input (session sign-in) ---------------------------------------------------
105
197
  function ask(question, mute = false) {
106
198
  return new Promise((resolve) => {
107
199
  const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
108
200
  if (mute) {
201
+ // Hide password echo: after the prompt renders, swallow subsequent output writes.
109
202
  const anyRl = rl;
110
203
  const write = anyRl._writeToOutput?.bind(rl);
111
204
  anyRl._writeToOutput = (s) => { if (s.includes(question))
@@ -115,20 +208,29 @@ function ask(question, mute = false) {
115
208
  process.stdout.write("\n"); resolve(answer.trim()); });
116
209
  });
117
210
  }
211
+ /** Owner SESSION token, memory-only. Sources in order: DECIDIO_SESSION_TOKEN env, interactive
212
+ * email+password (`/demo/login`), pasted sign-in link (`/demo/login-consume` — for the
213
+ * password-less accounts the welcome flow creates). Never persisted. */
118
214
  async function acquireSession(purpose) {
215
+ // TRANSPORT CHECK FIRST — before ANY credential is selected, including one already in the
216
+ // environment. This guard used to sit after the DECIDIO_SESSION_TOKEN shortcut, so an
217
+ // env-supplied session token would ride plaintext http to a remote host without ever being
218
+ // checked. The rule is about the WIRE, so it cannot depend on where the credential came from.
119
219
  if (/^http:\/\//i.test(API) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]|$)/i.test(API)) {
120
220
  err(`✘ refusing to send workspace credentials over plaintext http to ${API}.`);
121
- log(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
221
+ err(` Use https (the hosted sandbox is https://decidio-api.onrender.com) or localhost for local dev.`);
122
222
  return null;
123
223
  }
124
224
  if (process.env.DECIDIO_SESSION_TOKEN)
125
225
  return process.env.DECIDIO_SESSION_TOKEN;
126
226
  if (!process.stdin.isTTY) {
227
+ // WHOLE failure messages ride stderr (round-2 blind catch: the ✘ headline went to stderr
228
+ // while its remediation went to stdout — split mid-sentence for any consumer of either).
127
229
  err(`✘ ${purpose} needs a Decidio workspace sign-in, and this terminal can't prompt for one.`);
128
- log(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
129
- log(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
130
- log(` 2. POST its ?login= value — the response's "token" is your session token:`);
131
- log(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}'`);
230
+ err(` Fix: set DECIDIO_SESSION_TOKEN and re-run. To mint one:`);
231
+ err(` 1. Request a sign-in link from your workspace's sign-in screen (or use your welcome email).`);
232
+ err(` 2. POST its ?login= value — the response's "token" is your session token:`);
233
+ err(` curl -X POST ${API}/demo/login-consume -H "content-type: application/json" -d '{"token":"<login value>"}'`);
132
234
  return null;
133
235
  }
134
236
  log(`${purpose} needs your Decidio workspace sign-in (${API}).`);
@@ -160,21 +262,29 @@ async function acquireSession(purpose) {
160
262
  return null;
161
263
  }
162
264
  async function cmdInit(agentId, opts = {}) {
163
- log(`Registering agent "${agentId}" with Decidio at ${API}…`);
265
+ err(`Registering agent "${agentId}" with Decidio at ${API}…`);
266
+ // CLIENT-SIDE KEYGEN: generate the Ed25519 keypair LOCALLY and register only the PUBLIC
267
+ // did:key. The private key NEVER crosses the wire — it goes straight to this host's env.
164
268
  const existingEnv = readEnvFile();
165
269
  const existingKey = existingEnv.DECIDIO_AGENT_KEY;
166
270
  const sourceSystem = process.env.DECIDIO_SOURCE_SYSTEM || existingEnv.DECIDIO_SOURCE_SYSTEM;
271
+ // NEVER silently orphan a different agent's key. Registering a NEW name in a directory that
272
+ // already holds another agent's key would overwrite it — and keys never leave the machine that
273
+ // made them, so that loss is irrecoverable (it strands the old agent in init's own 409 stop).
274
+ // Checked BEFORE registering, so a refusal leaves nothing behind server-side either.
167
275
  if (existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId && !opts.force) {
168
276
  err(`✘ this directory's .env already holds the key for a DIFFERENT agent ("${existingEnv.DECIDIO_AGENT_ID}").`);
169
- log(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
170
- log(` overwritten is gone for good (it never left this machine). Nothing was written.`);
171
- log(` Pick one:`);
172
- log(` 1. New directory (cleanest): mkdir ${agentId} && cd ${agentId} && npx @decidio/sdk init ${agentId}`);
173
- log(` 2. Replace the old agent here, backing its .env up first: npx @decidio/sdk init ${agentId} --force`);
277
+ err(` Registering "${agentId}" here would overwrite that key — and a private key that is`);
278
+ err(` overwritten is gone for good (it never left this machine). Nothing was written.`);
279
+ err(` Pick one:`);
280
+ err(` 1. New directory (cleanest): mkdir ${agentId} && cd ${agentId} && npx @decidio/sdk init ${agentId}`);
281
+ err(` 2. Replace the old agent here, backing its .env up first: npx @decidio/sdk init ${agentId} --force`);
174
282
  return 1;
175
283
  }
176
284
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
177
285
  const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
286
+ // First try whatever bearer the env already carries (the local-dev / operator path — a static
287
+ // admin token, or dev-open auth). On 401/403 fall through to the self-serve session flow.
178
288
  let session = null;
179
289
  let r = await api("/api/agents/register", { method: "POST", body: registerBody });
180
290
  if (r.status === 401 || r.status === 403) {
@@ -185,6 +295,10 @@ async function cmdInit(agentId, opts = {}) {
185
295
  }
186
296
  let did, privateKey;
187
297
  if (r.status === 409) {
298
+ // Already registered to a server-held did. WITHOUT the original private key on this machine
299
+ // this is a HARD STOP, not a degraded continue (round-3 Codex catch: init used to write a
300
+ // keyless .env and print happy-path next steps — every request then routed unproven and
301
+ // execution evidence silently capped at agent_asserted).
188
302
  did = r.body?.did;
189
303
  if (existingKey) {
190
304
  log(`• "${agentId}" is already registered in this workspace (did ${did}) — reusing it with the DECIDIO_AGENT_KEY already in .env.`);
@@ -194,10 +308,10 @@ async function cmdInit(agentId, opts = {}) {
194
308
  else {
195
309
  const suggestion = `${agentId}-${randomBytes(2).toString("hex")}`;
196
310
  err(`✘ "${agentId}" is already registered in this workspace, and its private key is not on this machine`);
197
- log(` (keys never leave the machine that generated them — likely a previous run, or another directory).`);
198
- log(` Nothing was written. Two ways forward:`);
199
- log(` 1. Fresh agent (most common): npx @decidio/sdk init ${suggestion}`);
200
- log(` 2. Reuse "${agentId}": restore its original DECIDIO_AGENT_KEY into .env here, then re-run init.`);
311
+ err(` (keys never leave the machine that generated them — likely a previous run, or another directory).`);
312
+ err(` Nothing was written. Two ways forward:`);
313
+ err(` 1. Fresh agent (most common): npx @decidio/sdk init ${suggestion}`);
314
+ err(` 2. Reuse "${agentId}": restore its original DECIDIO_AGENT_KEY into .env here, then re-run init.`);
201
315
  return 1;
202
316
  }
203
317
  }
@@ -208,12 +322,18 @@ async function cmdInit(agentId, opts = {}) {
208
322
  }
209
323
  else {
210
324
  err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
211
- log(` Fix the cause above and re-run — init is safe to repeat.`);
325
+ err(` Fix the cause above and re-run — init is safe to repeat.`);
212
326
  return 1;
213
327
  }
328
+ // Mint the runtime credential: the floor-limited AGENT token (can call /agent/* only). This is
329
+ // what .env gets — the session token stays in memory and dies with this process. On boxes
330
+ // without the tester-lab fence (plain local dev) the mint endpoint refuses; that's fine —
331
+ // local dev auths with its existing env and we say so instead of failing the init.
214
332
  let agentToken, workspaceId, tokenJti;
215
333
  const mintBearer = session ?? TOKEN();
216
334
  if (mintBearer) {
335
+ // Right-sized lifetime, not the server's year-long default: a CLI-minted credential should
336
+ // expire on the horizon of the work it was minted for (--expires-days to override).
217
337
  const expiresInDays = opts.expiresDays ?? 90;
218
338
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "sdk-cli", expiresInDays }) }, mintBearer);
219
339
  if (mint.status >= 200 && mint.status < 300 && mint.body?.token) {
@@ -221,10 +341,16 @@ async function cmdInit(agentId, opts = {}) {
221
341
  workspaceId = mint.body.workspaceId;
222
342
  tokenJti = mint.body.jti;
223
343
  log(`✔ minted the agent's API token — floor-limited: it can request authorization, poll status and report confirmation; it cannot administer the workspace or approve anything${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ` (${expiresInDays} days)`}`);
344
+ // The server keeps ONE active token per agent, so a re-mint REVOKES the previous one — an
345
+ // authority event the person triggering it must see, not discover later in tokens list
346
+ // (round-3 blind catch: a still-valid token silently died with no mention).
347
+ const superseded = Array.isArray(mint.body.supersededJtis) ? mint.body.supersededJtis : [];
348
+ for (const s of superseded)
349
+ log(` • this replaces the agent's previous token (jti ${s}) — now REVOKED: the server keeps one active token per agent. Anything still using it will 401.`);
224
350
  }
225
351
  else if (session) {
226
352
  err(`✘ could not mint the agent's API token (${mint.status}): ${JSON.stringify(mint.body)}`);
227
- log(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
353
+ err(` Without it the SDK has no runtime credential. Fix the cause above and re-run init — it's safe to repeat.`);
228
354
  return 1;
229
355
  }
230
356
  else {
@@ -238,20 +364,28 @@ async function cmdInit(agentId, opts = {}) {
238
364
  if (did)
239
365
  envOut.DECIDIO_AGENT_DID = did;
240
366
  if (privateKey)
241
- envOut.DECIDIO_AGENT_KEY = privateKey;
367
+ envOut.DECIDIO_AGENT_KEY = privateKey; // written locally; never left this host
242
368
  if (agentToken)
243
- envOut.DECIDIO_API_TOKEN = agentToken;
369
+ envOut.DECIDIO_API_TOKEN = agentToken; // the FLOOR-LIMITED token — never the session
244
370
  if (workspaceId)
245
371
  envOut.DECIDIO_WORKSPACE_ID = workspaceId;
372
+ // Revocation metadata, not a secret — parity with the quickstart env (blind-review catch: the
373
+ // two files disagreed, and `tokens revoke` is unusable without the jti in hand).
246
374
  if (tokenJti)
247
375
  envOut.DECIDIO_TOKEN_JTI = tokenJti;
376
+ // IGNORE RULE BEFORE THE SECRET: writing .env first leaves a window (however short) where a
377
+ // private key sits in a repo with no ignore rule covering it.
248
378
  ensureGitignore();
249
379
  if (opts.force && existingKey && existingEnv.DECIDIO_AGENT_ID && existingEnv.DECIDIO_AGENT_ID !== agentId) {
380
+ // --force still must not DESTROY the old key: park it beside the new one, owner-only.
250
381
  const backup = `.env.${existingEnv.DECIDIO_AGENT_ID.replace(/[^\w.-]/g, "_")}.bak`;
251
382
  writeSecretFile(backup, readFileSync(ENV_PATH, "utf8"));
252
383
  log(`✔ backed the previous agent's .env up to ${backup} (its key would otherwise be unrecoverable)`);
253
384
  }
254
385
  upsertEnv(envOut);
386
+ // The closing doctor (and anything else this process does) must see what init just wrote —
387
+ // OVERWRITE process.env with the fresh values (round-3 Codex catch: doctor probed with the
388
+ // stale pre-init token and reported the mint it just watched as REJECTED).
255
389
  for (const [k, v] of Object.entries(envOut))
256
390
  process.env[k] = v;
257
391
  log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
@@ -286,63 +420,104 @@ async function cmdInit(agentId, opts = {}) {
286
420
  async function cmdDoctor() {
287
421
  const env = readEnvFile();
288
422
  let ok = true;
423
+ let warned = 0;
289
424
  const line = (good, label) => { log(`${good ? "✔" : "✘"} ${label}`); ok = ok && good; };
425
+ // Warnings COUNT in the summary (round-3 blind catch: "doctor: all green." directly under a ⚠
426
+ // line contradicts itself — green with a caveat must say so).
427
+ const warn = (label) => { warned++; log(`⚠ ${label}`); };
428
+ // An unset URL is a WORKING default (the hosted sandbox), not a failure — sandbox-first ruling.
290
429
  log(`✔ API URL: ${API}${(process.env.DECIDIO_API_URL || env.DECIDIO_API_URL) ? "" : " (default: the hosted sandbox — set DECIDIO_API_URL to point elsewhere)"}`);
291
430
  line(!!(process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID), `agent id: ${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || "(unset — run: npx @decidio/sdk init <agent-name>)"}`);
431
+ // The webhook secret is OPERATOR-ONLY transport config — a missing one must never fail doctor
432
+ // (blind-review catch: doctor exited 1 immediately after the product's own successful
433
+ // quickstart, whose env rightly omits it). Informational either way.
292
434
  const secret = process.env.DECIDIO_WEBHOOK_SECRET || env.DECIDIO_WEBHOOK_SECRET;
293
435
  log(secret ? "✔ webhook secret: set — operator-only transport (inactive against the hosted sandbox; the poll worker guard.worker() is the self-serve resume path)"
294
436
  : "• webhook secret: not set — only needed for the operator resume-webhook transport; the poll worker guard.worker() is the self-serve resume path (init generates one)");
437
+ // Signing key — REQUIRED to be ELIGIBLE for auto-approve. Whether anything auto-approves is
438
+ // the workspace policy's call, not this key's (Codex finding: the old wording claimed
439
+ // "auto-approve is in effect" while deny-by-default routed everything).
295
440
  const signing = (process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) && (process.env.DECIDIO_AGENT_DID || env.DECIDIO_AGENT_DID);
296
441
  if (signing)
297
442
  log("✔ signing key: set — the agent can prove its identity, making it ELIGIBLE for policy auto-approve. Whether a rule matches is your workspace policy's decision; with no matching rule every request routes to a human (deny-by-default).");
298
443
  else
299
- log("⚠ signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
444
+ warn("signing key: MISSING (DECIDIO_AGENT_KEY/DID) — the agent can't prove identity, so EVERY request routes to a human. Run `npx @decidio/sdk init` to generate one.");
445
+ // Reachability and token role are SEPARATE checks (the old probe called any <500 green, so a
446
+ // wrong-kind token looked healthy). /api/health is unauthenticated truth about reachability;
447
+ // /api/records then classifies the token.
300
448
  const h = await api("/api/health", {}, "");
301
449
  line(h.status >= 200 && h.status < 300, `connectivity: API reachable (${h.status})`);
450
+ // EXPIRY BEFORE IT BITES: a right-sized token is only an improvement if its end is visible in
451
+ // advance — otherwise a production agent simply starts 401ing one day. Read `exp` out of the
452
+ // token's own payload (display only; the server remains the authority on validity).
302
453
  const tok = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
454
+ // UNSET is its own diagnosis (round-3 blind catch: a bare directory probed the server with an
455
+ // EMPTY credential and told a first-run user their never-existent token was "invalid or
456
+ // expired"). Say what is true and stop — there is nothing to probe.
457
+ if (!tok) {
458
+ line(false, "agent token: NOT CONFIGURED — no DECIDIO_API_TOKEN in this directory's .env or your environment. Run `npx @decidio/sdk init <agent-name>` to register an agent and mint one.");
459
+ log(warned ? `\ndoctor: fix the ✘ items above, then re-run \`npx @decidio/sdk doctor\`. (${warned} warning${warned === 1 ? "" : "s"})` : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
460
+ return 1;
461
+ }
303
462
  if (tok) {
304
463
  try {
305
464
  const payload = JSON.parse(Buffer.from(tok.split(".")[1] ?? "", "base64url").toString("utf8"));
306
465
  if (typeof payload?.exp === "number") {
307
466
  const msLeft = payload.exp * 1000 - Date.now();
308
- const days = Math.floor(msLeft / 86_400_000);
467
+ // A minute of grace absorbs mint-to-doctor latency so a fresh 90-day token reads "90",
468
+ // matching the Python doctor (round-3 blind nit: 89 vs 90 for the same expiry).
469
+ const days = Math.floor((msLeft + 60_000) / 86_400_000);
470
+ // Under a day, days-precision reads as "0 day(s)" — which looks expired while ~23h remain
471
+ // (blind-review catch). Switch units instead of rounding away the truth.
309
472
  const left = msLeft < 86_400_000 ? `${Math.max(1, Math.floor(msLeft / 3_600_000))} hour(s)` : `${days} day(s)`;
310
473
  if (msLeft < 0)
311
474
  line(false, `agent token: EXPIRED ${-days} day(s) ago — re-run \`npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}\` to mint a fresh one.`);
312
475
  else if (days <= 14)
313
- log(`⚠ agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
476
+ warn(`agent token: expires in ${left} — mint a fresh one before it does: npx @decidio/sdk init ${env.DECIDIO_AGENT_ID ?? "<agent>"}`);
314
477
  else
315
478
  log(`✔ agent token: valid for another ${days} days`);
316
479
  }
317
480
  }
318
- catch { }
481
+ catch { /* not a JWT we can read — the scope probe below is the real check */ }
319
482
  }
320
483
  const rec = await api("/api/records");
321
484
  if (rec.status >= 200 && rec.status < 300)
322
485
  log("✔ token scope: session/admin (init, approvals and receipt work from this env)");
323
486
  else if (rec.status === 401) {
487
+ // A 401 off the agent floor could also mean an INVALID token (Codex catch: don't
488
+ // false-green expiry). Disambiguate with a floor probe: a valid agent token gets 404 for an
489
+ // unknown decision (authorized, not found); an invalid one gets 401 again. A rejection here
490
+ // flips the overall verdict — "all green" after a red line was round 3's other doctor bug.
324
491
  const probe = await api("/agent/status/doctor-scope-probe");
325
492
  if (probe.status === 404)
326
493
  log("✔ token scope: agent floor — correct for the runtime SDK (approvals/receipt will ask you to sign in)");
327
494
  else if (probe.status === 401)
328
495
  line(false, "token: REJECTED on the agent floor too — it's invalid or expired. Re-run `npx @decidio/sdk init` to mint a fresh one.");
329
496
  else
330
- log(`⚠ token scope: agent-floor probe returned ${probe.status} — inconclusive`);
497
+ warn(`token scope: agent-floor probe returned ${probe.status} — inconclusive`);
331
498
  }
332
499
  else
333
- log(`⚠ token scope: unexpected ${rec.status} from /api/records`);
334
- log(ok ? "\ndoctor: all green." : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
500
+ warn(`token scope: unexpected ${rec.status} from /api/records`);
501
+ const warnNote = warned ? ` (${warned} warning${warned === 1 ? "" : "s"} above — working, worth a look)` : "";
502
+ log(ok ? `\ndoctor: all green.${warnNote}` : "\ndoctor: fix the ✘ items above, then re-run `npx @decidio/sdk doctor`.");
335
503
  return ok ? 0 : 1;
336
504
  }
337
505
  async function cmdApprovals(sub, id, reason, flags = {}) {
506
+ // Approvals are a HUMAN act on the workspace — session scope, never the agent token. If the
507
+ // env's token is agent-floor (401 on /api/*), sign in interactively; memory-only as always.
338
508
  const jsonMode = !!flags.json;
339
509
  const mineOnly = !!flags.mine;
340
510
  if (sub?.startsWith("--"))
341
511
  sub = undefined;
512
+ // USAGE BEFORE AUTH (round-8 catch): a malformed command must fail on its shape, not probe the
513
+ // network first — validation needs no credentials.
342
514
  if ((sub === "approve" || sub === "reject") && !id) {
343
515
  err(`usage: npx @decidio/sdk approvals ${sub} <decisionId> [reason]`);
344
516
  return 2;
345
517
  }
518
+ // --mine without an agent identity cannot mean anything — and quietly returning [] (exit 0)
519
+ // would tell a script "no pending work" when the truth is "misconfigured" (blind-review catch:
520
+ // it leaked "undefined" in human mode and lied with [] in JSON mode). Shape error, before auth.
346
521
  if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
347
522
  err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
348
523
  err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
@@ -360,6 +535,9 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
360
535
  if (sub === "approve" || sub === "reject") {
361
536
  const r = await api(`/api/decisions/${encodeURIComponent(decisionArg)}/${sub === "approve" ? "approve" : "reject"}`, { method: "POST", body: JSON.stringify({ reason: reason ?? undefined }) }, bearer);
362
537
  if (r.status >= 200 && r.status < 300) {
538
+ // HONESTY over green checkmarks (Codex finding: the server resolves each decision exactly
539
+ // once and answers replays idempotently — a replay is a NO-OP and must read as one, never
540
+ // as a fresh approval). Non-zero exit so scripts can tell the difference.
363
541
  if (r.body?.alreadyResolved) {
364
542
  log(`• ${id} was already resolved — nothing changed.`);
365
543
  log(` Decisions are single-use: a second ${sub} can never re-execute the action (that's the replay protection working).`);
@@ -388,6 +566,9 @@ async function cmdApprovals(sub, id, reason, flags = {}) {
388
566
  }
389
567
  const cards = r.body?.cards ?? [];
390
568
  const pending = cards.filter((c) => c.isAgentRequest && c.status !== "sealed");
569
+ // FILTER FIRST, FORMAT SECOND (round-8 catch: --mine --json returned the UNFILTERED workspace
570
+ // list — and automation is exactly the consumer of the JSON form, so the human view and the
571
+ // machine view must be the same set).
391
572
  const myAgent = process.env.DECIDIO_AGENT_ID;
392
573
  const isMine = (c) => !!myAgent && typeof c.title === "string" && c.title.startsWith(`${myAgent}:`);
393
574
  const shown = mineOnly ? pending.filter(isMine) : pending;
@@ -419,6 +600,8 @@ async function cmdReceipt(id, jsonMode = false) {
419
600
  err(" Find decision ids via `npx @decidio/sdk approvals` or in your Decidio app.");
420
601
  return 2;
421
602
  }
603
+ // Receipts are workspace data — session scope, like approvals. (The record-detail /verify
604
+ // route carries the credential; the plain record route does not — Codex finding.)
422
605
  let bearer;
423
606
  let r = await api(`/api/records/${encodeURIComponent(id)}/verify`);
424
607
  if (r.status === 401 || r.status === 403) {
@@ -440,18 +623,28 @@ async function cmdReceipt(id, jsonMode = false) {
440
623
  const name = `decidio-receipt-${id.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
441
624
  const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
442
625
  if (existsSync(name)) {
626
+ // Never silently overwrite an audit artifact (round-6 catch). Byte-identical is fine (receipts
627
+ // are immutable); a DIFFERENT file at the same name is a refusal.
443
628
  if (readFileSync(name, "utf8") !== nextBytes) {
444
629
  err(`✘ ${name} already exists with DIFFERENT bytes — refusing to overwrite an audit artifact. Move it aside or delete it, then re-run.`);
445
630
  return 1;
446
631
  }
632
+ // In --json mode stdout is the JSON result ONLY — narrative rides stderr (stream contract).
447
633
  (jsonMode ? err : log)(`• ${name} already exists (byte-identical — receipts are immutable); leaving it in place.`);
448
634
  }
449
635
  else {
450
636
  writeFileSync(name, nextBytes);
451
637
  (jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
452
638
  }
639
+ // The credential seals at DECISION time (immutable), so execution evidence that arrived later
640
+ // lives on the record — surface the CURRENT tier here so the reader doesn't need a second
641
+ // lookup to see it (Codex catch).
453
642
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
454
643
  const conf = detail.body?.record?.confirmation;
644
+ // The trust anchor, delivered over the AUTHENTICATED channel. Reading the issuer out of the
645
+ // receipt you are about to verify is circular; this value comes from the server's own verify
646
+ // response, so pinning it actually means something (round-7 catch: the guided flow sent people
647
+ // to an app panel they could not reach, leaving provenance uncompletable).
455
648
  const issuer = r.body?.issuer;
456
649
  if (jsonMode) {
457
650
  log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null }, null, 2));
@@ -459,6 +652,8 @@ async function cmdReceipt(id, jsonMode = false) {
459
652
  }
460
653
  if (conf?.status)
461
654
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
655
+ // Emit EXACTLY the deterministic command we document (round-4 catch: the README and this hint
656
+ // disagreed, and bare npx re-probes the registry) — the verifier ships INSIDE this package.
462
657
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
463
658
  log(` npx @decidio/sdk verify ${name}`);
464
659
  if (issuer) {
@@ -472,86 +667,16 @@ async function cmdReceipt(id, jsonMode = false) {
472
667
  }
473
668
  return 0;
474
669
  }
475
- function parseReceiptStrict(text, file) {
476
- const value = JSON.parse(text);
477
- const stack = [];
478
- let i = 0;
479
- const readString = () => {
480
- let out = "";
481
- i++;
482
- while (i < text.length) {
483
- const ch = text[i];
484
- if (ch === "\\") {
485
- out += text[i] + text[i + 1];
486
- i += 2;
487
- continue;
488
- }
489
- if (ch === '"') {
490
- i++;
491
- return out;
492
- }
493
- out += ch;
494
- i++;
495
- }
496
- return out;
497
- };
498
- while (i < text.length) {
499
- const ch = text[i];
500
- if (ch === '"') {
501
- const s = readString();
502
- let j = i;
503
- while (j < text.length && /\s/.test(text[j]))
504
- j++;
505
- if (text[j] === ":" && stack.length > 0) {
506
- const key = JSON.parse(`"${s}"`);
507
- const top = stack[stack.length - 1];
508
- if (top.has(key))
509
- throw new Error(`${file}: duplicate JSON key ${JSON.stringify(key)} — RFC 8785 requires duplicate-free input, and different parsers disagree on which value is real`);
510
- top.add(key);
511
- }
512
- continue;
513
- }
514
- if (ch === "{")
515
- stack.push(new Set());
516
- else if (ch === "}")
517
- stack.pop();
518
- i++;
519
- }
520
- return value;
521
- }
522
- function chainOf(vcs, expectedIssuer) {
523
- if (!vcs.length)
524
- return { valid: false, length: 0, reason: "no receipts supplied" };
525
- const issuers = [...new Set(vcs.map((v) => String(v?.issuer ?? "")))];
526
- if (issuers.length > 1)
527
- return { valid: false, length: 0, reason: `these receipts come from ${issuers.length} DIFFERENT issuers — a chain must be issued end to end by one issuer` };
528
- let prev = null;
529
- let anchor = "segment";
530
- for (let i = 0; i < vcs.length; i++) {
531
- const r = verifyReceiptDoor(vcs[i], expectedIssuer);
532
- if (!r.valid)
533
- return { valid: false, length: i, reason: `receipt ${i} (${r.recordId ?? "?"}) invalid: ${r.reason}` };
534
- const cs = vcs[i]?.credentialSubject ?? {};
535
- if (i === 0)
536
- anchor = (cs.prevHash ?? null) === null ? "genesis" : "segment";
537
- else if ((cs.prevHash ?? null) !== prev)
538
- return { valid: false, length: i, reason: `receipt ${i} does not follow receipt ${i - 1} (prevHash mismatch)` };
539
- prev = cs.contentHash;
540
- }
541
- return { valid: true, length: vcs.length, anchor };
542
- }
543
- function verifyReceiptDoor(vc, expectedIssuer) {
544
- const res = verifyReceipt(vc, expectedIssuer);
545
- if (!res.valid && res.reason === "issuer DID is not the expected issuer") {
546
- const cs = vc?.credentialSubject ?? {};
547
- const derive = (canonical, prevHash) => createHash("sha256").update((prevHash ?? "") + canonical).digest("hex");
548
- const bound = typeof cs.recordCanonical === "string" && typeof cs.contentHash === "string"
549
- && derive(cs.recordCanonical, typeof cs.prevHash === "string" ? cs.prevHash : null) === cs.contentHash;
550
- return { ...res, contentBound: bound, recordId: typeof cs.recordId === "string" ? cs.recordId : res.recordId, reason: "issuer DID is not the expected issuer (the receipt itself may be internally consistent — it was just not issued by who you pinned)" };
551
- }
552
- return res;
553
- }
670
+ // ---- verify (embedded) ----------------------------------------------------------------------
671
+ // ONE implementation, all doors (0.1.9): the dependency floor now carries `runVerify`, so the
672
+ // strict-parse / chain / wrong-issuer local copies this door carried since 0.1.4 are DELETED —
673
+ // the sdk door and the standalone decidio-verify CLI are literally the same function, and the
674
+ // conformance runner's third door keeps them honest. This CLI keeps only what is CLI-shaped:
675
+ // flag grammar, I/O-vs-verdict separation, and usage.
554
676
  function cmdVerify(argsIn) {
677
+ // Positional parse mirroring the standalone decidio-verify CLI: --issuer in space AND `=` form
678
+ // (the `=` form silently dropped was the standalone's unpinned-VALID bug — one grammar, all
679
+ // doors). Unknown flags never reach here (main()'s strict table refuses them first).
555
680
  let expectedIssuer;
556
681
  const files = [];
557
682
  for (let i = 0; i < argsIn.length; i++) {
@@ -582,44 +707,30 @@ function cmdVerify(argsIn) {
582
707
  err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
583
708
  return 2;
584
709
  }
585
- const texts = [];
710
+ // An unreadable file is an I/O problem, not a cryptographic verdict (blind-review catch: a
711
+ // typo'd filename got the same "INVALID" a forged receipt gets).
712
+ const texts = new Map();
586
713
  for (const f of files) {
587
714
  try {
588
- texts.push(readFileSync(f, "utf8"));
715
+ texts.set(f, readFileSync(f, "utf8"));
589
716
  }
590
717
  catch (e) {
591
718
  err(`✘ cannot read ${f}: ${e?.code === "ENOENT" ? "no such file" : e?.message ?? e} (nothing was verified)`);
592
719
  return 1;
593
720
  }
594
721
  }
595
- let vcs;
596
- try {
597
- vcs = files.map((f, i) => parseReceiptStrict(texts[i], f));
598
- }
599
- catch (e) {
600
- log(`✘ INVALID ${String(e?.message ?? e)}`);
601
- return 1;
602
- }
603
- let allOk = true;
604
- vcs.forEach((vc, i) => {
605
- const res = verifyReceiptDoor(vc, expectedIssuer);
606
- allOk = allOk && res.valid;
607
- log(`${res.valid ? "✔ VALID " : "✘ INVALID"} ${files[i]}`);
608
- log(` record: ${res.recordId ?? "?"} issuer: ${res.issuer}${expectedIssuer ? "" : " (unpinned — pass --issuer to bind to your workspace's DID)"}`);
609
- log(` content-bound: ${res.contentBound}${res.reason ? ` reason: ${res.reason}` : ""}`);
610
- });
611
- if (vcs.length > 1) {
612
- const c = chainOf(vcs, expectedIssuer);
613
- allOk = allOk && c.valid;
614
- log(`\nchain: ${c.valid ? "✔ intact" : "✘ broken"} (${c.length}/${vcs.length})${c.valid ? (c.anchor === "segment" ? " — segment: ordering proven from the FIRST file forward; history before it is not covered by these receipts" : " — from genesis") : ""}${c.reason ? ` — ${c.reason}` : ""}`);
615
- }
616
- return allOk ? 0 : 1;
722
+ const { ok, lines } = runVerify(files, expectedIssuer, (f) => texts.get(f));
723
+ for (const l of lines)
724
+ log(l);
725
+ return ok ? 0 : 1;
617
726
  }
727
+ // ---- quickstart (the guided two-minute tour) ------------------------------------------------
618
728
  async function cmdQuickstart(opts = {}) {
619
729
  const t0 = Date.now();
620
730
  log("Decidio quickstart — gate an action, route it to a human, execute on approval, hold the proof.");
621
731
  log(` Target: ${API}${process.env.DECIDIO_API_URL ? "" : " (the hosted synthetic sandbox — set DECIDIO_API_URL to use your own)"}`);
622
732
  log("");
733
+ // SIDE EFFECTS UP FRONT (round-8 ask): say what the tour creates before creating any of it.
623
734
  log(" This tour will: register one synthetic agent · mint a 7-DAY floor-limited token ·");
624
735
  log(" write .env (or .env.quickstart) + .gitignore entries · create one synthetic decision");
625
736
  log(" and its sealed record (records are permanent by design). Cleanup offer at the end.");
@@ -627,6 +738,9 @@ async function cmdQuickstart(opts = {}) {
627
738
  const session = await acquireSession("The quickstart");
628
739
  if (!session)
629
740
  return 1;
741
+ // 1. A collision-proof agent, registered fresh every run (round-4 ask: idempotent sample
742
+ // registration). Key generated locally; floor-limited token minted; .env written so the
743
+ // take-away snippet works in this directory afterward.
630
744
  const agentId = `quickstart-${randomBytes(2).toString("hex")}`;
631
745
  log(`① Registering agent "${agentId}" (fresh name each run — no collisions)…`);
632
746
  const { did, privateKeyBase64 } = generateKeypair();
@@ -635,27 +749,40 @@ async function cmdQuickstart(opts = {}) {
635
749
  err(`✘ registration failed (${reg.status}): ${JSON.stringify(reg.body)}`);
636
750
  return 1;
637
751
  }
752
+ // A tour credential should outlive the tour and not much else — 7 days, disclosed.
638
753
  const mint = await api("/api/workspace/agent-token", { method: "POST", body: JSON.stringify({ agentId, label: "quickstart", expiresInDays: 7 }) }, session);
639
754
  if (!(mint.status >= 200 && mint.status < 300) || !mint.body?.token) {
640
755
  err(`✘ token mint failed (${mint.status}): ${JSON.stringify(mint.body)}`);
641
756
  return 1;
642
757
  }
758
+ // No webhook secret here (round-8 catch): the sandbox tour never uses the webhook transport,
759
+ // and inheriting the .env's existing secret duplicated one agent's secret into another's file.
760
+ // DECIDIO_TOKEN_JTI is revocation METADATA (not a secret) — it is what `quickstart cleanup`
761
+ // and `tokens revoke` need to kill the token later.
643
762
  const envOut = {
644
763
  DECIDIO_API_URL: API, DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: reg.body?.did ?? did,
645
764
  DECIDIO_AGENT_KEY: privateKeyBase64, DECIDIO_API_TOKEN: mint.body.token,
646
765
  DECIDIO_WORKSPACE_ID: mint.body.workspaceId ?? "",
647
766
  ...(mint.body.jti ? { DECIDIO_TOKEN_JTI: mint.body.jti } : {}),
648
767
  };
768
+ // NEVER clobber a real agent's key (guard catch: keys never leave this machine — overwriting
769
+ // .env's DECIDIO_AGENT_KEY orphans that agent irrecoverably). A directory that already holds
770
+ // one gets a TOUR-SCOPED env file instead — through the SAME secure writer as .env (round-8
771
+ // catch: this path used plain writeFileSync).
649
772
  let envFile = ENV_PATH;
650
773
  if (readEnvFile().DECIDIO_AGENT_KEY) {
651
774
  envFile = ".env.quickstart";
652
775
  ensureGitignore();
776
+ // A THIRD tour in the same directory would otherwise overwrite the previous tour agent's
777
+ // private key — unrecoverable, same class as the .env clobber init refuses (blind-review
778
+ // catch). Same remedy: back the old file up first, owner-only.
653
779
  if (existsSync(envFile)) {
654
780
  const prev = readFileSync(envFile, "utf8");
655
781
  const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
656
782
  if (prevId && prevId !== agentId) {
657
- writeSecretFile(".env.quickstart.bak", prev);
658
- log(` ✔ previous tour agent (${prevId}) backed up to .env.quickstart.bak — its key would otherwise be unrecoverable`);
783
+ const bak = `.env.quickstart.${prevId.replace(/[^\w.-]/g, "_")}.bak`;
784
+ writeSecretFile(bak, prev);
785
+ log(` ✔ previous tour agent (${prevId}) backed up to ${bak} — its key would otherwise be unrecoverable (one backup PER agent: serial tours never clobber an earlier backup)`);
659
786
  }
660
787
  }
661
788
  writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
@@ -667,6 +794,9 @@ async function cmdQuickstart(opts = {}) {
667
794
  }
668
795
  log(` ✔ key generated locally (public half registered) · 7-day floor-limited token minted${mint.body.expiresAt ? ` (expires ${mint.body.expiresAt})` : ""} · ${envFile} written`);
669
796
  log("");
797
+ // 2. Gate a demo action. Show the ACTUAL wrap the tour runs (round-6 catch: the printed line
798
+ // differed from the code — this tour uses blocking mode; the bare guard.protect default is
799
+ // durable and needs a worker/webhook, which the README covers).
670
800
  log(`② Gating a demo action — the tour runs this (blocking mode: watch it live):`);
671
801
  log("");
672
802
  log(` const payInvoice = guard.protect(payInvoiceRaw,`);
@@ -682,6 +812,9 @@ async function cmdQuickstart(opts = {}) {
682
812
  };
683
813
  const payInvoice = withApproval(async (inv) => ({ paid: true, id: inv.id }), (inv) => ({ action: "payInvoice", amount: inv.amount, scope: "Invoice" }), cfg);
684
814
  const running = payInvoice({ id: "INV-2026-001", amount: 86_000 });
815
+ // An EARLY rejection (before routing) must be diagnosed as ITSELF, not as a routing timeout
816
+ // (round-2 blind catch: the sandbox's per-hour action cap surfaced here as "check
817
+ // connectivity", and doctor then corroborated the wrong suspect with "all green").
685
818
  let earlyError;
686
819
  running.catch((e) => { earlyError = e; });
687
820
  const waitStart = Date.now();
@@ -704,6 +837,10 @@ async function cmdQuickstart(opts = {}) {
704
837
  log(` ✔ $86,000 payInvoice ROUTED to a human (decision ${decisionId}) — this agent has no auto-approve rule,`);
705
838
  log(` so deny-by-default sends everything to a person. Nothing executes until someone decides.`);
706
839
  log("");
840
+ // 3. The human moment — and it must BE one. The tour's whole claim is "routes it to YOU,
841
+ // executes on your yes"; a headless run that approves itself has no discrete yes (round-8
842
+ // catch: "headless quickstart breaks the central story"). So headless STOPS here unless
843
+ // --auto-approve-demo was passed explicitly, and that path is labeled automated authority.
707
844
  const interactive = process.stdin.isTTY;
708
845
  if (interactive) {
709
846
  await ask(`③ YOU are the approver. Press Enter to approve it here (or approve it in your Decidio queue first, then Enter): `);
@@ -721,6 +858,7 @@ async function cmdQuickstart(opts = {}) {
721
858
  log(" Then re-run the quickstart interactively to watch the whole loop — or, for CI, re-run");
722
859
  log(" with --auto-approve-demo to let the tour approve itself under that explicit label.");
723
860
  log(` Tidy up this tour's agent + token any time: npx @decidio/sdk quickstart cleanup`);
861
+ // The blocking poll keeps the event loop alive; the tour is over, so leave cleanly.
724
862
  process.exit(0);
725
863
  }
726
864
  const ap = await api(`/api/decisions/${encodeURIComponent(decisionId)}/approve`, { method: "POST", body: JSON.stringify({ reason: opts.autoApproveDemo && !interactive ? "quickstart --auto-approve-demo (automated)" : "quickstart" }) }, session);
@@ -729,6 +867,7 @@ async function cmdQuickstart(opts = {}) {
729
867
  return 1;
730
868
  }
731
869
  log(` ✔ approved${ap.body?.alreadyResolved ? " (it was already approved from your app — even better)" : ""}`);
870
+ // 4. The blocking wrapper wakes and runs the agent's own function.
732
871
  let result;
733
872
  try {
734
873
  result = await running;
@@ -739,6 +878,7 @@ async function cmdQuickstart(opts = {}) {
739
878
  }
740
879
  log(` ✔ your function executed AFTER approval: ${JSON.stringify(result)}`);
741
880
  log("");
881
+ // 5. The proof: download the receipt and verify it offline, all in-process.
742
882
  log("④ Downloading the sealed Authority Receipt and verifying it OFFLINE…");
743
883
  const rec = await api(`/api/records/${encodeURIComponent(decisionId)}/verify`, {}, session);
744
884
  if (!(rec.status >= 200 && rec.status < 300) || !rec.body?.credential) {
@@ -747,6 +887,8 @@ async function cmdQuickstart(opts = {}) {
747
887
  }
748
888
  const fname = `decidio-receipt-${decisionId.replace(/[^A-Za-z0-9_-]/g, "")}.json`;
749
889
  writeFileSync(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
890
+ // Verify PINNED to the issuer the server just told us over the authenticated channel — an
891
+ // unpinned VALID only proves self-consistency, and the tour should demonstrate the real thing.
750
892
  const issuer = rec.body?.issuer;
751
893
  const v = verifyReceipt(rec.body.credential, issuer);
752
894
  log(` ${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? `, PINNED to your workspace issuer ${issuer}` : "; issuer unpinned"})`);
@@ -766,6 +908,9 @@ async function cmdQuickstart(opts = {}) {
766
908
  log(` // production is DURABLE mode (default) + one worker: guard.worker(); // README has the full pattern`);
767
909
  log("");
768
910
  log(`Your workspace + queue: ${API.replace("decidio-api.onrender.com", "app.decidioai.com")} · Next: the README's 7-step quickstart (durable, workers, adapters).`);
911
+ // 6. Leave nothing behind unless asked to (round-8 ask: each tour left a live 7-day token and
912
+ // local credentials with no cleanup path). Interactive: offer, default KEEP (they may want
913
+ // to build on it). Automated (--auto-approve-demo): default CLEANUP unless --keep-agent.
769
914
  let doCleanup = false;
770
915
  if (interactive) {
771
916
  const answer = await ask(`\nKeep this agent for development? [Enter = keep · c + Enter = clean up now]: `);
@@ -781,6 +926,9 @@ async function cmdQuickstart(opts = {}) {
781
926
  await quickstartCleanup(session, envFile);
782
927
  return v.valid ? 0 : 1;
783
928
  }
929
+ /** Revoke the tour's token and remove its local credential file. Honest about what remains:
930
+ * sealed records are PERMANENT by design, and the agent's registration row stays (there is no
931
+ * deregistration endpoint yet) — but with its token revoked and its key deleted it is inert. */
784
932
  async function quickstartCleanup(session, envFile) {
785
933
  const env = (() => {
786
934
  const out = {};
@@ -794,7 +942,9 @@ async function quickstartCleanup(session, envFile) {
794
942
  })();
795
943
  if (env.DECIDIO_TOKEN_JTI) {
796
944
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(env.DECIDIO_TOKEN_JTI)}`, { method: "DELETE" }, session);
797
- if (rev.status >= 200 && rev.status < 300)
945
+ if (rev.status >= 200 && rev.status < 300 && rev.body?.changed === false)
946
+ log(` • token was already revoked (jti ${env.DECIDIO_TOKEN_JTI}) — nothing to do.`);
947
+ else if (rev.status >= 200 && rev.status < 300)
798
948
  log(` ✔ token revoked (jti ${env.DECIDIO_TOKEN_JTI})`);
799
949
  else
800
950
  log(` ⚠ token revoke returned ${rev.status} — it still expires on its own within 7 days. List tokens: npx @decidio/sdk tokens list`);
@@ -802,6 +952,10 @@ async function quickstartCleanup(session, envFile) {
802
952
  else {
803
953
  log(` ⚠ no DECIDIO_TOKEN_JTI in ${envFile} (older tour) — revoke by hand: npx @decidio/sdk tokens list, then tokens revoke <jti>. It expires on its own within 7 days.`);
804
954
  }
955
+ // Remove ONLY what the tour wrote (guard catch: in a project directory the tour MERGES into an
956
+ // existing .env, so deleting the whole file would destroy the project's own variables — a
957
+ // cleanup must never remove anything it didn't create). The file itself goes only when nothing
958
+ // else lives in it.
805
959
  if (existsSync(envFile)) {
806
960
  const TOUR_KEYS = new Set(["DECIDIO_API_URL", "DECIDIO_AGENT_ID", "DECIDIO_AGENT_DID", "DECIDIO_AGENT_KEY", "DECIDIO_API_TOKEN", "DECIDIO_WORKSPACE_ID", "DECIDIO_TOKEN_JTI"]);
807
961
  const kept = readFileSync(envFile, "utf8").split(/\r?\n/).filter((line) => {
@@ -817,6 +971,9 @@ async function quickstartCleanup(session, envFile) {
817
971
  log(` ✔ tour credentials removed from ${envFile} — its ${kept.length} other line${kept.length === 1 ? "" : "s"} untouched (the agent's local key is gone — the registered agent is now inert)`);
818
972
  }
819
973
  }
974
+ // HONEST ACCOUNTING includes the queue (blind-review catch: a headless-stopped tour leaves its
975
+ // routed decision pending in the SHARED workspace queue, and cleanup said nothing about it).
976
+ // Listing is a read; clearing it stays a human act — we print the command, we don't run it.
820
977
  const tourAgent = env.DECIDIO_AGENT_ID;
821
978
  if (tourAgent) {
822
979
  const cards = await api("/api/decisions/cards", {}, session);
@@ -830,9 +987,10 @@ async function quickstartCleanup(session, envFile) {
830
987
  log(` and the agent's registration row (inert without key or token; server-side deregistration is on the roadmap).`);
831
988
  return 0;
832
989
  }
833
- async function cmdTokens(sub, jti) {
990
+ /** tokens list [--mine] / tokens revoke <jti> — the workspace's agent-token lifecycle. */
991
+ async function cmdTokens(sub, jti, mineOnly = false) {
834
992
  if (sub !== "list" && sub !== "revoke") {
835
- err("usage: npx @decidio/sdk tokens list");
993
+ err("usage: npx @decidio/sdk tokens list [--mine]");
836
994
  err(" npx @decidio/sdk tokens revoke <jti>");
837
995
  err(" Agent tokens are shown by jti (their revocation id) — the token VALUE is never shown again after minting.");
838
996
  return 2;
@@ -841,6 +999,14 @@ async function cmdTokens(sub, jti) {
841
999
  err("usage: npx @decidio/sdk tokens revoke <jti> (find the jti with: npx @decidio/sdk tokens list)");
842
1000
  return 2;
843
1001
  }
1002
+ // Same grammar and same shape-error as approvals --mine (round-9 catch: a first run in the
1003
+ // SHARED sandbox listed 138 workspace-wide rows — accurate, documented, and still terrible
1004
+ // isolation optics with no way to narrow).
1005
+ if (mineOnly && !process.env.DECIDIO_AGENT_ID) {
1006
+ err(`✘ --mine needs an agent identity, and there is no DECIDIO_AGENT_ID in this directory's .env or your environment.`);
1007
+ err(` Run this from your agent's directory (where init wrote .env), or drop --mine for the workspace-wide list.`);
1008
+ return 2;
1009
+ }
844
1010
  let bearer;
845
1011
  let r = await api("/api/workspace/agent-token");
846
1012
  if (r.status === 401 || r.status === 403) {
@@ -854,6 +1020,9 @@ async function cmdTokens(sub, jti) {
854
1020
  if (sub === "revoke") {
855
1021
  const rev = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
856
1022
  if (rev.status >= 200 && rev.status < 300) {
1023
+ // Replay honesty (blind-review catch + the SDK's own contract): revoking an already-revoked
1024
+ // token changes nothing and must read as a no-op, not a fresh kill. The server says which
1025
+ // via `changed`.
857
1026
  if (rev.body?.changed === false) {
858
1027
  log(`• ${jti} was already revoked — nothing changed.`);
859
1028
  log(` Revocation is permanent; there is no re-revoke. See it: npx @decidio/sdk tokens list`);
@@ -873,16 +1042,23 @@ async function cmdTokens(sub, jti) {
873
1042
  err(`✘ could not list tokens (${r.status}): ${JSON.stringify(r.body).slice(0, 300)}`);
874
1043
  return 1;
875
1044
  }
876
- const tokens = r.body?.tokens ?? r.body ?? [];
1045
+ const all = r.body?.tokens ?? r.body ?? [];
1046
+ const myAgent = process.env.DECIDIO_AGENT_ID;
1047
+ const tokens = mineOnly ? all.filter((t) => t.agentId === myAgent) : all;
877
1048
  if (!Array.isArray(tokens) || !tokens.length) {
878
- log("No agent tokens in this workspace.");
1049
+ log(mineOnly ? `No agent tokens for "${myAgent}"${all.length ? ` (${all.length} elsewhere in this workspace — drop --mine to see them)` : ""}.` : "No agent tokens in this workspace.");
879
1050
  return 0;
880
1051
  }
1052
+ // Status is derived per row, revoked first — a list that showed revoked tokens as "Active" made
1053
+ // the revocation audit surface unable to confirm its own revocations (blind-review catch).
881
1054
  const status = (t) => t.revokedAt ? `REVOKED ${String(t.revokedAt).slice(0, 10)}`
882
1055
  : (t.expiresAt && Date.parse(t.expiresAt) < Date.now()) ? "expired" : "active";
883
- log(`Agent tokens (${tokens.length}):`);
1056
+ log(`Agent tokens (${tokens.length}${mineOnly ? ` for "${myAgent}"` : ""})${!mineOnly && myAgent ? ` — workspace-wide (a shared workspace shows every member's agents; --mine narrows to this directory's agent, ${myAgent})` : ""}:`);
884
1057
  for (const t of tokens)
885
1058
  log(` ${t.jti} ${status(t)} agent=${t.agentId ?? "?"} label=${t.label ?? ""} expires=${t.expiresAt ?? "?"}`);
1059
+ // The governed-action allowance rides the same response — showing it here is what keeps the
1060
+ // per-hour cap from ever being a surprise 429 (round-2 blind catch: the cap was undocumented
1061
+ // and its refusal untyped).
886
1062
  const al = r.body?.allowance;
887
1063
  if (al?.limit != null)
888
1064
  log(`\nGoverned-action allowance: ${al.remaining}/${al.limit} remaining this hour${al.resetAt ? ` (resets ${al.resetAt})` : ""}.`);
@@ -890,6 +1066,8 @@ async function cmdTokens(sub, jti) {
890
1066
  return 0;
891
1067
  }
892
1068
  function cmdDev(target, port) {
1069
+ // Signal-only relay: receive Decidio's webhook here (a stable URL) and forward it to
1070
+ // your local agent's resume listener. Useful when the agent isn't directly reachable.
893
1071
  log(`decidio dev relay on http://localhost:${port}/decidio/resume → forwarding to ${target}`);
894
1072
  createServer((req, res) => {
895
1073
  if (req.method !== "POST") {
@@ -921,16 +1099,18 @@ const USAGE = `decidio — the Decidio SDK command line
921
1099
  npx @decidio/sdk quickstart
922
1100
 
923
1101
  It gates a demo action, routes it to YOU for approval, executes on your yes, and hands you a
924
- cryptographically verifiable receipt. (Guided and explicit — this CLI never acts, signs you
925
- in, or writes files unless you ask it to.)
1102
+ cryptographically verifiable receipt of the DECISION (execution evidence then accrues on the
1103
+ hosted record — the receipt seals who authorized what, not what ran afterward). Guided and
1104
+ explicit — this CLI never acts, signs you in, or writes files unless you ask it to.
926
1105
 
927
1106
  npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
928
1107
  (interactive: YOUR Enter is the approval. Headless it stops
929
1108
  at the routed decision; --auto-approve-demo for CI, labeled
930
1109
  automated; ends with a keep-or-cleanup choice)
931
1110
  npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials
932
- npx @decidio/sdk tokens list agent tokens with status (active/expired/REVOKED) by jti —
933
- token values are never re-shown
1111
+ npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
1112
+ jti — token values are never re-shown; the list is
1113
+ workspace-wide, --mine narrows to this directory's agent
934
1114
  npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
935
1115
  npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
936
1116
  --unique append a random suffix (collision-proof samples)
@@ -946,10 +1126,12 @@ const USAGE = `decidio — the Decidio SDK command line
946
1126
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
947
1127
  npx @decidio/sdk --version print the CLI version
948
1128
 
949
- Every command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
950
- DECIDIO_API_URL says otherwise; the target host is announced on stderr each run.
1129
+ Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
1130
+ DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
1131
+ fully offline — it touches no network and reads no .env.
951
1132
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error.
952
- Errors and usage go to stderr; stdout carries results only (safe to pipe).
1133
+ Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
1134
+ pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
953
1135
  Docs: the README that shipped with this package (quickstart, durable resume, adapters).`;
954
1136
  async function main(argv) {
955
1137
  const [cmd, a, b, ...rest] = argv.slice(2);
@@ -966,6 +1148,9 @@ async function main(argv) {
966
1148
  log(USAGE);
967
1149
  return 0;
968
1150
  }
1151
+ // STRICT FLAGS (round-8 catch: `verify --strict` was silently ignored — a security CLI must
1152
+ // fail closed on options it doesn't understand, BEFORE any auth or network). Each command
1153
+ // whitelists its flags; anything else exits 2. Value-taking flags name themselves.
969
1154
  const argsIn = argv.slice(3);
970
1155
  const KNOWN = {
971
1156
  quickstart: { flags: ["--auto-approve-demo", "--keep-agent"] },
@@ -974,7 +1159,7 @@ async function main(argv) {
974
1159
  approvals: { flags: ["--json", "--mine"] },
975
1160
  receipt: { flags: ["--json"] },
976
1161
  verify: { flags: [], valued: ["--issuer"] },
977
- tokens: { flags: [] },
1162
+ tokens: { flags: ["--mine"] },
978
1163
  dev: { flags: [], valued: ["--target", "--port"] },
979
1164
  };
980
1165
  const spec = KNOWN[cmd];
@@ -990,14 +1175,19 @@ async function main(argv) {
990
1175
  if (valued.includes(a)) {
991
1176
  i++;
992
1177
  continue;
993
- }
1178
+ } // its value is consumed
994
1179
  if (valued.some((v) => a.startsWith(v + "=")))
995
- continue;
1180
+ continue; // --flag=value form
996
1181
  if (!spec.flags.includes(a)) {
997
1182
  err(`✘ unknown option "${a}" for \`${cmd}\` — refusing to guess (a flag you meant to matter must never be silently ignored).\n\n${USAGE}`);
998
1183
  return 2;
999
1184
  }
1000
1185
  }
1186
+ // POSITIONAL arity is part of the same contract (round-2 blind catch: `tokens revoke jti1
1187
+ // jti2` revoked only the first and reported unqualified success — silent false success on a
1188
+ // security control). Anything beyond what the command reads is refused, not dropped.
1189
+ // approvals approve/reject take a free-text reason, so their tail is exempt; verify takes
1190
+ // any number of files.
1001
1191
  const MAX_POS = { quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
1002
1192
  const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
1003
1193
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
@@ -1020,6 +1210,7 @@ async function main(argv) {
1020
1210
  switch (cmd) {
1021
1211
  case "quickstart": {
1022
1212
  if (argsIn.includes("cleanup")) {
1213
+ // Standalone cleanup: prefer the tour-scoped file; fall back to a tour-created .env.
1023
1214
  const envFile = existsSync(".env.quickstart") ? ".env.quickstart"
1024
1215
  : (readEnvFile().DECIDIO_AGENT_ID ?? "").startsWith("quickstart-") ? ENV_PATH : undefined;
1025
1216
  if (!envFile) {
@@ -1035,6 +1226,9 @@ async function main(argv) {
1035
1226
  }
1036
1227
  case "init": {
1037
1228
  const unique = argsIn.includes("--unique");
1229
+ // BOTH forms (guard catch: the strict table's generic `=` allowance let --expires-days=30
1230
+ // pass the gate while this parser read only the space form — a silently-ignored
1231
+ // credential-LIFETIME flag, the exact class the verifier doors just fixed).
1038
1232
  const expIdx = argsIn.indexOf("--expires-days");
1039
1233
  const expEq = argsIn.find((x) => x.startsWith("--expires-days="));
1040
1234
  const expRaw = expIdx >= 0 ? argsIn[expIdx + 1] : expEq ? expEq.slice("--expires-days=".length) : undefined;
@@ -1057,7 +1251,7 @@ async function main(argv) {
1057
1251
  case "verify": return cmdVerify(argsIn);
1058
1252
  case "tokens": {
1059
1253
  const positional = argsIn.filter((x) => !x.startsWith("-"));
1060
- return cmdTokens(positional[0], positional[1]);
1254
+ return cmdTokens(positional[0], positional[1], argsIn.includes("--mine"));
1061
1255
  }
1062
1256
  case "dev": {
1063
1257
  const flag = (name) => {
@@ -1068,6 +1262,8 @@ async function main(argv) {
1068
1262
  return eq ? eq.slice(name.length + 1) : undefined;
1069
1263
  };
1070
1264
  const target = flag("--target") || process.env.DECIDIO_AGENT_RESUME_URL || "http://localhost:4100/decidio/resume";
1265
+ // Validate on shape, before binding anything (blind-review catch: `--port abc` printed
1266
+ // localhost:NaN and died with a raw internal error).
1071
1267
  const portRaw = flag("--port") || process.env.DECIDIO_DEV_PORT || "4099";
1072
1268
  if (!/^\d+$/.test(portRaw) || Number(portRaw) < 1 || Number(portRaw) > 65535) {
1073
1269
  err(`✘ --port needs a number between 1 and 65535 (got "${portRaw}").`);
@@ -1080,7 +1276,14 @@ async function main(argv) {
1080
1276
  return cmdDev(target, Number(portRaw));
1081
1277
  }
1082
1278
  default:
1083
- err(`✘ unknown command "${cmd}"\n\n${USAGE}`);
1279
+ // Exit 2, not 1: an unknown command is a USAGE error, and the help text promised that
1280
+ // contract (round-8 catch).
1281
+ // A flag in command position gets a NAMED fix, not just a refusal (round-3 blind nit:
1282
+ // `--json approvals` earned "unknown command" with no hint that flags follow the command).
1283
+ if (cmd.startsWith("-"))
1284
+ err(`✘ unknown command "${cmd}" — flags go AFTER the command (e.g. npx @decidio/sdk approvals ${cmd}).\n\n${USAGE}`);
1285
+ else
1286
+ err(`✘ unknown command "${cmd}"\n\n${USAGE}`);
1084
1287
  return 2;
1085
1288
  }
1086
1289
  }