karajan-code 4.38.0 → 4.40.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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/packages/ai-trash/src/destructive-parser.js +34 -5
  3. package/packages/ai-trash/src/hook.js +28 -6
  4. package/src/audit/ai-slop-findings.js +4 -2
  5. package/src/audit/circular-deps.js +8 -7
  6. package/src/audit/webperf-input.js +3 -1
  7. package/src/checks/method.js +1 -1
  8. package/src/checks/release-check.js +3 -1
  9. package/src/cli/advanced-commands.js +1 -1
  10. package/src/cli/register-meta.js +112 -1
  11. package/src/cli/register-pipeline.js +2 -1
  12. package/src/commands/init.js +6 -2
  13. package/src/commands/review-gate.js +21 -22
  14. package/src/commands/rules-approve.js +59 -0
  15. package/src/commands/rules-compile.js +116 -0
  16. package/src/commands/rules-decide.js +51 -0
  17. package/src/commands/rules-review.js +44 -0
  18. package/src/commands/rules.js +169 -0
  19. package/src/config/loader.js +23 -1
  20. package/src/environment/adr.js +5 -2
  21. package/src/harden/config-templates.js +3 -0
  22. package/src/harden/harness-hooks.js +7 -85
  23. package/src/harden/hook-templates.js +6 -5
  24. package/src/harden/human-act.js +70 -0
  25. package/src/harden/phone-sign.js +26 -0
  26. package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
  27. package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
  28. package/src/harden/sentinel/sentinel-discard.mjs +33 -4
  29. package/src/harden/sentinel/sentinel-rules.mjs +86 -0
  30. package/src/harden/sentinel/sentinel-shell.mjs +34 -2
  31. package/src/harden/sentinel/sessionstart.mjs +18 -9
  32. package/src/harden/sentinel-hooks.js +111 -194
  33. package/src/harden/supervisor-commit.js +13 -56
  34. package/src/mcp/handlers/run-handler.js +5 -0
  35. package/src/mcp/sovereignty-guard.js +16 -15
  36. package/src/orchestrator/preflight-checks.js +4 -3
  37. package/src/policy/supervisor-verify.js +20 -6
  38. package/src/privacy/scan.js +7 -0
  39. package/src/review/card-first.js +3 -5
  40. package/src/review/gate-gitignore.js +8 -0
  41. package/src/review/policy-gate.js +4 -6
  42. package/src/review/sonar-pregate.js +3 -3
  43. package/src/review/tests-with-code.js +3 -6
  44. package/src/rules/approval-view.js +49 -0
  45. package/src/rules/compiled.js +108 -0
  46. package/src/rules/coverage.js +23 -0
  47. package/src/rules/evaluate.js +60 -0
  48. package/src/rules/inventory.js +118 -0
  49. package/src/sonar/api.js +21 -0
  50. package/src/sonar/config-resolver.js +19 -1
  51. package/src/sonar/scanner.js +22 -3
  52. package/src/utils/run-log.js +74 -2
  53. package/src/utils/stack-detect.js +12 -0
