@decidio/sdk 0.6.4 → 0.8.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/dist/cli.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync } from "node:fs";
3
- import { randomBytes } from "node:crypto";
2
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, chmodSync, rmSync, rmdirSync, openSync, writeSync, fsyncSync, closeSync, renameSync, mkdirSync, mkdtempSync, readdirSync, copyFileSync, statSync, lstatSync } from "node:fs";
3
+ import { randomBytes, createHash } from "node:crypto";
4
4
  import { homedir, tmpdir } from "node:os";
5
- import { join as joinPath, resolve as resolvePath, sep as pathSep, basename } from "node:path";
5
+ import { join as joinPath, resolve as resolvePath, sep as pathSep, basename, relative, isAbsolute } from "node:path";
6
6
  import { createServer } from "node:http";
7
+ import { isIP } from "node:net";
7
8
  import { execFileSync, spawnSync } from "node:child_process";
8
9
  import { createInterface } from "node:readline";
9
10
  import { pathToFileURL as toFileUrl, fileURLToPath as fromFileUrl } from "node:url";
@@ -11,8 +12,9 @@ import { realpathSync as realPath } from "node:fs";
11
12
  import { createRequire } from "node:module";
12
13
  import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
13
14
  import { hardenFile } from "./resume.js";
14
- import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, didFromPrivateKey } from "./index.js";
15
- import { verifyReceipt, runVerify, pinRequiredMessage } from "@decidio/verify";
15
+ import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, DecidioHttpError, didFromPrivateKey, checkApproval, signConfirm, buildConfirmBody, readConfirmResponse, bindingMaterial, boundScope, idempotencyKey, decisionUrl, decisionIdProblem } from "./index.js";
16
+ import { gateAuthorizeTwinAware, adapterRequest } from "./adapters/_gate.js";
17
+ import { verifyReceipt, runVerify, pinRequiredMessage, builtInAnchor, KNOWN_ISSUERS } from "@decidio/verify";
16
18
  import { startDemoGate } from "./demo-gate.js";
17
19
  const VERSION = (() => {
18
20
  try {
@@ -50,6 +52,10 @@ const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].in
50
52
  || wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]) || helpFlagAnywhere(ARGV_TAIL);
51
53
  if (!OFFLINE_CMD) {
52
54
  readEnvFile();
55
+ if (envUnreadable && ARGV_TAIL[0] === "hook" && ARGV_TAIL[1] === "pretool") {
56
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "Decidio hook: this project's .env exists but could not be read, so the agent's credentials are unknown - refusing this tool call (fail closed). Ask the user to fix the file's permissions." } }) + "\n");
57
+ process.exit(0);
58
+ }
53
59
  if (envUnreadable) {
54
60
  console.error(`✘ .env exists here but could not be read, so this command cannot know what is already configured in this directory.`);
55
61
  console.error(` Treating it as empty would risk overwriting an agent key that cannot be recovered. Fix the file's permissions (or move it aside) and run the command again. Nothing was changed.`);
@@ -63,16 +69,19 @@ if (!OFFLINE_CMD)
63
69
  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.");
64
70
  continue;
65
71
  }
72
+ if (!k.startsWith("DECIDIO_"))
73
+ continue;
66
74
  if (process.env[k] === undefined)
67
75
  process.env[k] = v;
68
76
  }
69
- let API = (process.env.DECIDIO_API_URL ?? "https://decidio-api.onrender.com").replace(/\/$/, "");
77
+ const DEFAULT_API = "https://decidio-api.onrender.com";
78
+ let API = (process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
70
79
  let DEMO = false;
71
80
  function enterDemo(url) { API = url; process.env.DECIDIO_API_URL = url; DEMO = true; }
72
81
  const pinWords = () => (DEMO ? "PINNED to this demo's own key" : "PINNED to your workspace issuer");
73
82
  const issuerNote = () => (DEMO
74
83
  ? "· a key this demo generated for this run — real tours pin the host's published DID at https://decidio-api.onrender.com/api/did"
75
- : `· your trust anchor — first seen over this signed-in connection; cross-check ${API}/api/did, then verify --issuer <did> <receipt.json>`);
84
+ : `· your trust anchor — named by this server over the signed-in connection, never read from the receipt; cross-check ${API}/api/did`);
76
85
  const TOKEN = () => process.env.DECIDIO_API_TOKEN;
77
86
  const ENV_PATH = ".env";
78
87
  const log = (...a) => console.log(...a);
@@ -509,14 +518,15 @@ if (process.env.DECIDIO_DEBUG_INPUT && process.stdin.isTTY) {
509
518
  }
510
519
  function terminalId() {
511
520
  for (const k of ["WT_SESSION", "VSCODE_IPC_HOOK_CLI", "TERM_SESSION_ID", "ITERM_SESSION_ID", "TMUX_PANE", "KONSOLE_DBUS_SESSION", "GNOME_TERMINAL_SCREEN", "WINDOWID"]) {
512
- const v = process.env[k];
521
+ const v = SHELL_ENV[k];
513
522
  if (v)
514
523
  return `${k}=${v}`;
515
524
  }
516
525
  return "";
517
526
  }
518
527
  function sessionDir() {
519
- const base = process.platform === "win32" ? (process.env.LOCALAPPDATA ?? joinPath(homedir(), "AppData", "Local")) : (process.env.XDG_CONFIG_HOME ?? joinPath(homedir(), ".config"));
528
+ const home = process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
529
+ const base = process.platform === "win32" ? (SHELL_ENV.LOCALAPPDATA ?? joinPath(home, "AppData", "Local")) : (SHELL_ENV.XDG_CONFIG_HOME ?? joinPath(home, ".config"));
520
530
  return joinPath(base, "decidio");
521
531
  }
522
532
  const sessionFile = () => joinPath(sessionDir(), "session.json");
@@ -572,6 +582,140 @@ function endSession() {
572
582
  return false;
573
583
  }
574
584
  }
585
+ const issuersFile = () => joinPath(sessionDir(), "issuers.json");
586
+ const DID_KEY_SHAPE = /^did:key:z[1-9A-HJ-NP-Za-km-z]{40,60}$/;
587
+ const STORE_HOST_LABELS = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/;
588
+ export function issuerStoreKey(url) {
589
+ const raw = url.trim();
590
+ const low = raw.replace(/\/+$/, "").toLowerCase();
591
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)(?:[/?#]|$)/.exec(raw);
592
+ if (!m)
593
+ return low;
594
+ const scheme = m[1].toLowerCase();
595
+ if (scheme !== "http" && scheme !== "https")
596
+ return low;
597
+ let authority = m[2];
598
+ const at = authority.lastIndexOf("@");
599
+ if (at >= 0)
600
+ authority = authority.slice(at + 1);
601
+ let host, portStr = "";
602
+ if (authority.startsWith("[")) {
603
+ const rb = authority.indexOf("]");
604
+ if (rb < 0)
605
+ return low;
606
+ const inner = authority.slice(1, rb).toLowerCase();
607
+ const rest = authority.slice(rb + 1);
608
+ if (isIP(inner) !== 6)
609
+ return low;
610
+ if (rest !== "") {
611
+ if (!/^:\d+$/.test(rest))
612
+ return low;
613
+ portStr = rest.slice(1);
614
+ }
615
+ host = `[${inner}]`;
616
+ }
617
+ else {
618
+ const c = authority.lastIndexOf(":");
619
+ if (c >= 0) {
620
+ const p = authority.slice(c + 1);
621
+ if (!/^\d+$/.test(p))
622
+ return low;
623
+ portStr = p;
624
+ host = authority.slice(0, c).toLowerCase();
625
+ }
626
+ else
627
+ host = authority.toLowerCase();
628
+ if (!STORE_HOST_LABELS.test(host))
629
+ return low;
630
+ }
631
+ const port = portStr === "" ? null : Number(portStr);
632
+ const dflt = scheme === "https" ? 443 : 80;
633
+ return port !== null && port !== dflt ? `${scheme}://${host}:${port}` : `${scheme}://${host}`;
634
+ }
635
+ function readIssuerStore() {
636
+ const f = issuersFile();
637
+ if (!existsSync(f))
638
+ return {};
639
+ const rawText = readFileSync(f, "utf8");
640
+ const s = JSON.parse(rawText);
641
+ const vLiteral = /"v"\s*:\s*(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/.exec(rawText)?.[1] ?? null;
642
+ const hosts = s?.v === 1 && vLiteral === "1" && s.hosts && typeof s.hosts === "object" && !Array.isArray(s.hosts) ? s.hosts : null;
643
+ if (!hosts)
644
+ throw new Error("not a v1 issuer store");
645
+ const out = {};
646
+ for (const [h, e] of Object.entries(hosts)) {
647
+ if (typeof e?.did !== "string" || !DID_KEY_SHAPE.test(e.did) || typeof e?.firstSeen !== "string")
648
+ throw new Error(`malformed entry for ${h}`);
649
+ const k = issuerStoreKey(h);
650
+ if (out[k] && out[k].did !== e.did)
651
+ throw new Error(`two entries for ${k} name different DIDs`);
652
+ out[k] ??= { did: e.did, firstSeen: e.firstSeen };
653
+ }
654
+ return out;
655
+ }
656
+ function rememberedIssuer(host = API) { return readIssuerStore()[issuerStoreKey(host)] ?? null; }
657
+ const verifyHost = () => (SHELL_ENV.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
658
+ function warnIssuerChanged(known, seen, via) {
659
+ const key = issuerStoreKey(API);
660
+ err(`⚠ THE ISSUER DID OF ${key} HAS CHANGED — it was NOT adopted.`);
661
+ err(` remembered (first seen ${known.firstSeen.slice(0, 10)}): ${known.did}`);
662
+ err(` ${via} now says: ${seen}`);
663
+ err(` Receipts from this host are still verified against the REMEMBERED DID. A new DID means a new signing key: a planned`);
664
+ err(` rotation, another deployment behind the same address, or someone answering in its place. Confirm it with the host's`);
665
+ err(` operator out of band before trusting it; to adopt it, remove the "${key}" entry from ${issuersFile()} and sign in again.`);
666
+ }
667
+ async function rememberHostIssuer() {
668
+ if (DEMO)
669
+ return;
670
+ let store;
671
+ try {
672
+ store = readIssuerStore();
673
+ }
674
+ catch (e) {
675
+ err(`⚠ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — left as found, nothing new remembered. Repair the file or move it aside.`);
676
+ return;
677
+ }
678
+ const r = await api("/api/did", {}, "", true);
679
+ const did = typeof r.body?.did === "string" && DID_KEY_SHAPE.test(r.body.did) ? r.body.did : null;
680
+ if (!did) {
681
+ log(dim(` (${API}/api/did did not answer with a DID (${r.status || "no response"}), so this host's issuer is not remembered yet — verify needs --issuer for its receipts until a later sign-in remembers it)`));
682
+ return;
683
+ }
684
+ const key = issuerStoreKey(API);
685
+ const known = store[key];
686
+ if (known?.did === did)
687
+ return;
688
+ if (known) {
689
+ warnIssuerChanged(known, did, `${API}/api/did`);
690
+ return;
691
+ }
692
+ store[key] = { did, firstSeen: new Date().toISOString() };
693
+ try {
694
+ mkdirSync(sessionDir(), { recursive: true });
695
+ writeSecretFile(issuersFile(), JSON.stringify({ v: 1, hosts: store }, null, 2) + "\n");
696
+ }
697
+ catch (e) {
698
+ err(`⚠ could not remember this host's issuer DID (${issuersFile()}: ${e?.message ?? e}) — verify will need --issuer for its receipts.`);
699
+ return;
700
+ }
701
+ log(` ✔ remembered this host's issuer DID (from ${API}/api/did) — verify pins its receipts to it, no --issuer needed: ${did}`);
702
+ }
703
+ function hostAnchor(body) {
704
+ const named = typeof body?.expectedIssuer === "string" && DID_KEY_SHAPE.test(body.expectedIssuer) ? body.expectedIssuer : null;
705
+ if (DEMO)
706
+ return named;
707
+ let mem;
708
+ try {
709
+ mem = rememberedIssuer();
710
+ }
711
+ catch (e) {
712
+ err(`⚠ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — no trust anchor until it is repaired or moved aside.`);
713
+ return null;
714
+ }
715
+ if (mem && named && mem.did !== named)
716
+ warnIssuerChanged(mem, named, "this server's verify response");
717
+ return mem?.did ?? named;
718
+ }
575
719
  async function loginFlow(purpose, emailArg) {
576
720
  log(purpose === "Signing in" ? `Sign in once — kept on this computer for 30 days, or until this terminal closes, logout, or cleanup.` : `${purpose} needs your Decidio workspace sign-in — once: kept on this computer for 30 days, or until this terminal closes, logout, or cleanup.`);
577
721
  const email = emailArg ?? await ask(" email: ");
@@ -585,6 +729,7 @@ async function loginFlow(purpose, emailArg) {
585
729
  if (r.status >= 200 && r.status < 300 && r.body?.token) {
586
730
  const s = saveSession(String(r.body.token), email);
587
731
  log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
732
+ await rememberHostIssuer();
588
733
  return s.token;
589
734
  }
590
735
  log(` ✘ password sign-in failed (${r.status})${r.body?.error ? ` — ${r.body.error}` : ""}. No problem — the emailed link works for every account:`);
@@ -610,6 +755,7 @@ async function loginFlow(purpose, emailArg) {
610
755
  if (r.status >= 200 && r.status < 300 && r.body?.token) {
611
756
  const s = saveSession(String(r.body.token), email);
612
757
  log(` ✔ signed in as ${email} until ${s.expiresAt.slice(0, 10)}${s.terminal ? "" : dim(" (no terminal id here: the sign-in ends on logout or cleanup, not on close)")}`);
758
+ await rememberHostIssuer();
613
759
  return s.token;
614
760
  }
615
761
  if (isLink) {
@@ -772,7 +918,9 @@ async function cmdInit(agentId, opts = {}) {
772
918
  return 1;
773
919
  }
774
920
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
775
- const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}) });
921
+ const soxEnv = (process.env.DECIDIO_SOX_RELEVANT ?? "").trim().toLowerCase();
922
+ const soxRelevant = soxEnv === "yes" || soxEnv === "true" ? true : soxEnv === "no" || soxEnv === "false" ? false : undefined;
923
+ const registerBody = JSON.stringify({ agentId, did: localDid, ...(sourceSystem ? { sourceSystem } : {}), ...(soxRelevant !== undefined ? { soxRelevant } : {}) });
776
924
  let envPrepared = false;
777
925
  const prepareEnvForWrite = () => {
778
926
  if (envPrepared)
@@ -818,6 +966,12 @@ async function cmdInit(agentId, opts = {}) {
818
966
  prepareEnvForWrite();
819
967
  upsertEnv({ DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: did ?? localDid, DECIDIO_AGENT_KEY: localKey });
820
968
  }
969
+ else if (r.status === 400 && r.body?.code === "OWNER_REQUIRED") {
970
+ err(`✘ ${r.body?.message ?? "an agent needs a human owner"}`);
971
+ err(` Sign in as a person so you own it: unset DECIDIO_API_TOKEN (the operator token names no one) and re-run init — it asks you to sign in.`);
972
+ err(` Or have a workspace owner register it with an ownerUserId (POST /api/agents/register) or set one under Connect > Agents.`);
973
+ return 1;
974
+ }
821
975
  else {
822
976
  err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
823
977
  err(` Fix the cause above and re-run — init is safe to repeat.`);
@@ -897,6 +1051,7 @@ async function cmdInit(agentId, opts = {}) {
897
1051
  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)`);
898
1052
  if (privateKey)
899
1053
  log(` (the agent's private signing key is in there — for production, move it to your secret manager)`);
1054
+ await rememberHostIssuer();
900
1055
  log("");
901
1056
  log("Next: protect one function and watch it route. Two steps:");
902
1057
  log("");
@@ -1151,19 +1306,42 @@ async function cmdReceipt(id, jsonMode = false) {
1151
1306
  }
1152
1307
  const name = receiptFileName(id);
1153
1308
  const nextBytes = JSON.stringify(r.body.credential, null, 2) + "\n";
1154
- const wrote = writeReceiptFile(name, nextBytes, jsonMode ? err : log);
1309
+ const issuer = hostAnchor(r.body);
1310
+ const local = issuer ? verifyReceipt(r.body.credential, issuer) : null;
1311
+ const serverInvalid = r.body?.valid === false;
1312
+ const localInvalid = !!local && !local.valid;
1313
+ const bad = serverInvalid || localInvalid;
1314
+ const say = jsonMode ? err : log;
1315
+ const wrote = writeReceiptFile(name, nextBytes, say);
1155
1316
  if (wrote === "conflict")
1156
1317
  return 1;
1157
1318
  if (wrote === "written")
1158
- (jsonMode ? err : log)(`✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
1319
+ say(bad ? `• wrote ${name} — kept for inspection only: it does NOT verify (below)` : `✔ wrote ${name} (downloaded from your workspace — the download is online; VERIFYING it is offline)`);
1159
1320
  const detail = await api(`/api/records/${encodeURIComponent(id)}`, {}, bearer);
1160
1321
  const conf = detail.body?.record?.confirmation;
1161
- const issuer = r.body?.issuer;
1162
1322
  const resolved = r.body?.resolved;
1323
+ const badSaid = () => {
1324
+ if (serverInvalid) {
1325
+ err(`✘ the server's own check says this record does NOT verify: ${r.body?.reason ?? "no reason given"}`);
1326
+ err(` (signature ${r.body?.signatureValid === false ? "INVALID" : "valid"} · content bound ${r.body?.contentBound === false ? "NO" : "yes"} · matches the stored record ${r.body?.matchesRecord === false ? "NO" : "yes"} · stored payload ${r.body?.payloadMatches === false ? "ALTERED" : "intact"})`);
1327
+ }
1328
+ if (localInvalid)
1329
+ err(`✘ this receipt does NOT verify against this Decidio's issuer DID (${issuer}): ${local.reason ?? "no reason given"}`);
1330
+ err(` Do not rely on ${name}. Report it to the operator of ${API} — a sealed record is immutable, so this is tampering, a defect, or an unannounced key change (see any warning above), never a normal state.`);
1331
+ };
1163
1332
  if (jsonMode) {
1164
- log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null, ...(resolved ? { resolved } : {}) }, null, 2));
1333
+ const valid = bad ? false : local?.valid === true ? true : null;
1334
+ log(JSON.stringify({ file: name, decisionId: id, evidenceTier: conf?.status ?? null, confirmed: !!conf?.confirmed, issuer: issuer ?? null, valid, ...(bad ? { reason: serverInvalid ? r.body?.reason ?? null : local?.reason ?? null } : {}), ...(resolved ? { resolved } : {}) }, null, 2));
1335
+ if (bad) {
1336
+ badSaid();
1337
+ return 1;
1338
+ }
1165
1339
  return 0;
1166
1340
  }
1341
+ if (bad) {
1342
+ badSaid();
1343
+ return 1;
1344
+ }
1167
1345
  if (conf?.status)
1168
1346
  log(` Sealed credential: the authority decision (its confirmation field reads "pending" forever — immutable). Current record evidence tier: ${conf.status}${conf.confirmed ? " (confirmed)" : ""}.`);
1169
1347
  if (resolved && Object.keys(resolved).length && !process.stdout.isTTY) {
@@ -1176,19 +1354,37 @@ async function cmdReceipt(id, jsonMode = false) {
1176
1354
  }
1177
1355
  log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
1178
1356
  if (issuer) {
1179
- log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
1357
+ const vHost = verifyHost();
1358
+ let plain = false, via = "";
1359
+ try {
1360
+ const mem = rememberedIssuer(vHost);
1361
+ const claimed = String(r.body.credential?.issuer ?? "");
1362
+ plain = (mem ? mem.did : KNOWN_ISSUERS.find((k) => k.did === claimed)?.did) === issuer;
1363
+ via = mem ? "remembered on this computer" : "built into the verifier";
1364
+ }
1365
+ catch {
1366
+ plain = false;
1367
+ }
1368
+ if (plain) {
1369
+ log(` npx @decidio/sdk verify ${name}`);
1370
+ log(` No --issuer needed here: it pins to this Decidio's issuer (${via}), which the server also named:`);
1371
+ }
1372
+ else {
1373
+ log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
1374
+ }
1180
1375
  log(``);
1181
1376
  log(`${issuer}`);
1182
1377
  log(``);
1183
1378
  log(` Cross-check it out of band — the same DID is published by this Decidio host at ${API}/api/did (public, no sign-in).`);
1184
- log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one,`);
1185
- log(` which your terminal may wrap):`);
1379
+ log(plain ? ` Anywhere else, pin it explicitly (copy the DID from the line above — this one may wrap):` : ` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one, which your terminal may wrap):`);
1186
1380
  log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
1187
- log(` The verifier refuses to run without --issuer: a pin is what makes VALID mean "issued by your`);
1188
- log(` workspace"; --allow-unpinned runs a structural check only, and says so.`);
1381
+ if (!plain && issuerStoreKey(vHost) !== issuerStoreKey(API))
1382
+ log(` (verify reads no .env, so a flagless run pins to the DID for ${vHost}, not ${API} - export DECIDIO_API_URL=${API} in your shell to verify this host's receipts without --issuer.)`);
1383
+ else if (!plain)
1384
+ log(` Or sign in once (npx @decidio/sdk login): this computer then remembers the DID, and verify needs no --issuer.`);
1189
1385
  }
1190
1386
  else {
1191
- log(` To also prove WHO issued it, pin your workspace's issuer DID (shown in your app's record verify panel):`);
1387
+ log(` To also prove WHO issued it, pin your Decidio's issuer DID — published at ${API}/api/did (public, no sign-in), never read off the receipt:`);
1192
1388
  log(` npx @decidio/sdk verify --issuer <did:key:...> ${name}`);
1193
1389
  }
1194
1390
  return 0;
@@ -1237,7 +1433,7 @@ function cmdVerify(argsIn) {
1237
1433
  }
1238
1434
  }
