karajan-code 4.33.0 → 4.34.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.
@@ -10,9 +10,10 @@
10
10
 
11
11
  import { readFileSync } from "node:fs";
12
12
  import { execFileSync } from "node:child_process";
13
+ import { createHash } from "node:crypto";
13
14
  import { join } from "node:path";
14
15
  import { CARD_REF_RE } from "../review/card-first.js";
15
- import { mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
16
+ import { HARNESS_BODIES, mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
16
17
 
17
18
  /** The harness lives at the PROJECT root — resolve it even from a subdir. */
18
19
  export function resolveSentinelRoot(dir = process.cwd()) {
@@ -134,7 +135,7 @@ import process from "node:process";
134
135
  import { relative } from "node:path";
135
136
  import { spawnSync } from "node:child_process";
136
137
  import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
137
- 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"];
138
+ 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"];
138
139
  let raw = "";
139
140
  process.stdin.on("data", (d) => { raw += d; });
140
141
  process.stdin.on("end", () => {
@@ -364,6 +365,18 @@ process.stdin.on("end", () => {
364
365
  if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
365
366
  process.exit(0);
366
367
  }
368
+ // KJC-TSK-0868: si el anfitrion ha escrito codigo teniendo otro coder
369
+ // declarado, eso no se bloquea, se REGISTRA: el usuario eligio quien
370
+ // escribe y merece enterarse por el decision log, no por casualidad. Una
371
+ // vez por sesion, y si kj no responde se reintenta en el turno siguiente.
372
+ if (!s.panel_sealed && (s.edited_sources || []).length > 0) {
373
+ const p = spawnSync("kj", ["sentinel", "panel-check", "--session", sid], { cwd: ROOT, encoding: "utf8" });
374
+ if (!p.error && p.status === 0) {
375
+ s.panel_sealed = true;
376
+ save(state);
377
+ if ((p.stdout || "").trim()) console.error(p.stdout.trim());
378
+ }
379
+ }
367
380
  const v = violations(s, branchOf());
368
381
  if (claimsBlock) v.push(claimsBlock);
369
382
  if (!v.length) {
@@ -406,9 +419,19 @@ import { dirname, join, relative, resolve } from "node:path";
406
419
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
407
420
  import { spawnSync } from "node:child_process";
408
421
  import { homedir } from "node:os";
422
+ import { fileURLToPath } from "node:url";
409
423
  import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
410
424
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
411
- const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
425
+ // KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
426
+ // (firebase --account a@b --project p deploy --only hosting:main), asi que la
427
+ // adyacencia dejaba pasar sin mirar justo el despliegue que el gate vigila.
428
+ // Las palabras del verbo tienen que aparecer en orden, no pegadas.
429
+ const PUBLISH_VERBS = [["npm", "publish"], ["firebase", "deploy"], ["gh", "release", "create"]];
430
+ // Cada palabra se lee por su basename: /usr/bin/npm publish es la misma
431
+ // publicacion que npm publish (catch de la review).
432
+ const wordsOf = (text) => String(text).split(/[^A-Za-z0-9_.@:/-]+/).filter(Boolean).map((w) => w.slice(w.lastIndexOf("/") + 1)).filter(Boolean);
433
+ const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[i] && ++i === want.length) return true; return false; };
434
+ const isPublish = (cmd) => { const got = wordsOf(cmd); return PUBLISH_VERBS.some((verb) => inOrder(verb, got)); };
412
435
  const PUSH = /\\bgit\\s+push\\b/;
413
436
  const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
414
437
  let raw = "";
@@ -849,7 +872,30 @@ process.stdin.on("end", () => {
849
872
  process.exit(2);
850
873
  }
851
874
  } else if (pres.status !== 0) {
852
- if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
875
+ // KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
876
+ // kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
877
+ // la sesion sin Edit, sin Write y sin Bash, o sea sin las herramientas
878
+ // para arreglar el fichero, y el remedio que sugeria (kj policy check)
879
+ // tampoco arrancaba. Se avisa fuerte y se deja pasar: el resto de este
880
+ // guard sigue vigente, que es lo que protege el metodo.
881
+ const broken = /SyntaxError|ReferenceError|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM/.exec(String(pres.stderr || ""));
882
+ // Abrir el gate entero seria un bypass a la carta (romper un modulo y
883
+ // seguir sin policy), catch de la review. Se permite EXACTAMENTE una
884
+ // cosa: editar el fichero que rompe kj. Lo demas sigue denegado.
885
+ const at = broken ? (String(pres.stderr || "").split("\\n").map((l) => l.trim()).find((l) => /^[^ ]+\\.(m?js|cjs|ts):\\d+$/.test(l)) || "") : "";
886
+ // Node localiza los errores de un modulo ESM como file:///ruta.js:124,
887
+ // y esa forma no es una ruta de fichero (catch de la review).
888
+ const asPath = (p) => (p.startsWith("file:") ? fileURLToPath(p) : p);
889
+ const brokenFile = at ? asPath(at.slice(0, at.lastIndexOf(":"))) : "";
890
+ if (broken) {
891
+ const target = input.file_path || input.notebook_path || "";
892
+ const repairing = EDIT_TOOLS.includes(tool) && brokenFile && target && resolve(String(target)) === resolve(brokenFile);
893
+ if (repairing) console.error("karajan sentinel: kj no arranca (" + broken[0] + " en " + at + ") — la policy no se puede evaluar, asi que solo se permite arreglar ESE fichero. Arreglalo y vuelve." + doc("policy"));
894
+ else {
895
+ 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"));
896
+ process.exit(2);
897
+ }
898
+ } else if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
853
899
  else {
854
900
  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"));
855
901
  process.exit(2);
@@ -907,12 +953,19 @@ process.stdin.on("end", () => {
907
953
  const rs = load().sessions?.[sid] || {};
908
954
  const hits = rs.rag_hits || [];
909
955
  const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
910
- const fresh = !existsSync(String(file));
956
+ // KJC-BUG-0210: nuevo no es "no existe en disco". En cuanto la sesion
957
+ // lo crea, la siguiente edicion exigia una respuesta del RAG sobre un
958
+ // fichero que el indice NO puede tener. El hecho comprobable: un
959
+ // fichero que git no conoce no ha podido indexarse.
960
+ // Solo se pregunta a git en el camino lento, cuando el gate iba a
961
+ // denegar: exit 1 = git responde que no lo conoce; cualquier otro
962
+ // resultado se comporta como hasta ahora y bloquea.
963
+ const fresh = () => !existsSync(String(file)) || spawnSync("git", ["-C", ROOT, "ls-files", "--error-unmatch", "--", rel], { encoding: "utf8" }).status === 1;
911
964
  // A path outside this tree is another lane's business (the lane guard
912
965
  // above rules on it) and the ledger only holds in-repo paths.
913
966
  // KJC-BUG-0190: with nothing indexed, no query can cover anything —
914
967
  // the gate asks for kj rag index instead of denying the impossible.
915
- const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || (fresh && (rs.rag_queries || []).length > 0) || rs.rag_index_empty === true;
968
+ 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;
916
969
  if (!covered) {
917
970
  if (escOn("KJ_ALLOW_NO_RAG")) {
918
971
  // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
@@ -930,15 +983,54 @@ process.stdin.on("end", () => {
930
983
  // and nothing advances (commit, new PR, another merge; push and the end of
931
984
  // the turn go through violations()) until the card is moved. The board is
932
985
  // true at all times or the method stops — never a rule in the agent's memory.
986
+ // ENF-B (KJC-TSK-0854): el review es obligatorio, no una sugerencia. Una
987
+ // bandera no puede apagar el gate de commit, y mover core.hooksPath lo
988
+ // apaga entero. El --no-verify que kj harden --commit usa por dentro no
989
+ // pasa por aqui: no es una tool call. Escape humano, sellado como el resto.
990
+ const words = wordsOf(cmd);
991
+ // git acepta abreviaturas no ambiguas de las opciones largas, asi que
992
+ // --no-ver salta el hook igual que --no-verify (catch de la review).
993
+ const shortWithN = (w) => w.length > 1 && w[0] === "-" && w[1] !== "-" && w.includes("n");
994
+ const skipsVerify = (w) => shortWithN(w) || (w.length >= 5 && "--no-verify".startsWith(w));
995
+ // Mover core.hooksPath apaga TODOS los hooks. git tiene varias puertas
996
+ // para lo mismo (config, -c, --config-env, GIT_CONFIG_KEY_n) y no
997
+ // distingue mayusculas en la llave, asi que lo que se vigila es la LLAVE
998
+ // en cualquier posicion (dos catches de la review). Leerla no hace nada.
999
+ // Nombrar la llave basta para denegar: exceptuar las lecturas invitaba a
1000
+ // colar una detras del cambio (git config core.hooksPath /x; cat algo),
1001
+ // catch de la review. Leerla tiene remedio barato, perderla no.
1002
+ // Se busca la llave DENTRO del token y no solo al principio: las formas
1003
+ // de inyeccion la empotran (GIT_CONFIG_KEY_0=core.hooksPath,
1004
+ // --config-env=core.hooksPath=VAR) y una propiedad de seguridad no puede
1005
+ // depender de como parta las palabras el separador (catch de la review).
1006
+ const movesHooks = cmd.toLowerCase().includes("core.hookspath");
1007
+ if ((inOrder(["git", "commit"], words) && words.some(skipsVerify)) || movesHooks) {
1008
+ if (escOn("KJ_ALLOW_NO_VERIFY")) { recordEscape(sid, "KJ_ALLOW_NO_VERIFY", tool); }
1009
+ else {
1010
+ 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"));
1011
+ process.exit(2);
1012
+ }
1013
+ }
933
1014
  const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
934
1015
  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)
935
1016
  const mergeM = cmd.match(MERGE);
936
1017
  const pend = pendingMoves(load().sessions?.[sid]);
937
1018
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
938
- if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
939
- else {
940
- console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + pend.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)" + doc("board-sync"));
941
- process.exit(2);
1019
+ // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1020
+ // por su primera mitad. kj decide con un hecho comprobable (otra PR
1021
+ // abierta de la card); si kj no responde, se bloquea como siempre.
1022
+ const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
1023
+ let blocking = pend, carried = [];
1024
+ if (!bg.error && (bg.status === 0 || bg.status === 2)) {
1025
+ try { const d = JSON.parse(bg.stdout); blocking = d.blocking || []; carried = d.carried || []; } catch { /* sin respuesta legible: como siempre */ }
1026
+ }
1027
+ for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1028
+ if (blocking.length > 0) {
1029
+ if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
1030
+ else {
1031
+ 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"));
1032
+ process.exit(2);
1033
+ }
942
1034
  }
943
1035
  }
944
1036
  // (the pending entry itself is recorded by the PostToolUse hook, only once
@@ -956,13 +1048,24 @@ process.stdin.on("end", () => {
956
1048
  process.exit(2);
957
1049
  }
958
1050
  }
959
- if (PUBLISH.test(cmd)) {
1051
+ if (isPublish(cmd)) {
960
1052
  if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
961
- const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
1053
+ // KJC-BUG-0204: el check se evalua PARA este comando. Un item que
1054
+ // declara remedied_by no bloquea el comando que lo repara (desplegar
1055
+ // la landing era justo lo que el check pedia); publicar no se exime.
1056
+ const res = spawnSync("kj", ["release", "check", "--json", "--for-command", cmd], { cwd: ROOT, encoding: "utf8" });
962
1057
  if (res.error || res.status === null) process.exit(0);
1058
+ let parsed = null;
1059
+ try { parsed = JSON.parse(res.stdout); } catch { /* raw output */ }
1060
+ const lifted = (parsed && parsed.lifted) || [];
1061
+ if (res.status === 0 && lifted.length > 0) {
1062
+ // En voz alta: una exencion que nadie ve no se distingue de un gate que no mira.
1063
+ console.error("karajan sentinel: release check en rojo SOLO por lo que este comando repara (" + lifted.join(", ") + ") — pasa, y el resto sigue en verde" + doc("release"));
1064
+ process.exit(0);
1065
+ }
963
1066
  if (res.status !== 0) {
964
1067
  let items = "";
965
- try { items = (JSON.parse(res.stdout).checks || []).filter((c) => !c.ok).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
1068
+ try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
966
1069
  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"));
967
1070
  process.exit(2);
968
1071
  }
@@ -1000,24 +1103,124 @@ const SCRIPT_BODIES = {
1000
1103
  * package — outside the project tree, beyond the session's tool reach —
1001
1104
  * because nothing under .karajan can vouch for itself.
1002
1105
  */
1003
- export function verifySentinelScripts({ projectDir } = {}) {
1004
- const dir = join(projectDir || resolveSentinelRoot(), ".karajan", "harness");
1106
+ const PROVENANCE_PATH = ".karajan/supervisor-provenance.json";
1107
+
1108
+ /**
1109
+ * KJC-BUG-0211: el cuerpo que kj escribiria para ese guardia, venga de las
1110
+ * plantillas del Sentinel o de las del harness. Quien verifica un sello
1111
+ * recomputa con esto; sin el, un sello que cubre los guardias (KJC-BUG-0197)
1112
+ * no se puede ni commitear, porque nadie sabe comprobarlos.
1113
+ */
1114
+ export const canonicalHarnessBody = (name) => SCRIPT_BODIES[name] ?? HARNESS_BODIES[name] ?? null;
1115
+
1116
+ /**
1117
+ * KJC-BUG-0197: what did a human actually seal, per guard?
1118
+ *
1119
+ * Read from **git**, never from the working tree. The provenance is tracked and
1120
+ * written only by `kj harden --commit` (a human act with four layers, ADR 0009),
1121
+ * so the committed copy is the external reference; the copy on disk is as
1122
+ * forgeable as the guard it would vouch for, and the review caught exactly that.
1123
+ * No git, no commit, no seal: then nothing is forgiven and the check fails
1124
+ * closed, which is the safe direction.
1125
+ *
1126
+ * The map is keyed BY PATH: a hash sealed for one file never vouches for
1127
+ * another (also the review's catch).
1128
+ *
1129
+ * @returns {Map<string, string>} relative path → sealed sha256
1130
+ */
1131
+ function sealedByPath(projectDir, gitShowFn) {
1132
+ try {
1133
+ const raw = gitShowFn
1134
+ ? gitShowFn(projectDir, PROVENANCE_PATH)
1135
+ : execFileSync("git", ["-C", projectDir, "show", `HEAD:${PROVENANCE_PATH}`], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1136
+ const files = JSON.parse(raw)?.files;
1137
+ const out = new Map();
1138
+ for (const entry of Array.isArray(files) ? files : []) {
1139
+ if (typeof entry?.file === "string" && typeof entry?.sha256 === "string") out.set(entry.file, entry.sha256);
1140
+ }
1141
+ return out;
1142
+ } catch {
1143
+ return new Map();
1144
+ }
1145
+ }
1146
+
1147
+ /**
1148
+ * @returns {{ok: boolean, mismatched: string[], drift?: string[], reason?: string}}
1149
+ * `mismatched` is TAMPERING: a guard holds content nobody sealed, and that is
1150
+ * what the hooks block on. `drift` is a sealed guard whose templates have
1151
+ * moved on, which `kj harden` applies and which is not a defect.
1152
+ */
1153
+ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, gitShowFn = null } = {}) {
1154
+ const root = projectDir || resolveSentinelRoot();
1155
+ const dir = join(root, ".karajan", "harness");
1156
+ const installed = new Map();
1005
1157
  const mismatched = [];
1006
1158
  for (const [name, body] of Object.entries(SCRIPT_BODIES)) {
1007
1159
  try {
1008
- if (readFileSync(join(dir, name), "utf8") !== body) mismatched.push(name);
1160
+ const text = readFileFn(join(dir, name), "utf8");
1161
+ installed.set(name, text);
1162
+ if (text !== body) mismatched.push(name);
1009
1163
  } catch {
1010
- mismatched.push(name);
1164
+ mismatched.push(name); // missing: there is no content for a seal to cover
1011
1165
  }
1012
1166
  }
1013
- return { ok: mismatched.length === 0, mismatched };
1167
+ if (mismatched.length === 0) return { ok: true, mismatched: [] };
1168
+
1169
+ // A mismatch is only forgiven when what sits there is something a human
1170
+ // sealed. Editing a guard produces content no seal covers, so the forgiveness
1171
+ // cannot be borrowed by also editing a template: that was the hole in the
1172
+ // first attempt at this fix, and the review was right to reject it.
1173
+ const sealed = sealedByPath(root, gitShowFn);
1174
+ const drift = [];
1175
+ const tampered = [];
1176
+ for (const name of mismatched) {
1177
+ const text = installed.get(name);
1178
+ const sealedHash = sealed.get(`.karajan/harness/${name}`);
1179
+ if (text !== undefined && sealedHash && sealedHash === createHash("sha256").update(text).digest("hex")) drift.push(name);
1180
+ else tampered.push(name);
1181
+ }
1182
+ if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}) };
1183
+ return {
1184
+ ok: true,
1185
+ mismatched: [],
1186
+ drift,
1187
+ reason: "el harness instalado está sellado y las plantillas de este árbol van por delante (kj harden lo pone al día) — no es manipulación",
1188
+ };
1014
1189
  }
1015
1190
 
1016
- /** Write the sentinel scripts (shared lib + state writer + gates) and wire them. */
1017
- export function installSentinelHooks({ projectDir = process.cwd(), logger = console } = {}) {
1018
- const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) =>
1019
- writeHarnessScript(projectDir, name, body),
1020
- );
1191
+ /**
1192
+ * Write the sentinel scripts (shared lib + state writer + gates) and wire them.
1193
+ *
1194
+ * KJC-BUG-0193: a guard holding exactly what a human sealed is LEFT ALONE. This
1195
+ * is the command a session reaches for when it has edited the templates, and
1196
+ * rewriting the guards here un-sealed the ones actually running, so the very act
1197
+ * of unblocking destroyed the seal that made unblocking unnecessary. Advancing
1198
+ * them is a human act: `kj harden --commit` moves guards and seal together.
1199
+ * Anything nobody sealed is rewritten as before, because restoring a tampered
1200
+ * guard is still the right move.
1201
+ */
1202
+ export function installSentinelHooks({ projectDir = process.cwd(), logger = console, human = false, gitShowFn = null } = {}) {
1203
+ const sealed = human ? new Map() : sealedByPath(projectDir, gitShowFn);
1204
+ const deferred = [];
1205
+ const keepsItsSeal = (name) => {
1206
+ const sealedHash = sealed.get(`.karajan/harness/${name}`);
1207
+ if (!sealedHash) return false;
1208
+ try {
1209
+ return sealedHash === createHash("sha256").update(readFileSync(join(projectDir, ".karajan", "harness", name), "utf8")).digest("hex");
1210
+ } catch {
1211
+ return false; // no hay contenido que un sello pueda cubrir
1212
+ }
1213
+ };
1214
+ const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) => {
1215
+ if (keepsItsSeal(name)) {
1216
+ deferred.push(name);
1217
+ return join(projectDir, ".karajan", "harness", name);
1218
+ }
1219
+ return writeHarnessScript(projectDir, name, body);
1220
+ });
1221
+ if (deferred.length > 0) {
1222
+ logger?.info?.(`kj harden: ${deferred.join(", ")} se queda como está — es lo que un humano selló, y avanzarlo es suyo: kj harden --commit`);
1223
+ }
1021
1224
  const { wired } = mergeClaudeHooks({
1022
1225
  projectDir,
1023
1226
  logger,
@@ -1033,5 +1236,5 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1033
1236
  { event: "Stop", script: "stop.mjs" },
1034
1237
  ],
1035
1238
  });
1036
- return { scripts: [lib, post, stop, pre], wired };
1239
+ return { scripts: [lib, post, stop, pre], wired, deferred };
1037
1240
  }
@@ -16,6 +16,13 @@ import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
16
16
 
17
17
  export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
18
18
  const HOOKS_PREFIX = ".karajan/hooks/";
19
+ // KJC-BUG-0197: la procedencia del supervisor no cubría los guardias del
20
+ // supervisor. Sellaba los hooks de git y ningún .mjs del harness, que son
21
+ // justo los que vigilan la sesión, así que nada externo podía decir si un
22
+ // guardia instalado era auténtico. Los ficheros siguen fuera de git (los
23
+ // veredictos son locales por diseño); lo que viaja y se firma es su HUELLA,
24
+ // dentro de una procedencia que sí está trackeada.
25
+ const HARNESS_PREFIX = ".karajan/harness/";
19
26
 
20
27
  const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
21
28
 
@@ -58,6 +65,23 @@ function defaultConfirm(nonce) {
58
65
  }
59
66
 
60
67
  /** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
68
+ /**
69
+ * KJC-BUG-0197: los guardias del Sentinel que el sello debe cubrir. No salen de
70
+ * `git status` (están gitignorados), así que se leen del disco. Un proyecto sin
71
+ * harness (perfil minimal) sella los hooks y nada más.
72
+ * @returns {string[]} rutas relativas, ordenadas
73
+ */
74
+ export function harnessGuards(projectDir) {
75
+ try {
76
+ return readdirSync(join(projectDir, HARNESS_PREFIX))
77
+ .filter((f) => f.endsWith(".mjs"))
78
+ .map((f) => HARNESS_PREFIX + f)
79
+ .sort();
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
61
85
  export function supervisorDrift({ projectDir, gitFn }) {
62
86
  const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
63
87
  // Un rename sale como "R old -> new" (catch de codex): ambas rutas son
@@ -107,10 +131,26 @@ export async function commitSupervisorRegeneration({
107
131
  // La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
108
132
  // en el primer estreno real: un sello parcial pisaba al anterior y dejaba
109
133
  // ficheros sin respaldo). Borrados del drift ⇒ deleted (catch de codex).
110
- const current = readdirSync(join(projectDir, HOOKS_PREFIX)).map((f) => HOOKS_PREFIX + f).sort();
134
+ const current = [
135
+ ...readdirSync(join(projectDir, HOOKS_PREFIX)).map((f) => HOOKS_PREFIX + f),
136
+ ...harnessGuards(projectDir),
137
+ ].sort();
138
+ // KJC-BUG-0197: un guardia BORRADO se anota como borrado, igual que un hook.
139
+ // No hacía falta para desencadenar el re-sello (la lista encoge, así que la
140
+ // cobertura ya falla), pero registrar la ausencia dice más que omitirla:
141
+ // quien lea la procedencia ve que ese guardia se fue, no que nunca estuvo.
142
+ const previousGuards = (() => {
143
+ try {
144
+ return JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8"))?.files ?? [];
145
+ } catch { return []; }
146
+ })();
147
+ const goneGuards = previousGuards
148
+ .map((f) => f?.file)
149
+ .filter((f) => typeof f === "string" && f.startsWith(HARNESS_PREFIX) && !existsSync(join(projectDir, f)));
111
150
  const hashed = [
112
151
  ...current.map((file) => ({ file, sha256: sha256(join(projectDir, file)) })),
113
152
  ...drift.filter((f) => !existsSync(join(projectDir, f))).map((file) => ({ file, deleted: true })),
153
+ ...goneGuards.map((file) => ({ file, deleted: true })),
114
154
  ];
115
155
  let previous = null;
116
156
  try { previous = JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8")); } catch { /* primer sello */ }
@@ -196,9 +196,15 @@ export const SHRINK_BUDGET_WORKFLOW = [
196
196
  " BASE_REF: ${{ github.base_ref }}",
197
197
  " run: |",
198
198
  // KJC-TSK-0795 AC4 — measured in GREBLA: a 414-line warning that was almost
199
- // all pnpm-lock.yaml. Generated files are weight nobody wrote: the generated
200
- // gate applies the same exclusions kj's own repo applies.
201
- " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' || true)",
199
+ // all pnpm-lock.yaml. Generated files are weight nobody wrote.
200
+ //
201
+ // KJC-BUG-0205: this line used to exclude `**/*.md` wholesale while claiming
202
+ // to apply "the same exclusions kj's own repo applies". It did not: kj counts
203
+ // AI-rule files (CLAUDE.md, AGENTS.md, templates/**) ON PURPOSE, because they
204
+ // enter the agent's context every run and unbounded growth there dilutes the
205
+ // signal. Documentation is exempt where documentation lives; markdown that
206
+ // instructs an agent is code.
207
+ " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/docs/**/*.md' ':!**/docs/**/*.mdx' ':!**/docs/**/*.txt' ':!**/docs/**/*.rst' ':!README.md' ':!README.*.md' ':!CHANGELOG.md' ':!CONTRIBUTING.md' ':!SECURITY.md' ':!CODE_OF_CONDUCT.md' ':!MIGRATION*.md' ':!TODO*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' ':!**/*.min.js' ':!**/*.map' || true)",
202
208
  ' a=$(printf "%s\\n" "$d" | awk \'$1!="-"{s+=$1}END{print s+0}\')',
203
209
  ' r=$(printf "%s\\n" "$d" | awk \'$2!="-"{s+=$2}END{print s+0}\')',
204
210
  ' net=$((a - r)); echo "net=$net (limit=$LOC_LIMIT)"',
@@ -9,9 +9,11 @@ import { lstatSync, readFileSync } from "node:fs";
9
9
  import { resolve, sep } from "node:path";
10
10
 
11
11
  import { renderCanonicalHook } from "../harden/harden-engine.js";
12
+ import { canonicalHarnessBody } from "../harden/sentinel-hooks.js";
12
13
  import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
13
14
 
14
15
  const HOOKS_PREFIX = ".karajan/hooks/";
16
+ const HARNESS_PREFIX = ".karajan/harness/";
15
17
  // globalHooksDir es el único texto libre que se interpola en el render:
16
18
  // vocabulario cerrado de ruta o la provenance no verifica NADA.
17
19
  const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
@@ -34,36 +36,63 @@ export function verifiedSupervisorFiles({ projectDir }) {
34
36
  return { files: new Set(), reason: "globalHooksDir no verificable en la provenance" };
35
37
  }
36
38
  const ok = new Set();
39
+ const unjudgeable = new Set();
37
40
  const entries = Array.isArray(prov?.files) ? prov.files : [];
38
- const hooksRoot = resolve(projectDir, HOOKS_PREFIX);
41
+ // KJC-BUG-0211: desde KJC-BUG-0197 el sello cubre tambien los guardias del
42
+ // supervisor, y aqui solo se sabian verificar los hooks. Como `complete`
43
+ // exige que TODAS las entradas verifiquen, el sello no se podia ni commitear.
44
+ const ROOTS = [HOOKS_PREFIX, HARNESS_PREFIX].map((p) => [p, resolve(projectDir, p)]);
39
45
  for (const entry of entries) {
40
- if (typeof entry?.file !== "string" || !entry.file.startsWith(HOOKS_PREFIX)) continue;
46
+ if (typeof entry?.file !== "string") continue;
47
+ const kind = ROOTS.find(([prefix]) => entry.file.startsWith(prefix));
48
+ if (!kind) continue;
49
+ const [prefix, root] = kind;
41
50
  // Contención por resolución, no por prefijo textual (catch de codex).
42
51
  const abs = resolve(projectDir, entry.file);
43
- if (!abs.startsWith(hooksRoot + sep)) continue;
52
+ if (!abs.startsWith(root + sep)) continue;
44
53
  let st = null;
45
54
  try { st = lstatSync(abs); } catch { /* no hay objeto en la ruta */ }
46
55
  if (entry.deleted === true) {
47
56
  if (st === null) ok.add(entry.file);
48
57
  continue;
49
58
  }
59
+ // KJC-BUG-0212: los guardias estan en .gitignore, viven solo en la maquina
60
+ // que ejecuta kj harden. Que no esten AQUI no es que no verifiquen: es que
61
+ // este checkout (CI, otro clon) no puede juzgarlos. Un hook ausente si
62
+ // significa algo, porque los hooks si viajan con el repo.
63
+ // Pero "no esta aqui" no vale como cheque en blanco: el hash declarado
64
+ // tiene que ser el de un guardia que kj CONOCE, o una procedencia forjada
65
+ // colaria entradas ausentes con cualquier hash (catch de la review).
66
+ if (prefix === HARNESS_PREFIX && st === null) {
67
+ const body = canonicalHarnessBody(entry.file.slice(prefix.length));
68
+ if (body != null && typeof entry.sha256 === "string" && sha256(Buffer.from(body, "utf8")) === entry.sha256) unjudgeable.add(entry.file);
69
+ continue;
70
+ }
50
71
  if (typeof entry.sha256 !== "string" || !st?.isFile()) continue;
51
72
  if (sha256(readFileSync(abs)) !== entry.sha256) continue;
73
+ // No se confia en el hash sellado: se recomputa lo que kj escribiria.
52
74
  let canonical;
53
75
  try {
54
- canonical = renderCanonicalHook(entry.file.slice(HOOKS_PREFIX.length), gen);
76
+ const name = entry.file.slice(prefix.length);
77
+ canonical = prefix === HOOKS_PREFIX ? renderCanonicalHook(name, gen) : canonicalHarnessBody(name);
55
78
  } catch {
56
79
  continue;
57
80
  }
58
- if (sha256(Buffer.from(canonical, "utf8")) === entry.sha256) ok.add(entry.file);
81
+ if (canonical != null && sha256(Buffer.from(canonical, "utf8")) === entry.sha256) ok.add(entry.file);
59
82
  }
60
- // `complete` = CADA entrada verificó: solo entonces la provenance se
61
- // describe con honestidad y su propio diff puede alzarse.
62
- const complete = entries.length > 0 && entries.every((e) => typeof e?.file === "string" && ok.has(e.file));
63
- return { files: ok, complete };
83
+ // `complete` = ninguna entrada FALLO y alguna verifico. Exigir que todas
84
+ // verifiquen hacia imposible sellar desde cualquier sitio que no fuera la
85
+ // maquina que sello, porque los guardias no viajan con el repo (0212).
86
+ const judged = (e) => ok.has(e.file) || unjudgeable.has(e.file);
87
+ const complete = ok.size > 0 && entries.every((e) => typeof e?.file === "string" && judged(e));
88
+ return { files: ok, complete, unjudgeable };
64
89
  }
65
90
 
66
- /** Filtra violaciones de supervisor respaldadas — {violations, lifted}. */
91
+ /**
92
+ * Filtra violaciones de supervisor respaldadas — {violations, lifted, note?}.
93
+ * `note` dice lo que este sitio NO ha podido juzgar, para que un levantamiento
94
+ * mas laxo de lo que parece no pase por silencio (KJC-BUG-0212).
95
+ */
67
96
  export function liftSealedSupervisorViolations({ projectDir, violations }) {
68
97
  const RULE = "defaults.supervisor.write";
69
98
  if (!violations.some((v) => v.rule_id === RULE && v.file)) return { violations, lifted: 0 };
@@ -75,5 +104,10 @@ export function liftSealedSupervisorViolations({ projectDir, violations }) {
75
104
  && v.file
76
105
  && (sealed.files.has(v.file) || (v.file === PROVENANCE_FILE && sealed.complete === true));
77
106
  const kept = violations.filter((v) => !liftable(v));
78
- return { violations: kept, lifted: violations.length - kept.length };
107
+ const unjudged = sealed.unjudgeable?.size ?? 0;
108
+ return {
109
+ violations: kept,
110
+ lifted: violations.length - kept.length,
111
+ ...(unjudged > 0 ? { note: `${unjudged} guardia(s) del sello no existen en este checkout y no se han podido comprobar aquí` } : {}),
112
+ };
79
113
  }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Which pending board moves actually block (KJC-BUG-0198).
3
+ *
4
+ * board-sync blocked every advance until a merged PR's card reached a closing
5
+ * state, which assumes one PR per card while the project's rule splits any card
6
+ * over ~150 lines. With KJC-BUG-0197 (#1802 and #1803) that demanded a move that
7
+ * would have been a lie, and the way out was `KJ_ALLOW_BOARD=1`, which switches
8
+ * the whole gate off. A card still being delivered is not a lying board; a TURN
9
+ * ending with work nobody recorded is, and that stays the Stop gate's job.
10
+ *
11
+ * One verifiable fact carries a pending, never a promise in prose: the same card
12
+ * has another OPEN pull request. A branch named after the card is not enough,
13
+ * because the merged branch carries that name too, so accepting it would forgive
14
+ * every merge made from its own lane.
15
+ */
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import { readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ import { CARD_REF_RE } from "./card-first.js";
22
+
23
+ const cardOf = (text) => {
24
+ const m = CARD_REF_RE.exec(String(text || ""));
25
+ return m ? m[0].toUpperCase() : null;
26
+ };
27
+
28
+ /**
29
+ * `openPrHeads` null means the list could not be read, and then nothing is
30
+ * forgiven. `openPrNumbers` pairs with it so a pending never forgives itself.
31
+ *
32
+ * @returns {{blocking: Array<object>, carried: Array<{card: string, why: string}>, reason?: string}}
33
+ */
34
+ export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers = [] }) {
35
+ if (pendings.length === 0) return { blocking: [], carried: [] };
36
+ if (openPrHeads === null) return { blocking: [...pendings], carried: [], reason: "no se pudo comprobar si la card sigue viva en otra PR abierta (gh no respondió) — se bloquea" };
37
+ const openCards = new Set();
38
+ for (const [i, head] of openPrHeads.entries()) {
39
+ // A pending must never forgive itself: the PR it came from is merged, and if
40
+ // a stale listing still shows it, it does not count as "still open".
41
+ if (openPrNumbers[i] !== undefined && pendings.some((p) => Number(p.pr) === Number(openPrNumbers[i]))) continue;
42
+ const card = cardOf(head);
43
+ if (card) openCards.add(card);
44
+ }
45
+ const blocking = [], carried = [];
46
+ for (const p of pendings) {
47
+ const card = p.card ? String(p.card).toUpperCase() : null;
48
+ if (!card) { blocking.push(p); continue; } // nothing to check against
49
+ if (openCards.has(card)) carried.push({ card, why: "sigue entregándose en una PR abierta" });
50
+ else blocking.push(p);
51
+ }
52
+ return { blocking, carried };
53
+ }
54
+
55
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } };
56
+
57
+ /** The decision the guard asks for, gathering the facts here so the rule ships
58
+ * with npm instead of waiting for the human to reseal the harness. */
59
+ export function boardGate({ projectDir = process.cwd(), sessionId = "default", deps = {} } = {}) {
60
+ const state = deps.readState?.() ?? readJson(join(projectDir, ".karajan", "harness", "sentinel-state.json"));
61
+ const pendings = state?.sessions?.[sessionId]?.pending_moves;
62
+ if (!Array.isArray(pendings) || pendings.length === 0) return { blocking: [], carried: [] };
63
+ const run = deps.run ?? ((cmd, args) => execFileSync(cmd, args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }));
64
+ let open = null;
65
+ try { open = JSON.parse(run("gh", ["pr", "list", "--state", "open", "--limit", "100", "--json", "number,headRefName"])); } catch { /* fail closed */ }
66
+ const listed = Array.isArray(open) ? open : null;
67
+ return blockingMoves({ pendings, openPrHeads: listed?.map((p) => p.headRefName) ?? null, openPrNumbers: listed?.map((p) => Number(p.number)) ?? [] });
68
+ }