karajan-code 4.65.0 → 4.66.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.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // kj tool gate (KJC-TSK-0710) — managed by `kj harden`. Exit 2 blocks the
3
+ // tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
4
+ // so a gate bug never bricks the session.
5
+ import console from "node:console";
6
+ import process from "node:process";
7
+ import { existsSync } from "node:fs";
8
+ let raw = "";
9
+ process.stdin.on("data", (d) => { raw += d; });
10
+ process.stdin.on("end", () => {
11
+ try {
12
+ const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
13
+ if (tool === "Write" && input.file_path && existsSync(input.file_path)) {
14
+ console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes.");
15
+ process.exit(2);
16
+ }
17
+ } catch { /* fail open */ }
18
+ process.exit(0);
19
+ });
@@ -0,0 +1,114 @@
1
+ // kj sentinel shared lib (KJC-TSK-0714) — managed by `kj harden`.
2
+ // Single source for every sentinel script: state, branch, classification,
3
+ // and violations.
4
+ import process from "node:process";
5
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { execSync, spawnSync } from "node:child_process";
7
+ import { basename, dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ // KJC-TSK-1202 (ADR 0030): installed in a project the guard sits in .karajan/harness,
11
+ // two levels below its root; run from the kj package, the project is the one the
12
+ // host names (CLAUDE_PROJECT_DIR) or the working directory, at its git root.
13
+ const installed = basename(here) === "harness" && basename(dirname(here)) === ".karajan";
14
+ const projectRoot = () => {
15
+ const start = process.env.CLAUDE_PROJECT_DIR || process.cwd();
16
+ try { return execSync("git rev-parse --show-toplevel", { cwd: start, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() || start; }
17
+ catch { return start; }
18
+ };
19
+ export const ROOT = installed ? join(here, "..", "..") : projectRoot();
20
+ export const STATE = join(ROOT, ".karajan", "harness", "sentinel-state.json");
21
+ // INF-A (KJC-TSK-0758): infra IS source — terraform/k8s/shell/Docker edits
22
+ // engage the method like app code; *_test.* files (terratest) are tests.
23
+ export const TESTS = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[a-z]+$|_test\.[a-z]+$/;
24
+ export const CODE = /\.(m?[jt]sx?|c[jt]s|py|go|rs|java|rb|php|cs|swift|kt|astro|svelte|vue|c|h|cc|cpp|hpp|tf|hcl|ya?ml|sh)$|(^|\/)(Dockerfile|Makefile|Vagrantfile)$/;
25
+ export const CARD = new RegExp("\\b[a-z][a-z0-9]{1,9}(?:-[a-z][a-z0-9]{1,9})*-\\d{1,6}\\b(?!\\.\\d)", "i");
26
+ export const BASE_BRANCHES = new Set(["main", "master"]);
27
+ export const load = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
28
+ export const save = (state) => {
29
+ mkdirSync(dirname(STATE), { recursive: true });
30
+ writeFileSync(STATE, JSON.stringify(state, null, 2));
31
+ };
32
+ export const doc = (a) => " — doc: https://karajancode.com/docs/guides/sentinel/#" + a;
33
+ export const session = (state, sid) => {
34
+ state.sessions ||= {};
35
+ return (state.sessions[sid] ||= { edited_sources: [], edited_tests: [], escapes: [], errors: [], blocks: 0 });
36
+ };
37
+ export const branchOf = () => {
38
+ try { return execSync("git rev-parse --abbrev-ref HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); }
39
+ catch { return null; }
40
+ };
41
+ const _git = (args, cwd) => {
42
+ try { return execSync("git " + args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; }
43
+ };
44
+ let _home;
45
+ // MONO-0 (ADR 0002): lane identity = worktree toplevel; repo identity =
46
+ // git-common-dir. Same repo but another toplevel than OUR root => foreign
47
+ // lane (returns its toplevel); anything else => null.
48
+ export const foreignLane = (targetDir) => {
49
+ const top = _git("rev-parse --show-toplevel", targetDir);
50
+ if (!top) return null;
51
+ _home ||= { top: _git("rev-parse --show-toplevel", ROOT), common: _git("rev-parse --path-format=absolute --git-common-dir", ROOT) };
52
+ if (!_home.top || top === _home.top) return null;
53
+ const common = _git("rev-parse --path-format=absolute --git-common-dir", targetDir);
54
+ return common && common === _home.common ? top : null;
55
+ };
56
+ // KJC-TSK-0765 board-sync: cards merged in this session but not yet moved in
57
+ // the tracker. The method does not advance until the board is true.
58
+ export const pendingMoves = (s) => (s && Array.isArray(s.pending_moves) ? s.pending_moves : []);
59
+ export const pendingText = (p) => (p.card ? p.card : "card sin identificar") + " (PR #" + p.pr + ") mergeada sin mover en el tracker — muevela a To Validate / Fixed con sus commits (update_card del PG o kj hu move)";
60
+ // KJC-BUG-0185: bootstrap phase = the repo has no commit yet. There is no base
61
+ // branch to protect and no coverage to demand over a tree that does not exist,
62
+ // and `git diff HEAD` cannot even run. The phase is a verifiable FACT, never a
63
+ // flag, and it ends at the first commit.
64
+ export const bootstrapPhase = () => {
65
+ try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
66
+ catch { return true; }
67
+ };
68
+ // KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
69
+ // open. kj decides with that checkable fact, the same answer for commit and push
70
+ // (the Stop gate still holds the turn: the board owes the move); when kj does not
71
+ // answer, every pending move blocks.
72
+ export const boardGate = (sid, pend) => {
73
+ if (!pend.length) return { blocking: [], carried: [] };
74
+ const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
75
+ if (!bg.error && (bg.status === 0 || bg.status === 2)) {
76
+ try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [], reason: d.reason || "" }; } catch { /* unreadable: as always */ }
77
+ }
78
+ return { blocking: pend, carried: [] };
79
+ };
80
+ // KJC-BUG-0265: on the base branch what counts is work still there (the session's
81
+ // files dirty, or local commits the remote does not have), not the ledger: after a
82
+ // merged PR and a sync of main the ledger stays full while the base holds nothing.
83
+ // Anything git cannot answer (no upstream, odd path) counts as work: fail closed.
84
+ const baseHoldsWork = (files) => {
85
+ if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
86
+ try {
87
+ if (execSync("git status --porcelain -- " + files.join(" "), { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim()) return true;
88
+ return execSync("git rev-list @{u}..HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== "";
89
+ } catch { return true; }
90
+ };
91
+ export const violations = (s, branch, pending = pendingMoves(s)) => {
92
+ const v = pending.map(pendingText);
93
+ if (!s || !(s.edited_sources || []).length) return v;
94
+ if (bootstrapPhase()) return v;
95
+ if (BASE_BRANCHES.has(branch)) { if (baseHoldsWork(s.edited_sources)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion"); }
96
+ else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
97
+ if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
98
+ return v;
99
+ };
100
+ // KJC-TSK-0795 AC1: deleting code adds no behavior to test — a session whose
101
+ // source edits only REMOVED lines owes no test. Unreadable git = conservative
102
+ // (the violation stands): a gate must never stand down on a shrug.
103
+ const sessionAddsCode = (s) => {
104
+ const files = s.edited_sources || [];
105
+ // _git builds a shell string: any path outside a strict charset stays out of
106
+ // it — and the gate stays ARMED for it (conservative, and injection-proof).
107
+ if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
108
+ const stat = _git("diff HEAD --numstat -- " + files.join(" "), ROOT);
109
+ if (!stat) return true;
110
+ return stat.split(String.fromCharCode(10)).some((l) => {
111
+ const a = l.trim().replaceAll(String.fromCharCode(9), " ").split(" ").filter(Boolean)[0];
112
+ return a === "-" || Number(a) > 0;
113
+ });
114
+ };
@@ -38,13 +38,23 @@ process.stdin.on("end", () => {
38
38
  lines.push("Karajan (reglas que la compactación se lleva):", ...RULES, `Estado: rama ${branch}, card ${card}${unmoved}.`);
39
39
  }
40
40
  if (drift) lines.push(`Karajan: ${drift}`);
41
+ // KJC-TSK-1205 (ADR 0030): integrity is checked when the session starts, not only
42
+ // when a turn ends. Said first, to the agent and to the person; kj missing says nothing.
43
+ const ver = spawnSync("kj", ["sentinel", "verify", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 15000 });
44
+ let alarm = null;
45
+ if (!ver.error && ver.status !== 0 && ver.status !== null) {
46
+ let what = ["?"];
47
+ try { what = JSON.parse(ver.stdout).mismatched || what; } catch { /* unreadable: still tampered */ }
48
+ alarm = `el supervisor está manipulado (${what.join(", ")}): restaura con kj harden y avisa a tu usuario antes de seguir.`;
49
+ lines.unshift(`Karajan: ${alarm}`);
50
+ }
41
51
  // KJC-TSK-0986 (HUM-F, ADR 0018): once per kj version, the invitation to
42
52
  // enroll the phone when none is; kj decides and remembers, the hook relays.
43
53
  const invite = spawnSync("kj", ["identity", "phone-invite"], { encoding: "utf8", timeout: 8000 });
44
54
  const inviteText = invite.status === 0 ? String(invite.stdout || "").trim() : "";
45
55
  if (inviteText) lines.push(`Karajan: ${inviteText}`);
46
56
  if (lines.length === 0) process.exit(0);
47
- process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } }));
57
+ process.stdout.write(JSON.stringify({ ...(alarm ? { systemMessage: `Karajan: ${alarm}` } : {}), hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } }));
48
58
  } catch { /* never fails a session */ }
49
59
  process.exit(0);
50
60
  });
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // kj sentinel stop gate (KJC-TSK-0713) — managed by `kj harden`. Exit 2
3
+ // BLOCKS ending the turn while method violations are open (stderr lists each
4
+ // one with its remediation). Fails OPEN on corrupt state, git errors, or
5
+ // after 3 unresolved blocks — a sentinel bug never bricks the session — and
6
+ // the fail-open is recorded in the state. `--status` prints, never blocks.
7
+ import console from "node:console";
8
+ import process from "node:process";
9
+ import { spawnSync } from "node:child_process";
10
+ import { readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { doc, load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
14
+ // KJC-TSK-0774: when the turn ends green with cards closed, remind the user
15
+ // where to look — the board URL if it runs (pid file alive), else how to start it.
16
+ const boardHint = () => {
17
+ const home = process.env.KARAJAN_HOME || process.env.KJ_HOME || join(homedir(), ".karajan");
18
+ try {
19
+ process.kill(Number(readFileSync(join(home, "hu-board.pid"), "utf8").trim()), 0);
20
+ return "http://localhost:" + (process.env.KJ_BOARD_PORT || "4000") + "/#governance";
21
+ } catch { return "no esta arrancado: kj board start (luego http://localhost:4000/#governance)"; }
22
+ };
23
+ if (process.argv.includes("--status")) {
24
+ const st = load();
25
+ const branch = branchOf();
26
+ const sessions = Object.entries(st.sessions || {});
27
+ if (!sessions.length) console.log("sentinel: sin actividad registrada en esta sesion");
28
+ for (const [sid, s] of sessions) {
29
+ console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : "") + " rag=" + (s.rag_queries || []).length + " queries/" + (s.rag_hits || []).length + " sources");
30
+ for (const x of violations(s, branch)) console.log(" ROJO: " + x);
31
+ }
32
+ process.exit(0);
33
+ }
34
+ let raw = "";
35
+ process.stdin.on("data", (d) => { raw += d; });
36
+ process.stdin.on("end", () => {
37
+ try {
38
+ if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
39
+ const { session_id: sid = "default", transcript_path: transcript = null } = JSON.parse(raw);
40
+ const state = load();
41
+ // Tamper check runs regardless of session state: shell indirection leaves
42
+ // no edit-tool record, but it cannot survive `kj sentinel verify`, whose
43
+ // root of trust is the INSTALLED kj package (outside this project tree) —
44
+ // nothing under .karajan can vouch for itself. No kj on PATH → fail open.
45
+ const ver = spawnSync("kj", ["sentinel", "verify", "--json"], { cwd: ROOT, encoding: "utf8" });
46
+ let tampered = [];
47
+ if (!ver.error && ver.status !== null && ver.status !== 0) {
48
+ try { tampered = JSON.parse(ver.stdout).mismatched || ["unknown"]; } catch { tampered = ["unknown"]; }
49
+ }
50
+ if (tampered.length) {
51
+ // KJC-TSK-1205 (ADR 0030): tampering is not a Sentinel failure, so it never
52
+ // fails open; the count stays as a record of how long it went unrestored.
53
+ state.tamper_blocks = (state.tamper_blocks || 0) + 1;
54
+ save(state);
55
+ console.error("karajan sentinel: scripts del supervisor modificados fuera de kj harden (" + tampered.join(", ") + ") — restaura con kj harden antes de terminar; si lo cambiaste tu (humano), reinstalar lo deja en verde." + doc("supervisor"));
56
+ process.exit(2);
57
+ }
58
+ state.tamper_blocks = 0;
59
+ // CLM-B (KJC-TSK-0802, claims-with-evidence ADR): the hard data of the turn's
60
+ // final message, crossed against that turn's outputs. It runs even without a
61
+ // tracked session — a turn with no edits still ends with a final message. The
62
+ // hook carries no policy: kj reads method_gates.claims and answers 0 (off,
63
+ // clean, or warn) or 2 (a datum DENIED by its own source, in block mode). A
64
+ // missing kj or an errored spawn fails open.
65
+ let claimsNote = "";
66
+ let claimsBlock = null;
67
+ if (transcript) {
68
+ const c = spawnSync("kj", ["claims", "gate", "--transcript", transcript], { cwd: ROOT, encoding: "utf8" });
69
+ if (!c.error && c.status === 2) claimsBlock = "claims: un dato del mensaje final esta DESMENTIDO por las salidas de este turno — verificalo o marcalo como no comprobado. Detalle: kj claims check --transcript " + transcript;
70
+ else if (!c.error && c.status === 0 && (c.stderr || "").trim()) claimsNote = "kj claims: " + c.stderr.trim().split(String.fromCharCode(10))[0] + " — datos sin respaldo en este turno (informa, no bloquea).";
71
+ }
72
+ const s = state.sessions?.[sid];
73
+ if (!s) {
74
+ if (claimsBlock) { console.error("karajan sentinel: el turno NO puede terminar:" + String.fromCharCode(10) + "- " + claimsBlock + doc("claims")); process.exit(2); }
75
+ if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
76
+ process.exit(0);
77
+ }
78
+ // KJC-TSK-0868: si el anfitrion ha escrito codigo teniendo otro coder
79
+ // declarado, eso no se bloquea, se REGISTRA: el usuario eligio quien
80
+ // escribe y merece enterarse por el decision log, no por casualidad. Una
81
+ // vez por sesion, y si kj no responde se reintenta en el turno siguiente.
82
+ if (!s.panel_sealed && (s.edited_sources || []).length > 0) {
83
+ const p = spawnSync("kj", ["sentinel", "panel-check", "--session", sid], { cwd: ROOT, encoding: "utf8" });
84
+ if (!p.error && p.status === 0) {
85
+ s.panel_sealed = true;
86
+ save(state);
87
+ if ((p.stdout || "").trim()) console.error(p.stdout.trim());
88
+ }
89
+ }
90
+ const v = violations(s, branchOf());
91
+ if (claimsBlock) v.push(claimsBlock);
92
+ if (!v.length) {
93
+ s.blocks = 0;
94
+ const notes = [];
95
+ if (claimsNote) notes.push(claimsNote);
96
+ if ((s.closed_cards || []).length) {
97
+ notes.push("karajan sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
98
+ s.closed_cards = [];
99
+ }
100
+ save(state);
101
+ if (notes.length) console.log(JSON.stringify({ systemMessage: notes.join("\n") }));
102
+ process.exit(0);
103
+ }
104
+ s.blocks = (s.blocks || 0) + 1;
105
+ if (s.blocks > 3) {
106
+ (s.errors ||= []).push("fail-open: 3 bloqueos consecutivos sin resolver — el sentinel se aparta para no colgar la sesion");
107
+ save(state);
108
+ console.error("karajan sentinel: fail-open tras 3 bloqueos sin resolver — revisa kj sentinel status con tu usuario" + doc("stop-gate"));
109
+ process.exit(0);
110
+ }
111
+ save(state);
112
+ console.error("karajan sentinel: el turno NO puede terminar con el metodo en rojo:\n" + v.map((x) => "- " + x).join("\n") + "\nResuelve las violaciones (o pide a tu usuario el escape) y termina de nuevo. Estado: kj sentinel status" + doc("stop-gate"));
113
+ process.exit(2);
114
+ } catch { /* fail open */ }
115
+ process.exit(0);
116
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The Sentinel's guards as the hooks name them (KJC-TSK-1204, ADR 0030): one
3
+ * closed list for both ways of running them from the installed package, the
4
+ * kj-hook bin and `kj sentinel hook` (the single-file binary has no kj-hook).
5
+ * Each entry loads only its module, so running a guard never pays the CLI.
6
+ */
7
+ export const SENTINEL_GUARDS = {
8
+ pretooluse: () => import("./sentinel/pretooluse.mjs"),
9
+ "pretooluse-sentinel": () => import("./sentinel/pretooluse-sentinel.mjs"),
10
+ "pretooluse-rules": () => import("./sentinel/pretooluse-rules.mjs"),
11
+ posttooluse: () => import("./sentinel/posttooluse.mjs"),
12
+ stop: () => import("./sentinel/stop.mjs"),
13
+ sessionstart: () => import("./sentinel/sessionstart.mjs"),
14
+ };
15
+
16
+ /**
17
+ * Run one guard: it reads the hook's input from stdin and exits on its own.
18
+ * A name outside the list exits 2, because a wiring that names no known guard
19
+ * cannot be trusted to guard anything.
20
+ */
21
+ export async function runGuard(name, { logger = console, exit = (code) => process.exit(code) } = {}) {
22
+ if (!Object.hasOwn(SENTINEL_GUARDS, name ?? "")) {
23
+ logger.error(`kj-hook: "${name ?? ""}" is not a Sentinel guard (${Object.keys(SENTINEL_GUARDS).join(", ")}) — run kj harden to rewrite the wiring`);
24
+ exit(2);
25
+ return;
26
+ }
27
+ await SENTINEL_GUARDS[name]();
28
+ }