1239
1435
  if (!files.length) {
1240
- err("usage: npx @decidio/sdk verify --issuer <did:key> <receipt.json> [more.json ...] (or --allow-unpinned)");
1436
+ err("usage: npx @decidio/sdk verify [--issuer <did:key>] <receipt.json> [more.json ...] (or --allow-unpinned)");
1241
1437
  err(" Verifies Authority Receipts OFFLINE — signature, content binding, and (for several files) chain order.");
1242
1438
  return 2;
1243
1439
  }
@@ -1245,10 +1441,6 @@ function cmdVerify(argsIn) {
1245
1441
  err("✘ --allow-unpinned and --issuer together — an unpinned check and a pinned one are different questions; ask one.");
1246
1442
  return 2;
1247
1443
  }
1248
- if (expectedIssuer === undefined && !allowUnpinned) {
1249
- err(pinRequiredMessage("npx @decidio/sdk verify"));
1250
- return 2;
1251
- }
1252
1444
  const texts = new Map();
1253
1445
  for (const f of files) {
1254
1446
  try {
@@ -1259,7 +1451,34 @@ function cmdVerify(argsIn) {
1259
1451
  return 1;
1260
1452
  }
1261
1453
  }
1262
- const { ok, lines } = runVerify(files, expectedIssuer, (f) => texts.get(f));
1454
+ const read = (f) => texts.get(f);
1455
+ let anchor = expectedIssuer !== undefined ? "--issuer" : undefined;
1456
+ if (expectedIssuer === undefined && !allowUnpinned) {
1457
+ let mem;
1458
+ try {
1459
+ mem = rememberedIssuer(verifyHost());
1460
+ }
1461
+ catch (e) {
1462
+ err(`✘ the issuer DIDs this computer remembers (${issuersFile()}) could not be read (${e?.message ?? e}) — refusing to fall back to another trust anchor.`);
1463
+ err(` Pass --issuer <did:key> for this run, or repair the file (or move it aside and sign in again). Nothing was verified.`);
1464
+ return 2;
1465
+ }
1466
+ if (mem) {
1467
+ expectedIssuer = mem.did;
1468
+ anchor = `remembered for ${issuerStoreKey(verifyHost())} since ${mem.firstSeen.slice(0, 10)}`;
1469
+ }
1470
+ else {
1471
+ const pick = builtInAnchor(files, read);
1472
+ if ("refusal" in pick) {
1473
+ err(pinRequiredMessage("npx @decidio/sdk verify", pick.refusal));
1474
+ err(` Nothing is remembered for ${issuerStoreKey(verifyHost())} on this computer: signing in once (npx @decidio/sdk login) remembers its DID, and then no --issuer is needed.`);
1475
+ return 2;
1476
+ }
1477
+ expectedIssuer = pick.anchor.did;
1478
+ anchor = `built in: ${pick.anchor.label}, ${pick.anchor.host}`;
1479
+ }
1480
+ }
1481
+ const { ok, lines } = runVerify(files, expectedIssuer, read, anchor);
1263
1482
  for (const l of lines)
1264
1483
  log(l);
1265
1484
  return ok ? 0 : 1;
@@ -1705,12 +1924,13 @@ async function cmdQuickstartInner(opts = {}) {
1705
1924
  const wrote = writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n");
1706
1925
  if (wrote === "conflict")
1707
1926
  return failTour(1);
1708
- const issuer = rec.body?.issuer;
1927
+ const issuer = hostAnchor(rec.body) ?? undefined;
1709
1928
  const v = verifyReceipt(rec.body.credential, issuer);
1710
- const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1711
- if (!v.valid || !issuer) {
1929
+ const serverInvalid = rec.body?.valid === false;
1930
+ const kindLine = `${v.valid && !serverInvalid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1931
+ if (!v.valid || !issuer || serverInvalid) {
1712
1932
  log(` ${kindLine}`);
1713
- err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no issuer DID from the server" : v.reason ?? "no reason given"}) — the tour did not complete.`);
1933
+ err(` ✘ the denial's receipt did not verify PINNED (${!issuer ? "no trust anchor: the server named no issuer and none is remembered for this host" : !v.valid ? v.reason ?? "no reason given" : `the server's own check says it does NOT verify: ${rec.body?.reason ?? "no reason given"}`}) — the tour did not complete.`);
1714
1934
  return failTour(1);
1715
1935
  }
1716
1936
  block("④", "The record", [
@@ -1780,19 +2000,24 @@ async function cmdQuickstartInner(opts = {}) {
1780
2000
  const fname = receiptFileName(decisionId);
1781
2001
  if (writeReceiptFile(fname, JSON.stringify(rec.body.credential, null, 2) + "\n") === "conflict")
1782
2002
  return failTour(1);
1783
- const issuer = rec.body?.issuer;
2003
+ const issuer = hostAnchor(rec.body) ?? undefined;
1784
2004
  const v = verifyReceipt(rec.body.credential, issuer);
1785
- const kindLine = `${v.valid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
1786
- if (!v.valid || !issuer)
2005
+ const serverInvalid = rec.body?.valid === false;
2006
+ const kindLine = `${v.valid && !serverInvalid ? "✔ VALID" : "✘ INVALID"} — ${fname} (signature + content binding, offline${issuer ? ", " + pinWords() : ""}) · record kind: ${recordKindOf(rec.body.credential) ?? "?"}`;
2007
+ if (!v.valid || !issuer || serverInvalid)
1787
2008
  log(` ${kindLine}`);
1788
2009
  if (!v.valid) {
1789
2010
  err(` ✘ the receipt did not verify (${v.reason ?? "no reason given"}) — the tour did not complete.`);
1790
2011
  return failTour(1);
1791
2012
  }
2013
+ if (serverInvalid) {
2014
+ err(` ✘ the server's own check says this record does NOT verify (${rec.body?.reason ?? "no reason given"}) — the tour did not complete.`);
2015
+ return failTour(1);
2016
+ }
1792
2017
  if (!issuer) {
1793
- err(` ⚠ the server did not return an issuer DID, so this check ran UNPINNED — it proves the`);
1794
- err(` receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
1795
- err(` Get your workspace's issuer DID from the app's record-verify panel, then re-check:`);
2018
+ err(` ⚠ the server named no issuer (expectedIssuer) and no DID is remembered for this host, so this check ran UNPINNED —`);
2019
+ err(` it proves the receipt is internally consistent, NOT who issued it (a self-signed forgery also passes).`);
2020
+ err(` Take your Decidio's issuer DID from ${API}/api/did (public, no sign-in) — never from the receipt — then re-check:`);
1796
2021
  err(` npx @decidio/sdk verify --issuer <did:key:...> ${fname}`);
1797
2022
  err(` ✘ the tour does not count an UNPINNED check as success.`);
1798
2023
  return failTour(1);
@@ -2132,174 +2357,1763 @@ function cmdDev(target, port) {
2132
2357
  }).listen(port, "127.0.0.1");
2133
2358
  return 0;
2134
2359
  }
2135
- const USAGE = `✦ Decidio — the SDK command line
2136
-
2137
- The wrapper IS the SDK: guard.protect(fn, describe) ships in this package (import { guard } from "@decidio/sdk").
2138
- This command line is the tour, the sign-in, and the token admin around it.
2139
-
2140
- New here? See the whole thing work in about two minutes:
2141
-
2142
- npx @decidio/sdk quickstart
2143
-
2144
- With no sign-in on this computer it first asks ONE question - do you have an invite? - and, if you do,
2145
- what next: the tour, or wiring up your own agent. Every answer shows the command it stands for. The bare
2146
- npx @decidio/sdk on a terminal asks the same (skipping the invite question when you are signed in).
2147
-
2148
- It gates a demo action, routes it to YOU for approval, executes on your yes, and hands you a
2149
- cryptographically verifiable receipt of the DECISION (execution evidence then accrues on the
2150
- hosted record — the receipt seals who authorized what, not what ran afterward). Guided and
2151
- explicit — this CLI never acts, signs you in, or writes files unless you ask it to.
2152
-
2153
- THE TOUR
2154
- npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
2155
- (interactive: y approves, n rejects - Enter is not an answer. Headless it stops
2156
- at the routed decision; --auto-approve-demo for CI, labeled
2157
- automated; ends with a keep-or-cleanup choice; --explain prints
2158
- the long narration of each step; --phone web makes the QR open
2159
- the phone web rendering even when the Decidio app is installed)
2160
- npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials (the inert
2161
- registration row and the permanent sealed record stay)
2162
- (and end the stored sign-in)
2163
- npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
2164
- npx @decidio/sdk quickstart --demo the same tour against a LOCAL stand-in of the API — no account needed; the
2165
- web/phone door is a hosted demo page; the receipt is the one thing kept
2166
- (--keep-agent is refused here: nothing of the demo's can be kept)
2167
-
2168
- SIGN IN (once)
2169
- npx @decidio/sdk login [email] sign in ONCE: the CLI emails you an 8-character code, you type it; the sign-in
2170
- is kept on this computer 30 days, or until this terminal closes, logout, or cleanup
2171
- npx @decidio/sdk logout forget the stored sign-in on this computer (the token itself stays valid
2172
- until it expires — there is no server-side revocation yet)
2173
-
2174
- YOUR AGENT AND ITS TOKENS
2175
- npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
2176
- (agentId defaults to this folder's name; any name works - it is how the
2177
- workspace shows the request and the key .env is written under)
2178
- --unique append a random suffix (collision-proof samples)
2179
- --force replace a different agent already in this .env
2180
- (its .env is backed up first)
2181
- --expires-days N agent-token lifetime (default 90)
2182
- npx @decidio/sdk doctor check config, connectivity, and what your token can do
2183
- npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
2184
- jti — token values are never re-shown; the list is
2185
- workspace-wide, --mine narrows to this directory's agent
2186
- npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
2187
-
2188
- APPROVALS AND RECEIPTS
2189
- npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
2190
- npx @decidio/sdk approvals approve <id> [reason]
2191
- npx @decidio/sdk approvals reject <id> [reason]
2192
- the reason is FREE TEXT, sent exactly as typed. It seals
2193
- immutably; personal or sensitive values are tokenized at
2194
- rest, so the STORED text can differ (see: receipt <id>).
2195
- If it starts with a dash, put it after -- or use --reason:
2196
- ... approve <id> -- "-50% cut, per the board"
2197
- ... approve <id> --reason "-50% cut, per the board"
2198
- npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt to a .json FILE; --json prints
2199
- a summary to stdout (file name, evidence, issuer, the identities you may see) -
2200
- the credential itself is the file
2201
- npx @decidio/sdk verify --issuer <did> <receipt.json> [more...] verify receipts OFFLINE, pinned to your
2202
- workspace's issuer (--allow-unpinned: structural check only)
2203
-
2204
- DEVELOPMENT
2205
- npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
2206
- npx @decidio/sdk --version print the CLI version
2207
-
2208
- NOTES
2209
- Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
2210
- DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
2211
- fully offline — it touches no network and reads no .env.
2212
- Update check: network commands (and the bare command on a terminal) ask the public npm registry once a day
2213
- whether a newer version exists. If so,
2214
- an interactive run asks "Upgrade now? [y/n]" first (y upgrades the way you installed - global, or in the
2215
- project for a local install; under npx it prints the @latest form - and re-runs your command; n exits); a headless
2216
- run prints the notice and continues (DECIDIO_REQUIRE_LATEST=1 makes it exit). DECIDIO_NO_UPDATE_CHECK=1 skips
2217
- the check. Nothing is sent to Decidio; verify never checks.
2218
- Help: --help (or -h) after any command prints this page - one page for every command.
2219
- Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
2220
- 3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
2221
- receipt written — pass --auto-approve-demo for a run that completes unattended).
2222
- Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
2223
- pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
2224
- Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
2225
- (quickstart, durable resume, workers, adapters).`;
2226
- const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens"]);
2227
- function semverNewer(a, b) {
2228
- const pa = a.split(".").map((x) => parseInt(x, 10)), pb = b.split(".").map((x) => parseInt(x, 10));
2229
- if (pa.length < 3 || pb.length < 3 || pa.some((x) => Number.isNaN(x)) || pb.some((x) => Number.isNaN(x)))
2230
- return false;
2231
- for (let i = 0; i < 3; i++)
2232
- if (pa[i] !== pb[i])
2233
- return pa[i] > pb[i];
2234
- return false;
2360
+ const HOOK_HOLD_CAP_MS = 120_000;
2361
+ function hookSegments(command) {
2362
+ const WRAPPERS = new Set(["sudo", "doas", "env", "nohup", "time", "command", "exec", "builtin", "xargs", "nice", "ionice", "timeout", "stdbuf", "caffeinate"]);
2363
+ return command.split(/\s*(?:\|\||&&|\||;|\r?\n)\s*/).map((raw) => raw.trim()).filter(Boolean).map((raw) => {
2364
+ const tokens = raw.split(/\s+/).filter(Boolean);
2365
+ while (tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]) || WRAPPERS.has(tokens[0].toLowerCase()))) {
2366
+ const w = tokens.shift().toLowerCase();
2367
+ if (w === "timeout" && tokens.length && /^\d/.test(tokens[0]))
2368
+ tokens.shift();
2369
+ if (WRAPPERS.has(w))
2370
+ while (tokens.length && tokens[0].startsWith("-"))
2371
+ tokens.shift();
2372
+ }
2373
+ const program = basename((tokens[0] ?? "").replace(/^["']|["']$/g, "")).toLowerCase();
2374
+ return { program, args: tokens.slice(1), raw };
2375
+ });
2235
2376
  }
2236
- async function latestPublished() {
2237
- const cache = joinPath(sessionDir(), "update-check.json");
2377
+ const hookFlag = (args, letters, longs = []) => args.some((a) => (/^-[A-Za-z]+$/.test(a) && [...letters].some((l) => a.slice(1).includes(l))) || longs.includes(a));
2378
+ const hookSub = (s, i = 0) => (s.args.filter((a) => !a.startsWith("-"))[i] ?? "").toLowerCase();
2379
+ const HOOK_DB_CLIENTS = ["psql", "pgcli", "mysql", "mysqlsh", "mycli", "mariadb", "sqlite3", "sqlite", "litecli", "mongosh", "mongo", "redis-cli", "clickhouse-client", "clickhouse", "sqlcmd", "bq", "duckdb", "cqlsh", "snowsql", "usql", "dbmate", "goose", "flyway", "liquibase", "atlas", "alembic", "knex", "sequelize", "typeorm", "drizzle-kit", "supabase", "wrangler", "turso", "pscale"];
2380
+ const HOOK_RULES = [
2381
+ { category: "delete-recursive", summary: "rm -r/-rf, find -delete / -exec rm, rmdir|rd|del /s, Remove-Item -Recurse, shred",
2382
+ matches: (segs) => segs.some((s) => (s.program === "rm" && hookFlag(s.args, "rR", ["--recursive"]))
2383
+ || (s.program === "find" && (s.args.includes("-delete") || s.args.some((a, i) => i > 0 && s.args[i - 1] === "-exec" && basename(a) === "rm")))
2384
+ || ((s.program === "rmdir" || s.program === "rd" || s.program === "del") && s.args.some((a) => /^\/s$/i.test(a)))
2385
+ || (s.program === "remove-item" && s.args.some((a) => /^-recurse$/i.test(a)))
2386
+ || s.program === "shred") },
2387
+ { category: "git-rewrite", summary: "git push --force|-f|+ref|:ref|--delete|--mirror|--prune, reset --hard, branch -D, clean -f|-x, stash drop|clear, filter-branch|filter-repo, update-ref -d, reflog expire, gc --prune",
2388
+ matches: (segs) => segs.some((s) => s.program === "git" && ((hookSub(s) === "push" && (hookFlag(s.args, "fd", ["--force", "--force-with-lease", "--force-if-includes", "--delete", "--mirror", "--prune"]) || s.args.some((a) => /^\+\S/.test(a) || /^:\S/.test(a) || /^\S+:\s*$/.test(a))))
2389
+ || (hookSub(s) === "reset" && s.args.includes("--hard"))
2390
+ || (hookSub(s) === "branch" && (s.args.some((a) => /^-[a-zA-Z]*D/.test(a)) || (s.args.includes("--delete") && s.args.includes("--force"))))
2391
+ || (hookSub(s) === "clean" && hookFlag(s.args, "fxX", ["--force"]))
2392
+ || (hookSub(s) === "stash" && ["drop", "clear"].includes(hookSub(s, 1)))
2393
+ || ["filter-branch", "filter-repo"].includes(hookSub(s))
2394
+ || (hookSub(s) === "update-ref" && s.args.includes("-d"))
2395
+ || (hookSub(s) === "reflog" && hookSub(s, 1) === "expire")
2396
+ || (hookSub(s) === "gc" && s.args.some((a) => a.startsWith("--prune"))))) },
2397
+ { category: "sql-destructive", summary: "DROP TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE, TRUNCATE, DELETE FROM - sent to a database client (psql, mysql, sqlite3, mongosh, sqlcmd, bq, ...); prisma migrate reset / db push --force-reset; rails db:drop|db:reset",
2398
+ matches: (segs, cmd) => {
2399
+ const toDb = segs.some((s) => HOOK_DB_CLIENTS.includes(s.program) || /\bprisma\b/.test(s.raw) || /\.sql\b/.test(s.raw));
2400
+ return (toDb && (/\bDROP\s+(TABLE|DATABASE|SCHEMA|INDEX|VIEW|USER|ROLE|EXTENSION)\b/i.test(cmd) || /\bTRUNCATE\s+(TABLE\s+)?\S/i.test(cmd) || /\bDELETE\s+FROM\s+\S/i.test(cmd)))
2401
+ || segs.some((s) => (/\bprisma\b/.test(s.raw) && (/\bmigrate\s+reset\b/.test(s.raw) || /--force-reset\b/.test(s.raw)))
2402
+ || (["rails", "rake"].includes(s.program) && /\bdb:(drop|reset|schema:load)\b/.test(s.raw)));
2403
+ } },
2404
+ { category: "infra-apply", summary: "terraform|tofu apply|destroy, pulumi up|destroy, cdk deploy|destroy, sam|serverless deploy|remove, kubectl delete|drain|replace|apply --prune, helm uninstall|rollback, aws|gcloud|az|doctl|fly|heroku|vercel … delete|terminate|destroy|purge|remove|rm|rb",
2405
+ matches: (segs) => segs.some((s) => (["terraform", "tofu"].includes(s.program) && ["apply", "destroy"].includes(hookSub(s)))
2406
+ || (s.program === "pulumi" && ["up", "destroy"].includes(hookSub(s)))
2407
+ || (s.program === "cdk" && ["deploy", "destroy"].includes(hookSub(s)))
2408
+ || (s.program === "sam" && ["deploy", "delete"].includes(hookSub(s)))
2409
+ || (["serverless", "sls"].includes(s.program) && ["deploy", "remove"].includes(hookSub(s)))
2410
+ || (s.program === "kubectl" && (["delete", "drain", "replace"].includes(hookSub(s)) || (hookSub(s) === "apply" && s.args.includes("--prune"))))
2411
+ || (s.program === "helm" && ["uninstall", "delete", "rollback"].includes(hookSub(s)))
2412
+ || (["aws", "gcloud", "az", "doctl", "fly", "flyctl", "heroku", "vercel", "oci", "ibmcloud"].includes(s.program)
2413
+ && s.args.some((a) => /^(delete|terminate|destroy|purge|remove|deregister|rm|rb|apps:destroy|pg:reset)(-[a-z-]+)?$/i.test(a)))) },
2414
+ { category: "containers", summary: "docker|podman rm|rmi|kill, system|volume|image|container|network prune|rm, compose down -v|--volumes|--rmi",
2415
+ matches: (segs) => segs.some((s) => {
2416
+ const compose = (s.program === "docker-compose" && hookSub(s) === "down") || (["docker", "podman"].includes(s.program) && hookSub(s) === "compose" && hookSub(s, 1) === "down");
2417
+ return (["docker", "podman", "nerdctl"].includes(s.program) && (["rm", "rmi", "kill"].includes(hookSub(s))
2418
+ || (["system", "volume", "image", "container", "network", "builder"].includes(hookSub(s)) && ["prune", "rm"].includes(hookSub(s, 1)))))
2419
+ || (compose && (hookFlag(s.args, "v", ["--volumes"]) || s.args.some((a) => a.startsWith("--rmi"))));
2420
+ }) },
2421
+ { category: "publish", summary: "npm|pnpm|yarn|bun publish|unpublish|deprecate|dist-tag|owner, cargo publish|yank, gem push|yank, twine upload, poetry publish, dotnet|nuget push|delete, mvn|gradle deploy|publish, docker push, gh release create|delete|upload, gh repo delete|archive, gh api -X DELETE|PUT|PATCH",
2422
+ matches: (segs) => segs.some((s) => (["npm", "pnpm", "yarn", "bun"].includes(s.program) && ["publish", "unpublish", "deprecate", "dist-tag", "owner"].includes(hookSub(s)))
2423
+ || (s.program === "cargo" && ["publish", "yank"].includes(hookSub(s)))
2424
+ || (s.program === "gem" && ["push", "yank"].includes(hookSub(s)))
2425
+ || (s.program === "twine" && hookSub(s) === "upload") || (/^python[0-9.]*$/.test(s.program) && s.args[0] === "-m" && s.args[1] === "twine")
2426
+ || (s.program === "poetry" && hookSub(s) === "publish")
2427
+ || (s.program === "dotnet" && hookSub(s) === "nuget" && ["push", "delete"].includes(hookSub(s, 1))) || (s.program === "nuget" && ["push", "delete"].includes(hookSub(s)))
2428
+ || (["mvn", "gradle", "gradlew"].includes(s.program) && s.args.some((a) => /^(deploy|publish|publishToMavenCentral|uploadArchives)$/.test(a)))
2429
+ || (["docker", "podman"].includes(s.program) && hookSub(s) === "push")
2430
+ || (s.program === "gh" && ((hookSub(s) === "release" && ["create", "delete", "upload"].includes(hookSub(s, 1)))
2431
+ || (hookSub(s) === "repo" && ["delete", "archive"].includes(hookSub(s, 1)))
2432
+ || (hookSub(s) === "api" && s.args.some((a, i) => /^(-X|--method)$/.test(a) && /^(DELETE|PUT|PATCH)$/i.test(s.args[i + 1] ?? "")))))) },
2433
+ { category: "pipe-to-shell", summary: "curl|wget … | sh|bash|zsh|python|node, bash <(curl …), iex (irm …)",
2434
+ matches: (segs, cmd) => segs.some((s, i) => i > 0
2435
+ && ["sh", "bash", "zsh", "fish", "dash", "ksh", "node", "perl", "ruby", "pwsh", "powershell", "python", "python3"].includes(s.program)
2436
+ && ["curl", "wget", "fetch", "http", "https", "invoke-webrequest", "iwr", "irm", "invoke-restmethod"].includes(segs[i - 1].program))
2437
+ || /\b(sh|bash|zsh)\s+<\(\s*(curl|wget)\b/.test(cmd) || /\biex\b.*\b(irm|iwr|Invoke-RestMethod|Invoke-WebRequest|DownloadString)\b/i.test(cmd) },
2438
+ { category: "system", summary: "mkfs, dd of=/dev/…, > /dev/sd…, shutdown|reboot|halt|poweroff, systemctl disable|mask, crontab -r, chmod|chown -R on /, iptables -F, diskutil erase…, format X:, Format-Volume, Clear-Disk, a fork bomb",
2439
+ matches: (segs, cmd) => segs.some((s) => /^mkfs(\.|$)/.test(s.program) || (s.program === "dd" && s.args.some((a) => /^of=\/dev\//.test(a)))
2440
+ || ["shutdown", "reboot", "halt", "poweroff"].includes(s.program) || (s.program === "systemctl" && ["disable", "mask"].includes(hookSub(s)))
2441
+ || (s.program === "crontab" && s.args.includes("-r"))
2442
+ || (["chmod", "chown"].includes(s.program) && hookFlag(s.args, "R", ["--recursive"]) && s.args.some((a) => /^\/\*?$/.test(a)))
2443
+ || (s.program === "iptables" && (s.args.includes("-F") || s.args.includes("--flush")))
2444
+ || (s.program === "diskutil" && /^erase/i.test(hookSub(s))) || (s.program === "format" && s.args.some((a) => /^[a-z]:$/i.test(a)))
2445
+ || ["format-volume", "clear-disk", "initialize-disk"].includes(s.program))
2446
+ || />\s*\/dev\/(sd|nvme|hd|disk|mmcblk)/.test(cmd) || /:\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(cmd) },
2447
+ ];
2448
+ function hookExtraRules() {
2449
+ const file = process.env.DECIDIO_HOOK_EXTRA_RULES;
2450
+ if (!file)
2451
+ return [];
2452
+ let parsed;
2238
2453
  try {
2239
- const c = JSON.parse(readFileSync(cache, "utf8"));
2240
- if (c && typeof c.latest === "string" && Date.now() - Number(c.checkedAt) < 24 * 3600 * 1000)
2241
- return c.latest;
2454
+ parsed = JSON.parse(readFileSync(file, "utf8"));
2242
2455
  }
2243
- catch { }
2244
- const reg = (SHELL_ENV.DECIDIO_UPDATE_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
2245
- try {
2246
- const ctl = new AbortController();
2247
- const t = setTimeout(() => ctl.abort(), 2000);
2248
- const r = await fetch(`${reg}/@decidio%2fsdk/latest`, { signal: ctl.signal });
2249
- clearTimeout(t);
2250
- if (!r.ok)
2456
+ catch (e) {
2457
+ throw new Error(`DECIDIO_HOOK_EXTRA_RULES (${file}) could not be read as JSON (${e?.message ?? e})`);
2458
+ }
2459
+ if (!Array.isArray(parsed))
2460
+ throw new Error(`DECIDIO_HOOK_EXTRA_RULES (${file}) must be a JSON array of { category, pattern, flags?, summary? }`);
2461
+ return parsed.map((r, i) => {
2462
+ if (!r || typeof r.category !== "string" || !/^[a-z0-9-]{1,40}$/.test(r.category) || typeof r.pattern !== "string")
2463
+ throw new Error(`DECIDIO_HOOK_EXTRA_RULES (${file}) entry ${i} needs a category ([a-z0-9-]) and a pattern`);
2464
+ let re;
2465
+ try {
2466
+ re = new RegExp(r.pattern, typeof r.flags === "string" ? r.flags.replace(/[^gimsuy]/g, "") : "");
2467
+ }
2468
+ catch (e) {
2469
+ throw new Error(`DECIDIO_HOOK_EXTRA_RULES (${file}) entry ${i}: ${e?.message ?? e}`);
2470
+ }
2471
+ return { category: r.category, summary: typeof r.summary === "string" ? r.summary : `/${r.pattern}/${r.flags ?? ""}`, matches: (_s, cmd) => { re.lastIndex = 0; return re.test(cmd); } };
2472
+ });
2473
+ }
2474
+ function hookMatch(command) {
2475
+ const segs = hookSegments(command);
2476
+ for (const r of [...HOOK_RULES, ...hookExtraRules()])
2477
+ if (r.matches(segs, command))
2478
+ return r;
2479
+ return null;
2480
+ }
2481
+ function hookRedact(command) {
2482
+ return command
2483
+ .replace(/(\b(?:bearer|basic)\s+)[A-Za-z0-9._~+/=-]{8,}/gi, "$1[REDACTED]")
2484
+ .replace(/(\b[A-Za-z0-9_]*(?:token|password|passwd|pwd|secret|api[_-]?key|access[_-]?key|private[_-]?key|auth|credentials?)\s*[=:]\s*)(["']?)[^\s"';&|]+\2/gi, "$1$2[REDACTED]$2")
2485
+ .replace(/(:\/\/[^/\s:@]+:)[^@\s/]+(@)/g, "$1[REDACTED]$2")
2486
+ .replace(/((?:^|\s)(?:-u|--user|--password-stdin|-p)\s+["']?[^\s:"']+:)[^\s"']+/g, "$1[REDACTED]")
2487
+ .replace(/((?:-H|--header)\s+["']?(?:authorization|x-api-key|cookie)\s*:\s*)[^"']+/gi, "$1[REDACTED]")
2488
+ .replace(/\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk_(?:live|test)_[A-Za-z0-9]{10,})\b/g, "[REDACTED]");
2489
+ }
2490
+ function hookSubject(category, segs, cwdAbs, projectDir) {
2491
+ const textOnly = (why) => ({ fingerprint: null, display: `text-only (${why})` });
2492
+ const relOrAbs = (p) => { const r = relative(projectDir, p); return r && !r.startsWith("..") && !isAbsolute(r) ? r.split(pathSep).join("/") : p; };
2493
+ const nonFlags = (s) => s.args.filter((a) => !a.startsWith("-"));
2494
+ const git = () => {
2495
+ try {
2496
+ const run = (args) => execFileSync("git", args, { cwd: cwdAbs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
2497
+ const head = run(["rev-parse", "HEAD"]);
2498
+ const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
2499
+ return /^[0-9a-f]{40}$/.test(head) && branch ? { head, branch } : null;
2500
+ }
2501
+ catch {
2251
2502
  return null;
2252
- const body = await r.json();
2253
- const latest = typeof body?.version === "string" && /^\d+\.\d+\.\d+$/.test(body.version) ? body.version : null;
2254
- if (latest) {
2255
- try {
2256
- mkdirSync(sessionDir(), { recursive: true });
2257
- writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), latest }));
2258
- }
2259
- catch { }
2260
2503
  }
2261
- return latest;
2504
+ };
2505
+ const firstOf = (programs) => segs.findIndex((s) => programs.includes(s.program));
2506
+ const movedTree = (i) => segs.slice(0, i).some((s) => s.program === "cd" || s.program === "pushd");
2507
+ if (category === "git-rewrite" || category === "infra-apply") {
2508
+ const i = category === "git-rewrite" ? firstOf(["git"]) : firstOf(["terraform", "tofu", "pulumi", "cdk", "sam", "serverless", "sls", "kubectl", "helm", "aws", "gcloud", "az", "doctl", "fly", "flyctl", "heroku", "vercel", "oci", "ibmcloud"]);
2509
+ if (i < 0)
2510
+ return textOnly("no subject");
2511
+ if (movedTree(i))
2512
+ return textOnly("a cd on the same line moves the call to another directory");
2513
+ const g = git();
2514
+ if (!g)
2515
+ return textOnly("not a git repository here");
2516
+ const seg = segs[i];
2517
+ const args = nonFlags(seg);
2518
+ const extra = category === "git-rewrite" ? args.slice(1).join(" ") : [relOrAbs(cwdAbs), ...seg.args.filter((a) => /^(-var-file|--var-file|-plan|--plan)/.test(a) || /\.(tfplan|tfvars)$/.test(a))].join(" ");
2519
+ return { fingerprint: `${category}:${g.head}:${g.branch}:${extra}`, display: `HEAD ${g.head.slice(0, 7)} on ${g.branch}${extra ? ` · ${hookRedact(extra).slice(0, 120)}` : ""}` };
2520
+ }
2521
+ if (category === "delete-recursive") {
2522
+ const i = firstOf(["rm", "find", "rmdir", "rd", "del", "remove-item", "shred"]);
2523
+ if (i < 0)
2524
+ return textOnly("no subject");
2525
+ if (movedTree(i))
2526
+ return textOnly("a cd on the same line moves the call to another directory");
2527
+ const targets = nonFlags(segs[i]).filter((a) => !/^[{}();\\]+$/.test(a) && !(segs[i].program === "find" && /^(-|\{\})/.test(a))).map((a) => resolvePath(cwdAbs, a.replace(/^["']|["']$/g, "")));
2528
+ if (!targets.length)
2529
+ return textOnly("no path named");
2530
+ const sorted = [...new Set(targets)].sort();
2531
+ return { fingerprint: `paths:${sorted.join("\0")}`, display: hookRedact(sorted.map(relOrAbs).join(", ")).slice(0, 160) };
2532
+ }
2533
+ if (category === "sql-destructive") {
2534
+ const stIdx = segs.findIndex((s) => /\b(DROP|TRUNCATE|DELETE)\b/i.test(s.raw));
2535
+ const dbIdx = firstOf(HOOK_DB_CLIENTS);
2536
+ const client = dbIdx >= 0 ? segs[dbIdx] : stIdx >= 0 ? segs[stIdx] : null;
2537
+ if (!client)
2538
+ return textOnly("no statement");
2539
+ const stmtSeg = stIdx >= 0 ? segs[stIdx] : client;
2540
+ const stmt = (stmtSeg.raw.match(/\b(DROP\s+\w+[^;'"]*|TRUNCATE[^;'"]*|DELETE\s+FROM[^;'"]*)/i)?.[1] ?? stmtSeg.raw).replace(/\s+/g, " ").trim();
2541
+ const stmtAt = client.args.findIndex((a) => /^["']?(DROP|TRUNCATE|DELETE)\b/i.test(a));
2542
+ const target = (stmtAt >= 0 ? client.args.slice(0, stmtAt) : client.args).filter((a) => !a.startsWith("-")).join(" ");
2543
+ return { fingerprint: `sql:${client.program}:${target}:${stmt.toUpperCase()}`, display: hookRedact(`${client.program}${target ? ` ${target}` : ""}: ${stmt}`).slice(0, 160) };
2544
+ }
2545
+ return textOnly("text binds this category");
2546
+ }
2547
+ async function readHookStdin() {
2548
+ if (process.stdin.isTTY)
2549
+ return { ok: false, why: "no hook input on stdin - this command is run BY Claude Code with the tool call as JSON on stdin (set it up with: npx @decidio/sdk hook install)" };
2550
+ let raw = "";
2551
+ try {
2552
+ process.stdin.setEncoding("utf8");
2553
+ for await (const chunk of process.stdin) {
2554
+ raw += chunk;
2555
+ if (raw.length > 4_000_000)
2556
+ break;
2557
+ }
2558
+ }
2559
+ catch (e) {
2560
+ return { ok: false, why: `stdin could not be read (${e?.message ?? e})` };
2561
+ }
2562
+ if (!raw.trim())
2563
+ return { ok: false, why: "empty hook input on stdin" };
2564
+ try {
2565
+ const v = JSON.parse(raw);
2566
+ if (!v || typeof v !== "object" || Array.isArray(v))
2567
+ return { ok: false, why: "the hook input is not a JSON object" };
2568
+ return { ok: true, input: v };
2262
2569
  }
2263
2570
  catch {
2264
- return null;
2571
+ return { ok: false, why: "the hook input is not JSON" };
2265
2572
  }
2266
2573
  }
2267
- async function updateGate(cmd, argv) {
2268
- const gatedHere = cmd ? GATED_CMDS.has(cmd) : !!(process.stdin.isTTY && process.stdout.isTTY);
2269
- if (!gatedHere || SHELL_ENV.DECIDIO_NO_UPDATE_CHECK || SHELL_ENV.DECIDIO_UPDATE_REEXEC || VERSION === "unknown" || VERSION.includes("-"))
2270
- return null;
2271
- const latest = await latestPublished();
2272
- if (!latest || !semverNewer(latest, VERSION))
2273
- return null;
2274
- const entry = process.argv[1] ?? "";
2275
- const underNpx = /[\\/]_npx[\\/]/.test(entry);
2276
- const localRoot = (() => {
2277
- const m = /^(.*?)[\\/]node_modules[\\/]@decidio[\\/]sdk[\\/]/.exec(entry);
2278
- if (!m)
2574
+ function hookConfig() {
2575
+ const token = process.env.DECIDIO_API_TOKEN;
2576
+ if (!token)
2577
+ return { missing: "DECIDIO_API_TOKEN is not set - no agent token in this folder's .env or the environment" };
2578
+ return {
2579
+ apiUrl: API, agentId: process.env.DECIDIO_AGENT_ID || defaultAgentId(), apiToken: token, sourceSystem: "claude-code",
2580
+ agentKey: process.env.DECIDIO_AGENT_KEY || undefined, agentDid: process.env.DECIDIO_AGENT_DID || undefined, workspaceId: process.env.DECIDIO_WORKSPACE_ID || undefined,
2581
+ appUrl: process.env.DECIDIO_APP_URL || APP_URL,
2582
+ };
2583
+ }
2584
+ const hookStateDir = () => (process.env.CLAUDE_PLUGIN_DATA ? joinPath(process.env.CLAUDE_PLUGIN_DATA, "hooks") : joinPath(sessionDir(), "hooks"));
2585
+ const hookStatePath = (toolUseId) => joinPath(hookStateDir(), toolUseId.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120) + ".json");
2586
+ function writeHookState(toolUseId, s) {
2587
+ if (typeof toolUseId !== "string" || !toolUseId)
2588
+ return;
2589
+ try {
2590
+ mkdirSync(hookStateDir(), { recursive: true });
2591
+ writeFileSync(hookStatePath(toolUseId), JSON.stringify(s) + "\n");
2592
+ }
2593
+ catch (e) {
2594
+ err(`decidio hook: could not keep the approval ${s.decisionId} for posttool (${e?.message ?? e}) - the execution will not be reported; the approval lapses on its own`);
2595
+ }
2596
+ }
2597
+ const JUSTIFY_MAX = 500;
2598
+ const justifyPath = (decisionId) => joinPath(hookStateDir(), "justify-" + decisionId.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 120) + ".json");
2599
+ function readJustification(decisionId) {
2600
+ try {
2601
+ const p = justifyPath(decisionId);
2602
+ if (!existsSync(p))
2603
+ return null;
2604
+ if (statSync(p).mtimeMs < Date.now() - 24 * 3600_000) {
2605
+ rmSync(p, { force: true });
2279
2606
  return null;
2280
- const norm = (s) => (process.platform === "win32" ? s.toLowerCase() : s);
2281
- const root = resolvePath(m[1]);
2282
- const cwd = norm(process.cwd());
2283
- return cwd === norm(root) || cwd.startsWith(norm(root) + pathSep) ? root : null;
2284
- })();
2285
- const viaAlias = /[\\/]decidio$/.test(argv[1] ?? "");
2286
- const pkgName = viaAlias ? "decidio" : "@decidio/sdk";
2287
- const upgradeCmd = underNpx ? `npx ${pkgName}@latest ${argv.slice(2).join(" ")}` : localRoot ? `npm i ${pkgName}@latest` : `npm i -g ${pkgName}@latest`;
2288
- const notice = `${latest} is available (you have ${VERSION}).`;
2289
- if (!(process.stdin.isTTY && process.stdout.isTTY)) {
2290
- err(`⚠ ${notice} Upgrade: ${upgradeCmd}`);
2291
- if (SHELL_ENV.DECIDIO_REQUIRE_LATEST) {
2292
- err(`✘ DECIDIO_REQUIRE_LATEST is set — not running a stale version.`);
2293
- return 1;
2294
2607
  }
2295
- return null;
2608
+ const j = JSON.parse(readFileSync(p, "utf8"));
2609
+ return typeof j?.justification === "string" && j.justification.trim() ? j.justification : null;
2296
2610
  }
2297
- const a = (await ask(`${notice} Upgrade now? [y/n]: `)).toLowerCase();
2298
- if (a !== "y" && a !== "yes") {
2299
- err(`✘ not upgraded — run this when ready: ${upgradeCmd}`);
2300
- return 1;
2611
+ catch {
2612
+ return null;
2301
2613
  }
2302
- if (underNpx) {
2614
+ }
2615
+ const clearJustification = (decisionId) => { try {
2616
+ rmSync(justifyPath(decisionId), { force: true });
2617
+ }
2618
+ catch { } };
2619
+ function justificationProblem(text) {
2620
+ if (!text.trim())
2621
+ return "a justification cannot be empty";
2622
+ if (text.length > JUSTIFY_MAX)
2623
+ return `keep the justification to ${JUSTIFY_MAX} characters`;
2624
+ if (/[\r\n]/.test(text))
2625
+ return "a justification is one line";
2626
+ return null;
2627
+ }
2628
+ function sweepHookState() {
2629
+ try {
2630
+ const dir = hookStateDir();
2631
+ if (!existsSync(dir))
2632
+ return;
2633
+ const cutoff = Date.now() - 24 * 3600_000;
2634
+ for (const f of readdirSync(dir)) {
2635
+ const p = joinPath(dir, f);
2636
+ try {
2637
+ if (statSync(p).mtimeMs < cutoff)
2638
+ rmSync(p, { force: true });
2639
+ }
2640
+ catch { }
2641
+ }
2642
+ }
2643
+ catch { }
2644
+ }
2645
+ const hookErr = (e) => e instanceof DecidioHttpError ? `HTTP ${e.status} from ${e.endpoint}` : String(e?.cause?.code ?? e?.message ?? e);
2646
+ const hookShort = (cmd) => (cmd.length > 120 ? cmd.slice(0, 117) + "..." : cmd);
2647
+ async function hookStatus(cfg, decisionId, waitSec) {
2648
+ assertSafeTransport(cfg.apiUrl, true);
2649
+ const path = `/agent/status/${encodeURIComponent(decisionId)}?wait=${waitSec}`;
2650
+ const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + path, { headers: { authorization: `Bearer ${cfg.apiToken}` }, signal: AbortSignal.timeout((waitSec + 20) * 1000) });
2651
+ if (!r.ok)
2652
+ throw new DecidioHttpError(r.status, path, await r.json().catch(() => undefined));
2653
+ const body = await r.json();
2654
+ if (!body || typeof body !== "object" || typeof body.state !== "string")
2655
+ throw new Error("the gate answered a status this SDK does not understand");
2656
+ return body;
2657
+ }
2658
+ async function hookHold(cfg, decisionId, holdMs) {
2659
+ const deadline = Date.now() + holdMs;
2660
+ let status = null, lastError = null;
2661
+ const sleep = (ms) => new Promise((r) => setTimeout(r, Math.max(0, ms)));
2662
+ for (;;) {
2663
+ const left = deadline - Date.now();
2664
+ if (left < 1000)
2665
+ break;
2666
+ const t0 = Date.now();
2667
+ try {
2668
+ status = await hookStatus(cfg, decisionId, Math.min(30, Math.floor(left / 1000)));
2669
+ lastError = null;
2670
+ }
2671
+ catch (e) {
2672
+ lastError = hookErr(e);
2673
+ const transient = (e instanceof DecidioHttpError && (e.status >= 500 || e.status === 408 || e.status === 429))
2674
+ || (!(e instanceof DecidioHttpError) && (e?.name === "AbortError" || e?.name === "TimeoutError" || typeof e?.cause?.code === "string" || /fetch failed/i.test(String(e?.message ?? ""))));
2675
+ if (!transient)
2676
+ break;
2677
+ await sleep(Math.min(2000, deadline - Date.now()));
2678
+ continue;
2679
+ }
2680
+ if (status.state !== "pending")
2681
+ break;
2682
+ if (Date.now() - t0 < 500)
2683
+ await sleep(Math.min(1000, deadline - Date.now()));
2684
+ }
2685
+ return { status, lastError };
2686
+ }
2687
+ async function cmdHookPretool() {
2688
+ const out = (o) => { process.stdout.write(JSON.stringify(o) + "\n"); return 0; };
2689
+ const deny = (reason, user) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason }, ...(user ? { systemMessage: user } : {}) });
2690
+ const allow = (reason) => out({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", permissionDecisionReason: reason } });
2691
+ try {
2692
+ const read = await readHookStdin();
2693
+ if (!read.ok)
2694
+ return deny(`Decidio hook: ${read.why} - refusing this tool call (fail closed).`);
2695
+ const input = read.input;
2696
+ const tool = typeof input.tool_name === "string" ? input.tool_name : "";
2697
+ const command = typeof input.tool_input?.command === "string" ? input.tool_input.command : "";
2698
+ if (!["Bash", "PowerShell"].includes(tool) || !command.trim())
2699
+ return 0;
2700
+ let rule;
2701
+ try {
2702
+ rule = hookMatch(command);
2703
+ }
2704
+ catch (e) {
2705
+ return deny(`Decidio hook: ${e?.message ?? e} - every command is refused until it is fixed.`);
2706
+ }
2707
+ if (!rule)
2708
+ return 0;
2709
+ const short = hookShort(hookRedact(command.replace(/\s+/g, " ").trim()));
2710
+ const projectDir = resolvePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
2711
+ const cfg = hookConfig();
2712
+ if ("missing" in cfg)
2713
+ return deny(`Decidio: "${short}" is a ${rule.category} command that needs a person's approval, but this project has no Decidio agent credentials (${cfg.missing}). Nothing ran. Ask the user to run \`npx @decidio/sdk init\` in ${projectDir}, then retry the same command.`, `Decidio: no agent credentials in ${projectDir} - run npx @decidio/sdk init`);
2714
+ const canonical = command.replace(/\s+/g, " ").trim();
2715
+ const cwdAbs = resolvePath(typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd());
2716
+ const rel = relative(projectDir, cwdAbs);
2717
+ const cwdRel = !rel ? "." : rel.startsWith("..") || isAbsolute(rel) ? cwdAbs : rel;
2718
+ const session = typeof input.session_id === "string" ? input.session_id : "";
2719
+ const subject = hookSubject(rule.category, hookSegments(command), cwdAbs, projectDir);
2720
+ let material;
2721
+ try {
2722
+ material = bindingMaterial(cfg);
2723
+ }
2724
+ catch (e) {
2725
+ return deny(`Decidio hook: this command cannot be bound to an approval (${e?.message ?? e}) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2726
+ }
2727
+ if (!material)
2728
+ return deny(`Decidio hook: this command cannot be bound to an approval (no key material (DECIDIO_API_TOKEN, or DECIDIO_AGENT_KEY + DECIDIO_AGENT_DID)) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2729
+ const ctx = { action: "shellExec", amount: 0, scope: boundScope(material, canonical + "\n" + (subject.fingerprint ?? "")),
2730
+ context: { command: hookRedact(canonical).slice(0, 400), cwd: cwdRel.slice(0, 200), category: rule.category, host: "claude-code", tool, subject: hookRedact(subject.display).slice(0, 200) } };
2731
+ const request = adapterRequest(ctx);
2732
+ let key;
2733
+ try {
2734
+ key = idempotencyKey(material, { host: "claude-code", tool, command: canonical, cwd: cwdRel, session, subject: subject.fingerprint ?? "" });
2735
+ }
2736
+ catch (e) {
2737
+ return deny(`Decidio hook: this command cannot be bound to an approval (${e?.message ?? e}) - refusing to run it. Ask the user to check ${projectDir}/.env with \`npx @decidio/sdk doctor\`.`);
2738
+ }
2739
+ let auth;
2740
+ try {
2741
+ let a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key });
2742
+ if (a.decision === "duplicate") {
2743
+ const twin = a.duplicateOf;
2744
+ const label = twin.displayNumber ?? twin.decisionId;
2745
+ const when = twin.refusedAt ? ` on ${String(twin.refusedAt).slice(0, 10)}` : "";
2746
+ const why = twin.reason ? ` ("${twin.reason}")` : "";
2747
+ const stored = readJustification(twin.decisionId);
2748
+ if (!stored) {
2749
+ return deny(`Decidio: "${short}" looks like a duplicate of ${label}, which a person refused${when}${why}. Nothing ran. If this change is different, ask the user what changed, then run \`npx @decidio/sdk hook justify ${twin.decisionId} "<their words>"\` and retry EXACTLY the same command; the approver will see their justification beside the earlier refusal. A different commit or path is a new request on its own.`, `Decidio: ${label} was refused${when} - a justification is needed to ask again`);
2750
+ }
2751
+ a = await gateAuthorizeTwinAware(cfg, ctx, { idempotencyKey: key, justification: stored });
2752
+ if (a.decision === "duplicate")
2753
+ return deny(`Decidio: "${short}" is still a duplicate of ${label} and the stored justification was not accepted (${a.duplicateOf.reason ?? "no reason given"}). Nothing ran.`);
2754
+ clearJustification(twin.decisionId);
2755
+ }
2756
+ auth = a;
2757
+ }
2758
+ catch (e) {
2759
+ return deny(`Decidio hook: the approval gate could not be asked about "${short}" (${hookErr(e)}) - refusing to run it (fail closed). Nothing ran. ${e instanceof DecidioRateLimitError ? "The workspace's governed-action allowance for this hour is used up; try later." : `Check the network and ${projectDir}/.env (\`npx @decidio/sdk doctor\`), then retry the same command.`}`);
2760
+ }
2761
+ const where = decisionUrl(cfg.appUrl, auth.decisionId);
2762
+ if (auth.decision === "block")
2763
+ return deny(`Decidio BLOCKED "${short}" by policy (decision ${auth.decisionId}${auth.reason ? `: ${auth.reason}` : ""}). Do not run it, or a variant of it; tell the user.`, `Decidio blocked ${auth.decisionId}`);
2764
+ if (auth.decision === "proceed") {
2765
+ writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: auth.receiptId ?? null, tool, category: rule.category, at: new Date().toISOString() });
2766
+ return allow(`Decidio: "${short}" is authorized by policy (decision ${auth.decisionId}${auth.receiptId ? `, receipt ${auth.receiptId}` : ""}, sealed). This covers this one execution only.`);
2767
+ }
2768
+ const holdRaw = Number(process.env.DECIDIO_HOOK_HOLD_MS ?? "90000");
2769
+ const held = await hookHold(cfg, auth.decisionId, Number.isFinite(holdRaw) && holdRaw >= 0 ? Math.min(holdRaw, HOOK_HOLD_CAP_MS) : 90_000);
2770
+ const st = held.status;
2771
+ if (!st)
2772
+ return deny(`Decidio: "${short}" is routed for approval as decision ${auth.decisionId}, but its status could not be read (${held.lastError ?? "no answer"}). Nothing ran. When the gate is reachable, run \`npx @decidio/sdk hook wait ${auth.decisionId}\` and then retry EXACTLY the same command.`);
2773
+ if (st.state === "pending")
2774
+ return deny(`Decidio: "${short}" needs a person's approval - decision ${auth.decisionId} is in their queue${where ? ` (${where})` : ""}. Nothing ran. To continue: run \`npx @decidio/sdk hook wait ${auth.decisionId}\` (it returns when they decide), then retry EXACTLY the same command - not a variant of it, and do not work around this gate.`, `Decidio: ${auth.decisionId} awaits approval${where ? ` - ${where}` : ""}`);
2775
+ if (st.state === "rejected" || st.state === "blocked")
2776
+ return deny(`Decidio: "${short}" was ${st.state} (decision ${auth.decisionId}${st.reason ? `: ${st.reason}` : ""}) for this exact change (${hookRedact(subject.display)}). Do not retry it or a variant of it; tell the user. ${subject.fingerprint ? "If the user changes what it would act on (a new commit, a different path), that is a new request and may be asked." : ""}`.trim(), `Decidio: ${auth.decisionId} ${st.state}`);
2777
+ const chk = await checkApproval(cfg, auth.decisionId, request, { status: st });
2778
+ if (!chk.ok)
2779
+ return deny(`Decidio: "${short}" is NOT cleared to run - ${chk.reason ?? chk.code} (decision ${auth.decisionId}). Nothing ran.${chk.code === "spent" || chk.code === "expired" ? " Retry the same command to ask for a fresh approval." : ""}`);
2780
+ writeHookState(input.tool_use_id, { v: 1, decisionId: auth.decisionId, receiptId: st.receiptId ?? auth.receiptId ?? null, tool, category: rule.category, at: new Date().toISOString() });
2781
+ return allow(`Decidio: "${short}" is approved (decision ${auth.decisionId}${st.receiptId ?? auth.receiptId ? `, receipt ${st.receiptId ?? auth.receiptId}` : ""}). This approval covers this one execution only; its record is sealed.`);
2782
+ }
2783
+ catch (e) {
2784
+ return deny(`Decidio hook: unexpected failure (${e?.message ?? e}) - refusing this tool call (fail closed). Nothing ran.`);
2785
+ }
2786
+ }
2787
+ async function cmdHookPosttool() {
2788
+ try {
2789
+ const read = await readHookStdin();
2790
+ if (!read.ok) {
2791
+ err(`decidio hook posttool: ${read.why}`);
2792
+ return 0;
2793
+ }
2794
+ const id = typeof read.input.tool_use_id === "string" ? read.input.tool_use_id : "";
2795
+ if (!id)
2796
+ return 0;
2797
+ const path = hookStatePath(id);
2798
+ sweepHookState();
2799
+ if (!existsSync(path))
2800
+ return 0;
2801
+ let s;
2802
+ try {
2803
+ s = JSON.parse(readFileSync(path, "utf8"));
2804
+ }
2805
+ catch {
2806
+ rmSync(path, { force: true });
2807
+ return 0;
2808
+ }
2809
+ rmSync(path, { force: true });
2810
+ if (!s || s.v !== 1 || typeof s.decisionId !== "string")
2811
+ return 0;
2812
+ const cfg = hookConfig();
2813
+ if ("missing" in cfg) {
2814
+ err(`decidio hook posttool: ${cfg.missing} - the execution of ${s.decisionId} is not reported`);
2815
+ return 0;
2816
+ }
2817
+ const resp = read.input.tool_response;
2818
+ const interrupted = !!(resp && typeof resp === "object" && resp.interrupted);
2819
+ const captured = { host: "claude-code", tool: s.tool, category: s.category, interrupted, ok: !interrupted };
2820
+ const body = buildConfirmBody(cfg, s.decisionId, captured, signConfirm(s.decisionId, cfg), "wrapper");
2821
+ assertSafeTransport(cfg.apiUrl, true);
2822
+ const r = await fetch(cfg.apiUrl.replace(/\/$/, "") + "/agent/confirm", { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiToken}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(20_000) });
2823
+ const parsed = await r.json().catch(() => null);
2824
+ if (!r.ok) {
2825
+ err(`decidio hook posttool: the execution of ${s.decisionId} could not be recorded (HTTP ${r.status}${parsed?.message ? `: ${parsed.message}` : ""}) - the approval is not marked spent; it lapses on its own`);
2826
+ return 0;
2827
+ }
2828
+ const ev = readConfirmResponse(parsed, { signed: !!body.requesterIdentity, capturedResponse: captured });
2829
+ const line = ev.reportRecorded
2830
+ ? `Decidio recorded the execution of ${s.decisionId} (evidence tier ${ev.evidenceTier ?? "recorded"}); that approval is now spent - the next destructive command needs its own.`
2831
+ : `Decidio did not record the execution of ${s.decisionId}: ${ev.diagnostic ?? "no report recorded"}.`;
2832
+ err(`decidio hook: ${line}`);
2833
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: line } }) + "\n");
2834
+ }
2835
+ catch (e) {
2836
+ err(`decidio hook posttool: ${e?.message ?? e}`);
2837
+ }
2838
+ return 0;
2839
+ }
2840
+ async function cmdHookWait(id, timeoutSec) {
2841
+ if (!id) {
2842
+ err("✘ hook wait needs the decision id the hook named: npx @decidio/sdk hook wait <decision-id> [--timeout 600]");
2843
+ return 2;
2844
+ }
2845
+ const problem = decisionIdProblem(id);
2846
+ if (problem) {
2847
+ err(`✘ "${id}" ${problem}`);
2848
+ return 2;
2849
+ }
2850
+ const cfg = hookConfig();
2851
+ if ("missing" in cfg) {
2852
+ err(`✘ ${cfg.missing} - run \`npx @decidio/sdk init\` in this project first.`);
2853
+ return 1;
2854
+ }
2855
+ const where = decisionUrl(cfg.appUrl, id);
2856
+ err(`· decidio → ${API}`);
2857
+ err(`· waiting for ${id}${where ? ` - approve at ${where}` : ""} (up to ${timeoutSec}s; Ctrl-C stops waiting, the request stays open)`);
2858
+ const held = await hookHold(cfg, id, timeoutSec * 1000);
2859
+ const st = held.status;
2860
+ if (!st) {
2861
+ err(`✘ could not read ${id}: ${held.lastError ?? "no answer"}`);
2862
+ return 1;
2863
+ }
2864
+ log(st.state);
2865
+ if (st.state === "pending") {
2866
+ err(`· still pending after ${timeoutSec}s - run this again${where ? `, or approve at ${where}` : ""}.`);
2867
+ return 1;
2868
+ }
2869
+ if (st.state === "rejected" || st.state === "blocked") {
2870
+ err(`✘ ${id} was ${st.state}${st.reason ? `: ${st.reason}` : ""} - do not retry the command.`);
2871
+ return 3;
2872
+ }
2873
+ err(`✔ ${id} ${st.state}${st.spent ? " - but already spent (an execution is recorded on it): the retry will ask again" : ""}${st.grantExpiresAt ? ` - valid until ${st.grantExpiresAt}` : ""}. Retry exactly the same command now.`);
2874
+ return 0;
2875
+ }
2876
+ function cmdHookJustify(id, text) {
2877
+ if (!id) {
2878
+ err(`usage: npx @decidio/sdk hook justify <decisionId> "<what changed, in the user's words>"`);
2879
+ return 2;
2880
+ }
2881
+ const idP = decisionIdProblem(id);
2882
+ if (idP) {
2883
+ err(`✘ ${idP}`);
2884
+ return 2;
2885
+ }
2886
+ const tp = justificationProblem(text);
2887
+ if (tp) {
2888
+ err(`✘ ${tp}.`);
2889
+ return 2;
2890
+ }
2891
+ try {
2892
+ mkdirSync(hookStateDir(), { recursive: true });
2893
+ writeFileSync(justifyPath(id), JSON.stringify({ v: 1, decisionId: id, justification: text.trim(), at: new Date().toISOString() }) + "\n");
2894
+ }
2895
+ catch (e) {
2896
+ err(`✘ could not store the justification (${e?.message ?? e}).`);
2897
+ return 1;
2898
+ }
2899
+ log(`✔ justification stored for ${id}. Retry EXACTLY the same command within a day: the request goes to the approver with these words beside the earlier refusal, and the justification is used once.`);
2900
+ return 0;
2901
+ }
2902
+ function cmdHookRules() {
2903
+ let extras = [];
2904
+ try {
2905
+ extras = hookExtraRules();
2906
+ }
2907
+ catch (e) {
2908
+ err(`✘ ${e?.message ?? e} - while this stands, the hook refuses EVERY command.`);
2909
+ return 1;
2910
+ }
2911
+ log(`Commands the Decidio hook gates (a person approves before they run; everything else runs as usual):`);
2912
+ for (const r of HOOK_RULES)
2913
+ log(` ${r.category.padEnd(18)} ${r.summary}`);
2914
+ if (extras.length) {
2915
+ log(`From DECIDIO_HOOK_EXTRA_RULES (${process.env.DECIDIO_HOOK_EXTRA_RULES}):`);
2916
+ for (const r of extras)
2917
+ log(` ${r.category.padEnd(18)} ${r.summary}`);
2918
+ }
2919
+ else
2920
+ log(`Add your own: DECIDIO_HOOK_EXTRA_RULES=<file.json> with [{ "category": "deploy", "pattern": "^make deploy\\\\b", "summary": "make deploy" }] - each a regex over the whole command.`);
2921
+ log(`The list is a client-side filter; your workspace's policy decides each request the hook sends. Hold: DECIDIO_HOOK_HOLD_MS (90000; capped at ${HOOK_HOLD_CAP_MS}, below the hook's 150 s timeout).`);
2922
+ log(`Each request is bound to its SUBJECT as well as its text: a push to the branch and commit it would push, a delete to the paths, a statement to its database - so a refusal means "not this", and a new commit or a different path is a new request.`);
2923
+ return 0;
2924
+ }
2925
+ function hookRunner() {
2926
+ const q = (p) => `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
2927
+ const entry = process.argv[1] ?? "";
2928
+ if (!entry || /[\\/]_npx[\\/]/.test(entry))
2929
+ return `npx -y @decidio/sdk@${VERSION}`;
2930
+ let real = entry;
2931
+ try {
2932
+ real = realPath(entry);
2933
+ }
2934
+ catch { }
2935
+ const localRoot = resolvePath("node_modules") + pathSep;
2936
+ if (real.startsWith(localRoot))
2937
+ real = "$CLAUDE_PROJECT_DIR/node_modules/" + real.slice(localRoot.length).split(pathSep).join("/");
2938
+ real = real.split(pathSep).join("/");
2939
+ if (/\.ts$/.test(real))
2940
+ return `npx tsx ${q(real)}`;
2941
+ return `node ${q(real)}`;
2942
+ }
2943
+ async function cmdHookInstall(opts) {
2944
+ const settingsPath = joinPath(".claude", opts.project ? "settings.json" : "settings.local.json");
2945
+ let settings = {};
2946
+ if (existsSync(settingsPath)) {
2947
+ try {
2948
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
2949
+ }
2950
+ catch (e) {
2951
+ err(`✘ ${settingsPath} exists but is not valid JSON (${e?.message ?? e}) - fix it first; nothing was written.`);
2952
+ return 1;
2953
+ }
2954
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
2955
+ err(`✘ ${settingsPath} is not a JSON object - nothing was written.`);
2956
+ return 1;
2957
+ }
2958
+ }
2959
+ if (opts.cli !== undefined && !opts.cli) {
2960
+ err("✘ --cli needs the path of the CLI to run from the hook (e.g. --cli packages/decidio-guard/dist/cli.js)");
2961
+ return 2;
2962
+ }
2963
+ const cliAt = opts.cli ? resolvePath(opts.cli).split(pathSep).join("/") : "";
2964
+ const runner = opts.cli ? (/\.ts$/.test(opts.cli) ? `npx tsx "${cliAt}"` : `node "${cliAt}"`) : hookRunner();
2965
+ const hooks = (settings.hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks) ? settings.hooks : {});
2966
+ const place = (event, command, extra) => {
2967
+ const list = Array.isArray(hooks[event]) ? hooks[event] : (hooks[event] = []);
2968
+ const entry = { matcher: "Bash|PowerShell", hooks: [{ type: "command", command, ...extra }] };
2969
+ const i = list.findIndex((e) => Array.isArray(e?.hooks) && e.hooks.some((h) => typeof h?.command === "string" && /\bhook (pretool|posttool)\b/.test(h.command) && /decidio|cli\.(js|ts)/.test(h.command)));
2970
+ if (i >= 0) {
2971
+ const same = JSON.stringify(list[i]) === JSON.stringify(entry);
2972
+ list[i] = entry;
2973
+ return same ? "kept" : "updated";
2974
+ }
2975
+ list.push(entry);
2976
+ return "added";
2977
+ };
2978
+ const pre = place("PreToolUse", `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook pretool`, { timeout: 150, onFailure: "block" });
2979
+ const post = place("PostToolUse", `cd "$CLAUDE_PROJECT_DIR" && ${runner} hook posttool`, { timeout: 30 });
2980
+ mkdirSync(".claude", { recursive: true });
2981
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2982
+ log(`✔ ${settingsPath.split(pathSep).join("/")}: PreToolUse hook ${pre}, PostToolUse hook ${post} (matcher Bash|PowerShell${opts.project ? "; this file is shared with everyone who clones the project" : "; this file is yours alone - Claude Code keeps it out of git"}).`);
2983
+ log(` Runs: ${runner} hook pretool|posttool - the pretool hook times out at 150 s and BLOCKS the call if it fails (fail closed).`);
2984
+ const env = readEnvFile();
2985
+ const token = process.env.DECIDIO_API_TOKEN || env.DECIDIO_API_TOKEN;
2986
+ if (token)
2987
+ log(`✔ agent credentials: ${ENV_PATH} holds DECIDIO_API_TOKEN${(process.env.DECIDIO_AGENT_KEY || env.DECIDIO_AGENT_KEY) ? " and the agent's signing key" : ""} for "${process.env.DECIDIO_AGENT_ID || env.DECIDIO_AGENT_ID || defaultAgentId()}".`);
2988
+ else
2989
+ log(`⚠ no agent credentials here (no DECIDIO_API_TOKEN in ${ENV_PATH}) - until there are, EVERY gated command is refused. Run: npx @decidio/sdk init ${defaultAgentId()}`);
2990
+ log(` Gated: ${HOOK_RULES.map((r) => r.category).join(", ")} (npx @decidio/sdk hook rules lists the patterns; DECIDIO_HOOK_EXTRA_RULES adds yours).`);
2991
+ log(` Approver: anyone in your workspace with a role other than the agent's owner (the person who ran init) - on the phone app, the web app, Slack or Teams.`);
2992
+ log(` Nobody yet? Invite one: npx @decidio/sdk hook install --approver <email> (single-use link; share by WhatsApp, SMS or email)`);
2993
+ log(` Flow: a gated command is held up to ${Math.min(Number(process.env.DECIDIO_HOOK_HOLD_MS ?? 90_000) || 90_000, HOOK_HOLD_CAP_MS) / 1000} s for the approver, then refused with its decision id; \`npx @decidio/sdk hook wait <id>\` returns when they decide,`);
2994
+ log(` and the SAME command retried then runs - once. A lab workspace allows 30 governed actions an hour; a gated command spends two (ask, retry).`);
2995
+ log(` Reload: restart Claude Code, or run /hooks in it - hooks are read when a session starts.`);
2996
+ if (opts.approver !== undefined)
2997
+ return inviteApprover(opts.approver);
2998
+ return 0;
2999
+ }
3000
+ async function inviteApprover(email) {
3001
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
3002
+ err(`✘ --approver needs an email address (got "${email}")`);
3003
+ return 2;
3004
+ }
3005
+ const session = await acquireSession("Inviting an approver");
3006
+ if (!session)
3007
+ return 1;
3008
+ const r = await api("/demo/invite", { method: "POST", body: JSON.stringify({}) }, session);
3009
+ if (r.status !== 200 || typeof r.body?.link !== "string") {
3010
+ err(`✘ could not create an invite (HTTP ${r.status}${r.body?.error ? `: ${r.body.error}` : ""}) - only the workspace owner can invite on a lab workspace; elsewhere add the approver in the app's Roles screen.`);
3011
+ return 1;
3012
+ }
3013
+ const link = String(r.body.link);
3014
+ const until = typeof r.body.expiresAt === "string" ? r.body.expiresAt.slice(0, 10) : "in 7 days";
3015
+ const text = `I'm using Decidio to route my coding agent's risky commands to a person for approval - I'd like that person to be you. This link opens my workspace for you (single use, expires ${until}): ${link}`;
3016
+ log(`✔ invite created (single use, expires ${until}). Send it to ${email} by whichever channel they read - these carry the invite, nothing else:`);
3017
+ log(` WhatsApp https://wa.me/?text=${encodeURIComponent(text)}`);
3018
+ log(` SMS sms:?&body=${encodeURIComponent(text)}`);
3019
+ log(` Email mailto:${encodeURIComponent(email)}?subject=${encodeURIComponent("Approve my coding agent's risky commands")}&body=${encodeURIComponent(text)}`);
3020
+ log(` Link ${link}`);
3021
+ log(` Opening it signs them into the web app (the phone build at the same address; the app stores can wait) as a recognized approver in your workspace.`);
3022
+ return 0;
3023
+ }
3024
+ const PACK_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
3025
+ const PACK_SKILL_PREFIX = "decidio-";
3026
+ const PACK_IGNORE_ENTRY = ".claude/skills/decidio-*/";
3027
+ const PACK_RULES_IGNORE_ENTRY = ".claude/rules/decidio-*.md";
3028
+ function claudeHome() {
3029
+ return process.platform === "win32" ? (SHELL_ENV.USERPROFILE ?? homedir()) : (SHELL_ENV.HOME ?? homedir());
3030
+ }
3031
+ function ensurePackSkillsIgnored() {
3032
+ try {
3033
+ if (lstatSync(".gitignore").isSymbolicLink()) {
3034
+ err(`· .gitignore is a symbolic link - add ${PACK_IGNORE_ENTRY} and ${PACK_RULES_IGNORE_ENTRY} to it yourself`);
3035
+ return;
3036
+ }
3037
+ }
3038
+ catch { }
3039
+ const creating = !existsSync(".gitignore");
3040
+ let existing = creating ? "" : readFileSync(".gitignore", "utf8");
3041
+ for (const entry of [PACK_IGNORE_ENTRY, PACK_RULES_IGNORE_ENTRY]) {
3042
+ if (existing.split(/\r?\n/).some((l) => l.trim() === entry))
3043
+ continue;
3044
+ appendFileSync(".gitignore", (existing && !existing.endsWith("\n") ? "\n" : "") + entry + "\n");
3045
+ existing = readFileSync(".gitignore", "utf8");
3046
+ log(`✔ ${creating && entry === PACK_IGNORE_ENTRY ? "wrote" : "added"} ${entry} to .gitignore (a pulled pack is yours to read, not the repository's to carry)`);
3047
+ }
3048
+ }
3049
+ const yamlQuote = (s) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
3050
+ const mdCell = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
3051
+ export function packScopeLabel(scope) {
3052
+ if (!scope || typeof scope !== "object")
3053
+ return null;
3054
+ if (scope.kind === "org")
3055
+ return "the organisation";
3056
+ if (scope.kind === "personal")
3057
+ return "yours";
3058
+ if (scope.kind === "unit") {
3059
+ const type = typeof scope.unitType === "string" && /^[a-z_]{1,20}$/.test(scope.unitType) ? (scope.unitType === "project" ? "program" : scope.unitType) : "unit";
3060
+ const name = typeof scope.unitName === "string" && scope.unitName.trim() ? ` "${scope.unitName.replace(/["\r\n]/g, "'").slice(0, 80)}"` : "";
3061
+ return `${type}${name}`;
3062
+ }
3063
+ return null;
3064
+ }
3065
+ export function hereQuery(cwd = process.cwd()) {
3066
+ const cfg = joinPath(cwd, ".decidio.json");
3067
+ try {
3068
+ if (lstatSync(cfg).isSymbolicLink())
3069
+ return { error: ".decidio.json is a symbolic link - replace it with the file itself (npx @decidio/sdk context bind --unit <id> writes it)" };
3070
+ }
3071
+ catch { }
3072
+ if (existsSync(cfg)) {
3073
+ let unit;
3074
+ try {
3075
+ unit = JSON.parse(readFileSync(cfg, "utf8"))?.unit;
3076
+ }
3077
+ catch {
3078
+ return { error: ".decidio.json is not JSON - it holds {\"unit\": \"<unit id>\"} (npx @decidio/sdk context bind --unit <id> writes it)" };
3079
+ }
3080
+ if (typeof unit !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(unit))
3081
+ return { error: ".decidio.json's \"unit\" is not a unit id (npx @decidio/sdk context bind --unit <id> writes it)" };
3082
+ return { unit };
3083
+ }
3084
+ let remote = "";
3085
+ try {
3086
+ remote = execFileSync("git", ["remote", "get-url", "origin"], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5_000 }).trim();
3087
+ }
3088
+ catch {
3089
+ return {};
3090
+ }
3091
+ const m = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/(?:[^@/]+@)?github\.com\/)([A-Za-z0-9][A-Za-z0-9-]{0,38})\/([A-Za-z0-9._-]{1,100}?)(?:\.git)?\/?$/.exec(remote);
3092
+ return m ? { repo: `${m[1]}/${m[2]}` } : {};
3093
+ }
3094
+ export function plainProjectPath(parts) {
3095
+ let p = ".claude";
3096
+ for (const part of ["", ...parts]) {
3097
+ if (part)
3098
+ p = joinPath(p, part);
3099
+ try {
3100
+ if (lstatSync(p).isSymbolicLink())
3101
+ return false;
3102
+ }
3103
+ catch { }
3104
+ }
3105
+ return true;
3106
+ }
3107
+ export function isPackFileOf(text, slug, kind) {
3108
+ if (kind === "skill")
3109
+ return text.startsWith(`---\nname: ${PACK_SKILL_PREFIX}${slug}\n`) && /\n# .*\(Decidio context pack\)\n/.test(text);
3110
+ return /^# .*\(Decidio context pack - read first\)\n/.test(text);
3111
+ }
3112
+ function gitTracked(path) {
3113
+ try {
3114
+ execFileSync("git", ["ls-files", "--error-unmatch", "--", path], { stdio: "ignore", timeout: 5_000 });
3115
+ return true;
3116
+ }
3117
+ catch {
3118
+ return false;
3119
+ }
3120
+ }
3121
+ export function packSkillMarkdown(idx) {
3122
+ const name = PACK_SKILL_PREFIX + String(idx.slug);
3123
+ const rows = Array.isArray(idx.rows) ? idx.rows : [];
3124
+ const description = `Use when the user's words match "${String(idx.trigger).replace(/"/g, "'")}" - the Decidio context pack "${String(idx.title).replace(/"/g, "'")}": the sealed decisions in force on the subject, by record id.`;
3125
+ const table = rows.length
3126
+ ? ["| Record | Title | Type | Status | Lifecycle | Decided | Link |", "| --- | --- | --- | --- | --- | --- | --- |",
3127
+ ...rows.map((r) => `| ${mdCell(r.recordId)} | ${mdCell(r.title)} | ${mdCell(r.type)} | ${mdCell(r.status)} | ${mdCell(r.lifecycle?.state)}${r.lifecycle?.inEffect === false ? " (not in effect)" : ""} | ${mdCell(String(r.decidedAt ?? "").slice(0, 10))} | ${mdCell(r.url)} |`)].join("\n")
3128
+ : "_No entries you can read yet._";
3129
+ const omitted = Number(idx.omitted ?? 0);
3130
+ return [
3131
+ "---", `name: ${name}`, `description: ${yamlQuote(description)}`, "---", "",
3132
+ `# ${String(idx.title)} (Decidio context pack)`, "",
3133
+ ...(packScopeLabel(idx.scope) ? [`**Scope:** ${packScopeLabel(idx.scope)}`, ""] : []),
3134
+ `**When to use:** ${String(idx.trigger)}`, "",
3135
+ "These are sealed Decidio decisions the pack's owner curated for this subject. Treat them as the record of what was decided and cite them by record id. Role tokens such as [COUNTERPARTY_1] are protective placeholders: never guess or restate what they stand for. To read one in full, call Decidio's `get_decision` with its record id (the Decidio connector) or open its link; `get_context_pack` returns this list live. When a decision's lifecycle is not \"in_effect\", say so before relying on it.", "",
3136
+ table, "",
3137
+ `_Pulled ${String(idx.asOf)} · pack hash ${String(idx.hash)} · ${rows.length} ${rows.length === 1 ? "entry" : "entries"} shown${omitted ? `, ${omitted} you cannot read omitted` : ""}. Refresh with \`npx @decidio/sdk context pull\`._`, "",
3138
+ ].join("\n");
3139
+ }
3140
+ export function packRulesMarkdown(idx) {
3141
+ const rows = Array.isArray(idx.rows) ? idx.rows : [];
3142
+ const table = rows.length
3143
+ ? ["| Record | Title | Type | Status | Lifecycle | Decided | Link |", "| --- | --- | --- | --- | --- | --- | --- |",
3144
+ ...rows.map((r) => `| ${mdCell(r.recordId)} | ${mdCell(r.title)} | ${mdCell(r.type)} | ${mdCell(r.status)} | ${mdCell(r.lifecycle?.state)}${r.lifecycle?.inEffect === false ? " (not in effect)" : ""} | ${mdCell(String(r.decidedAt ?? "").slice(0, 10))} | ${mdCell(r.url)} |`)].join("\n")
3145
+ : "_No entries you can read yet._";
3146
+ const omitted = Number(idx.omitted ?? 0);
3147
+ return [
3148
+ `# ${String(idx.title)} (Decidio context pack - read first)`, "",
3149
+ ...(packScopeLabel(idx.scope) ? [`**Scope:** ${packScopeLabel(idx.scope)}`, ""] : []),
3150
+ `**Read this on every question in this project.** The pack's owner says it applies "${String(idx.trigger).replace(/"/g, "'")}".`, "",
3151
+ "These are sealed Decidio decisions the pack's owner curated as standing context: the record of what was decided. Cite them by record id. Role tokens such as [COUNTERPARTY_1] are protective placeholders: never guess or restate what they stand for; for the full record open the link, or call get_decision on the Decidio connector. The other Decidio packs (the skills pulled beside this file) build on this one.", "",
3152
+ table, "",
3153
+ `_Pulled ${String(idx.asOf)} · pack hash ${String(idx.hash)} · ${rows.length} ${rows.length === 1 ? "entry" : "entries"} shown${omitted ? `, ${omitted} you cannot read omitted` : ""}. Refresh with \`npx @decidio/sdk context pull\`._`, "",
3154
+ ].join("\n");
3155
+ }
3156
+ function packChatgptBlock(idx) {
3157
+ const rows = Array.isArray(idx.rows) ? idx.rows : [];
3158
+ return [
3159
+ `## Decidio context pack: ${String(idx.title)}${idx.always ? " - READ FIRST, on every question" : ""}`,
3160
+ `When to use: ${idx.always ? "always - read this before the other packs; the owner says " : ""}"${String(idx.trigger).replace(/"/g, "'")}"`,
3161
+ "These are sealed decisions, by record id; cite them, never guess what a role token like [COUNTERPARTY_1] stands for. For the full record call get_context_pack / get_decision on the Decidio connector, or open the link.",
3162
+ ...rows.map((r) => `- ${mdCell(r.recordId)} · ${mdCell(r.title)} · ${mdCell(r.type)} · ${mdCell(r.lifecycle?.state)} · ${mdCell(r.url)}`),
3163
+ `(pulled ${String(idx.asOf)}, hash ${String(idx.hash)}${Number(idx.omitted ?? 0) ? `, ${idx.omitted} you cannot read omitted` : ""})`,
3164
+ ].join("\n");
3165
+ }
3166
+ async function cmdContext(sub, pos, flags) {
3167
+ const usage = () => { err(`usage: npx @decidio/sdk context list | create <slug> --title "…" --trigger "…" [--unit <unitId> | --org] | add <slug> <record…> | remove <slug> <record…> | trigger <slug> "<phrase>" | always <slug> on|off | delete <slug> | bind --unit <unitId> | pull [--here] [--pack <slug>] [--chatgpt]`); return 2; };
3168
+ const slug = pos[0];
3169
+ if (!sub)
3170
+ return usage();
3171
+ if (["create", "add", "remove", "trigger", "always", "delete"].includes(sub)) {
3172
+ if (!slug)
3173
+ return usage();
3174
+ if (!PACK_SLUG_RE.test(slug)) {
3175
+ err(`✘ a pack slug is 1-40 lower-case letters, digits or dashes, starting with a letter or digit (got "${slug}").`);
3176
+ return 2;
3177
+ }
3178
+ }
3179
+ if (sub === "create" && (!flags.title || !flags.trigger)) {
3180
+ err(`✘ create needs --title "…" and --trigger "…" (the trigger is the user's own words for WHEN a model should use the pack, e.g. "whenever we discuss the ACME application").`);
3181
+ return 2;
3182
+ }
3183
+ if ((sub === "add" || sub === "remove") && pos.length < 2) {
3184
+ err(`✘ ${sub} needs at least one record (a D·number or decision id) after the slug.`);
3185
+ return 2;
3186
+ }
3187
+ if (sub === "trigger" && !pos[1]) {
3188
+ err(`✘ trigger needs the phrase: npx @decidio/sdk context trigger ${slug} "whenever we discuss …"`);
3189
+ return 2;
3190
+ }
3191
+ if (sub === "always" && !["on", "off"].includes(pos[1] ?? "")) {
3192
+ err(`✘ always takes on or off: npx @decidio/sdk context always ${slug} on (read on every question, as a Claude Code rules file) · off (fires on its trigger only)`);
3193
+ return 2;
3194
+ }
3195
+ if (sub === "pull" && flags.pack && !PACK_SLUG_RE.test(flags.pack)) {
3196
+ err(`✘ --pack names a pack by its slug (got "${flags.pack}").`);
3197
+ return 2;
3198
+ }
3199
+ const unitFlag = flags.unit ?? flags.team;
3200
+ if (sub === "create" && ((flags.unit && flags.team) || (unitFlag && flags.org))) {
3201
+ err(`✘ create takes one scope: --unit <unitId> (or --team), or --org - not both.`);
3202
+ return 2;
3203
+ }
3204
+ if (unitFlag !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(unitFlag)) {
3205
+ err(`✘ --unit names a unit by its id (got "${unitFlag}").`);
3206
+ return 2;
3207
+ }
3208
+ if (sub === "bind" && !flags.unit) {
3209
+ err(`✘ bind needs --unit <unitId>: it writes .decidio.json here, so \`context pull --here\` pulls that unit's packs.`);
3210
+ return 2;
3211
+ }
3212
+ if (!["list", "create", "add", "remove", "trigger", "always", "delete", "pull", "bind"].includes(sub))
3213
+ return usage();
3214
+ const session = await acquireSession("Context packs");
3215
+ if (!session)
3216
+ return 1;
3217
+ const call = async (method, path, body) => api(path, { method, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }, session);
3218
+ const refused = (r) => {
3219
+ const words = r.body && typeof r.body === "object" && typeof r.body.error === "string" ? r.body.error : `HTTP ${r.status}`;
3220
+ err(`✘ ${words}${r.body?.code ? ` (${r.body.code})` : ""}`);
3221
+ return 1;
3222
+ };
3223
+ const showPack = (p) => log(` ${p.slug} ${packScopeLabel(p.scope) ?? (p.audience === "team" ? `team ${p.teamUnitId ?? ""}`.trim() : p.audience === "org" ? "the organisation" : "personal")} ${p.entries} ${p.entries === 1 ? "entry" : "entries"} "${p.title}"${p.always ? " · read on every question" : ""}\n when: ${p.trigger}`);
3224
+ if (sub === "list") {
3225
+ const r = await call("GET", "/api/context-packs");
3226
+ if (r.status !== 200)
3227
+ return refused(r);
3228
+ const packs = Array.isArray(r.body?.packs) ? r.body.packs : [];
3229
+ if (flags.json) {
3230
+ log(JSON.stringify({ packs }, null, 2));
3231
+ return 0;
3232
+ }
3233
+ if (!packs.length) {
3234
+ log(`No context packs yet. Create one: npx @decidio/sdk context create <slug> --title "…" --trigger "whenever we discuss …"`);
3235
+ return 0;
3236
+ }
3237
+ log(`${packs.length} context pack${packs.length === 1 ? "" : "s"}:`);
3238
+ packs.forEach(showPack);
3239
+ return 0;
3240
+ }
3241
+ if (sub === "create") {
3242
+ const r = await call("POST", "/api/context-packs", { slug, title: flags.title, trigger: flags.trigger, ...(unitFlag ? { audience: "team", teamUnitId: unitFlag } : flags.org ? { audience: "org" } : { audience: "personal" }) });
3243
+ if (r.status !== 201)
3244
+ return refused(r);
3245
+ if (flags.json) {
3246
+ log(JSON.stringify(r.body, null, 2));
3247
+ return 0;
3248
+ }
3249
+ log(`✔ created pack ${slug}`);
3250
+ showPack(r.body.pack);
3251
+ log(` Its title and trigger are shared with the model IN CLEAR, every turn${flags.team ? ", and with everyone in the unit" : ""} - that is how the model knows when to use it. Keep people's names out of them; decisions inside stay tokenized.`);
3252
+ log(` Add decisions: npx @decidio/sdk context add ${slug} D·0412 D·0418 then: npx @decidio/sdk context pull`);
3253
+ return 0;
3254
+ }
3255
+ if (sub === "add" || sub === "remove") {
3256
+ const r = await call(sub === "add" ? "POST" : "DELETE", `/api/context-packs/${slug}/entries`, { ids: pos.slice(1) });
3257
+ if (r.status !== 200)
3258
+ return refused(r);
3259
+ if (flags.json) {
3260
+ log(JSON.stringify(r.body, null, 2));
3261
+ return 0;
3262
+ }
3263
+ if (sub === "add")
3264
+ log(`✔ ${r.body.added?.length ?? 0} added to ${slug} (${r.body.pack?.entries ?? "?"} entries now)${(r.body.added?.length ?? 0) < pos.length - 1 ? " - the rest were already in it" : ""}`);
3265
+ else
3266
+ log(`✔ ${r.body.removed ?? 0} removed from ${slug} (${r.body.pack?.entries ?? "?"} entries now)`);
3267
+ return 0;
3268
+ }
3269
+ if (sub === "trigger") {
3270
+ const r = await call("PATCH", `/api/context-packs/${slug}`, { trigger: pos.slice(1).join(" ") });
3271
+ if (r.status !== 200)
3272
+ return refused(r);
3273
+ if (flags.json) {
3274
+ log(JSON.stringify(r.body, null, 2));
3275
+ return 0;
3276
+ }
3277
+ log(`✔ ${slug} now fires: ${r.body.pack?.trigger}`);
3278
+ log(` Re-pull to update the skill files: npx @decidio/sdk context pull`);
3279
+ return 0;
3280
+ }
3281
+ if (sub === "always") {
3282
+ const on = pos[1] === "on";
3283
+ const r = await call("PATCH", `/api/context-packs/${slug}`, { always: on });
3284
+ if (r.status !== 200)
3285
+ return refused(r);
3286
+ if (flags.json) {
3287
+ log(JSON.stringify(r.body, null, 2));
3288
+ return 0;
3289
+ }
3290
+ if (on) {
3291
+ log(`✔ ${slug} is now read on every question: pull writes it as a Claude Code rules file (.claude/rules/${PACK_SKILL_PREFIX}${slug}.md, loaded whole at launch), not a skill. Keep it short - it is in context every turn.`);
3292
+ }
3293
+ else
3294
+ log(`✔ ${slug} now fires on its trigger only (pull writes it back as a skill and removes the rules file)`);
3295
+ log(` Re-pull to update the files: npx @decidio/sdk context pull`);
3296
+ return 0;
3297
+ }
3298
+ if (sub === "delete") {
3299
+ const r = await call("DELETE", `/api/context-packs/${slug}`);
3300
+ if (r.status !== 200)
3301
+ return refused(r);
3302
+ log(`✔ deleted pack ${slug} (its pulled skill files stay until you remove them: .claude/skills/${PACK_SKILL_PREFIX}${slug}/ here or in ${joinPath(claudeHome(), ".claude", "skills")}; an always-pack's .claude/rules/${PACK_SKILL_PREFIX}${slug}.md likewise)`);
3303
+ return 0;
3304
+ }
3305
+ if (sub === "bind") {
3306
+ const r = await call("GET", `/api/context-packs/here?unit=${encodeURIComponent(flags.unit)}`);
3307
+ if (r.status !== 200)
3308
+ return refused(r);
3309
+ const tmpCfg = `.decidio.json.${process.pid}.tmp`;
3310
+ writeFileSync(tmpCfg, JSON.stringify({ unit: flags.unit }, null, 2) + "\n", { flag: "wx" });
3311
+ renameSync(tmpCfg, ".decidio.json");
3312
+ const chain = Array.isArray(r.body?.scope?.chain) ? r.body.scope.chain : [];
3313
+ log(`✔ wrote .decidio.json: this folder is ${chain.length ? chain.map((u) => packScopeLabel({ kind: "unit", unitType: u.type, unitName: u.name })).join(" → ") : `unit ${flags.unit}`}`);
3314
+ log(` Commit it so the team shares the scope; then: npx @decidio/sdk context pull --here`);
3315
+ return 0;
3316
+ }
3317
+ let hereScope = null;
3318
+ let r;
3319
+ if (flags.here) {
3320
+ const q = hereQuery();
3321
+ if (q.error) {
3322
+ err(`✘ ${q.error}`);
3323
+ return 1;
3324
+ }
3325
+ r = await call("GET", `/api/context-packs/here${q.unit ? `?unit=${encodeURIComponent(q.unit)}` : q.repo ? `?repo=${encodeURIComponent(q.repo)}` : ""}`);
3326
+ if (r.status !== 200)
3327
+ return refused(r);
3328
+ hereScope = r.body?.scope ?? null;
3329
+ }
3330
+ else {
3331
+ r = await call("GET", "/api/context-packs");
3332
+ if (r.status !== 200)
3333
+ return refused(r);
3334
+ }
3335
+ let packs = Array.isArray(r.body?.packs) ? r.body.packs : [];
3336
+ if (flags.pack) {
3337
+ packs = packs.filter((p) => p.slug === flags.pack);
3338
+ if (!packs.length) {
3339
+ err(`✘ no context pack of yours is called "${flags.pack}" (npx @decidio/sdk context list shows them).`);
3340
+ return 1;
3341
+ }
3342
+ }
3343
+ if (!packs.length && !flags.here) {
3344
+ log(`No context packs to pull. Create one: npx @decidio/sdk context create <slug> --title "…" --trigger "whenever we discuss …"`);
3345
+ return 0;
3346
+ }
3347
+ packs = [...packs.filter((p) => p?.always === true), ...packs.filter((p) => p?.always !== true)];
3348
+ const written = [];
3349
+ let wroteProject = false;
3350
+ for (const p of packs) {
3351
+ if (typeof p?.slug !== "string" || !PACK_SLUG_RE.test(p.slug)) {
3352
+ err(`✘ refusing a pack whose slug is not a slug (${JSON.stringify(String(p?.slug)).slice(0, 60)}) - nothing written for it.`);
3353
+ continue;
3354
+ }
3355
+ const ix = await call("GET", `/api/context-packs/${p.slug}/index`);
3356
+ if (ix.status !== 200)
3357
+ return refused(ix);
3358
+ const idx = ix.body;
3359
+ const always = p.always === true;
3360
+ idx.scope = p.scope ?? null;
3361
+ if (flags.chatgpt) {
3362
+ log(packChatgptBlock({ ...idx, always }));
3363
+ log("");
3364
+ continue;
3365
+ }
3366
+ const project = p.audience === "team" || p.audience === "org";
3367
+ if (project && (!plainProjectPath(["skills", PACK_SKILL_PREFIX + p.slug, "SKILL.md"]) || !plainProjectPath(["rules", PACK_SKILL_PREFIX + p.slug + ".md"]))) {
3368
+ err(`✘ refusing to write pack ${p.slug}: a path under .claude is a symbolic link - nothing written for it.`);
3369
+ continue;
3370
+ }
3371
+ const base = project ? ".claude" : joinPath(claudeHome(), ".claude");
3372
+ const dir = joinPath(base, "skills", PACK_SKILL_PREFIX + p.slug);
3373
+ const rules = joinPath(base, "rules", PACK_SKILL_PREFIX + p.slug + ".md");
3374
+ let path;
3375
+ if (always) {
3376
+ mkdirSync(joinPath(base, "rules"), { recursive: true });
3377
+ writeFileSync(rules, packRulesMarkdown({ ...idx, always }));
3378
+ rmSync(joinPath(dir, "SKILL.md"), { force: true });
3379
+ try {
3380
+ rmdirSync(dir);
3381
+ }
3382
+ catch { }
3383
+ path = rules;
3384
+ }
3385
+ else {
3386
+ mkdirSync(dir, { recursive: true });
3387
+ writeFileSync(joinPath(dir, "SKILL.md"), packSkillMarkdown(idx));
3388
+ rmSync(rules, { force: true });
3389
+ path = joinPath(dir, "SKILL.md");
3390
+ }
3391
+ if (project)
3392
+ wroteProject = true;
3393
+ written.push({ slug: p.slug, path, rows: Array.isArray(idx.rows) ? idx.rows.length : 0, omitted: Number(idx.omitted ?? 0), hash: String(idx.hash), always });
3394
+ }
3395
+ if (flags.chatgpt) {
3396
+ err(`· paste each block above into the ChatGPT Project's instructions (or wherever that host keeps standing context); the Decidio connector's get_context_pack returns the same list live.`);
3397
+ return 0;
3398
+ }
3399
+ if (wroteProject)
3400
+ ensurePackSkillsIgnored();
3401
+ const removed = [];
3402
+ if (flags.here && !flags.pack) {
3403
+ const keep = new Set(written.map((w) => w.slug));
3404
+ const skillsDir = joinPath(".claude", "skills"), rulesDir = joinPath(".claude", "rules");
3405
+ const readText = (path) => { try {
3406
+ return readFileSync(path, "utf8");
3407
+ }
3408
+ catch {
3409
+ return "";
3410
+ } };
3411
+ if (existsSync(skillsDir) && plainProjectPath(["skills"]))
3412
+ for (const d of readdirSync(skillsDir)) {
3413
+ const slugOf = d.startsWith(PACK_SKILL_PREFIX) ? d.slice(PACK_SKILL_PREFIX.length) : "";
3414
+ const file = joinPath(skillsDir, d, "SKILL.md");
3415
+ if (!PACK_SLUG_RE.test(slugOf) || keep.has(slugOf) || !plainProjectPath(["skills", d, "SKILL.md"]) || !existsSync(file))
3416
+ continue;
3417
+ if (!isPackFileOf(readText(file), slugOf, "skill") || gitTracked(file))
3418
+ continue;
3419
+ rmSync(file, { force: true });
3420
+ try {
3421
+ rmdirSync(joinPath(skillsDir, d));
3422
+ }
3423
+ catch { }
3424
+ removed.push(slugOf);
3425
+ }
3426
+ if (existsSync(rulesDir) && plainProjectPath(["rules"]))
3427
+ for (const f of readdirSync(rulesDir)) {
3428
+ const m = /^decidio-([a-z0-9][a-z0-9-]{0,39})\.md$/.exec(f);
3429
+ const file = joinPath(rulesDir, f);
3430
+ if (!m || keep.has(m[1]) || !plainProjectPath(["rules", f]) || !isPackFileOf(readText(file), m[1], "rules") || gitTracked(file))
3431
+ continue;
3432
+ rmSync(file, { force: true });
3433
+ if (!removed.includes(m[1]))
3434
+ removed.push(m[1]);
3435
+ }
3436
+ }
3437
+ if (flags.json) {
3438
+ log(JSON.stringify({ written, ...(flags.here ? { scope: hereScope, removed } : {}) }, null, 2));
3439
+ return 0;
3440
+ }
3441
+ if (flags.here) {
3442
+ const chain = Array.isArray(hereScope?.chain) ? hereScope.chain : [];
3443
+ const where = chain.length ? chain.map((u) => packScopeLabel({ kind: "unit", unitType: u.type, unitName: u.name })).join(" → ") : hereScope?.source === "repo" ? "a repository in no unit of Decidio" : "no unit";
3444
+ log(`Here: the organisation → ${where}${!chain.length ? " (the organisation's packs and your own)" : ""}`);
3445
+ if (!written.length)
3446
+ log(`No context packs apply here.`);
3447
+ }
3448
+ for (const w of written)
3449
+ log(`✔ ${w.path.split(pathSep).join("/")} (${w.rows} ${w.rows === 1 ? "entry" : "entries"}${w.omitted ? `, ${w.omitted} you cannot read omitted` : ""}; hash ${w.hash}${w.always ? "; read on every question" : ""})`);
3450
+ if (removed.length)
3451
+ log(`✔ removed ${removed.length} pack file${removed.length === 1 ? "" : "s"} that ${removed.length === 1 ? "does" : "do"} not apply here: ${removed.join(", ")}`);
3452
+ log(` Claude Code loads each pack's trigger line every turn and the list only when the trigger applies${written.some((w) => w.always) ? "; an always-pack (.claude/rules) is loaded whole at launch" : ""}. Re-run after curating.`);
3453
+ return 0;
3454
+ }
3455
+ const ADR_TAKE = new Set(["accepted", "approved", "adopted", "decided"]);
3456
+ export function parseAdr(text) {
3457
+ let body = text.replace(/\r\n/g, "\n");
3458
+ let status = null;
3459
+ const fm = /^---\n([\s\S]*?)\n---\n/.exec(body);
3460
+ if (fm) {
3461
+ const st = /^status:\s*["']?([A-Za-z]+)/im.exec(fm[1]);
3462
+ if (st)
3463
+ status = st[1].toLowerCase();
3464
+ body = body.slice(fm[0].length);
3465
+ }
3466
+ const titleLine = /^#\s+(.+)$/m.exec(body);
3467
+ if (!titleLine)
3468
+ return null;
3469
+ const title = titleLine[1].replace(/^(?:ADR[-\s]?)?\d+[:.\-)]?\s*/i, "").replace(/[*_`]/g, "").trim().slice(0, 200);
3470
+ if (!title)
3471
+ return null;
3472
+ const sections = new Map();
3473
+ const parts = body.split(/^##\s+/m).slice(1);
3474
+ for (const part of parts) {
3475
+ const nl = part.indexOf("\n");
3476
+ const head = (nl < 0 ? part : part.slice(0, nl)).trim().toLowerCase();
3477
+ sections.set(head, (nl < 0 ? "" : part.slice(nl + 1)).trim());
3478
+ }
3479
+ const pick = (...names) => { for (const [h, v] of sections)
3480
+ if (names.some((n) => h === n || h.startsWith(n)))
3481
+ return v; return ""; };
3482
+ if (!status) {
3483
+ const inline = /^\s*\**status\**\s*:\s*\**\s*([A-Za-z]+)/im.exec(body);
3484
+ const sec = pick("status").split("\n").map((l) => l.replace(/[*_`\-]/g, "").trim()).find(Boolean);
3485
+ status = (inline?.[1] ?? sec?.split(/\s+/)[0] ?? "").toLowerCase() || null;
3486
+ }
3487
+ const clean = (t, max) => t.replace(/\n{3,}/g, "\n\n").trim().slice(0, max);
3488
+ const consequences = pick("consequences");
3489
+ const context = pick("context and problem statement", "context");
3490
+ const rationale = clean([context, consequences ? `Consequences: ${consequences}` : ""].filter(Boolean).join("\n\n"), 4000);
3491
+ const decided = clean(pick("decision outcome", "decision"), 4000);
3492
+ const alternatives = pick("considered options", "options considered", "alternatives", "options").split("\n")
3493
+ .map((l) => /^\s*(?:[-*+]|\d+\.)\s+(.+)$/.exec(l)?.[1]?.replace(/[*_`]/g, "").trim() ?? "").filter(Boolean).slice(0, 30).map((x) => x.slice(0, 1000));
3494
+ return { title, status, decided, rationale, alternatives };
3495
+ }
3496
+ async function cmdDecisionsImport(dir, dryRun) {
3497
+ if (!dir) {
3498
+ err("✘ import needs --adr <folder of ADR markdown files>, e.g. --adr docs/adr");
3499
+ return 2;
3500
+ }
3501
+ const abs = resolvePath(dir);
3502
+ let files;
3503
+ try {
3504
+ files = readdirSync(abs).filter((f) => /\.md$/i.test(f) && !/^(readme|index|template)/i.test(f)).sort().slice(0, 200);
3505
+ }
3506
+ catch {
3507
+ err(`✘ ${dir} is not a folder this command can read.`);
3508
+ return 1;
3509
+ }
3510
+ let repoKey = "";
3511
+ try {
3512
+ repoKey = execFileSync("git", ["remote", "get-url", "origin"], { cwd: abs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
3513
+ }
3514
+ catch {
3515
+ repoKey = "";
3516
+ }
3517
+ let root = abs;
3518
+ try {
3519
+ root = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: abs, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim() || abs;
3520
+ }
3521
+ catch {
3522
+ root = abs;
3523
+ }
3524
+ const take = [], skipped = [];
3525
+ for (const f of files) {
3526
+ const p = joinPath(abs, f);
3527
+ try {
3528
+ if (lstatSync(p).isSymbolicLink() || statSync(p).size > 200_000) {
3529
+ skipped.push(`${f} (not a plain file under 200 KB)`);
3530
+ continue;
3531
+ }
3532
+ }
3533
+ catch {
3534
+ continue;
3535
+ }
3536
+ const adr = parseAdr(readFileSync(p, "utf8"));
3537
+ if (!adr) {
3538
+ skipped.push(`${f} (no "# title")`);
3539
+ continue;
3540
+ }
3541
+ if (adr.status && !ADR_TAKE.has(adr.status)) {
3542
+ skipped.push(`${f} (${adr.status})`);
3543
+ continue;
3544
+ }
3545
+ if (!adr.decided && !adr.rationale) {
3546
+ skipped.push(`${f} (no decision or context section)`);
3547
+ continue;
3548
+ }
3549
+ const rel = relative(root, p).split(pathSep).join("/");
3550
+ take.push({ file: f, adr, key: `adr:${createHash("sha256").update(`${repoKey || root}\u0000${rel}`).digest("hex").slice(0, 16)}` });
3551
+ }
3552
+ if (dryRun) {
3553
+ for (const t of take)
3554
+ log(`would import ${t.file}: "${t.adr.title}"${t.adr.alternatives.length ? ` (${t.adr.alternatives.length} options considered)` : ""}`);
3555
+ for (const s of skipped)
3556
+ log(`would skip ${s}`);
3557
+ log(`${take.length} to import as drafts, ${skipped.length} skipped. Nothing was sent.`);
3558
+ return 0;
3559
+ }
3560
+ if (!take.length) {
3561
+ log(`Nothing to import (${skipped.length} skipped${skipped.length ? `: ${skipped.slice(0, 5).join("; ")}` : ""}).`);
3562
+ return 0;
3563
+ }
3564
+ const session = await acquireSession("Importing decision records");
3565
+ if (!session)
3566
+ return 1;
3567
+ const call = async (method, path, body) => api(path, { method, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) }, session);
3568
+ const me = await call("GET", "/api/me");
3569
+ const userId = typeof me.body?.userId === "string" ? me.body.userId : null;
3570
+ if (!userId) {
3571
+ err("✘ sign in as yourself first (npx @decidio/sdk login) - an imported draft is owned by the person who imports it.");
3572
+ return 1;
3573
+ }
3574
+ let made = 0, again = 0, failed = 0;
3575
+ for (const t of take) {
3576
+ const c = await call("POST", "/api/decisions", { type: "deliberation", title: t.adr.title, ownerId: userId, draft: true, sourceSystem: "adr", sourceExternalId: t.key, tags: ["adr"] });
3577
+ if (c.status === 409 && c.body?.code === "ALREADY_IMPORTED") {
3578
+ again++;
3579
+ log(`· ${t.file}: imported before - kept as it is`);
3580
+ continue;
3581
+ }
3582
+ const id = typeof c.body?.id === "string" ? c.body.id : null;
3583
+ if (c.status !== 201 || !id) {
3584
+ failed++;
3585
+ err(`✘ ${t.file}: ${c.body?.message ?? c.body?.error ?? `HTTP ${c.status}`}`);
3586
+ continue;
3587
+ }
3588
+ const body = {};
3589
+ if (t.adr.decided)
3590
+ body.decided = t.adr.decided;
3591
+ if (t.adr.rationale)
3592
+ body.rationale = t.adr.rationale;
3593
+ if (t.adr.alternatives.length)
3594
+ body.alternatives = t.adr.alternatives;
3595
+ const w = await call("POST", `/api/decisions/${encodeURIComponent(id)}/complete`, body);
3596
+ if (w.status >= 300) {
3597
+ failed++;
3598
+ err(`✘ ${t.file}: the draft was made but its text was not saved (${w.body?.message ?? `HTTP ${w.status}`}) - open it in Decidio`);
3599
+ continue;
3600
+ }
3601
+ made++;
3602
+ log(`✔ ${t.file} → a draft: "${t.adr.title}"`);
3603
+ }
3604
+ log(`${made} imported as drafts, ${again} imported before, ${failed} failed, ${skipped.length} skipped. Review and seal them in Decidio (the Queue): only sealed decisions are cited.`);
3605
+ return failed ? 1 : 0;
3606
+ }
3607
+ const GH_ENV_RE = /^[A-Za-z0-9._-]{1,64}$/;
3608
+ const GH_DECISION_RE = /^[A-Za-z0-9_-]{8,64}$/;
3609
+ const GH_WORKFLOW_RE = /^\.github\/workflows\/[A-Za-z0-9._-]{1,100}\.ya?ml$/;
3610
+ const GH_JUSTIFICATION_MAX = 280;
3611
+ export const AGENTS_MD_START = "<!-- decidio:start -->";
3612
+ export const AGENTS_MD_END = "<!-- decidio:end -->";
3613
+ async function actionsOidcToken() {
3614
+ const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL, tok = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
3615
+ if (!url || !tok) {
3616
+ err("✘ no GitHub OIDC token here: run this inside a GitHub Actions job whose workflow sets `permissions: id-token: write`.");
3617
+ return null;
3618
+ }
3619
+ const aud = (process.env.DECIDIO_GITHUB_OIDC_AUDIENCE ?? "decidio").trim() || "decidio";
3620
+ let u;
3621
+ try {
3622
+ u = new URL(url);
3623
+ }
3624
+ catch {
3625
+ err("✘ ACTIONS_ID_TOKEN_REQUEST_URL is not a URL - nothing was sent.");
3626
+ return null;
3627
+ }
3628
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && (u.hostname === "127.0.0.1" || u.hostname === "localhost"))) {
3629
+ err("✘ GitHub's token service must be https - nothing was sent.");
3630
+ return null;
3631
+ }
3632
+ u.searchParams.set("audience", aud);
3633
+ try {
3634
+ const r = await fetch(u, { headers: { authorization: `bearer ${tok}`, accept: "application/json" } });
3635
+ const j = await r.json().catch(() => null);
3636
+ if (!r.ok || typeof j?.value !== "string" || !j.value) {
3637
+ err(`✘ GitHub did not issue an OIDC token (${r.status}) - nothing was sent to Decidio.`);
3638
+ return null;
3639
+ }
3640
+ return j.value;
3641
+ }
3642
+ catch (e) {
3643
+ err(`✘ could not reach GitHub's token service (${e?.cause?.code ?? e?.message ?? "network error"}) - nothing was sent to Decidio.`);
3644
+ return null;
3645
+ }
3646
+ }
3647
+ async function cmdGithubRequest(environment, justification) {
3648
+ if (!environment || !GH_ENV_RE.test(environment)) {
3649
+ err("✘ request needs --environment <name>: one of the environments the repository was bound with (letters, digits, . _ -).");
3650
+ return 2;
3651
+ }
3652
+ if (justification !== undefined && (!justification.trim() || justification.length > GH_JUSTIFICATION_MAX || /[\r\n]/.test(justification))) {
3653
+ err(`✘ --justification is one line of up to ${GH_JUSTIFICATION_MAX} characters.`);
3654
+ return 2;
3655
+ }
3656
+ const token = await actionsOidcToken();
3657
+ if (!token)
3658
+ return 1;
3659
+ const r = await api("/api/github/runs/request", { method: "POST", headers: { "x-decidio-client": `sdk/${VERSION}` }, body: JSON.stringify({ environment, ...(justification ? { justification: justification.trim() } : {}) }) }, token);
3660
+ const b = (r.body && typeof r.body === "object" ? r.body : {});
3661
+ const ref = `${b.displayNumber ?? b.decisionId ?? ""}${b.url ? ` ${b.url}` : ""}`.trim();
3662
+ if (r.status === 200 && (b.decision === "route" || b.decision === "proceed")) {
3663
+ log(b.decision === "proceed"
3664
+ ? `✔ ${environment}: no approval needed - Decidio starts the deploy (${ref})`
3665
+ : `✔ asked: a teammate approves ${environment} in Decidio, then Decidio starts the deploy. This job is done (${ref})`);
3666
+ if (b.existing)
3667
+ log(" An approval request for this commit was already open - that one stands.");
3668
+ return 0;
3669
+ }
3670
+ if (r.status === 200 && b.decision === "duplicate") {
3671
+ err(`✘ ${String(b.error ?? "a person refused this commit before")}`);
3672
+ err(` To ask again, say what changed: npx @decidio/sdk github request --environment ${environment} --justification "<what changed>"`);
3673
+ return 1;
3674
+ }
3675
+ if (r.status === 200 && b.decision === "block") {
3676
+ err(`✘ the workspace's policy blocks this deploy (${ref}).`);
3677
+ return 1;
3678
+ }
3679
+ err(`✘ Decidio refused the request (${r.status}${b.code ? ` ${b.code}` : ""}): ${String(b.error ?? "no reason given")}`);
3680
+ return 1;
3681
+ }
3682
+ async function cmdGithubVerify(decisionArg) {
3683
+ const decision = (decisionArg ?? process.env.DECIDIO_DECISION ?? "").trim();
3684
+ if (!GH_DECISION_RE.test(decision)) {
3685
+ err("✘ verify needs the decision id Decidio dispatched: set DECIDIO_DECISION to ${{ inputs.decidio }} (or pass --decision). Do not deploy.");
3686
+ return 2;
3687
+ }
3688
+ const token = await actionsOidcToken();
3689
+ if (!token) {
3690
+ err(" Do not deploy.");
3691
+ return 1;
3692
+ }
3693
+ const r = await api("/api/github/runs/verify", { method: "POST", headers: { "x-decidio-client": `sdk/${VERSION}` }, body: JSON.stringify({ decision }) }, token);
3694
+ const b = (r.body && typeof r.body === "object" ? r.body : {});
3695
+ if (r.status === 200 && b.ok === true && typeof b.sha === "string" && /^[0-9a-f]{40}$/.test(b.sha)) {
3696
+ const out = process.env.GITHUB_OUTPUT;
3697
+ if (out)
3698
+ appendFileSync(out, `sha=${b.sha}\ndecision=${decision}\n`);
3699
+ log(`✔ approved: deploy ${b.sha.slice(0, 12)} to ${String(b.environment ?? "")} (${b.displayNumber ?? decision}). This approval is now used.`);
3700
+ return 0;
3701
+ }
3702
+ err(`✘ not approved - do not deploy (${r.status}${b.code ? ` ${b.code}` : ""}): ${String(b.error ?? "no reason given")}`);
3703
+ return 1;
3704
+ }
3705
+ function plainRepoPath(root, rel) {
3706
+ let p = root;
3707
+ for (const part of rel.split("/")) {
3708
+ p = joinPath(p, part);
3709
+ try {
3710
+ if (lstatSync(p).isSymbolicLink())
3711
+ return false;
3712
+ }
3713
+ catch { }
3714
+ }
3715
+ return true;
3716
+ }
3717
+ export const DECIDIO_ACTION = "OmniTwin/decidio-actions@e40ba8e75a223f2475038d38a16df5a5885afde4 # v1.0.0";
3718
+ export const CHECKOUT_ACTION = "actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0";
3719
+ export function githubRequestWorkflow(api, environment, branch = "main") {
3720
+ return `# Written by npx @decidio/sdk github init. Asks a teammate to approve each deploy; Decidio starts the deploy workflow once
3721
+ # approved. No repository secret: the job's own OIDC token is the credential.
3722
+ name: Ask Decidio to deploy
3723
+ on:
3724
+ push:
3725
+ branches: [${branch}]
3726
+ permissions:
3727
+ id-token: write
3728
+ contents: read
3729
+ jobs:
3730
+ ask:
3731
+ runs-on: ubuntu-latest
3732
+ steps:
3733
+ - uses: ${DECIDIO_ACTION}
3734
+ with:
3735
+ step: request
3736
+ environment: ${environment}
3737
+ api-url: ${api}
3738
+ `;
3739
+ }
3740
+ export function githubVerifySteps(api) {
3741
+ return ` - id: decidio
3742
+ uses: ${DECIDIO_ACTION}
3743
+ with:
3744
+ step: verify
3745
+ decision: \${{ inputs.decidio }}
3746
+ api-url: ${api}
3747
+ - uses: ${CHECKOUT_ACTION}
3748
+ with:
3749
+ ref: \${{ steps.decidio.outputs.sha }}
3750
+ `;
3751
+ }
3752
+ export function githubDeployWorkflow(api, environment) {
3753
+ return `# Written by npx @decidio/sdk github init. Decidio starts this workflow once the deploy is approved; its first step proves the
3754
+ # approval (once, for this commit and environment) before anything deploys. Restrict who may run it to the Decidio app in the
3755
+ # repository's workflow execution protections.
3756
+ name: Deploy
3757
+ run-name: Decidio \${{ inputs.decidio }}
3758
+ on:
3759
+ workflow_dispatch:
3760
+ inputs:
3761
+ decidio:
3762
+ description: Set by Decidio
3763
+ required: true
3764
+ permissions:
3765
+ id-token: write
3766
+ contents: read
3767
+ jobs:
3768
+ deploy:
3769
+ runs-on: ubuntu-latest
3770
+ environment: ${environment}
3771
+ steps:
3772
+ ${githubVerifySteps(api)} - name: Deploy
3773
+ run: ./deploy.sh # your deploy command
3774
+ `;
3775
+ }
3776
+ export function agentsMdSection(appUrl) {
3777
+ return `${AGENTS_MD_START}
3778
+ ## Decisions (Decidio)
3779
+
3780
+ This repository's sealed decisions live in Decidio (${appUrl}); sign in to read the ones you may see. An agent connected to
3781
+ Decidio's MCP server calls \`check_plan\` with its plan before acting: it answers which decisions the plan would contradict.
3782
+ Nothing of a decision is kept in this file.
3783
+ ${AGENTS_MD_END}
3784
+ `;
3785
+ }
3786
+ export function withAgentsMdSection(text, section) {
3787
+ const m = new RegExp(`${AGENTS_MD_START}\\r?\\n## Decisions \\(Decidio\\)\\r?\\n`).exec(text);
3788
+ const a = m ? m.index : -1, b = m ? text.indexOf(AGENTS_MD_END, a + m[0].length) : -1;
3789
+ if (a >= 0 && b > a)
3790
+ return text.slice(0, a) + section.trimEnd() + text.slice(b + AGENTS_MD_END.length);
3791
+ return `${text}${text && !text.endsWith("\n") ? "\n" : ""}${text ? "\n" : ""}${section}`;
3792
+ }
3793
+ async function cmdGithubInit(opts) {
3794
+ const environment = opts.environment ?? "production", workflow = opts.workflow ?? ".github/workflows/deploy.yml";
3795
+ if (!GH_ENV_RE.test(environment)) {
3796
+ err("✘ --environment is the environment the deploy job declares (letters, digits, . _ -).");
3797
+ return 2;
3798
+ }
3799
+ if (!GH_WORKFLOW_RE.test(workflow)) {
3800
+ err("✘ --workflow is the deploy workflow's path, .github/workflows/<name>.yml");
3801
+ return 2;
3802
+ }
3803
+ const apiUrl = (opts.api ?? process.env.DECIDIO_API_URL ?? DEFAULT_API).replace(/\/$/, "");
3804
+ try {
3805
+ assertSafeTransport(apiUrl, true);
3806
+ }
3807
+ catch (e) {
3808
+ err(`✘ ${String(e?.message ?? e).replace(/^\[decidio\] /, "")}`);
3809
+ return 2;
3810
+ }
3811
+ if (!/^https?:\/\/[A-Za-z0-9.-]+(?::\d+)?$/.test(apiUrl)) {
3812
+ err("✘ --api is the Decidio API's origin, e.g. https://decidio-api.onrender.com");
3813
+ return 2;
3814
+ }
3815
+ let root;
3816
+ try {
3817
+ root = execFileSync("git", ["rev-parse", "--show-toplevel"], { timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
3818
+ }
3819
+ catch {
3820
+ err("✘ run this inside the repository's checkout (git could not find its root).");
3821
+ return 1;
3822
+ }
3823
+ if (!root) {
3824
+ err("✘ run this inside the repository's checkout (git could not find its root).");
3825
+ return 1;
3826
+ }
3827
+ let branch = "main";
3828
+ try {
3829
+ const head = execFileSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd: root, timeout: 3000, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim().replace(/^origin\//, "");
3830
+ if (/^[A-Za-z0-9._\/-]{1,100}$/.test(head) && !head.includes(".."))
3831
+ branch = head;
3832
+ }
3833
+ catch { }
3834
+ const write = (rel, text) => {
3835
+ if (!plainRepoPath(root, rel))
3836
+ return "refused";
3837
+ const abs = joinPath(root, rel);
3838
+ if (existsSync(abs))
3839
+ return "kept";
3840
+ mkdirSync(joinPath(abs, ".."), { recursive: true });
3841
+ writeFileSync(abs, text, { flag: "wx" });
3842
+ return "written";
3843
+ };
3844
+ const said = (rel, w, what) => w === "written" ? log(`✔ wrote ${rel} - ${what}`) : w === "kept" ? log(`· ${rel} already exists - kept as it is`) : err(`✘ ${rel}: a symbolic link is on its path - nothing was written through it`);
3845
+ const reqRel = ".github/workflows/decidio-request.yml";
3846
+ said(reqRel, write(reqRel, githubRequestWorkflow(apiUrl, environment, branch)), `asks for approval on every push to ${branch} (edit the trigger to suit)`);
3847
+ const dep = write(workflow, githubDeployWorkflow(apiUrl, environment));
3848
+ said(workflow, dep, "the deploy Decidio starts once approved (replace ./deploy.sh with your deploy)");
3849
+ if (dep === "kept") {
3850
+ log(` Your deploy workflow must: run on workflow_dispatch with an input named decidio; set permissions id-token: write;`);
3851
+ log(` declare environment: ${environment}; and make these its FIRST steps (they check out the approved commit):`);
3852
+ for (const line of githubVerifySteps(apiUrl).trimEnd().split("\n"))
3853
+ log(` ${line}`);
3854
+ }
3855
+ const agentsRel = "AGENTS.md", agentsAbs = joinPath(root, agentsRel);
3856
+ const appUrl = (process.env.DECIDIO_APP_URL || apiUrl.replace(/^https:\/\/(decidio-api\.onrender\.com|mcp\.decidioai\.com)$/, "https://app.decidioai.com")).replace(/\/$/, "");
3857
+ const section = agentsMdSection(appUrl);
3858
+ if (!plainRepoPath(root, agentsRel))
3859
+ err(`✘ ${agentsRel} is a symbolic link - nothing was written through it`);
3860
+ else if (existsSync(agentsAbs) || opts.agentsMd) {
3861
+ const before = existsSync(agentsAbs) ? readFileSync(agentsAbs, "utf8") : "";
3862
+ const after = withAgentsMdSection(before, section);
3863
+ if (after !== before) {
3864
+ writeFileSync(agentsAbs, after);
3865
+ log(`✔ ${agentsRel}: a link to Decidio for agents (no decision text)`);
3866
+ }
3867
+ else
3868
+ log(`· ${agentsRel} already links to Decidio`);
3869
+ }
3870
+ else
3871
+ log(`· no AGENTS.md here - add --agents-md to create one with a link to Decidio for agents (no decision text)`);
3872
+ log("");
3873
+ log("Next:");
3874
+ log(` 1. In Decidio: Connect → GitHub approvals → bind this repository with ${workflow} and the environment ${environment}.`);
3875
+ log(` 2. In the repository's settings: restrict who may run ${workflow} to the Decidio app (workflow execution protections),`);
3876
+ log(` so a deploy runs only when Decidio starts it.`);
3877
+ log(` 3. Commit the workflows and push: the first push to ${branch} asks a teammate.`);
3878
+ return 0;
3879
+ }
3880
+ const USAGE = `✦ Decidio — the SDK command line
3881
+
3882
+ The wrapper IS the SDK: guard.protect(fn, describe) ships in this package (import { guard } from "@decidio/sdk").
3883
+ This command line is the tour, the sign-in, and the token admin around it.
3884
+
3885
+ New here? See the whole thing work in about two minutes:
3886
+
3887
+ npx @decidio/sdk quickstart
3888
+
3889
+ With no sign-in on this computer it first asks ONE question - do you have an invite? - and, if you do,
3890
+ what next: the tour, or wiring up your own agent. Every answer shows the command it stands for. The bare
3891
+ npx @decidio/sdk on a terminal asks the same (skipping the invite question when you are signed in).
3892
+
3893
+ It gates a demo action, routes it to YOU for approval, executes on your yes, and hands you a
3894
+ cryptographically verifiable receipt of the DECISION (execution evidence then accrues on the
3895
+ hosted record — the receipt seals who authorized what, not what ran afterward). Guided and
3896
+ explicit — this CLI never acts, signs you in, or writes files unless you ask it to.
3897
+
3898
+ THE TOUR
3899
+ npx @decidio/sdk quickstart the guided two-minute tour: gate → approve → execute → proof
3900
+ (interactive: y approves, n rejects - Enter is not an answer. Headless it stops
3901
+ at the routed decision; --auto-approve-demo for CI, labeled
3902
+ automated; ends with a keep-or-cleanup choice; --explain prints
3903
+ the long narration of each step; --phone web makes the QR open
3904
+ the phone web rendering even when the Decidio app is installed)
3905
+ npx @decidio/sdk quickstart cleanup revoke the tour's token + delete its local credentials (the inert
3906
+ registration row and the permanent sealed record stay)
3907
+ (and end the stored sign-in)
3908
+ npx @decidio/sdk quickstart --auto-approve-demo headless tour; cleans up after itself
3909
+ npx @decidio/sdk quickstart --demo the same tour against a LOCAL stand-in of the API — no account needed; the
3910
+ web/phone door is a hosted demo page; the receipt is the one thing kept
3911
+ (--keep-agent is refused here: nothing of the demo's can be kept)
3912
+
3913
+ SIGN IN (once)
3914
+ npx @decidio/sdk login [email] sign in ONCE: the CLI emails you an 8-character code, you type it; the sign-in
3915
+ is kept on this computer 30 days, or until this terminal closes, logout, or cleanup
3916
+ npx @decidio/sdk logout forget the stored sign-in on this computer (the token itself stays valid
3917
+ until it expires — there is no server-side revocation yet)
3918
+
3919
+ YOUR AGENT AND ITS TOKENS
3920
+ npx @decidio/sdk init [agentId] sign in, register the agent, mint its API token, write .env
3921
+ (agentId defaults to this folder's name; any name works - it is how the
3922
+ workspace shows the request and the key .env is written under)
3923
+ --unique append a random suffix (collision-proof samples)
3924
+ --force replace a different agent already in this .env
3925
+ (its .env is backed up first)
3926
+ --expires-days N agent-token lifetime (default 90)
3927
+ npx @decidio/sdk doctor check config, connectivity, and what your token can do
3928
+ npx @decidio/sdk tokens list [--mine] agent tokens with status (active/expired/REVOKED) by
3929
+ jti — token values are never re-shown; the list is
3930
+ workspace-wide, --mine narrows to this directory's agent
3931
+ npx @decidio/sdk tokens revoke <jti> kill a token now instead of waiting out its lifetime
3932
+
3933
+ APPROVALS AND RECEIPTS
3934
+ npx @decidio/sdk approvals [--json] [--mine] list pending agent approvals
3935
+ npx @decidio/sdk approvals approve <id> [reason]
3936
+ npx @decidio/sdk approvals reject <id> [reason]
3937
+ the reason is FREE TEXT, sent exactly as typed. It seals
3938
+ immutably; personal or sensitive values are tokenized at
3939
+ rest, so the STORED text can differ (see: receipt <id>).
3940
+ If it starts with a dash, put it after -- or use --reason:
3941
+ ... approve <id> -- "-50% cut, per the board"
3942
+ ... approve <id> --reason "-50% cut, per the board"
3943
+ npx @decidio/sdk receipt <id> [--json] download a sealed decision's Authority Receipt to a .json FILE; --json prints
3944
+ a summary to stdout (file name, evidence, issuer, the identities you may see) -
3945
+ the credential itself is the file
3946
+ npx @decidio/sdk verify [--issuer <did>] <receipt.json> [more...] verify receipts OFFLINE, pinned to a trust
3947
+ anchor it prints first: --issuer if given, else the DID this computer remembered
3948
+ for your Decidio when you signed in, else the built-in hosted-service issuer
3949
+ (never the receipt's own issuer field; --allow-unpinned: structural check only)
3950
+
3951
+ CODING AGENTS (Claude Code)
3952
+ npx @decidio/sdk hook install [--project] [--approver <email>] [--cli <path>]
3953
+ a person approves Claude Code's destructive shell commands before they run
3954
+ (rm -rf, git push --force, DROP TABLE, terraform apply, kubectl delete,
3955
+ npm publish, curl | sh, ...): writes the PreToolUse/PostToolUse hooks into
3956
+ .claude/settings.local.json (--project: the shared .claude/settings.json;
3957
+ --cli: run that CLI file from the hook instead of this one). --approver
3958
+ mints a single-use invite and prints WhatsApp / SMS / email share links.
3959
+ npx @decidio/sdk hook wait <decision-id> [--timeout 600]
3960
+ wait for the approver's decision the hook named, then retry the command
3961
+ (exit 0 approved · 3 rejected or blocked · 1 still pending)
3962
+ npx @decidio/sdk hook justify <decision-id> "<what changed>"
3963
+ when the hook says a command looks like a duplicate of a request a person already
3964
+ refused: store the user's one-line reason it is different, then retry the same
3965
+ command - the request reaches the approver with that reason beside the refusal
3966
+ npx @decidio/sdk hook rules the command patterns the hook gates (DECIDIO_HOOK_EXTRA_RULES adds yours)
3967
+ npx @decidio/sdk hook pretool|posttool the hooks themselves: run by Claude Code with the tool call as JSON on
3968
+ stdin, never by hand. A gated command is held up to DECIDIO_HOOK_HOLD_MS
3969
+ (90 s; at most 120 s) for the approver, then refused with its decision id; the SAME command
3970
+ retried after approval runs once - its execution is recorded and that
3971
+ approval is spent. No credentials, no gate, no answer = refused (fail closed).
3972
+
3973
+ CONTEXT PACKS (Claude Code, Claude.ai, ChatGPT)
3974
+ npx @decidio/sdk context create <slug> --title "ACME application" --trigger "whenever we discuss the ACME application"
3975
+ a pack: YOUR curated list of sealed decisions and the words that tell a model when
3976
+ to use it. The title and trigger are shared with the model IN CLEAR, every turn
3977
+ (that is how it matches); the decisions inside stay tokenized. --team <unitId>
3978
+ makes it the unit's: readable by its people, its words shared with all of them,
3979
+ holding only decisions the whole unit may read
3980
+ npx @decidio/sdk context add <slug> D·0412 D·0418 add sealed decisions (D·numbers or ids) · remove takes them out
3981
+ npx @decidio/sdk context trigger <slug> "<phrase>" change when it fires · delete <slug> · list [--json]
3982
+ npx @decidio/sdk context always <slug> on|off a FOUNDATION pack (how we work, the architecture): read on every
3983
+ question, whatever the trigger - up to two per workspace, 20 records each, so the
3984
+ other packs can build on it without repeating it. Pulled as a Claude Code rules
3985
+ file (.claude/rules/decidio-<slug>.md, loaded whole at launch); the connector's
3986
+ list_packs inlines it for ChatGPT and Claude.ai.
3987
+ npx @decidio/sdk context pull [--here] [--pack <slug>] [--chatgpt]
3988
+ npx @decidio/sdk context bind --unit <unitId> (this folder's unit, in .decidio.json: --here pulls its packs)
3989
+ write each pack as a Claude Code skill: a team pack to .claude/skills/decidio-<slug>/
3990
+ (git-ignored - each reader pulls their own), a personal one to ~/.claude/skills/.
3991
+ The trigger is the skill's one-line description; the decisions load only when it
3992
+ applies. --chatgpt prints paste-ready blocks instead (a ChatGPT Project's
3993
+ instructions), always-packs first; the connector returns the same lists live.
3994
+
3995
+ DECISIONS FROM A REPOSITORY
3996
+ npx @decidio/sdk decisions import --adr <folder> [--dry-run]
3997
+ each ACCEPTED architecture decision record (MADR or Nygard markdown) becomes a DRAFT
3998
+ decision you review and seal in Decidio - never sealed here; proposed, rejected,
3999
+ superseded and deprecated ones are listed, not imported; a re-run finds earlier
4000
+ imports (keyed by repository and path); --dry-run sends nothing
4001
+
4002
+ GITHUB DEPLOY APPROVALS (the Decidio GitHub App)
4003
+ npx @decidio/sdk github init [--environment production] [--workflow .github/workflows/deploy.yml] [--api <origin>] [--agents-md]
4004
+ in a repository's checkout: writes the workflow that asks for approval
4005
+ (.github/workflows/decidio-request.yml) and the deploy workflow when there is none
4006
+ (an existing one is never changed: init prints the steps to add), both using
4007
+ Decidio's GitHub Action pinned to its v1.0.0 commit; adds a link to Decidio for agents to AGENTS.md (--agents-md creates it) -
4008
+ never a decision's text
4009
+ npx @decidio/sdk github request --environment <name> [--justification "<what changed>"]
4010
+ inside a GitHub Actions job (permissions id-token: write): ask a teammate to approve
4011
+ deploying this commit; the job ends at once - Decidio starts the deploy when approved
4012
+ npx @decidio/sdk github verify [--decision <id>] the deploy job's first step: proves the approval once for this run,
4013
+ commit and environment, and writes sha= to $GITHUB_OUTPUT; anything else exits 1 -
4014
+ do not deploy (DECIDIO_DECISION carries the decidio input)
4015
+
4016
+ DEVELOPMENT
4017
+ npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
4018
+ npx @decidio/sdk --version print the CLI version
4019
+
4020
+ NOTES
4021
+ Every NETWORK command talks to the hosted sandbox (https://decidio-api.onrender.com) unless
4022
+ DECIDIO_API_URL says otherwise; the target host is announced on stderr each run. "verify" is
4023
+ fully offline — it touches no network and reads no .env (only the issuer DIDs remembered at sign-in).
4024
+ Update check: network commands (and the bare command on a terminal) ask the public npm registry once a day
4025
+ whether a newer version exists. If so,
4026
+ an interactive run asks "Upgrade now? [y/n]" first (y upgrades the way you installed - global, or in the
4027
+ project for a local install; under npx it prints the @latest form - and re-runs your command; n exits); a headless
4028
+ run prints the notice and continues (DECIDIO_REQUIRE_LATEST=1 makes it exit). DECIDIO_NO_UPDATE_CHECK=1 skips
4029
+ the check. Nothing is sent to Decidio; verify never checks.
4030
+ Help: --help (or -h) after any command prints this page - one page for every command.
4031
+ Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
4032
+ 3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
4033
+ receipt written — pass --auto-approve-demo for a run that completes unattended), or
4034
+ hook wait: the approver said no. hook pretool/posttool always exit 0 (their answer is the JSON).
4035
+ Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
4036
+ pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
4037
+ Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
4038
+ (quickstart, durable resume, workers, adapters).`;
4039
+ const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens", "context"]);
4040
+ function semverNewer(a, b) {
4041
+ const parse = (s) => (/^\d+\.\d+\.\d+$/.test(s) ? s.split(".").map((x) => parseInt(x, 10)) : null);
4042
+ const pa = parse(a), pb = parse(b);
4043
+ if (!pa || !pb)
4044
+ return false;
4045
+ for (let i = 0; i < 3; i++)
4046
+ if (pa[i] !== pb[i])
4047
+ return pa[i] > pb[i];
4048
+ return false;
4049
+ }
4050
+ async function latestPublished() {
4051
+ const cache = joinPath(sessionDir(), "update-check.json");
4052
+ try {
4053
+ const c = JSON.parse(readFileSync(cache, "utf8"));
4054
+ if (c && typeof c.latest === "string" && /^\d+\.\d+\.\d+$/.test(c.latest) && Date.now() - Number(c.checkedAt) < 24 * 3600 * 1000)
4055
+ return c.latest;
4056
+ }
4057
+ catch { }
4058
+ const reg = (SHELL_ENV.DECIDIO_UPDATE_REGISTRY ?? "https://registry.npmjs.org").replace(/\/+$/, "");
4059
+ try {
4060
+ const ctl = new AbortController();
4061
+ const t = setTimeout(() => ctl.abort(), 2000);
4062
+ const r = await fetch(`${reg}/@decidio%2fsdk/latest`, { signal: ctl.signal });
4063
+ clearTimeout(t);
4064
+ if (!r.ok)
4065
+ return null;
4066
+ const body = await r.json();
4067
+ const latest = typeof body?.version === "string" && /^\d+\.\d+\.\d+$/.test(body.version) ? body.version : null;
4068
+ if (latest) {
4069
+ try {
4070
+ mkdirSync(sessionDir(), { recursive: true });
4071
+ writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), latest }));
4072
+ }
4073
+ catch { }
4074
+ }
4075
+ return latest;
4076
+ }
4077
+ catch {
4078
+ return null;
4079
+ }
4080
+ }
4081
+ async function updateGate(cmd, argv) {
4082
+ const gatedHere = cmd ? GATED_CMDS.has(cmd) : !!(process.stdin.isTTY && process.stdout.isTTY);
4083
+ if (!gatedHere || SHELL_ENV.DECIDIO_NO_UPDATE_CHECK || SHELL_ENV.DECIDIO_UPDATE_REEXEC || VERSION === "unknown" || VERSION.includes("-"))
4084
+ return null;
4085
+ const latest = await latestPublished();
4086
+ if (!latest || !semverNewer(latest, VERSION))
4087
+ return null;
4088
+ const entry = process.argv[1] ?? "";
4089
+ const underNpx = /[\\/]_npx[\\/]/.test(entry);
4090
+ const localRoot = (() => {
4091
+ const m = /^(.*?)[\\/]node_modules[\\/]@decidio[\\/]sdk[\\/]/.exec(entry);
4092
+ if (!m)
4093
+ return null;
4094
+ const norm = (s) => (process.platform === "win32" ? s.toLowerCase() : s);
4095
+ const root = resolvePath(m[1]);
4096
+ const cwd = norm(process.cwd());
4097
+ return cwd === norm(root) || cwd.startsWith(norm(root) + pathSep) ? root : null;
4098
+ })();
4099
+ const viaAlias = /[\\/]decidio$/.test(argv[1] ?? "");
4100
+ const pkgName = viaAlias ? "decidio" : "@decidio/sdk";
4101
+ const upgradeCmd = underNpx ? `npx ${pkgName}@latest ${argv.slice(2).join(" ")}` : localRoot ? `npm i ${pkgName}@latest` : `npm i -g ${pkgName}@latest`;
4102
+ const notice = `${latest} is available (you have ${VERSION}).`;
4103
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
4104
+ err(`⚠ ${notice} Upgrade: ${upgradeCmd}`);
4105
+ if (SHELL_ENV.DECIDIO_REQUIRE_LATEST) {
4106
+ err(`✘ DECIDIO_REQUIRE_LATEST is set — not running a stale version.`);
4107
+ return 1;
4108
+ }
4109
+ return null;
4110
+ }
4111
+ const a = (await ask(`${notice} Upgrade now? [y/n]: `)).toLowerCase();
4112
+ if (a !== "y" && a !== "yes") {
4113
+ err(`✘ not upgraded — run this when ready: ${upgradeCmd}`);
4114
+ return 1;
4115
+ }
4116
+ if (underNpx) {
2303
4117
  err(` Under npx there is nothing installed to upgrade — re-run as: ${upgradeCmd}`);
2304
4118
  return 1;
2305
4119
  }
@@ -2362,6 +4176,9 @@ async function main(argv) {
2362
4176
  verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
2363
4177
  tokens: { flags: ["--mine"] },
2364
4178
  dev: { flags: [], valued: ["--target", "--port"] },
4179
+ hook: { flags: ["--project"], valued: ["--timeout", "--approver", "--cli"] },
4180
+ github: { flags: ["--agents-md"], valued: ["--environment", "--justification", "--decision", "--workflow", "--api"] },
4181
+ decisions: { flags: ["--dry-run"], valued: ["--adr"] },
2365
4182
  };
2366
4183
  const spec = KNOWN[cmd];
2367
4184
  if (spec) {
@@ -2395,8 +4212,8 @@ async function main(argv) {
2395
4212
  return 2;
2396
4213
  }
2397
4214
  }
2398
- const MAX_POS = { login: 1, logout: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
2399
- const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || cmd === "verify";
4215
+ const MAX_POS = { login: 1, logout: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0, hook: 2, github: 1, decisions: 1 };
4216
+ const exemptTail = (cmd === "approvals" && (positionals[0] === "approve" || positionals[0] === "reject")) || (cmd === "hook" && positionals[0] === "justify") || cmd === "verify";
2400
4217
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
2401
4218
  err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
2402
4219
  return 2;
@@ -2537,6 +4354,83 @@ async function main(argv) {
2537
4354
  }
2538
4355
  return cmdDev(target, Number(portRaw));
2539
4356
  }
4357
+ case "context": {
4358
+ const VALUED = ["--title", "--trigger", "--team", "--unit", "--pack"];
4359
+ const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
4360
+ const sub = pos[0];
4361
+ const allowed = { list: ["--json"], create: ["--title", "--trigger", "--team", "--unit", "--org", "--json"], add: ["--json"], remove: ["--json"], trigger: ["--json"], always: ["--json"], delete: [], bind: ["--unit"], pull: ["--pack", "--here", "--chatgpt", "--json"] };
4362
+ const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
4363
+ if (sub && allowed[sub]) {
4364
+ const stray = given.find((f) => !allowed[sub].includes(f));
4365
+ if (stray) {
4366
+ err(`✘ unknown option "${stray}" for \`context ${sub}\` — refusing to guess (a flag you meant to matter must never be silently ignored).`);
4367
+ return 2;
4368
+ }
4369
+ }
4370
+ return cmdContext(sub, pos.slice(1), { title: valuedOpt(argsIn, "--title"), trigger: valuedOpt(argsIn, "--trigger"), team: valuedOpt(argsIn, "--team"), unit: valuedOpt(argsIn, "--unit"), org: argsIn.includes("--org"), here: argsIn.includes("--here"), pack: valuedOpt(argsIn, "--pack"), chatgpt: argsIn.includes("--chatgpt"), json: argsIn.includes("--json") });
4371
+ }
4372
+ case "hook": {
4373
+ const VALUED = ["--timeout", "--approver", "--cli"];
4374
+ const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
4375
+ const sub = pos[0];
4376
+ const allowed = { pretool: [], posttool: [], wait: ["--timeout"], install: ["--project", "--approver", "--cli"], rules: [], justify: [] };
4377
+ const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
4378
+ if (sub && allowed[sub]) {
4379
+ const stray = given.find((f) => !allowed[sub].includes(f));
4380
+ if (stray) {
4381
+ err(`✘ unknown option "${stray}" for \`hook ${sub}\` — refusing to guess (a flag you meant to matter must never be silently ignored).`);
4382
+ return 2;
4383
+ }
4384
+ }
4385
+ switch (sub) {
4386
+ case "pretool": return cmdHookPretool();
4387
+ case "posttool": return cmdHookPosttool();
4388
+ case "wait": {
4389
+ const t = valuedOpt(argsIn, "--timeout") ?? "600";
4390
+ if (!/^\d+$/.test(t) || Number(t) < 1 || Number(t) > 86_400) {
4391
+ err(`✘ --timeout needs a whole number of seconds from 1 to 86400 (got "${t}").`);
4392
+ return 2;
4393
+ }
4394
+ return cmdHookWait(pos[1], Number(t));
4395
+ }
4396
+ case "install": return cmdHookInstall({ project: argsIn.includes("--project"), approver: valuedOpt(argsIn, "--approver"), cli: valuedOpt(argsIn, "--cli") });
4397
+ case "rules": return cmdHookRules();
4398
+ case "justify": return cmdHookJustify(pos[1], pos.slice(2).join(" "));
4399
+ default:
4400
+ err(`✘ hook needs a subcommand: install | wait <decision-id> | justify <decision-id> "<what changed>" | rules (pretool and posttool are run by Claude Code).\n\n${USAGE}`);
4401
+ return 2;
4402
+ }
4403
+ }
4404
+ case "decisions": {
4405
+ const sub = argsIn.find((x, i) => !x.startsWith("-") && argsIn[i - 1] !== "--adr");
4406
+ if (sub !== "import") {
4407
+ err(`✘ decisions needs a subcommand: import --adr <folder> [--dry-run].\n\n${USAGE}`);
4408
+ return 2;
4409
+ }
4410
+ return cmdDecisionsImport(valuedOpt(argsIn, "--adr"), argsIn.includes("--dry-run"));
4411
+ }
4412
+ case "github": {
4413
+ const VALUED = ["--environment", "--justification", "--decision", "--workflow", "--api"];
4414
+ const pos = argsIn.filter((x, i) => !x.startsWith("-") && !VALUED.includes(argsIn[i - 1] ?? ""));
4415
+ const sub = pos[0];
4416
+ const allowed = { request: ["--environment", "--justification"], verify: ["--decision"], init: ["--environment", "--workflow", "--api", "--agents-md"] };
4417
+ const given = argsIn.filter((x) => x.startsWith("--")).map((x) => x.split("=")[0]);
4418
+ if (sub && allowed[sub]) {
4419
+ const stray = given.find((f) => !allowed[sub].includes(f));
4420
+ if (stray) {
4421
+ err(`✘ unknown option "${stray}" for \`github ${sub}\` — refusing to guess (a flag you meant to matter must never be silently ignored).`);
4422
+ return 2;
4423
+ }
4424
+ }
4425
+ switch (sub) {
4426
+ case "request": return cmdGithubRequest(valuedOpt(argsIn, "--environment"), valuedOpt(argsIn, "--justification"));
4427
+ case "verify": return cmdGithubVerify(valuedOpt(argsIn, "--decision"));
4428
+ case "init": return cmdGithubInit({ environment: valuedOpt(argsIn, "--environment"), workflow: valuedOpt(argsIn, "--workflow"), api: valuedOpt(argsIn, "--api"), agentsMd: argsIn.includes("--agents-md") });
4429
+ default:
4430
+ err(`✘ github needs a subcommand: init | request --environment <name> | verify.\n\n${USAGE}`);
4431
+ return 2;
4432
+ }
4433
+ }
2540
4434
  default:
2541
4435
  if (cmd.startsWith("-"))
2542
4436
  err(`✘ unknown command "${cmd}" — flags go AFTER the command (e.g. npx @decidio/sdk approvals ${cmd}).\n\n${USAGE}`);
@@ -2568,4 +4462,11 @@ if (invokedAsProgram)
2568
4462
  await new Promise((r) => process.stderr.write("", () => r()));
2569
4463
  process.exit(code);
2570
4464
  }
2571
- }).catch((e) => { console.error(`✘ unexpected error: ${e?.message ?? e}`); process.exit(1); });
4465
+ }).catch((e) => {
4466
+ if (ARGV_TAIL[0] === "hook" && ARGV_TAIL[1] === "pretool") {
4467
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Decidio hook: unexpected failure (${e?.message ?? e}) - refusing this tool call (fail closed). Nothing ran.` } }) + "\n");
4468
+ process.exit(0);
4469
+ }
4470
+ console.error(`✘ unexpected error: ${e?.message ?? e}`);
4471
+ process.exit(1);
4472
+ });