@@ -28,8 +28,7 @@ export function resolveSentinelRoot(dir = process.cwd()) {
28
28
 
29
29
  const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
30
30
  // Single source for every sentinel script: state, branch, classification,
31
- // violations, and escape recording.
32
- import console from "node:console";
31
+ // and violations.
33
32
  import { readFileSync, writeFileSync } from "node:fs";
34
33
  import { execSync, spawnSync } from "node:child_process";
35
34
  import { dirname, join } from "node:path";
@@ -81,11 +80,34 @@ export const bootstrapPhase = () => {
81
80
  try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
82
81
  catch { return true; }
83
82
  };
84
- export const violations = (s, branch) => {
85
- const v = pendingMoves(s).map(pendingText);
83
+ // KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
84
+ // open. kj decides with that checkable fact, the same answer for commit and push
85
+ // (the Stop gate still holds the turn: the board owes the move); when kj does not
86
+ // answer, every pending move blocks.
87
+ export const boardGate = (sid, pend) => {
88
+ if (!pend.length) return { blocking: [], carried: [] };
89
+ const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
90
+ if (!bg.error && (bg.status === 0 || bg.status === 2)) {
91
+ try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [] }; } catch { /* unreadable: as always */ }
92
+ }
93
+ return { blocking: pend, carried: [] };
94
+ };
95
+ // KJC-BUG-0265: on the base branch what counts is work still there (the session's
96
+ // files dirty, or local commits the remote does not have), not the ledger: after a
97
+ // merged PR and a sync of main the ledger stays full while the base holds nothing.
98
+ // Anything git cannot answer (no upstream, odd path) counts as work: fail closed.
99
+ const baseHoldsWork = (files) => {
100
+ if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
101
+ try {
102
+ if (execSync("git status --porcelain -- " + files.join(" "), { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim()) return true;
103
+ return execSync("git rev-list @{u}..HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== "";
104
+ } catch { return true; }
105
+ };
106
+ export const violations = (s, branch, pending = pendingMoves(s)) => {
107
+ const v = pending.map(pendingText);
86
108
  if (!s || !(s.edited_sources || []).length) return v;
87
109
  if (bootstrapPhase()) return v;
88
- if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
110
+ if (BASE_BRANCHES.has(branch)) { if (baseHoldsWork(s.edited_sources)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion"); }
89
111
  else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
90
112
  if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
91
113
  return v;
@@ -105,26 +127,6 @@ const sessionAddsCode = (s) => {
105
127
  return a === "-" || Number(a) > 0;
106
128
  });
107
129
  };
108
- export const recordEscape = (sid, escape, tool) => {
109
- const state = load();
110
- const s = session(state, sid);
111
- s.at = Date.now();
112
- if (!s.escapes.includes(escape)) s.escapes.push(escape);
113
- (state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
114
- save(state);
115
- // GOV-F (KJC-TSK-0768): el escape es una excepcion consciente — entra en el
116
- // decision log hash-encadenado (kj policy seal), no solo en este estado.
117
- // Best-effort CON aviso: fail-closed aqui encerraria la sesion justo cuando
118
- // kj no carga, que es cuando mas se usa el escape (restaurar con git).
119
- try {
120
- const args = ["policy", "seal", "--escape", escape];
121
- if (tool) args.push("--tool", String(tool));
122
- const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
123
- if (r.error || r.status !== 0) console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log (kj policy seal " + (r.error ? r.error.message : "exit " + r.status) + ") — revisa kj; el escape queda solo en sentinel-state.json" + doc("escapes"));
124
- } catch (e) {
125
- console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message + doc("escapes"));
126
- }
127
- };
128
130
  `;
129
131
 
130
132
  const POST_BODY = `#!/usr/bin/env node
@@ -137,7 +139,6 @@ import { relative } from "node:path";
137
139
  import { spawnSync } from "node:child_process";
138
140
  import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
139
141
  import { remindersFor } from "./sentinel-reminders.mjs";
140
- const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG", "KJ_ALLOW_NO_VERIFY"];
141
142
  let raw = "";
142
143
  process.stdin.on("data", (d) => { raw += d; });
143
144
  process.stdin.on("end", () => {
@@ -293,11 +294,6 @@ process.stdin.on("end", () => {
293
294
  const rel = relative(ROOT, file).replaceAll("\\\\", "/");
294
295
  const bucket = TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
295
296
  if (bucket && !bucket.includes(rel)) bucket.push(rel);
296
- for (const e of ESCAPES)
297
- if (process.env[e] === "1" && !s.escapes.includes(e)) {
298
- s.escapes.push(e);
299
- (state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
300
- }
301
297
  // KJC-TSK-0910: the branch size while it is written, with the CI budget
302
298
  // (kj pr-size), said once per threshold crossed. Context, never a block.
303
299
  let sizeNote = null;
@@ -417,7 +413,6 @@ process.stdin.on("end", () => {
417
413
  s.blocks = 0;
418
414
  const notes = [];
419
415
  if (claimsNote) notes.push(claimsNote);
420
- if ((s.escapes || []).length) notes.push("karajan sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
421
416
  if ((s.closed_cards || []).length) {
422
417
  notes.push("karajan sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
423
418
  s.closed_cards = [];
@@ -454,10 +449,10 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
454
449
  import { spawnSync } from "node:child_process";
455
450
  import { homedir } from "node:os";
456
451
  import { fileURLToPath } from "node:url";
457
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
452
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
458
453
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
459
- import { shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
460
- import { DISCARD_VERBS, discardOf, foreignLost } from "./sentinel-discard.mjs";
454
+ import { optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
455
+ import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
461
456
  import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
462
457
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
463
458
  // KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
@@ -481,6 +476,21 @@ process.stdin.on("end", () => {
481
476
  process.on("exit", (code) => {
482
477
  if (code === 2) console.error("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
483
478
  });
479
+ // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
480
+ // sesion toca un fichero se anota si estaba limpio: solo entonces un
481
+ // descarte posterior pierde nada mas que lo de la sesion. Antes de
482
+ // cualquier escape; git que falla cuenta como sucio (fail-closed).
483
+ const noteFirstTouch = (relT) => {
484
+ const st = load();
485
+ const touch = (session(st, sid).first_touch ||= {});
486
+ // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
487
+ if (Object.hasOwn(touch, relT)) return;
488
+ // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
489
+ const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
490
+ const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
491
+ Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
492
+ save(st);
493
+ };
484
494
  // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
485
495
  // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
486
496
  // only the human, editing outside it.
@@ -493,27 +503,16 @@ process.stdin.on("end", () => {
493
503
  }
494
504
  // KJC-TSK-0920 (SNT-E): policies, gate settings and exclusions are the human's,
495
505
  // like the supervisor: a session does not loosen the rules that govern it.
496
- if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
506
+ // .karajan/rules.yml (KJC-TSK-0947, ADR 0016): the compiled rules are the user's to approve.
507
+ // .karajan/rules.local.yml (KJC-TSK-0954, ADR 0017): the same rules, the ones from private sources.
508
+ if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".karajan/rules.yml", ".karajan/rules.local.yml", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
497
509
  console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
498
510
  process.exit(2);
499
511
  }
500
- // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
501
- // sesion toca un fichero se anota si estaba limpio: solo entonces un
502
- // descarte posterior pierde nada mas que lo de la sesion. Antes de
503
- // cualquier escape; git que falla cuenta como sucio (fail-closed).
504
- if (relT && !relT.startsWith("..") && !relT.startsWith("/")) {
505
- const st = load();
506
- const touch = (session(st, sid).first_touch ||= {});
507
- // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
508
- if (!Object.hasOwn(touch, relT)) {
509
- // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
510
- const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
511
- const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
512
- Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
513
- save(st);
514
- }
515
- }
512
+ if (relT && !relT.startsWith("..") && !relT.startsWith("/")) noteFirstTouch(relT);
516
513
  }
514
+ // KJC-BUG-0261: rm / git rm is the session touching those files, noted before they go.
515
+ if (tool === "Bash") for (const words of shellSegments(String(input.command || ""))) for (const f of removedFiles(words, ROOT)) noteFirstTouch(f);
517
516
  // Any Bash that NAMES the supervisor's files is denied — a write-verb
518
517
  // blocklist is bypassable (cp, dd, one-liners), and reading them is what
519
518
  // the Read/Grep tools are for. Shell indirection (variables, globs,
@@ -536,8 +535,8 @@ process.stdin.on("end", () => {
536
535
  if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghCmd) && /(^|[^a-z])(pr|issue|release)([^a-z]|$)/.test(ghCmd) && /(^|[^a-z])(create|edit|comment|review)([^a-z]|$)/.test(ghCmd)) {
537
536
  const attrib = /co-authored-by:.{0,120}(claude|gpt|copilot|gemini)|(generated|written|created) (by|with).{0,120}(claude|gpt|copilot|gemini|codex)|generated with [[]?claude|🤖/is;
538
537
  let corpus = ghCmd;
539
- for (const m of ghCmd.matchAll(/--(?:body-file|notes-file|comment-file)[= ]+("([^"]+)"|'([^']+)'|([^ ]+))/g)) {
540
- const bodyPath = m[2] || m[3] || m[4];
538
+ // KJC-BUG-0245: the shell reader ends the path at ; && | (a regex took "pr.md;").
539
+ for (const bodyPath of optionValues(ghCmd, ["--body-file", "--notes-file", "--comment-file"])) {
541
540
  try { corpus += "\\n" + readFileSync(bodyPath, "utf8"); } catch {
542
541
  console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
543
542
  process.exit(2);
@@ -598,68 +597,11 @@ process.stdin.on("end", () => {
598
597
  }
599
598
  }
600
599
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
601
- // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
602
- // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
603
- // prefijo jamas llegaba a process.env y el escape era inalcanzable
604
- // (deadlock real del gate de release: la landing solo se pone verde
605
- // tras el deploy y el deploy estaba bloqueado por el mismo gate). Un
606
- // escape es una excepcion CONSCIENTE y auditada, no un boundary de
607
- // seguridad (security sigue sin escape y la autoprotecion corre antes),
608
- // asi que el TEXTO del comando es fuente valida: solo asignaciones
609
- // LITERALES al inicio (tokens VAR=VAL antes del primer verbo) y solo
610
- // sobre un comando SIMPLE — con ;|& $() backtick o salto de linea el
611
- // prefijo shell no alcanzaria a los comandos posteriores (catch de
612
- // codex: "X=1 true && npm publish" escaparia sin serlo), asi que el
613
- // escape por texto se rechaza y queda la via env de siempre.
600
+ // ADR 0015 (KJC-TSK-0927): no gate has an escape any more, so the command
601
+ // text is read only by the identity lock below.
614
602
  const CMD_TEXT = tool === "Bash" ? String(input.command || "") : "";
615
603
  const ESC_TAB = String.fromCharCode(9);
616
604
  const ESC_NL = String.fromCharCode(10);
617
- const ESC_BT = String.fromCharCode(96);
618
- const escOn = (name) => {
619
- if (process.env[name] === "1") return true;
620
- let found = false;
621
- let i = 0;
622
- while (i < CMD_TEXT.length) {
623
- while (i < CMD_TEXT.length && (CMD_TEXT[i] === " " || CMD_TEXT[i] === ESC_TAB)) i += 1;
624
- const start = i;
625
- while (i < CMD_TEXT.length && CMD_TEXT[i] !== " " && CMD_TEXT[i] !== ESC_TAB && CMD_TEXT[i] !== ESC_NL) i += 1;
626
- const tok = CMD_TEXT.slice(start, i);
627
- const eq = tok.indexOf("=");
628
- if (eq <= 0 || !/^[A-Z][A-Z0-9_]*$/.test(tok.slice(0, eq))) break;
629
- if (tok === name + "=1") found = true;
630
- }
631
- if (!found) return false;
632
- // KJC-BUG-0147: lo que va ENTRE COMILLAS no encadena comandos — un
633
- // mensaje "fix(x): ... (KJC-TSK-1)" es un comando simple. Dentro de
634
- // comillas dobles $ y backtick siguen expandiendo (se rechazan); en
635
- // comillas simples todo es literal. Y si el escape esta pero se ignora,
636
- // SE DICE: un escape ignorado en silencio era el bug.
637
- const bad = escSimple();
638
- if (bad === null) return true;
639
- if (!escNoted.has(name)) {
640
- escNoted.add(name);
641
- console.error("karajan sentinel: " + name + "=1 presente pero IGNORADO — el escape solo vale en un comando simple y este contiene " + JSON.stringify(bad) + " fuera de comillas simples (sin ; | & $ ( ) backtick ni salto de linea: parte el comando o usa -F fichero)" + doc("escapes"));
642
- }
643
- return false;
644
- };
645
- const escNoted = new Set();
646
- const escSimple = () => {
647
- let q = null;
648
- for (let i = 0; i < CMD_TEXT.length; i += 1) {
649
- const c = CMD_TEXT[i];
650
- if (q === "'") { if (c === "'") q = null; continue; }
651
- if (q === '"') {
652
- if (c === '"') q = null;
653
- else if (c === "$" || c === ESC_BT) return c;
654
- else if (c === "\\\\") i += 1;
655
- continue;
656
- }
657
- if (c === "\\\\") { i += 1; continue; }
658
- if (c === "'" || c === '"') { q = c; continue; }
659
- if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT || c === "$" || c === "(" || c === ")") return c;
660
- }
661
- return q === null ? null : "comilla sin cerrar";
662
- };
663
605
  // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
664
606
  // run under an account other than the one THIS CLONE declared (the incident:
665
607
  // a gh comment without switch went out as the client account). Deny BEFORE
@@ -755,11 +697,8 @@ process.stdin.on("end", () => {
755
697
  };
756
698
  scan(CMD_TEXT, 0);
757
699
  if (problems.length > 0) {
758
- if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
759
- else {
760
- console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(KJ_ALLOW_IDENTITY=1 = excepcion consciente, queda registrada)" + doc("identity"));
761
- process.exit(2);
762
- }
700
+ console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(sin escape, ADR 0015: pon la cuenta en el comando)" + doc("identity"));
701
+ process.exit(2);
763
702
  }
764
703
  }
765
704
  // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
@@ -779,8 +718,7 @@ process.stdin.on("end", () => {
779
718
  return d ? foreignLane(d) : null;
780
719
  };
781
720
  const laneDeny = (what, lane) => {
782
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
783
- console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Cruce deliberado: KJ_ALLOW_CROSS_LANE=1, queda registrado." + doc("cross-lane"));
721
+ console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Sin escape (ADR 0015): un cruce deliberado lo hace tu usuario en su terminal." + doc("cross-lane"));
784
722
  return true;
785
723
  };
786
724
  if (EDIT_TOOLS.includes(tool)) {
@@ -817,11 +755,8 @@ process.stdin.on("end", () => {
817
755
  // directorio se deniega conservadoramente — el remedio es no
818
756
  // necesitarlo (git -C, npm --prefix, rutas absolutas).
819
757
  if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
820
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
821
- else {
822
- console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
823
- process.exit(2);
824
- }
758
+ console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd." + doc("cross-lane"));
759
+ process.exit(2);
825
760
  }
826
761
  // Absolute AND dot-relative tokens (../lane escapaba al primer scan —
827
762
  // reviewer catch); relative se resuelve contra el cwd de la sesion.
@@ -836,11 +771,8 @@ process.stdin.on("end", () => {
836
771
  // de tokens de abajo; el residuo (expansion anidada en segmentos
837
772
  // runner) queda documentado como en la regla de ficheros PROTECTED.
838
773
  if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
839
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
840
- else {
841
- console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
842
- process.exit(2);
843
- }
774
+ console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL." + doc("cross-lane"));
775
+ process.exit(2);
844
776
  }
845
777
  // Sustitucion de comandos ($(...) o backticks) EJECUTA su contenido
846
778
  // en cualquier posicion, y una ruta entrecomillada con espacios es
@@ -904,20 +836,14 @@ process.stdin.on("end", () => {
904
836
  // KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
905
837
  // of a text option (--title, -m...) is not a path.
906
838
  if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
907
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
908
- else {
909
- console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
910
- process.exit(2);
911
- }
839
+ console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion." + doc("cross-lane"));
840
+ process.exit(2);
912
841
  }
913
842
  const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
914
843
  const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
915
844
  if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
916
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
917
- else {
918
- console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
919
- process.exit(2);
920
- }
845
+ console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain." + doc("cross-lane"));
846
+ process.exit(2);
921
847
  }
922
848
  // ...incluidas relativas A PELO (los carriles kj viven en
923
849
  // .kj/worktrees/). Un falso token (s/a/b/) resuelve al arbol propio
@@ -955,8 +881,8 @@ process.stdin.on("end", () => {
955
881
  // Fail CLOSED por defecto (catches de codex; doctrina KJC-BUG-0095: un
956
882
  // gate no se cae en silencio): solo exit 0 permite. kj inejecutable =
957
883
  // deny duro (el remedio es restaurar kj); cualquier otro fallo de
958
- // evaluacion = deny con escape humano registrable — la sesion no queda
959
- // presa de un typo de YAML, pero abrirla es decision del usuario.
884
+ // evaluacion = deny sin escape (ADR 0015): un typo de YAML lo corrige el
885
+ // usuario fuera de la sesion.
960
886
  if ((EDIT_TOOLS.includes(tool) || tool === "Bash") && existsSync(resolve(ROOT, ".karajan", "policy.yml"))) {
961
887
  // PL-C: el rol que ACTÚA (KJ_POLICY_ROLE, sembrado por los runners de
962
888
  // kj run en los subprocesos) — el anfitrión-brain evalúa como coder.
@@ -970,11 +896,9 @@ process.stdin.on("end", () => {
970
896
  let v = null;
971
897
  try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
972
898
  const secure = !!(v && v.class === "security");
973
- if (!secure && escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
974
- else {
975
- console.error("karajan sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada; el commit exigira ademas KJ_POLICY_REASON)") + doc("policy"));
976
- process.exit(2);
977
- }
899
+ // ADR 0015 (KJC-TSK-0932): no escape; a rule that misfires is fixed by PR.
900
+ console.error("karajan sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (sin escape: si la regla falla, tu usuario corrige .karajan/policy.yml por PR)") + doc("policy"));
901
+ process.exit(2);
978
902
  } else if (pres.status !== 0) {
979
903
  // KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
980
904
  // kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
@@ -999,9 +923,8 @@ process.stdin.on("end", () => {
999
923
  console.error("karajan sentinel: kj no arranca (" + broken[0] + (at ? " en " + at : "") + "), asi que la policy no se puede evaluar y NADA MAS pasa: edita " + (brokenFile || "el fichero que rompe kj") + " para arreglarlo. No es un deny de policy, es kj roto." + doc("policy"));
1000
924
  process.exit(2);
1001
925
  }
1002
- } else if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
1003
- else {
1004
- console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada)." + doc("policy"));
926
+ } else {
927
+ console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check; .karajan/policy.yml lo corrige tu usuario fuera de la sesion." + doc("policy"));
1005
928
  process.exit(2);
1006
929
  }
1007
930
  }
@@ -1022,11 +945,8 @@ process.stdin.on("end", () => {
1022
945
  let blockOn = false;
1023
946
  try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
1024
947
  if (hard.length && blockOn) {
1025
- if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
1026
- else {
1027
- console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)" + doc("steward"));
1028
- process.exit(2);
1029
- }
948
+ console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(sin escape, ADR 0015: si el trabajo sigue, tu usuario cambia method_gates.steward a inform)" + doc("steward"));
949
+ process.exit(2);
1030
950
  }
1031
951
  const st = load();
1032
952
  const s = session(st, sid);
@@ -1043,11 +963,11 @@ process.stdin.on("end", () => {
1043
963
  if (rel && CODE.test(rel) && !TESTS.test(rel)) {
1044
964
  const branch = branchOf();
1045
965
  const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
966
+ // ADR 0015 (KJC-TSK-0925): no escape. Without a card there is no work.
1046
967
  if (why) {
1047
- if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
1048
968
  console.error(why === "base"
1049
- ? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first")
1050
- : "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first"));
969
+ ? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion." + doc("card-first")
970
+ : "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion." + doc("card-first"));
1051
971
  process.exit(2);
1052
972
  }
1053
973
  // KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
@@ -1070,15 +990,14 @@ process.stdin.on("end", () => {
1070
990
  // KJC-BUG-0190: with nothing indexed, no query can cover anything —
1071
991
  // the gate asks for kj rag index instead of denying the impossible.
1072
992
  const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
993
+ // ADR 0015 (KJC-TSK-0925): no escape. The cases it covered (new file, empty
994
+ // index, not indexable) are decided by the gate itself.
1073
995
  if (!covered) {
1074
- if (escOn("KJ_ALLOW_NO_RAG")) {
1075
- // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
1076
- if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
1077
- } else {
996
+ {
1078
997
  // KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
1079
998
  // sin adapter NO puede estar en el indice, asi que exigir una
1080
999
  // respuesta del RAG sobre el es pedir lo imposible, y empujaba a
1081
- // dejar KJ_ALLOW_NO_RAG puesta para siempre. La exencion es SOLO
1000
+ // dejar el escape puesto para siempre. La exencion es SOLO
1082
1001
  // esa: un indice vacio o por detras si tiene arreglo (kj rag index)
1083
1002
  // y sigue bloqueando, porque si no el gate desapareceria en
1084
1003
  // cualquier proyecto sin indexar. Nada legible = se bloquea.
@@ -1098,7 +1017,7 @@ process.stdin.on("end", () => {
1098
1017
  console.error("karajan sentinel: rag-first — el indice aun no tiene " + rel + " (" + absent + "); indexalo y consulta despues: kj_rag_query / kj rag query <que hace " + rel + ">" + doc("rag-first"));
1099
1018
  process.exit(2);
1100
1019
  } else {
1101
- console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
1020
+ console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>." + doc("rag-first"));
1102
1021
  process.exit(2);
1103
1022
  }
1104
1023
  }
@@ -1133,11 +1052,10 @@ process.stdin.on("end", () => {
1133
1052
  // depender de como parta las palabras el separador (catch de la review).
1134
1053
  const movesHooks = cmd.toLowerCase().includes("core.hookspath");
1135
1054
  if ((inOrder(["git", "commit"], words) && words.some(skipsVerify)) || movesHooks) {
1136
- if (escOn("KJ_ALLOW_NO_VERIFY")) { recordEscape(sid, "KJ_ALLOW_NO_VERIFY", tool); }
1137
- else {
1138
- console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta, es una decision de tu usuario. (KJ_ALLOW_NO_VERIFY=1 = excepcion consciente, queda registrada)" + doc("escapes"));
1139
- process.exit(2);
1140
- }
1055
+ // ADR 0015 (KJC-TSK-0926): no escape. A broken hook is fixed; skipping it,
1056
+ // if it ever must happen, is the user's act in their own terminal.
1057
+ console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
1058
+ process.exit(2);
1141
1059
  }
1142
1060
  const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
1143
1061
  const ADVANCE = /\\bgit\\s+commit\\b|\\bgit\\s+push\\b|\\bgh\\s+pr\\s+create\\b/; // push also falls under violations() — explicit here too (review catch)
@@ -1145,20 +1063,14 @@ process.stdin.on("end", () => {
1145
1063
  const pend = pendingMoves(load().sessions?.[sid]);
1146
1064
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
1147
1065
  // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1148
- // por su primera mitad. kj decide con un hecho comprobable (otra PR
1149
- // abierta de la card); si kj no responde, se bloquea como siempre.
1150
- const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
1151
- let blocking = pend, carried = [];
1152
- if (!bg.error && (bg.status === 0 || bg.status === 2)) {
1153
- try { const d = JSON.parse(bg.stdout); blocking = d.blocking || []; carried = d.carried || []; } catch { /* sin respuesta legible: como siempre */ }
1154
- }
1066
+ // por su primera mitad (boardGate, en la lib).
1067
+ const { blocking, carried } = boardGate(sid, pend);
1155
1068
  for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1069
+ // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
1070
+ // are already decided above (KJC-BUG-0230, 0198).
1156
1071
  if (blocking.length > 0) {
1157
- if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
1158
- else {
1159
- console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)" + doc("board-sync"));
1160
- process.exit(2);
1161
- }
1072
+ console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + doc("board-sync"));
1073
+ process.exit(2);
1162
1074
  }
1163
1075
  }
1164
1076
  // (the pending entry itself is recorded by the PostToolUse hook, only once
@@ -1167,9 +1079,9 @@ process.stdin.on("end", () => {
1167
1079
  // there misleads every future reader. When the command writes one from a
1168
1080
  // file, kj crosses ITS data against this turn's outputs; the hook carries
1169
1081
  // no policy (kj reads method_gates.claims; off = this check does not exist).
1170
- const bodyFile = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? /--body-file[= ]+("([^"]+)"|'([^']+)'|([^\\s"']+))/.exec(cmd) : null;
1171
- if (bodyFile && transcript) {
1172
- const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
1082
+ // KJC-BUG-0245: the path is read by the shell reader, so "pr.md;" is "pr.md".
1083
+ const body = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? optionValues(cmd, ["--body-file"])[0] : undefined;
1084
+ if (body && transcript) {
1173
1085
  const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
1174
1086
  if (!g.error && g.status === 2) {
1175
1087
  console.error("karajan sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body + doc("claims"));
@@ -1177,7 +1089,8 @@ process.stdin.on("end", () => {
1177
1089
  }
1178
1090
  }
1179
1091
  if (isPublish(cmd)) {
1180
- if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
1092
+ // ADR 0015 (KJC-TSK-0932): no escape — KJC-BUG-0204 and 0259 keep the
1093
+ // check from blocking its own fix.
1181
1094
  // KJC-BUG-0204: el check se evalua PARA este comando. Un item que
1182
1095
  // declara remedied_by no bloquea el comando que lo repara (desplegar
1183
1096
  // la landing era justo lo que el check pedia); publicar no se exime.
@@ -1194,19 +1107,15 @@ process.stdin.on("end", () => {
1194
1107
  if (res.status !== 0) {
1195
1108
  let items = "";
1196
1109
  try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
1197
- console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(KJ_ALLOW_RELEASE=1 = excepcion consciente, queda registrada)" + doc("release"));
1110
+ console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(sin escape: se repara lo que el check nombra)" + doc("release"));
1198
1111
  process.exit(2);
1199
1112
  }
1200
1113
  } else if (PUSH.test(cmd)) {
1201
- // KJC-BUG-0147: the board escape covers the push too — a pending move
1202
- // blocks the push like it blocks the commit, and the SAME conscious,
1203
- // recorded escape opens both (multi-PR cards are a legitimate plan).
1114
+ // KJC-BUG-0147: a pending move blocks the push like it blocks the commit.
1115
+ // ADR 0015 (KJC-TSK-0925): with no escape.
1116
+ // KJC-BUG-0262: a carried card does not block the push the commit let through.
1204
1117
  const sess = load().sessions?.[sid];
1205
- let v = violations(sess, branchOf());
1206
- if (v.length && pendingMoves(sess).length && escOn("KJ_ALLOW_BOARD")) {
1207
- recordEscape(sid, "KJ_ALLOW_BOARD", tool);
1208
- v = violations({ ...sess, pending_moves: [] }, branchOf());
1209
- }
1118
+ const v = violations(sess, branchOf(), boardGate(sid, pendingMoves(sess)).blocking);
1210
1119
  if (v.length) {
1211
1120
  console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
1212
1121
  process.exit(2);
@@ -1231,6 +1140,9 @@ const SCRIPT_BODIES = {
1231
1140
  "sentinel-bash-write.mjs": readFileSync(new URL("./sentinel/sentinel-bash-write.mjs", import.meta.url), "utf8"),
1232
1141
  "sentinel-reminders.mjs": readFileSync(new URL("./sentinel/sentinel-reminders.mjs", import.meta.url), "utf8"),
1233
1142
  "sessionstart.mjs": readFileSync(new URL("./sentinel/sessionstart.mjs", import.meta.url), "utf8"),
1143
+ // KJC-TSK-0946 (ADR 0016): the rules gate and its hook, for every tool call.
1144
+ "sentinel-rules.mjs": readFileSync(new URL("./sentinel/sentinel-rules.mjs", import.meta.url), "utf8"),
1145
+ "pretooluse-rules.mjs": readFileSync(new URL("./sentinel/pretooluse-rules.mjs", import.meta.url), "utf8"),
1234
1146
  };
1235
1147
 
1236
1148
  /**
@@ -1420,6 +1332,9 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1420
1332
  entries: [
1421
1333
  { event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
1422
1334
  { event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
1335
+ // KJC-TSK-0946 (ADR 0016): no matcher. The rules of the MD files are checked
1336
+ // before EVERY tool call, MCP ones included.
1337
+ { event: "PreToolUse", script: "pretooluse-rules.mjs" },
1423
1338
  { event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
1424
1339
  // KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
1425
1340
  // update_card) are observed AFTER the fact — found live: with only the
@@ -1433,6 +1348,8 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1433
1348
  // KJC-TSK-0918 (ADR 0014): the critical rules back after a compaction or a resume.
1434
1349
  { event: "SessionStart", matcher: "compact", script: "sessionstart.mjs" },
1435
1350
  { event: "SessionStart", matcher: "resume", script: "sessionstart.mjs" },
1351
+ // KJC-TSK-0949: a new session hears of MD rules with no gate, and nothing else.
1352
+ { event: "SessionStart", matcher: "startup", script: "sessionstart.mjs" },
1436
1353
  ],
1437
1354
  });
1438
1355
  return { scripts: [lib, post, stop, pre], wired, deferred };