karajan-code 4.37.0 → 4.39.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.
@@ -8,7 +8,7 @@
8
8
  * hooks, which is why the guaranteed level requires Claude as host (ADR).
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
14
  import { join } from "node:path";
@@ -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,8 +80,20 @@ 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
+ export const violations = (s, branch, pending = pendingMoves(s)) => {
96
+ const v = pending.map(pendingText);
86
97
  if (!s || !(s.edited_sources || []).length) return v;
87
98
  if (bootstrapPhase()) return v;
88
99
  if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
@@ -105,26 +116,6 @@ const sessionAddsCode = (s) => {
105
116
  return a === "-" || Number(a) > 0;
106
117
  });
107
118
  };
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
119
  `;
129
120
 
130
121
  const POST_BODY = `#!/usr/bin/env node
@@ -136,7 +127,7 @@ import process from "node:process";
136
127
  import { relative } from "node:path";
137
128
  import { spawnSync } from "node:child_process";
138
129
  import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
139
- 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"];
130
+ import { remindersFor } from "./sentinel-reminders.mjs";
140
131
  let raw = "";
141
132
  process.stdin.on("data", (d) => { raw += d; });
142
133
  process.stdin.on("end", () => {
@@ -273,6 +264,15 @@ process.stdin.on("end", () => {
273
264
  // "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
274
265
  // clear a pending — that would discard a LEGITIMATE one without touching the tracker.
275
266
  if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
267
+ // KJC-TSK-0917 (ADR 0014): the method's reminders, after the action that precedes
268
+ // the one each rule is about; at most once every 25 Bash actions each. Never a block.
269
+ const st = load();
270
+ const ss = session(st, sid);
271
+ ss.step = (ss.step || 0) + 1;
272
+ const due = remindersFor(cmdText, { seen: ss.reminded || {}, step: ss.step });
273
+ for (const r of due) (ss.reminded ||= {})[r.id] = ss.step;
274
+ save(st);
275
+ if (due.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: due.map((r) => r.say).join(" ") } }));
276
276
  process.exit(0);
277
277
  }
278
278
  const file = input.file_path || input.notebook_path;
