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.
- package/package.json +1 -1
- package/src/brain/one-shot-policy.js +26 -0
- package/src/brain/with-brain-recovery.js +4 -1
- package/src/checks/release-check.js +51 -5
- package/src/cli/register-meta.js +42 -2
- package/src/commands/code.js +18 -16
- package/src/commands/harden.js +9 -2
- package/src/commands/policy.js +21 -14
- package/src/commands/report.js +28 -0
- package/src/commands/review-gate.js +13 -11
- package/src/environment/panel.js +33 -0
- package/src/harden/guidelines-engine.js +7 -1
- package/src/harden/guidelines-templates.js +10 -1
- package/src/harden/harness-hooks.js +6 -0
- package/src/harden/sentinel-hooks.js +227 -24
- package/src/harden/supervisor-commit.js +41 -1
- package/src/harden/workflow-templates.js +9 -3
- package/src/policy/supervisor-verify.js +45 -11
- package/src/review/board-pending.js +68 -0
- package/src/review/loc-budget.js +97 -0
- package/src/roles/audit-role.js +28 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)) || (
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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 (
|
|
1051
|
+
if (isPublish(cmd)) {
|
|
960
1052
|
if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
|
|
961
|
-
|
|
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 = (
|
|
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
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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 =
|
|
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
|
|
200
|
-
//
|
|
201
|
-
|
|
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
|
-
|
|
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"
|
|
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(
|
|
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
|
-
|
|
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` =
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
}
|