karajan-code 4.21.0 → 4.22.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,81 @@
1
+ /**
2
+ * Crossing what was said against what actually ran (CLM-A, KJC-TSK-0801).
3
+ *
4
+ * The transcript is the register of sources: every command, query and read left
5
+ * its output there, so nothing has to be annotated by hand. A datum that appears
6
+ * in some output is BACKED; one that appears nowhere came out of the model's
7
+ * memory (UNBACKED); one whose own source says otherwise is DENIED — and that
8
+ * is the only verdict that blocks, because it is a proven hallucination and not
9
+ * a suspicion.
10
+ *
11
+ * What cannot be decided is NOT_CHECKABLE, never an accusation: a guard that
12
+ * cries wolf gets switched off (KJC-PCS-0082).
13
+ */
14
+ import { extractClaims } from "./extract.js";
15
+
16
+ export const BACKED = "backed";
17
+ export const UNBACKED = "unbacked";
18
+ export const DENIED = "denied";
19
+ export const NOT_CHECKABLE = "not_checkable";
20
+
21
+ const norm = (s) => String(s ?? "").toLowerCase();
22
+ const escape = (s) => s.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
23
+
24
+ /**
25
+ * Numbers must match as WHOLE tokens: searching "4" as a substring finds it inside "24.6 kB"
26
+ * and backs a figure nobody measured. Found while running this over a real message — the
27
+ * synthetic tests had passed. Ids, paths and versions are distinctive enough as substrings.
28
+ */
29
+ function appearsIn(output, claim) {
30
+ const needle = norm(claim.value);
31
+ if (claim.kind === "path" || claim.kind === "card" || claim.kind === "version") return output.includes(needle);
32
+ return new RegExp(`(?<![\\w.])${escape(needle)}(?![\\w.])`).test(output);
33
+ }
34
+
35
+ /** "no cards", "[]", "0 results" — a source that positively states emptiness. */
36
+ const SAYS_EMPTY = /(^|\W)(\[\]|\bnone\b|\bno results?\b|\bempty\b|\b0 (results?|items?|matches|cards?|files?)\b)/i;
37
+
38
+ /**
39
+ * @param {{text: string, outputs: string[], userSaid?: string}} input
40
+ * outputs: tool outputs of the turn, in order. userSaid: what the user wrote (also a source).
41
+ * @returns {{claims: Array<object>, denied: Array<object>, unbacked: Array<object>}}
42
+ */
43
+ export function crossCheck({ text, outputs = [], userSaid = "" }) {
44
+ const haystack = [...outputs.map(norm), norm(userSaid)];
45
+ const claims = extractClaims(text).map((claim) => ({ ...claim, status: verdictFor(claim, haystack) }));
46
+ return {
47
+ claims,
48
+ denied: claims.filter((c) => c.status === DENIED),
49
+ unbacked: claims.filter((c) => c.status === UNBACKED),
50
+ };
51
+ }
52
+
53
+ function verdictFor(claim, haystack) {
54
+ if (haystack.some((h) => appearsIn(h, claim))) return BACKED;
55
+
56
+ // A count stated as non-zero while every source that mentions the same noun says empty:
57
+ // that is the "four cards are waiting" case, and it is the one that blocks.
58
+ if (claim.kind === "count" && Number(claim.value) > 0) {
59
+ const noun = nounAfterCount(claim.sentence, claim.value);
60
+ if (noun) {
61
+ const mentions = haystack.filter((h) => h.includes(norm(noun)));
62
+ if (mentions.length && mentions.every((h) => SAYS_EMPTY.test(h))) return DENIED;
63
+ }
64
+ // Small numbers are prose as often as data ("las dos capas", "3 reglas"): not worth accusing.
65
+ if (Number(claim.value) <= 3) return NOT_CHECKABLE;
66
+ }
67
+ return UNBACKED;
68
+ }
69
+
70
+ function nounAfterCount(sentence, value) {
71
+ const m = new RegExp(`${value}\\s+([a-zá-ú]{4,})`, "i").exec(sentence);
72
+ return m ? m[1] : null;
73
+ }
74
+
75
+ /** One human line per problem; the message is what makes a guard usable. */
76
+ export function formatClaimReport({ denied, unbacked }) {
77
+ const lines = [];
78
+ for (const c of denied) lines.push(`✗ "${c.value}" (${c.kind}) is DENIED by the output that should back it — ${c.sentence.slice(0, 100)}`);
79
+ for (const c of unbacked) lines.push(`? "${c.value}" (${c.kind}) has no backing in this turn — verify it or say it is from memory`);
80
+ return lines.join("\n");
81
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Hard data in what the AI says (CLM-A, KJC-TSK-0801, ADR "claims with evidence").
3
+ *
4
+ * A model states an invented figure with the same confidence as a measured one,
5
+ * and that figure travels to a PR, a card, another session or the user, where
6
+ * nobody checks it again. This module pulls the CHECKABLE data out of a text —
7
+ * counts, versions, file paths, card ids, commit SHAs — so it can be crossed
8
+ * against what actually ran. Prose is left alone: only data is verifiable.
9
+ *
10
+ * Deterministic and free: no model in the loop. Verifying must be cheaper than
11
+ * inventing, or nobody will verify.
12
+ */
13
+
14
+ /** Sentences that ALREADY admit they are unverified are respected, never reported. */
15
+ const HEDGES = /\b(de memoria|sin (comprobar|verificar)|no (lo )?he (comprobado|verificado)|creo que|puede que|quizá|quizás|probablemente|from memory|unverified|not checked|i think|probably)\b/i;
16
+
17
+ // Most specific first: a number already claimed as a PR or a version is not also a bare count.
18
+ // A number with no unit next to it (an OTP, a phone) is deliberately NOT a claim: it is not
19
+ // verifiable from prose, and some of them are secrets that must never travel into a report.
20
+ const PATTERNS = [
21
+ { kind: "card", re: /\b([A-Z]{3}-(?:TSK|BUG|PCS|SPR|PLA|PRP)-\d{4})\b/g, value: (m) => m[1] },
22
+ { kind: "path", re: /\b((?:[\w.-]+\/){1,}[\w.-]+\.\w{1,5})\b/g, value: (m) => m[1] },
23
+ { kind: "version", re: /\bv?(\d+\.\d+\.\d+(?:-[\w.]+)?)\b/g, value: (m) => m[1] },
24
+ { kind: "pr", re: /(?:^|[\s(])#(\d{2,6})\b/g, value: (m) => m[1] },
25
+ { kind: "sha", re: /\b([0-9a-f]{7,40})\b/g, value: (m) => m[1] },
26
+ // A number that means something: "1004 ficheros", "8 ocurrencias", "53 tests".
27
+ { kind: "count", re: /\b(\d[\d.,]*)\s+(?=[a-záéíóúñ]{3,})/gi, value: (m) => m[1].replaceAll(".", "").replaceAll(",", "") },
28
+ ];
29
+
30
+ /** Splits into sentences so a hedge only covers what it is attached to. */
31
+ const sentences = (text) => String(text || "").split(/(?<=[.!?\n])\s+/).filter(Boolean);
32
+
33
+ /**
34
+ * @param {string} text
35
+ * @returns {Array<{kind: string, value: string, sentence: string}>} unique claims, in order.
36
+ */
37
+ export function extractClaims(text) {
38
+ const out = [];
39
+ const seen = new Set();
40
+ for (const sentence of sentences(text)) {
41
+ if (HEDGES.test(sentence)) continue; // saying "I did not check" is the behaviour to encourage
42
+ const claimedHere = new Set();
43
+ for (const { kind, re, value } of PATTERNS) {
44
+ for (const m of sentence.matchAll(re)) {
45
+ const v = value(m);
46
+ if (claimedHere.has(v)) continue; // already claimed as something more specific
47
+ claimedHere.add(v);
48
+ const key = `${kind}:${v}`;
49
+ if (seen.has(key)) continue;
50
+ seen.add(key);
51
+ out.push({ kind, value: v, sentence: sentence.trim() });
52
+ }
53
+ }
54
+ }
55
+ return out;
56
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Reading one turn out of the transcript (CLM-B, KJC-TSK-0802).
3
+ *
4
+ * The transcript is the register of sources: what the AI finally said, what the
5
+ * user asked, and every tool output in between. Nothing is annotated by hand —
6
+ * this just reads what the session already wrote.
7
+ *
8
+ * A turn = from the user's last real message to the end. A "user" entry whose
9
+ * content is a tool_result is NOT the user talking: it is the machine answering.
10
+ */
11
+ import { readFileSync } from "node:fs";
12
+
13
+ const MAX_OUTPUT = 20_000; // a single huge output must not eat the whole check
14
+ const blocks = (entry) => {
15
+ const c = entry?.message?.content;
16
+ return Array.isArray(c) ? c : typeof c === "string" ? [{ type: "text", text: c }] : [];
17
+ };
18
+ const isToolResult = (entry) => blocks(entry).some((b) => b.type === "tool_result");
19
+ const textOf = (value) =>
20
+ typeof value === "string" ? value : Array.isArray(value) ? value.map((b) => b?.text ?? "").join("\n") : String(value ?? "");
21
+
22
+ /** Parses the JSONL, ignoring lines that are not valid JSON (a partial write must not throw). */
23
+ export function readEntries(path) {
24
+ const out = [];
25
+ for (const line of readFileSync(path, "utf8").split("\n")) {
26
+ if (!line.trim()) continue;
27
+ try { out.push(JSON.parse(line)); } catch { /* a half-written line is not a reason to fail */ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * @param {string} path transcript_path given by the hook
34
+ * @returns {{text: string, outputs: string[], userSaid: string}}
35
+ * text: what the AI says at the end of the turn (its final prose, no thinking).
36
+ */
37
+ export function readTurn(path) {
38
+ const entries = readEntries(path);
39
+ const startedAt = entries.findLastIndex((e) => e.type === "user" && !isToolResult(e));
40
+ const turn = startedAt >= 0 ? entries.slice(startedAt) : entries;
41
+
42
+ const userSaid = startedAt >= 0 ? textOf(entries[startedAt]?.message?.content) : "";
43
+ const outputs = [];
44
+ for (const entry of turn) {
45
+ for (const b of blocks(entry)) {
46
+ if (b.type === "tool_result") outputs.push(textOf(b.content).slice(0, MAX_OUTPUT));
47
+ }
48
+ }
49
+ // The final message is the last assistant entry that actually says something
50
+ // (one with tool_use only is the AI working, not the AI reporting).
51
+ const finals = turn.filter((e) => e.type === "assistant" && blocks(e).some((b) => b.type === "text" && b.text?.trim()));
52
+ const text = finals.length ? blocks(finals.at(-1)).filter((b) => b.type === "text").map((b) => b.text).join("\n") : "";
53
+ return { text, outputs, userSaid };
54
+ }
@@ -30,7 +30,7 @@ export const ADVANCED_GROUPS = [
30
30
  { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
31
31
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
32
32
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
33
- { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy"] },
33
+ { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims"] },
34
34
  { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
35
35
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
36
36
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
@@ -5,6 +5,8 @@ import { architectCommand } from "../commands/architect.js";
5
5
  import { onboardCommand } from "../commands/onboard.js";
6
6
  import { startCommand } from "../commands/start.js";
7
7
  import { identityCommand } from "../commands/identity.js";
8
+ import { policyCommand } from "../commands/policy.js";
9
+ import { claimsCommand } from "../commands/claims.js";
8
10
  import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
9
11
  import { qmdQueryCommand } from "../commands/qmd.js";
10
12
  import { ragMcpCommand } from "../commands/rag-mcp.js";
@@ -311,6 +313,14 @@ export function registerMeta(program, { pkgVersion }) {
311
313
  });
312
314
 
313
315
  // KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
316
+ // CLM-B (KJC-TSK-0802): the data the AI states, checked against what actually ran.
317
+ program.command("claims").description("Afirmaciones con fuente: comprueba los datos que la IA afirma en un turno contra las salidas de ese turno (ADR claims-with-evidence)")
318
+ .command("check")
319
+ .description("Cruza el mensaje final del turno con sus salidas — exit 2 solo si un dato está DESMENTIDO por su propia fuente; falla abierto si no puede leer el transcript")
320
+ .requiredOption("--transcript <path>", "Ruta del transcript de la sesión (la que pasa el hook)")
321
+ .option("--json", "Machine-readable")
322
+ .action(async (flags) => { process.exitCode = await claimsCommand({ flags }); });
323
+
314
324
  const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
315
325
  policyCmd.command("eval")
316
326
  .description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
@@ -320,7 +330,6 @@ export function registerMeta(program, { pkgVersion }) {
320
330
  .option("--strict", "Exit 2 si deny")
321
331
  .action(async (flags) => {
322
332
  await withConfig(pkgVersion, "policy-eval", flags, async ({ config }) => {
323
- const { policyCommand } = await import("../commands/policy.js");
324
333
  process.exitCode = await policyCommand({ action: "eval", config, flags });
325
334
  });
326
335
  });
@@ -331,7 +340,6 @@ export function registerMeta(program, { pkgVersion }) {
331
340
  .option("--reason <text>", "Justificación escrita en el momento")
332
341
  .action(async (flags) => {
333
342
  await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
334
- const { policyCommand } = await import("../commands/policy.js");
335
343
  process.exitCode = await policyCommand({ action: "grant", config, flags });
336
344
  });
337
345
  });
@@ -348,10 +356,27 @@ export function registerMeta(program, { pkgVersion }) {
348
356
  .description("Verifica la cadena del decision log y sella su head-hash en .karajan/policy-anchor.json (trackeado) — anclaje temporal en la historia de git, GOV-C2")
349
357
  .action(async (flags) => {
350
358
  await withConfig(pkgVersion, "policy-anchor", flags, async ({ config }) => {
351
- const { policyCommand } = await import("../commands/policy.js");
352
359
  process.exitCode = await policyCommand({ action: "anchor", config, flags });
353
360
  });
354
361
  });
362
+ policyCmd.command("seal")
363
+ .description("Sella en el decision log un escape KJ_ALLOW_* usado en tool-time (exempt, chokepoint=tool, identidad declarada del clon) — lo invoca el Sentinel, GOV-F")
364
+ .requiredOption("--escape <name>", "Escape usado (p.ej. KJ_ALLOW_BOARD)")
365
+ .option("--tool <tool>", "Tool que lo usó (Bash, Edit…)")
366
+ .action(async (flags) => {
367
+ await withConfig(pkgVersion, "policy-seal", flags, async ({ config }) => {
368
+ process.exitCode = await policyCommand({ action: "seal", config, flags });
369
+ });
370
+ });
371
+ policyCmd.command("report")
372
+ .description("Informe determinista del decision log y las concesiones: avisos/denegaciones/exenciones por regla, denegaciones abiertas, concesiones vivas/vencidas/renovadas y señales — cadena rota = exit 1 (PL-E)")
373
+ .option("--soon <days>", "Días para considerar una concesión 'próxima a vencer'", "7")
374
+ .option("--json", "Machine-readable")
375
+ .action(async (flags) => {
376
+ await withConfig(pkgVersion, "policy-report", flags, async ({ config }) => {
377
+ process.exitCode = await policyCommand({ action: "report", config, flags });
378
+ });
379
+ });
355
380
  policyCmd.command("check")
356
381
  .description("Comprueba el diff (staged, o base...head con --range) contra la policy — warn por defecto; --strict devuelve exit 2 si hay violación enforcement=deny (tier C, merge-blocking)")
357
382
  .option("--role <role>", "Rol del agente", "coder")
@@ -360,7 +385,6 @@ export function registerMeta(program, { pkgVersion }) {
360
385
  .option("--json", "Machine-readable")
361
386
  .action(async (flags) => {
362
387
  await withConfig(pkgVersion, "policy-check", flags, async ({ config }) => {
363
- const { policyCommand } = await import("../commands/policy.js");
364
388
  process.exitCode = await policyCommand({ action: "check", config, flags });
365
389
  });
366
390
  });
@@ -599,9 +623,10 @@ export function registerMeta(program, { pkgVersion }) {
599
623
 
600
624
  program
601
625
  .command("board [action]")
602
- .description("Manage HU Board (start|stop|status|open|cleanup)")
626
+ .description("Manage HU Board (start|stop|status|open|cleanup) — SIN acción arranca un servidor persistente en segundo plano (equivale a `start`)")
603
627
  .option("--port <number>", "Port (default: 4000)", "4000")
604
628
  .option("--bind <host>", "Bind host (default: 127.0.0.1; use 0.0.0.0 to expose on LAN — token auth auto-enforced)")
629
+ .option("--force", "Arranca aunque hu_board.enabled sea false en kj.config.yml")
605
630
  .action(async (action = "start", opts) => {
606
631
  await withConfig(pkgVersion, "board", opts, async ({ config, logger }) => {
607
632
  // KJC-TSK-0684 (issue #1287): an external board is the source of
@@ -612,6 +637,21 @@ export function registerMeta(program, { pkgVersion }) {
612
637
  console.log(`⚠ this project's board lives in ${name} (state_backend: external) — kj does not run a parallel HU Board here.`);
613
638
  return;
614
639
  }
640
+ // KJC-BUG-0152 (issue #1427): `kj board` with no action starts a persistent server.
641
+ // Doing that while hu_board.enabled is false contradicts kj doctor, which reports the
642
+ // board as skipped — two commands saying opposite things about the same config. The
643
+ // system works or fails loudly; it never does the opposite of what the config says.
644
+ // Only `start` is gated (the bare command defaults to it): stop, status, cleanup and open
645
+ // never bring a server up, and blocking them would take away the way to tidy up.
646
+ if (config?.hu_board?.enabled === false && !opts.force && action === "start") {
647
+ logger.error(
648
+ `hu_board.enabled es false en kj.config.yml — no arranco el HU Board (kj doctor ya lo reporta como omitido).\n` +
649
+ ` Para arrancarlo igualmente: kj board ${action} --force\n` +
650
+ ` Para dejarlo activado siempre: pon hu_board.enabled: true en kj.config.yml`
651
+ );
652
+ process.exitCode = 1;
653
+ return;
654
+ }
615
655
  const port = Number(opts.port) || config.hu_board?.port || 4000;
616
656
  const bind = opts.bind || config.hu_board?.bind || "127.0.0.1";
617
657
  await boardCommand({ action, port, bind, logger });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `kj claims check --transcript <path>` (CLM-B, KJC-TSK-0802) — checks the data
3
+ * the AI states in a turn against what actually ran in that same turn.
4
+ *
5
+ * Deterministic and free: no model in the loop. Exit 2 only when a datum is
6
+ * DENIED by its own source — a proven hallucination. Everything else is
7
+ * reported, per the accepted ADR: inform always, block almost never.
8
+ *
9
+ * It fails OPEN. A verifier that cannot read the transcript says so and gets out
10
+ * of the way: a broken check must never hold a session hostage.
11
+ */
12
+ import { readTurn } from "../claims/turn.js";
13
+ import { crossCheck, formatClaimReport } from "../claims/cross-check.js";
14
+
15
+ export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn } = {}) {
16
+ const path = flags.transcript;
17
+ if (!path) {
18
+ logger.error("kj claims check: --transcript <path> is required");
19
+ return 1;
20
+ }
21
+ let turn;
22
+ try {
23
+ turn = readTurnFn(path);
24
+ } catch (err) {
25
+ // Not observable: the transcript could not be read. Never reported as clean.
26
+ const note = `kj claims: transcript not readable (${err.message}) — nothing checked`;
27
+ if (flags.json) logger.log(JSON.stringify({ ok: true, checked: false, reason: note }));
28
+ else logger.error(note);
29
+ return 0;
30
+ }
31
+
32
+ const result = crossCheck(turn);
33
+ if (flags.json) {
34
+ logger.log(JSON.stringify({ ok: true, checked: true, denied: result.denied, unbacked: result.unbacked, claims: result.claims.length }));
35
+ } else if (result.denied.length || result.unbacked.length) {
36
+ logger.error(formatClaimReport(result));
37
+ } else {
38
+ logger.log(`kj claims: ${result.claims.length} dato(s) comprobado(s), todos con respaldo en este turno`);
39
+ }
40
+ return result.denied.length ? 2 : 0;
41
+ }
@@ -862,7 +862,7 @@ export async function initCommand({ logger, flags = {} }) {
862
862
  await installAiTrashHook(logger);
863
863
  } else {
864
864
  logger.warn("ai-trash kj-trash binary not found — destructive ops unprotected.");
865
- logger.warn(" Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH.");
865
+ logger.warn(" Install karajan-code globally (npm i -g @karajan-family/code) so kj-trash is on PATH.");
866
866
  }
867
867
  }
868
868
 
@@ -12,7 +12,10 @@ import { createHash } from "node:crypto";
12
12
  import { readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
15
- import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
15
+ import { loadExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
16
+ import { buildPolicyReport } from "../policy/report.js";
17
+ import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
18
+ import { readIdentity } from "../identity/store.js";
16
19
  import { verifyDecisionChain } from "@karajan-family/governance";
17
20
 
18
21
  const execFileAsync = promisify(execFile);
@@ -123,6 +126,39 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
123
126
  }
124
127
  }
125
128
 
129
+ // PL-E (KJC-TSK-0767): el informe — evidencia de proceso determinista
130
+ // sobre los dos jsonl. Cadena rota = exit 1: un informe sobre un log
131
+ // manipulado no es un informe. El resto es informativo (exit 0).
132
+ if (action === "report") {
133
+ let decisionLines = [];
134
+ try {
135
+ decisionLines = readFileSync(join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8").split("\n").filter((l) => l.trim());
136
+ } catch { /* sin decisiones aún: el informe lo dice con ceros */ }
137
+ const exc = loadExceptionRecords(projectDir);
138
+ const soonDays = Number(flags.soon ?? 7);
139
+ const report = buildPolicyReport({ decisionLines, exceptionRecords: exc.records, policy, soonDays: Number.isFinite(soonDays) ? soonDays : 7 });
140
+ if (flags.json) {
141
+ logger.info?.(JSON.stringify({ ...report, exceptions_discarded: exc.discarded }));
142
+ return report.chain.ok ? 0 : 1;
143
+ }
144
+ const { chain, decisions: d, rules, grants: g } = report;
145
+ if (chain.ok) logger.info?.(`✓ decision log: cadena íntegra (${chain.length} decisiones)`);
146
+ else logger.error?.(`✗ decision log: cadena rota en la entrada ${chain.at} (${chain.reason}) — el log ha sido manipulado`);
147
+ if (d.discarded > 0 || exc.discarded > 0) logger.warn?.(`⚠ líneas corruptas descartadas: ${d.discarded} en decisiones, ${exc.discarded} en excepciones`);
148
+ const cps = Object.entries(d.chokepoints).map(([k, v]) => `${k} ${v}`).join(" · ") || "ninguno";
149
+ logger.info?.(`decisiones: allow ${d.allow} · deny ${d.deny} · exempt ${d.exempt} · abiertas ${d.open} (chokepoints: ${cps})`);
150
+ logger.info?.(rules.length > 0 ? "reglas (ordenadas por fricción):" : "reglas: ninguna ha avisado ni denegado todavía");
151
+ for (const r of rules) {
152
+ const meta = [r.enforcement && `enforcement=${r.enforcement}`, r.class && `class=${r.class}`].filter(Boolean).join(" ");
153
+ logger.info?.(` [${r.rule_id}] ${meta} — warn ${r.warns} · deny ${r.denies} · exempt ${r.exempts} · abiertas ${r.open}`);
154
+ }
155
+ logger.info?.(`concesiones: vivas ${g.alive.length} (próximas a vencer ${g.soon.length}) · vencidas ${g.expired.length} · puntuales ${g.point}`);
156
+ for (const e of g.alive) logger.info?.(` [${e.rule_id}] hasta ${e.expiresAt} — ${e.who?.git ?? "?"}: ${e.justification ?? "sin justificación"}`);
157
+ logger.info?.(report.signals.length > 0 ? "señales:" : "señales: ninguna");
158
+ for (const s of report.signals) logger.warn?.(` ⚠ ${s}`);
159
+ return chain.ok ? 0 : 1;
160
+ }
161
+
126
162
  if (action === "eval") {
127
163
  let input;
128
164
  try {
@@ -131,9 +167,54 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
131
167
  logger.error?.("policy eval: --input debe ser JSON válido");
132
168
  return 1;
133
169
  }
134
- const verdict = evalToolCall(policy, { role: flags.role || "coder", tool: flags.tool, input, root: projectDir });
170
+ const role = flags.role || "coder";
171
+ const verdict = evalToolCall(policy, { role, tool: flags.tool, input, root: projectDir });
172
+ const strictDeny = verdict.decision === "deny" && Boolean(flags.strict);
173
+ // GOV-F (KJC-TSK-0768): el deny de tool-time (contrato --strict del
174
+ // Sentinel) entra en la MISMA cadena que las decisiones de commit — con
175
+ // el hash del tool_input como artefacto. Un sello fallido se dice ANTES
176
+ // del veredicto (la última línea de stdout sigue siendo el JSON que el
177
+ // Sentinel parsea) y el deny se mantiene: jamás un allow por fallo de registro.
178
+ if (strictDeny) {
179
+ try {
180
+ recordGateDecision(projectDir, {
181
+ decision: "deny", chokepoint: "tool", rule_ids: [verdict.rule_id], role, tool: flags.tool ?? null,
182
+ ...(verdict.class ? { class: verdict.class } : {}),
183
+ // El hash es del payload CRUDO recibido (--input tal cual), no de su
184
+ // re-serialización: el mismo JSON con otro orden o espacios es otro
185
+ // artefacto para la auditoría (catch de codex).
186
+ policy_hash: policyFileHash(projectDir), artifact_hash: createHash("sha256").update(String(flags.input ?? ""), "utf8").digest("hex"),
187
+ });
188
+ } catch (err) {
189
+ logger.error?.(`policy eval: deny NO sellado en el decision log (${err.message}) — el deny se mantiene`);
190
+ }
191
+ }
135
192
  logger.info?.(JSON.stringify(verdict));
136
- return verdict.decision === "deny" && flags.strict ? 2 : 0;
193
+ return strictDeny ? 2 : 0;
194
+ }
195
+
196
+ // GOV-F (KJC-TSK-0768): un escape KJ_ALLOW_* usado en tool-time es una
197
+ // excepción consciente — se sella como exempt chokepoint=tool con la
198
+ // identidad DECLARADA del clon (identity.local.yml), para que el informe
199
+ // y el anchor la vean. Lo llama el Sentinel; cualquiera puede auditarlo.
200
+ if (action === "seal") {
201
+ if (!flags.escape) {
202
+ logger.error?.("policy seal: --escape <KJ_ALLOW_X> es obligatorio — un escape sin nombre no es auditable");
203
+ return 1;
204
+ }
205
+ try {
206
+ const who = readIdentity(projectDir);
207
+ const rec = recordGateDecision(projectDir, {
208
+ decision: "exempt", chokepoint: "tool", escape: flags.escape, tool: flags.tool ?? null,
209
+ who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
210
+ policy_hash: policyFileHash(projectDir),
211
+ });
212
+ logger.info?.(`✓ escape ${rec.escape} sellado (chokepoint tool${rec.tool ? `, ${rec.tool}` : ""})`);
213
+ return 0;
214
+ } catch (err) {
215
+ logger.error?.(`policy seal: ${err.message}`);
216
+ return 1;
217
+ }
137
218
  }
138
219
 
139
220
  // check — staged por defecto, base...head con --range (CI). Warn salvo
@@ -289,8 +289,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
289
289
  console.log(res.ok
290
290
  ? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
291
291
  : `✗ ${res.reason}`);
292
- // GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella.
293
- if (res.ok) seal("allow");
292
+ // GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella. PL-E
293
+ // (KJC-TSK-0767): con las reglas que AVISARON, para que "nace avisando y
294
+ // gana dientes" se decida con datos (kj policy report), no a ciegas.
295
+ if (res.ok) seal("allow", gate.warns.length > 0 ? { warn_rule_ids: gate.warns.map((w) => w.rule_id) } : {});
294
296
  process.exitCode = res.ok ? 0 : 1;
295
297
  return res;
296
298
  }
@@ -52,7 +52,8 @@ const playbookBody = (stateBackend, boardName) => `# Karajan method (v4)
52
52
 
53
53
  You are the orchestrator; Karajan governs. A task is DONE when its
54
54
  done-statement is literally true, the full suite is green, and every commit
55
- carries a cross-AI verdict.
55
+ carries a cross-AI verdict. A card closed? Point your user at the board
56
+ (\`kj board start\` → http://localhost:4000/#governance): that is where to look.
56
57
 
57
58
  Invariants (the git gates enforce these — they are not suggestions):
58
59
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Duplicate class members (KJC-TSK-0796) — a bug JavaScript refuses to report.
3
+ *
4
+ * Declaring the same member twice in a class is legal: the LAST one wins and the
5
+ * first disappears in silence. No runtime error, no lint warning, no compiler
6
+ * complaint. That is how a `updated()` that loaded the data was lost in GREBLA
7
+ * and a tab stayed empty in production for 17 days.
8
+ *
9
+ * What matters is not that there is a duplicate — it is WHAT WAS LOST: the body
10
+ * that will never run. That is what the finding says.
11
+ *
12
+ * Honest about its limits (the fourth verdict): a file this guard could not read,
13
+ * or one whose member names are computed at runtime, is reported as NOT OBSERVABLE
14
+ * — never as clean.
15
+ */
16
+ import { parse } from "@babel/parser";
17
+
18
+ const plugins = (file) => [
19
+ ...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []),
20
+ ...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []),
21
+ "decorators",
22
+ ];
23
+ const isClass = (node) => node?.type === "ClassDeclaration" || node?.type === "ClassExpression";
24
+
25
+ /** Walks every node of the tree; enough for a syntax-only guard (no scope analysis). */
26
+ function walk(node, visit) {
27
+ if (!node || typeof node.type !== "string") return;
28
+ visit(node);
29
+ for (const key of Object.keys(node)) {
30
+ const child = node[key];
31
+ if (Array.isArray(child)) child.forEach((c) => walk(c, visit));
32
+ else if (child && typeof child.type === "string") walk(child, visit);
33
+ }
34
+ }
35
+
36
+ const memberName = (member) => {
37
+ if (member.computed) return null; // `[expr]() {}` — the name is not known until it runs
38
+ const key = member.key ?? member.id;
39
+ if (key?.type === "Identifier") return key.name;
40
+ if (key?.type === "PrivateName") return `#${key.id.name}`;
41
+ if (key?.type === "StringLiteral" || key?.type === "NumericLiteral") return String(key.value);
42
+ return null;
43
+ };
44
+
45
+ /**
46
+ * @param {string} source
47
+ * @param {{file?: string}} [where]
48
+ * @returns {{findings: Array<object>, notObservable: {file: string, reason: string}|null}}
49
+ * Each finding: {file, className, member, lostLine, winnerLine, message}.
50
+ */
51
+ export function findDuplicateMembers(source, { file = "<source>" } = {}) {
52
+ let tree;
53
+ try {
54
+ tree = parse(source, { sourceType: "unambiguous", allowReturnOutsideFunction: true, plugins: plugins(file) });
55
+ } catch (err) {
56
+ return { findings: [], notObservable: { file, reason: `could not be parsed (${err.message}) — not read as clean` } };
57
+ }
58
+ const findings = [];
59
+ let computed = 0;
60
+ walk(tree, (node) => {
61
+ if (!isClass(node)) return;
62
+ const className = node.id?.name ?? "(anonymous)";
63
+ const seen = new Map();
64
+ for (const member of node.body.body) {
65
+ // get/set of the same name are legal, and a static member is a different slot from an
66
+ // instance one — treating either as a duplicate would be the false positive that gets a
67
+ // guard switched off (the credibility rule of KJC-PCS-0082).
68
+ // A TypeScript overload signature (TSDeclareMethod) declares the same name on purpose and
69
+ // carries no body: counting it would flag every overloaded method in the codebase.
70
+ if (member.type === "StaticBlock" || member.type === "TSDeclareMethod" || member.type === "TSIndexSignature" || member.kind === "get" || member.kind === "set") continue;
71
+ const name = memberName(member);
72
+ if (name === null) { computed += 1; continue; }
73
+ const key = `${member.static ? "static " : ""}${name}`;
74
+ const line = member.loc.start.line;
75
+ const first = seen.get(key);
76
+ if (first === undefined) { seen.set(key, line); continue; }
77
+ findings.push({
78
+ file, className, member: key, lostLine: first, winnerLine: line,
79
+ message: `${file}:${line} — ${className}: the ${key} of line ${first} never runs, the one of line ${line} replaces it`,
80
+ });
81
+ seen.set(key, line); // a third copy is reported against the second, not against the first
82
+ }
83
+ });
84
+ const notObservable = computed > 0 && findings.length === 0 ? { file, reason: `${computed} member name(s) computed at runtime — this file cannot be fully checked` } : null;
85
+ return { findings, notObservable };
86
+ }