@@ -283,11 +283,6 @@ process.stdin.on("end", () => {
283
283
  const rel = relative(ROOT, file).replaceAll("\\\\", "/");
284
284
  const bucket = TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
285
285
  if (bucket && !bucket.includes(rel)) bucket.push(rel);
286
- for (const e of ESCAPES)
287
- if (process.env[e] === "1" && !s.escapes.includes(e)) {
288
- s.escapes.push(e);
289
- (state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
290
- }
291
286
  // KJC-TSK-0910: the branch size while it is written, with the CI budget
292
287
  // (kj pr-size), said once per threshold crossed. Context, never a block.
293
288
  let sizeNote = null;
@@ -407,7 +402,6 @@ process.stdin.on("end", () => {
407
402
  s.blocks = 0;
408
403
  const notes = [];
409
404
  if (claimsNote) notes.push(claimsNote);
410
- 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.");
411
405
  if ((s.closed_cards || []).length) {
412
406
  notes.push("karajan sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
413
407
  s.closed_cards = [];
@@ -444,7 +438,11 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
444
438
  import { spawnSync } from "node:child_process";
445
439
  import { homedir } from "node:os";
446
440
  import { fileURLToPath } from "node:url";
447
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
441
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
442
+ // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
443
+ import { optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
444
+ import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
445
+ import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
448
446
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
449
447
  // KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
450
448
  // (firebase --account a@b --project p deploy --only hosting:main), asi que la
@@ -463,6 +461,25 @@ process.stdin.on("data", (d) => { raw += d; });
463
461
  process.stdin.on("end", () => {
464
462
  try {
465
463
  const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
464
+ // KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
465
+ process.on("exit", (code) => {
466
+ 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.");
467
+ });
468
+ // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
469
+ // sesion toca un fichero se anota si estaba limpio: solo entonces un
470
+ // descarte posterior pierde nada mas que lo de la sesion. Antes de
471
+ // cualquier escape; git que falla cuenta como sucio (fail-closed).
472
+ const noteFirstTouch = (relT) => {
473
+ const st = load();
474
+ const touch = (session(st, sid).first_touch ||= {});
475
+ // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
476
+ if (Object.hasOwn(touch, relT)) return;
477
+ // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
478
+ const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
479
+ const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
480
+ Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
481
+ save(st);
482
+ };
466
483
  // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
467
484
  // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
468
485
  // only the human, editing outside it.
@@ -473,7 +490,16 @@ process.stdin.on("end", () => {
473
490
  console.error("karajan sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion." + doc("supervisor"));
474
491
  process.exit(2);
475
492
  }
493
+ // KJC-TSK-0920 (SNT-E): policies, gate settings and exclusions are the human's,
494
+ // like the supervisor: a session does not loosen the rules that govern it.
495
+ if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
496
+ 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"));
497
+ process.exit(2);
498
+ }
499
+ if (relT && !relT.startsWith("..") && !relT.startsWith("/")) noteFirstTouch(relT);
476
500
  }
501
+ // KJC-BUG-0261: rm / git rm is the session touching those files, noted before they go.
502
+ if (tool === "Bash") for (const words of shellSegments(String(input.command || ""))) for (const f of removedFiles(words, ROOT)) noteFirstTouch(f);
477
503
  // Any Bash that NAMES the supervisor's files is denied — a write-verb
478
504
  // blocklist is bypassable (cp, dd, one-liners), and reading them is what
479
505
  // the Read/Grep tools are for. Shell indirection (variables, globs,
@@ -496,8 +522,8 @@ process.stdin.on("end", () => {
496
522
  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)) {
497
523
  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;
498
524
  let corpus = ghCmd;
499
- for (const m of ghCmd.matchAll(/--(?:body-file|notes-file|comment-file)[= ]+("([^"]+)"|'([^']+)'|([^ ]+))/g)) {
500
- const bodyPath = m[2] || m[3] || m[4];
525
+ // KJC-BUG-0245: the shell reader ends the path at ; && | (a regex took "pr.md;").
526
+ for (const bodyPath of optionValues(ghCmd, ["--body-file", "--notes-file", "--comment-file"])) {
501
527
  try { corpus += "\\n" + readFileSync(bodyPath, "utf8"); } catch {
502
528
  console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
503
529
  process.exit(2);
@@ -535,69 +561,34 @@ process.stdin.on("end", () => {
535
561
  process.exit(2);
536
562
  }
537
563
  }
564
+ // KJC-BUG-0238 (#1886): un agente no descarta cambios que no hizo. Sin
565
+ // escape y antes de KJ_SENTINEL_OFF: es perdida de datos del usuario, y
566
+ // guardarlo (git stash push) nunca pierde nada.
567
+ if (tool === "Bash") {
568
+ const cmdD = String(input.command || "");
569
+ // A git discard nested in $( ), <( ), backticks, eval, xargs or sh -c cannot be read: fail-closed.
570
+ const nested = ["$(", "<(", ">(", String.fromCharCode(96)].some((n) => cmdD.includes(n)) || /(^|[ ;&|])(eval|xargs|bash|sh|zsh|env)( |$)/.test(cmdD);
571
+ if (nested && /(^|[^a-z])git([^a-z]|$)/.test(cmdD) && DISCARD_VERBS.some((v) => cmdD.includes(v))) {
572
+ console.error("karajan sentinel: un descarte git dentro de $( ), backticks, eval, xargs o sh -c no es verificable — ejecutalo como comando simple." + doc("discard"));
573
+ process.exit(2);
574
+ }
575
+ const touch = load().sessions?.[sid]?.first_touch || {};
576
+ for (const words of shellSegments(cmdD)) {
577
+ const d = discardOf(words, ROOT);
578
+ const lost = d ? foreignLost(d, touch, ROOT) : [];
579
+ if (lost.length === 0) continue;
580
+ console.error(d.stash
581
+ ? "karajan sentinel: git stash drop/clear borra trabajo guardado que puede no ser de esta sesion — pideselo a tu usuario." + doc("discard")
582
+ : "karajan sentinel: ese comando descarta cambios que esta sesion no hizo (" + lost.slice(0, 5).join(", ") + (lost.length > 5 ? ", +" + (lost.length - 5) : "") + ") — pueden ser trabajo sin commitear de tu usuario. Si estorban, guardalos recuperables (git stash push -- <ficheros>) y avisale; si el cambio es tuyo sobre su trabajo, deshazlo con Edit. Sin escape: es perdida de datos." + doc("discard"));
583
+ process.exit(2);
584
+ }
585
+ }
538
586
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
539
- // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
540
- // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
541
- // prefijo jamas llegaba a process.env y el escape era inalcanzable
542
- // (deadlock real del gate de release: la landing solo se pone verde
543
- // tras el deploy y el deploy estaba bloqueado por el mismo gate). Un
544
- // escape es una excepcion CONSCIENTE y auditada, no un boundary de
545
- // seguridad (security sigue sin escape y la autoprotecion corre antes),
546
- // asi que el TEXTO del comando es fuente valida: solo asignaciones
547
- // LITERALES al inicio (tokens VAR=VAL antes del primer verbo) y solo
548
- // sobre un comando SIMPLE — con ;|& $() backtick o salto de linea el
549
- // prefijo shell no alcanzaria a los comandos posteriores (catch de
550
- // codex: "X=1 true && npm publish" escaparia sin serlo), asi que el
551
- // escape por texto se rechaza y queda la via env de siempre.
587
+ // ADR 0015 (KJC-TSK-0927): no gate has an escape any more, so the command
588
+ // text is read only by the identity lock below.
552
589
  const CMD_TEXT = tool === "Bash" ? String(input.command || "") : "";
553
590
  const ESC_TAB = String.fromCharCode(9);
554
591
  const ESC_NL = String.fromCharCode(10);
555
- const ESC_BT = String.fromCharCode(96);
556
- const escOn = (name) => {
557
- if (process.env[name] === "1") return true;
558
- let found = false;
559
- let i = 0;
560
- while (i < CMD_TEXT.length) {
561
- while (i < CMD_TEXT.length && (CMD_TEXT[i] === " " || CMD_TEXT[i] === ESC_TAB)) i += 1;
562
- const start = i;
563
- while (i < CMD_TEXT.length && CMD_TEXT[i] !== " " && CMD_TEXT[i] !== ESC_TAB && CMD_TEXT[i] !== ESC_NL) i += 1;
564
- const tok = CMD_TEXT.slice(start, i);
565
- const eq = tok.indexOf("=");
566
- if (eq <= 0 || !/^[A-Z][A-Z0-9_]*$/.test(tok.slice(0, eq))) break;
567
- if (tok === name + "=1") found = true;
568
- }
569
- if (!found) return false;
570
- // KJC-BUG-0147: lo que va ENTRE COMILLAS no encadena comandos — un
571
- // mensaje "fix(x): ... (KJC-TSK-1)" es un comando simple. Dentro de
572
- // comillas dobles $ y backtick siguen expandiendo (se rechazan); en
573
- // comillas simples todo es literal. Y si el escape esta pero se ignora,
574
- // SE DICE: un escape ignorado en silencio era el bug.
575
- const bad = escSimple();
576
- if (bad === null) return true;
577
- if (!escNoted.has(name)) {
578
- escNoted.add(name);
579
- 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"));
580
- }
581
- return false;
582
- };
583
- const escNoted = new Set();
584
- const escSimple = () => {
585
- let q = null;
586
- for (let i = 0; i < CMD_TEXT.length; i += 1) {
587
- const c = CMD_TEXT[i];
588
- if (q === "'") { if (c === "'") q = null; continue; }
589
- if (q === '"') {
590
- if (c === '"') q = null;
591
- else if (c === "$" || c === ESC_BT) return c;
592
- else if (c === "\\\\") i += 1;
593
- continue;
594
- }
595
- if (c === "\\\\") { i += 1; continue; }
596
- if (c === "'" || c === '"') { q = c; continue; }
597
- if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT || c === "$" || c === "(" || c === ")") return c;
598
- }
599
- return q === null ? null : "comilla sin cerrar";
600
- };
601
592
  // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
602
593
  // run under an account other than the one THIS CLONE declared (the incident:
603
594
  // a gh comment without switch went out as the client account). Deny BEFORE
@@ -693,11 +684,8 @@ process.stdin.on("end", () => {
693
684
  };
694
685
  scan(CMD_TEXT, 0);
695
686
  if (problems.length > 0) {
696
- if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
697
- else {
698
- 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"));
699
- process.exit(2);
700
- }
687
+ 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"));
688
+ process.exit(2);
701
689
  }
702
690
  }
703
691
  // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
@@ -717,8 +705,7 @@ process.stdin.on("end", () => {
717
705
  return d ? foreignLane(d) : null;
718
706
  };
719
707
  const laneDeny = (what, lane) => {
720
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
721
- 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"));
708
+ 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"));
722
709
  return true;
723
710
  };
724
711
  if (EDIT_TOOLS.includes(tool)) {
@@ -747,18 +734,16 @@ process.stdin.on("end", () => {
747
734
  // (find fuera: -delete/-exec mutan — reviewer catch; sus tokens de
748
735
  // carril los caza el escaner de abajo.)
749
736
  const READONLY = /^[ \\t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame))\\b[^;|&<>$\`(){}\\n\\r]*$/;
750
- if (!READONLY.test(cmd)) {
737
+ // KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
738
+ if (!READONLY.test(stripInertQuotes(cmd))) {
751
739
  // cd/pushd invalida TODO razonamiento textual de rutas posteriores
752
740
  // (carrera de bypasses confirmada en review: destino bare, con $,
753
741
  // relativas post-cd...): en un comando NO-read-only, cambiar de
754
742
  // directorio se deniega conservadoramente — el remedio es no
755
743
  // necesitarlo (git -C, npm --prefix, rutas absolutas).
756
744
  if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
757
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
758
- else {
759
- 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"));
760
- process.exit(2);
761
- }
745
+ 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"));
746
+ process.exit(2);
762
747
  }
763
748
  // Absolute AND dot-relative tokens (../lane escapaba al primer scan —
764
749
  // reviewer catch); relative se resuelve contra el cwd de la sesion.
@@ -773,11 +758,8 @@ process.stdin.on("end", () => {
773
758
  // de tokens de abajo; el residuo (expansion anidada en segmentos
774
759
  // runner) queda documentado como en la regla de ficheros PROTECTED.
775
760
  if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
776
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
777
- else {
778
- 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"));
779
- process.exit(2);
780
- }
761
+ 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"));
762
+ process.exit(2);
781
763
  }
782
764
  // Sustitucion de comandos ($(...) o backticks) EJECUTA su contenido
783
765
  // en cualquier posicion, y una ruta entrecomillada con espacios es
@@ -838,21 +820,17 @@ process.stdin.on("end", () => {
838
820
  if (q !== null) return true; // comilla sin cerrar: no verificable
839
821
  return flush();
840
822
  };
841
- if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
842
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
843
- else {
844
- 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"));
845
- process.exit(2);
846
- }
823
+ // KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
824
+ // of a text option (--title, -m...) is not a path.
825
+ if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
826
+ 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"));
827
+ process.exit(2);
847
828
  }
848
829
  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]*$/;
849
830
  const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
850
831
  if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
851
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
852
- else {
853
- 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"));
854
- process.exit(2);
855
- }
832
+ 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"));
833
+ process.exit(2);
856
834
  }
857
835
  // ...incluidas relativas A PELO (los carriles kj viven en
858
836
  // .kj/worktrees/). Un falso token (s/a/b/) resuelve al arbol propio
@@ -868,6 +846,21 @@ process.stdin.on("end", () => {
868
846
  }
869
847
  }
870
848
  }
849
+ // KJC-BUG-0237 (#1886): the repo is written through Edit/Write only, the path every gate
850
+ // guards. After the lane guard, whose message is the precise one for another lane.
851
+ if (tool === "Bash") {
852
+ let moved = false; // after cd/pushd/popd, a relative target cannot be placed: fail-closed
853
+ // $( ) and backticks run even inside double quotes: their bodies, read from the raw text, are commands too.
854
+ const raw = String(input.command || "");
855
+ const subs = [...[...raw.matchAll(/[$][(]([^()]*)[)]/g)].map((m) => m[1]), ...raw.split(String.fromCharCode(96)).filter((_, k) => k % 2 === 1)];
856
+ for (const words of [...shellSegments(raw), ...subs.flatMap((s) => shellSegments(s))]) {
857
+ if (["cd", "pushd", "popd"].includes(words[0])) moved = true;
858
+ const inRepo = shellWrites(words).filter((t) => writesRepo(t, ROOT) || (moved && !t.startsWith("/")));
859
+ if (inRepo.length === 0) continue;
860
+ console.error("karajan sentinel: escribir ficheros del repo desde Bash (" + inRepo.slice(0, 3).join(", ") + ") se salta los gates de Edit/Write — usa la tool Edit/Write; para renombrar, git mv; fuera del repo (/tmp, scratchpad) Bash es libre." + doc("bash-write"));
861
+ process.exit(2);
862
+ }
863
+ }
871
864
  // KJC-TSK-0734 (PL-B): con .karajan/policy.yml presente, la evaluacion
872
865
  // la hace el MOTOR via kj policy eval --strict (exit 2 = deny, contrato
873
866
  // PL-A); los defaults del supervisor viven en el motor y el check
@@ -875,8 +868,8 @@ process.stdin.on("end", () => {
875
868
  // Fail CLOSED por defecto (catches de codex; doctrina KJC-BUG-0095: un
876
869
  // gate no se cae en silencio): solo exit 0 permite. kj inejecutable =
877
870
  // deny duro (el remedio es restaurar kj); cualquier otro fallo de
878
- // evaluacion = deny con escape humano registrable — la sesion no queda
879
- // presa de un typo de YAML, pero abrirla es decision del usuario.
871
+ // evaluacion = deny sin escape (ADR 0015): un typo de YAML lo corrige el
872
+ // usuario fuera de la sesion.
880
873
  if ((EDIT_TOOLS.includes(tool) || tool === "Bash") && existsSync(resolve(ROOT, ".karajan", "policy.yml"))) {
881
874
  // PL-C: el rol que ACTÚA (KJ_POLICY_ROLE, sembrado por los runners de
882
875
  // kj run en los subprocesos) — el anfitrión-brain evalúa como coder.
@@ -890,11 +883,9 @@ process.stdin.on("end", () => {
890
883
  let v = null;
891
884
  try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
892
885
  const secure = !!(v && v.class === "security");
893
- if (!secure && escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
894
- else {
895
- 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"));
896
- process.exit(2);
897
- }
886
+ // ADR 0015 (KJC-TSK-0932): no escape; a rule that misfires is fixed by PR.
887
+ 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"));
888
+ process.exit(2);
898
889
  } else if (pres.status !== 0) {
899
890
  // KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
900
891
  // kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
@@ -919,9 +910,8 @@ process.stdin.on("end", () => {
919
910
  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"));
920
911
  process.exit(2);
921
912
  }
922
- } else if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
923
- else {
924
- 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"));
913
+ } else {
914
+ 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"));
925
915
  process.exit(2);
926
916
  }
927
917
  }
@@ -942,11 +932,8 @@ process.stdin.on("end", () => {
942
932
  let blockOn = false;
943
933
  try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
944
934
  if (hard.length && blockOn) {
945
- if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
946
- else {
947
- 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"));
948
- process.exit(2);
949
- }
935
+ 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"));
936
+ process.exit(2);
950
937
  }
951
938
  const st = load();
952
939
  const s = session(st, sid);
@@ -963,11 +950,11 @@ process.stdin.on("end", () => {
963
950
  if (rel && CODE.test(rel) && !TESTS.test(rel)) {
964
951
  const branch = branchOf();
965
952
  const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
953
+ // ADR 0015 (KJC-TSK-0925): no escape. Without a card there is no work.
966
954
  if (why) {
967
- if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
968
955
  console.error(why === "base"
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. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + 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. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first"));
956
+ ? "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")
957
+ : "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"));
971
958
  process.exit(2);
972
959
  }
973
960
  // KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
@@ -990,15 +977,14 @@ process.stdin.on("end", () => {
990
977
  // KJC-BUG-0190: with nothing indexed, no query can cover anything —
991
978
  // the gate asks for kj rag index instead of denying the impossible.
992
979
  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;
980
+ // ADR 0015 (KJC-TSK-0925): no escape. The cases it covered (new file, empty
981
+ // index, not indexable) are decided by the gate itself.
993
982
  if (!covered) {
994
- if (escOn("KJ_ALLOW_NO_RAG")) {
995
- // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
996
- if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
997
- } else {
983
+ {
998
984
  // KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
999
985
  // sin adapter NO puede estar en el indice, asi que exigir una
1000
986
  // respuesta del RAG sobre el es pedir lo imposible, y empujaba a
1001
- // dejar KJ_ALLOW_NO_RAG puesta para siempre. La exencion es SOLO
987
+ // dejar el escape puesto para siempre. La exencion es SOLO
1002
988
  // esa: un indice vacio o por detras si tiene arreglo (kj rag index)
1003
989
  // y sigue bloqueando, porque si no el gate desapareceria en
1004
990
  // cualquier proyecto sin indexar. Nada legible = se bloquea.
@@ -1018,7 +1004,7 @@ process.stdin.on("end", () => {
1018
1004
  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"));
1019
1005
  process.exit(2);
1020
1006
  } else {
1021
- 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"));
1007
+ 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"));
1022
1008
  process.exit(2);
1023
1009
  }
1024
1010
  }
@@ -1053,11 +1039,10 @@ process.stdin.on("end", () => {
1053
1039
  // depender de como parta las palabras el separador (catch de la review).
1054
1040
  const movesHooks = cmd.toLowerCase().includes("core.hookspath");
1055
1041
  if ((inOrder(["git", "commit"], words) && words.some(skipsVerify)) || movesHooks) {
1056
- if (escOn("KJ_ALLOW_NO_VERIFY")) { recordEscape(sid, "KJ_ALLOW_NO_VERIFY", tool); }
1057
- else {
1058
- 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"));
1059
- process.exit(2);
1060
- }
1042
+ // ADR 0015 (KJC-TSK-0926): no escape. A broken hook is fixed; skipping it,
1043
+ // if it ever must happen, is the user's act in their own terminal.
1044
+ 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"));
1045
+ process.exit(2);
1061
1046
  }
1062
1047
  const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
1063
1048
  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)
@@ -1065,20 +1050,14 @@ process.stdin.on("end", () => {
1065
1050
  const pend = pendingMoves(load().sessions?.[sid]);
1066
1051
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
1067
1052
  // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1068
- // por su primera mitad. kj decide con un hecho comprobable (otra PR
1069
- // abierta de la card); si kj no responde, se bloquea como siempre.
1070
- const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
1071
- let blocking = pend, carried = [];
1072
- if (!bg.error && (bg.status === 0 || bg.status === 2)) {
1073
- try { const d = JSON.parse(bg.stdout); blocking = d.blocking || []; carried = d.carried || []; } catch { /* sin respuesta legible: como siempre */ }
1074
- }
1053
+ // por su primera mitad (boardGate, en la lib).
1054
+ const { blocking, carried } = boardGate(sid, pend);
1075
1055
  for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1056
+ // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
1057
+ // are already decided above (KJC-BUG-0230, 0198).
1076
1058
  if (blocking.length > 0) {
1077
- if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
1078
- else {
1079
- 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"));
1080
- process.exit(2);
1081
- }
1059
+ 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"));
1060
+ process.exit(2);
1082
1061
  }
1083
1062
  }
1084
1063
  // (the pending entry itself is recorded by the PostToolUse hook, only once
@@ -1087,9 +1066,9 @@ process.stdin.on("end", () => {
1087
1066
  // there misleads every future reader. When the command writes one from a
1088
1067
  // file, kj crosses ITS data against this turn's outputs; the hook carries
1089
1068
  // no policy (kj reads method_gates.claims; off = this check does not exist).
1090
- const bodyFile = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? /--body-file[= ]+("([^"]+)"|'([^']+)'|([^\\s"']+))/.exec(cmd) : null;
1091
- if (bodyFile && transcript) {
1092
- const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
1069
+ // KJC-BUG-0245: the path is read by the shell reader, so "pr.md;" is "pr.md".
1070
+ const body = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? optionValues(cmd, ["--body-file"])[0] : undefined;
1071
+ if (body && transcript) {
1093
1072
  const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
1094
1073
  if (!g.error && g.status === 2) {
1095
1074
  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"));
@@ -1097,7 +1076,8 @@ process.stdin.on("end", () => {
1097
1076
  }
1098
1077
  }
1099
1078
  if (isPublish(cmd)) {
1100
- if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
1079
+ // ADR 0015 (KJC-TSK-0932): no escape — KJC-BUG-0204 and 0259 keep the
1080
+ // check from blocking its own fix.
1101
1081
  // KJC-BUG-0204: el check se evalua PARA este comando. Un item que
1102
1082
  // declara remedied_by no bloquea el comando que lo repara (desplegar
1103
1083
  // la landing era justo lo que el check pedia); publicar no se exime.
@@ -1114,19 +1094,15 @@ process.stdin.on("end", () => {
1114
1094
  if (res.status !== 0) {
1115
1095
  let items = "";
1116
1096
  try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
1117
- 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"));
1097
+ 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"));
1118
1098
  process.exit(2);
1119
1099
  }
1120
1100
  } else if (PUSH.test(cmd)) {
1121
- // KJC-BUG-0147: the board escape covers the push too — a pending move
1122
- // blocks the push like it blocks the commit, and the SAME conscious,
1123
- // recorded escape opens both (multi-PR cards are a legitimate plan).
1101
+ // KJC-BUG-0147: a pending move blocks the push like it blocks the commit.
1102
+ // ADR 0015 (KJC-TSK-0925): with no escape.
1103
+ // KJC-BUG-0262: a carried card does not block the push the commit let through.
1124
1104
  const sess = load().sessions?.[sid];
1125
- let v = violations(sess, branchOf());
1126
- if (v.length && pendingMoves(sess).length && escOn("KJ_ALLOW_BOARD")) {
1127
- recordEscape(sid, "KJ_ALLOW_BOARD", tool);
1128
- v = violations({ ...sess, pending_moves: [] }, branchOf());
1129
- }
1105
+ const v = violations(sess, branchOf(), boardGate(sid, pendingMoves(sess)).blocking);
1130
1106
  if (v.length) {
1131
1107
  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"));
1132
1108
  process.exit(2);
@@ -1143,6 +1119,14 @@ const SCRIPT_BODIES = {
1143
1119
  "posttooluse.mjs": POST_BODY,
1144
1120
  "stop.mjs": STOP_BODY,
1145
1121
  "pretooluse-sentinel.mjs": PRETOOL_BODY,
1122
+ // KJC-TSK-0915 (ADR 0014): real modules, copied byte for byte under the SAME name
1123
+ // (so they can import each other in both places). Being here, the installed
1124
+ // record, the tamper check and the human seal cover them as well.
1125
+ "sentinel-shell.mjs": readFileSync(new URL("./sentinel/sentinel-shell.mjs", import.meta.url), "utf8"),
1126
+ "sentinel-discard.mjs": readFileSync(new URL("./sentinel/sentinel-discard.mjs", import.meta.url), "utf8"),
1127
+ "sentinel-bash-write.mjs": readFileSync(new URL("./sentinel/sentinel-bash-write.mjs", import.meta.url), "utf8"),
1128
+ "sentinel-reminders.mjs": readFileSync(new URL("./sentinel/sentinel-reminders.mjs", import.meta.url), "utf8"),
1129
+ "sessionstart.mjs": readFileSync(new URL("./sentinel/sessionstart.mjs", import.meta.url), "utf8"),
1146
1130
  };
1147
1131
 
1148
1132
  /**
@@ -1246,6 +1230,7 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1246
1230
  // first attempt at this fix, and the review was right to reject it.
1247
1231
  const sealed = sealedByPath(root, gitShowFn);
1248
1232
  const ownRecord = readInstalledRecord(dir);
1233
+ const hasRecord = existsSync(join(dir, INSTALLED_RECORD));
1249
1234
  const drift = [];
1250
1235
  const tampered = [];
1251
1236
  const regenerated = [];
@@ -1254,6 +1239,10 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1254
1239
  const hash = text === undefined ? null : sha256(text);
1255
1240
  if (hash && sealed.get(`.karajan/harness/${name}`) === hash) drift.push(name);
1256
1241
  else if (hash && ownRecord[name] === hash) regenerated.push(name);
1242
+ // KJC-TSK-0915: absent from an install kj RECORDED (installed.json exists), never
1243
+ // recorded and never sealed = a guard newer than this install, not a deleted
1244
+ // one. Writing it only adds. A dir with no record is not a kj install at all.
1245
+ else if (!hash && hasRecord && !Object.hasOwn(ownRecord, name) && !sealed.has(`.karajan/harness/${name}`)) regenerated.push(name);
1257
1246
  else tampered.push(name);
1258
1247
  }
1259
1248
  // KJC-BUG-0224 caso 1: lo que kj escribio y nadie sello ni toco se pone al
@@ -1333,7 +1322,13 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1333
1322
  // edit matchers wired, the board-sync recorder never ran in production.
1334
1323
  { event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
1335
1324
  { event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
1325
+ // KJC-BUG-0236 (#1886): the rag-first ledger records MCP answers here;
1326
+ // unwired, the official tool never satisfied the gate.
1327
+ { event: "PostToolUse", matcher: "mcp__.*__kj_rag_query", script: "posttooluse.mjs" },
1336
1328
  { event: "Stop", script: "stop.mjs" },
1329
+ // KJC-TSK-0918 (ADR 0014): the critical rules back after a compaction or a resume.
1330
+ { event: "SessionStart", matcher: "compact", script: "sessionstart.mjs" },
1331
+ { event: "SessionStart", matcher: "resume", script: "sessionstart.mjs" },
1337
1332
  ],
1338
1333
  });
1339
1334
  return { scripts: [lib, post, stop, pre], wired, deferred };