@decidio/sdk 0.7.0 → 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,8 +1,8 @@
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
7
  import { isIP } from "node:net";
8
8
  import { execFileSync, spawnSync } from "node:child_process";
@@ -12,7 +12,8 @@ import { realpathSync as realPath } from "node:fs";
12
12
  import { createRequire } from "node:module";
13
13
  import { generateKeypair, assertSafeTransport, redactUrl } from "./signing.js";
14
14
  import { hardenFile } from "./resume.js";
15
- import { withApproval, DecidioRateLimitError, DecidioRejectedError, DecidioTimeoutError, didFromPrivateKey } from "./index.js";
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";
16
17
  import { verifyReceipt, runVerify, pinRequiredMessage, builtInAnchor, KNOWN_ISSUERS } from "@decidio/verify";
17
18
  import { startDemoGate } from "./demo-gate.js";
18
19
  const VERSION = (() => {
@@ -51,6 +52,10 @@ const OFFLINE_CMD = ["verify", "--version", "-v", "-V", "version", undefined].in
51
52
  || wantsHelp(ARGV_TAIL[0]) || wantsHelp(ARGV_TAIL[1]) || helpFlagAnywhere(ARGV_TAIL);
52
53
  if (!OFFLINE_CMD) {
53
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
+ }
54
59
  if (envUnreadable) {
55
60
  console.error(`✘ .env exists here but could not be read, so this command cannot know what is already configured in this directory.`);
56
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.`);
@@ -913,7 +918,9 @@ async function cmdInit(agentId, opts = {}) {
913
918
  return 1;
914
919
  }
915
920
  const { did: localDid, privateKeyBase64: localKey } = generateKeypair();
916
- 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 } : {}) });
917
924
  let envPrepared = false;
918
925
  const prepareEnvForWrite = () => {
919
926
  if (envPrepared)
@@ -959,6 +966,12 @@ async function cmdInit(agentId, opts = {}) {
959
966
  prepareEnvForWrite();
960
967
  upsertEnv({ DECIDIO_AGENT_ID: agentId, DECIDIO_AGENT_DID: did ?? localDid, DECIDIO_AGENT_KEY: localKey });
961
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
+ }
962
975
  else {
963
976
  err(`✘ registration failed (${r.status}): ${JSON.stringify(r.body)}`);
964
977
  err(` Fix the cause above and re-run — init is safe to repeat.`);
@@ -2344,6 +2357,1526 @@ function cmdDev(target, port) {
2344
2357
  }).listen(port, "127.0.0.1");
2345
2358
  return 0;
2346
2359
  }
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
+ });
2376
+ }
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;
2453
+ try {
2454
+ parsed = JSON.parse(readFileSync(file, "utf8"));
2455
+ }
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 {
2502
+ return null;
2503
+ }
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 };
2569
+ }
2570
+ catch {
2571
+ return { ok: false, why: "the hook input is not JSON" };
2572
+ }
2573
+ }
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 });
2606
+ return null;
2607
+ }
2608
+ const j = JSON.parse(readFileSync(p, "utf8"));
2609
+ return typeof j?.justification === "string" && j.justification.trim() ? j.justification : null;
2610
+ }
2611
+ catch {
2612
+ return null;
2613
+ }
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
+ }
2347
3880
  const USAGE = `✦ Decidio — the SDK command line
2348
3881
 
2349
3882
  The wrapper IS the SDK: guard.protect(fn, describe) ships in this package (import { guard } from "@decidio/sdk").
@@ -2415,6 +3948,71 @@ APPROVALS AND RECEIPTS
2415
3948
  for your Decidio when you signed in, else the built-in hosted-service issuer
2416
3949
  (never the receipt's own issuer field; --allow-unpinned: structural check only)
2417
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
+
2418
4016
  DEVELOPMENT
2419
4017
  npx @decidio/sdk dev [--target URL] [--port N] local relay for the resume webhook
2420
4018
  npx @decidio/sdk --version print the CLI version
@@ -2432,12 +4030,13 @@ NOTES
2432
4030
  Help: --help (or -h) after any command prints this page - one page for every command.
2433
4031
  Exit codes: 0 success · 1 failure or honest no-op (e.g. replay refused) · 2 usage error ·
2434
4032
  3 quickstart stopped at the human moment (headless: routed, but nothing executed and no
2435
- receipt written — pass --auto-approve-demo for a run that completes unattended).
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).
2436
4035
  Errors, and usage shown on ERROR paths, go to stderr; stdout carries results only (safe to
2437
4036
  pipe). Asked-for help — including this bare invocation — IS a result, so it prints here.
2438
4037
  Docs: the README that shipped with this package — node_modules/@decidio/sdk/README.md
2439
4038
  (quickstart, durable resume, workers, adapters).`;
2440
- const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens"]);
4039
+ const GATED_CMDS = new Set(["quickstart", "init", "login", "doctor", "approvals", "receipt", "tokens", "context"]);
2441
4040
  function semverNewer(a, b) {
2442
4041
  const parse = (s) => (/^\d+\.\d+\.\d+$/.test(s) ? s.split(".").map((x) => parseInt(x, 10)) : null);
2443
4042
  const pa = parse(a), pb = parse(b);
@@ -2577,6 +4176,9 @@ async function main(argv) {
2577
4176
  verify: { flags: ["--allow-unpinned"], valued: ["--issuer"] },
2578
4177
  tokens: { flags: ["--mine"] },
2579
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"] },
2580
4182
  };
2581
4183
  const spec = KNOWN[cmd];
2582
4184
  if (spec) {
@@ -2610,8 +4212,8 @@ async function main(argv) {
2610
4212
  return 2;
2611
4213
  }
2612
4214
  }
2613
- const MAX_POS = { login: 1, logout: 0, quickstart: 1, init: 1, doctor: 0, receipt: 1, tokens: 2, dev: 0 };
2614
- 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";
2615
4217
  if (!exemptTail && cmd === "approvals" && positionals.length > 0) {
2616
4218
  err(`✘ unknown approvals subcommand "${positionals[0]}" — use approve or reject (or no subcommand to list).`);
2617
4219
  return 2;
@@ -2752,6 +4354,83 @@ async function main(argv) {
2752
4354
  }
2753
4355
  return cmdDev(target, Number(portRaw));
2754
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
+ }
2755
4434
  default:
2756
4435
  if (cmd.startsWith("-"))
2757
4436
  err(`✘ unknown command "${cmd}" — flags go AFTER the command (e.g. npx @decidio/sdk approvals ${cmd}).\n\n${USAGE}`);
@@ -2783,4 +4462,11 @@ if (invokedAsProgram)
2783
4462
  await new Promise((r) => process.stderr.write("", () => r()));
2784
4463
  process.exit(code);
2785
4464
  }
2786
- }).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
+ });