karajan-code 4.17.0 → 4.19.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/README.md CHANGED
@@ -75,6 +75,16 @@ Karajan **governs** any agent with git gates — the false green is structurally
75
75
 
76
76
  Synchronous blocking hooks exist today only in Claude Code. That makes the supported setup explicit: **to guarantee a harness that controls the LLM, use Claude Code as the host** — Claude writes, Codex reviews (a review subprocess needs no hooks), and a third CLI arbitrates when available. On any other host Karajan still governs at the full git-gate level and tells you which level is active — it never pretends a supervision it cannot enforce.
77
77
 
78
+ With a declared `.karajan/policy.yml`, the policy layer enforces at three tiers, and which tier applies depends on the host:
79
+
80
+ | Tier | Where | Requires | What it guarantees |
81
+ |---|---|---|---|
82
+ | **A — tool time** | Sentinel PreToolUse → `kj policy eval --strict` | Claude Code as host (synchronous hooks) | The rule fires BEFORE the damage; the acting agent's role travels in `KJ_POLICY_ROLE` |
83
+ | **B — commit time** | `kj review --staged` / pre-commit → deny + evidentiary exceptions | Any host (the gate lives in git) | The violating diff never enters, no matter who wrote it or what host ran it |
84
+ | **C — merge time** | `kj-policy.yml` CI workflow → `kj policy check --range --strict` | GitHub Actions (seeded by `kj harden`) | Covers a tampered local hook: the PR diff is re-checked against the same policy, merge-blocking |
85
+
86
+ Tier B is the guarantee floor — hosts without hooks lose A, never B; C re-verifies both. Security-class rules and consumer defaults are non-exemptable at every tier: no escape, no arbitration, no grant.
87
+
78
88
  ## Headless mode
79
89
 
80
90
  The classic multiagent pipeline lives on for CI and automation: `kj run "<task>"` orchestrates coder/reviewer/tester subprocess roles unattended, with the same gates. Agents and CI pass `--non-interactive` (or `KJ_NON_INTERACTIVE=1`): safe gates auto-answer, FAIL findings stop the run with a real exit code. `kj advanced` lists the full surface. [Headless mode docs](https://karajancode.com/docs/v4/headless/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.17.0",
3
+ "version": "4.19.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -37,7 +37,8 @@
37
37
  "workspaces": [
38
38
  "packages/ai-trash",
39
39
  "packages/core",
40
- "packages/hu-board"
40
+ "packages/hu-board",
41
+ "packages/governance"
41
42
  ],
42
43
  "imports": {
43
44
  "#utils/*": "./src/utils/*",
@@ -100,6 +101,7 @@
100
101
  },
101
102
  "dependencies": {
102
103
  "@babel/parser": "^7.29.7",
104
+ "@karajan-family/governance": "^0.1.0",
103
105
  "@modelcontextprotocol/sdk": "^1.29.0",
104
106
  "better-sqlite3": "^12.10.0",
105
107
  "chokidar": "^5.0.0",
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dual-publish (MIG-A, KJC-TSK-0751, épica KJC-PCS-0077, ADR 0004) — el
4
+ * MISMO contenido sale bajo dos nombres npm durante la migración al scope:
5
+ * karajan-code (legacy, publish normal) y @karajan-family/code (scoped).
6
+ *
7
+ * Uso:
8
+ * node scripts/dual-publish.mjs --pack-only # genera+verifica el tarball scoped
9
+ * node scripts/dual-publish.mjs --publish --otp=XXX # publica el scoped (tras publicar el legacy)
10
+ *
11
+ * El swap del name es EN SITIO con restauración en finally: un crash a mitad
12
+ * no deja el manifest renombrado. La verificación es el MISMO verify-pack
13
+ * (name-agnóstico desde la parte 1): empaqueta, instala aislado y corre kj.
14
+ * El PRIMER publish del scoped lo hace el usuario (cuenta karajan-family,
15
+ * security key); los siguientes van con la sesión normal.
16
+ */
17
+ import { readFileSync, writeFileSync } from "node:fs";
18
+ import { execFileSync } from "node:child_process";
19
+ import { join, dirname, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ export const LEGACY_NAME = "karajan-code";
23
+ export const SCOPED_NAME = "@karajan-family/code";
24
+
25
+ /**
26
+ * Ejecuta `fn` con el manifest renombrado al scoped (+ publishConfig public,
27
+ * obligatorio para scoped) y lo RESTAURA byte a byte pase lo que pase.
28
+ */
29
+ export async function withScopedName(repoRoot, fn) {
30
+ const pkgPath = join(repoRoot, "package.json");
31
+ const original = readFileSync(pkgPath, "utf8");
32
+ const pkg = JSON.parse(original);
33
+ if (pkg.name !== LEGACY_NAME) {
34
+ throw new Error(`dual-publish: el manifest dice "${pkg.name}" y solo se renombra desde ${LEGACY_NAME} — jamás a ciegas`);
35
+ }
36
+ // publishConfig se MERGEA, no se pisa (catch de codex): un registry/tag
37
+ // preexistente debe sobrevivir al publish scoped.
38
+ writeFileSync(pkgPath, `${JSON.stringify({ ...pkg, name: SCOPED_NAME, publishConfig: { ...pkg.publishConfig, access: "public" } }, null, 2)}\n`, "utf8");
39
+ try {
40
+ return await fn();
41
+ } finally {
42
+ writeFileSync(pkgPath, original, "utf8");
43
+ }
44
+ }
45
+
46
+ // argv[1] normalizado (catch de codex): node lo absolutiza hoy, pero el
47
+ // guard no debe depender de ese detalle de implementación.
48
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
49
+ if (isMain) {
50
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
51
+ const args = process.argv.slice(2);
52
+ const publish = args.includes("--publish");
53
+ const otp = args.find((a) => a.startsWith("--otp="))?.slice(6);
54
+ await withScopedName(repoRoot, async () => {
55
+ console.log(`dual-publish: manifest → ${SCOPED_NAME} (temporal)`);
56
+ // La E2E real: empaqueta el scoped, instálalo aislado y corre kj.
57
+ execFileSync("node", [join(repoRoot, "scripts", "verify-pack.mjs")], { cwd: repoRoot, stdio: "inherit" });
58
+ if (publish) {
59
+ const pubArgs = ["publish", "--ignore-scripts", ...(otp ? [`--otp=${otp}`] : [])];
60
+ console.log(`dual-publish: npm ${pubArgs.join(" ").replace(/--otp=\S+/, "--otp=***")}`);
61
+ execFileSync("npm", pubArgs, { cwd: repoRoot, stdio: "inherit" });
62
+ } else {
63
+ console.log("dual-publish: --pack-only — verificado y SIN publicar (usa --publish en la release)");
64
+ }
65
+ });
66
+ console.log(`dual-publish: manifest restaurado a ${LEGACY_NAME} ✓`);
67
+ }
@@ -27,6 +27,9 @@ import { fileURLToPath } from "node:url";
27
27
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
28
  const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
29
29
  const expectedVersion = pkg.version;
30
+ // MIG-A (KJC-TSK-0751): el nombre sale del manifest — el MISMO verify-pack
31
+ // valida el tarball unscoped y el @karajan-family/* del dual-publish.
32
+ const pkgName = pkg.name;
30
33
 
31
34
  // Subprocess env: strip CLAUDECODE (Claude Code blocks nested non-interactive
32
35
  // runs otherwise) and force a non-interactive, quiet npm.
@@ -54,13 +57,13 @@ let gTmp = null;
54
57
  let pnpmTmp = null;
55
58
  let qsTmp = null;
56
59
  try {
57
- console.log(`verify-pack: packing karajan-code@${expectedVersion}…`);
60
+ console.log(`verify-pack: packing ${pkgName}@${expectedVersion}…`);
58
61
  // --json gives us the exact filename without parsing human output.
59
62
  const packOut = run("npm", ["pack", "--json", "--silent"], { cwd: repoRoot });
60
63
  const filename = JSON.parse(packOut)[0]?.filename;
61
64
  if (!filename) fail("npm pack did not report a filename", packOut);
62
- // npm normalizes scoped names in --json but karajan-code is unscoped;
63
- // the file lands in cwd under the reported name.
65
+ // npm --json reports the exact filename (scoped names normalized to
66
+ // scope-name-x.y.z.tgz); the file lands in cwd under that name.
64
67
  tgzPath = path.join(repoRoot, filename);
65
68
  if (!fs.existsSync(tgzPath)) fail(`packed tarball not found at ${tgzPath}`);
66
69
 
@@ -93,7 +96,7 @@ try {
93
96
  const { scanPaths, loadPrivacyList } = await import(
94
97
  path.join(repoRoot, "src", "privacy", "scan.js")
95
98
  );
96
- const shippedDir = path.join(tmpDir, "node_modules", "karajan-code");
99
+ const shippedDir = path.join(tmpDir, "node_modules", ...pkgName.split("/"));
97
100
  const pFindings = scanPaths([shippedDir], { list: loadPrivacyList() });
98
101
  const pBlocks = pFindings.filter((f) => f.severity === "block");
99
102
  if (pBlocks.length > 0) {
@@ -265,7 +268,7 @@ try {
265
268
  );
266
269
  }
267
270
 
268
- console.log(`\n✓ verify-pack: karajan-code@${expectedVersion} installs clean and runs.`);
271
+ console.log(`\n✓ verify-pack: ${pkgName}@${expectedVersion} installs clean and runs.`);
269
272
  } finally {
270
273
  if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
271
274
  if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -365,6 +365,11 @@ export class ClaudeAgent extends BaseAgent {
365
365
  async _runTaskExec(task, model, _role) {
366
366
  const args = buildPromptArgs(task);
367
367
  if (model) args.push("--model", model);
368
+ // PL-C (KJC-TSK-0735): el rol del subproceso viaja en KJ_POLICY_ROLE —
369
+ // si su harness corre hooks (claude -p), el tier A evalúa la policy con
370
+ // el rol que ACTÚA. El rol del ORQUESTADOR es autoritativo: task.env no
371
+ // puede spoofearlo (catch de codex: sería un bypass de reglas por rol).
372
+ const roleEnv = task.role ? { ...task.env, KJ_POLICY_ROLE: task.role } : task.env;
368
373
 
369
374
  // Use stream-json when onOutput is provided to get real-time feedback
370
375
  if (task.onOutput) {
@@ -374,7 +379,7 @@ export class ClaudeAgent extends BaseAgent {
374
379
  onOutput: streamFilter,
375
380
  silenceTimeoutMs: task.silenceTimeoutMs,
376
381
  timeout: task.timeoutMs,
377
- env: task.env,
382
+ env: roleEnv,
378
383
  cwd: task.cwd
379
384
  }));
380
385
  const raw = pickOutput(res);
@@ -385,7 +390,7 @@ export class ClaudeAgent extends BaseAgent {
385
390
 
386
391
  // Without streaming, use json output to get structured response via stderr
387
392
  args.push("--output-format", "json");
388
- const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: task.env, cwd: task.cwd }));
393
+ const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: roleEnv, cwd: task.cwd }));
389
394
  const raw = pickOutput(res);
390
395
  const output = extractTextFromStreamJson(raw);
391
396
  const usage = extractUsageFromStreamJson(raw);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Guarantee level en runtime (KJC-TSK-0756, hallazgo de campo de Pedro):
3
+ * la doctrina "kj jamás finge una supervisión que no puede aplicar" vivía
4
+ * solo en el README — ahora kj doctor la DECLARA para el host actual, con
5
+ * el porqué. El acoplamiento a Claude es UNA capa (tier A: hooks síncronos
6
+ * bloqueantes, que hoy solo ese harness expone) y es decisión de ADR
7
+ * (v4.13), no accidente; el tier B (git gates) es el suelo agnóstico.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { detectHostAgent } from "../utils/agent-detect.js";
12
+
13
+ /** Pure: computes the three tiers from observable facts. */
14
+ export function guaranteeLevel({ host, harnessWired, policyDeclared, policyWorkflow }) {
15
+ const tierA = host === "claude" && harnessWired
16
+ ? { active: true, reason: "Claude Code expone hooks síncronos bloqueantes y el harness del Sentinel está instalado (kj harden)" }
17
+ : host === "claude"
18
+ ? { active: false, reason: "host Claude sin harness instalado — corre kj harden para activar la supervisión en el turno" }
19
+ : { active: false, reason: `el host ${host ?? "desconocido"} no expone hooks síncronos bloqueantes — no se finge la supervisión; el tier B sigue siendo el suelo de garantía` };
20
+ const tierB = { active: true, reason: "los gates viven en el repositorio, no en el harness: cualquier host, el diff violador no entra" };
21
+ const tierC = policyDeclared && policyWorkflow
22
+ ? { active: true, reason: "kj-policy.yml re-verifica el PR contra la policy declarada (merge-blocking)" }
23
+ : policyDeclared
24
+ ? { active: false, reason: "hay policy declarada pero sin workflow de CI — corre kj harden para sembrar kj-policy.yml" }
25
+ : { active: false, reason: "sin .karajan/policy.yml declarada — el tier C solo existe donde hay policy que verificar" };
26
+ return { host: host ?? null, tierA, tierB, tierC };
27
+ }
28
+
29
+ /** Facts from disk + env; injectable for tests. */
30
+ export function collectGuaranteeFacts({ projectDir = process.cwd(), deps = {} } = {}) {
31
+ const { detectHost = detectHostAgent, exists = existsSync } = deps;
32
+ return guaranteeLevel({
33
+ host: detectHost(),
34
+ harnessWired: exists(join(projectDir, ".karajan", "harness", "pretooluse-sentinel.mjs")),
35
+ policyDeclared: exists(join(projectDir, ".karajan", "policy.yml")),
36
+ policyWorkflow: exists(join(projectDir, ".github", "workflows", "kj-policy.yml")),
37
+ });
38
+ }
@@ -27,6 +27,7 @@ import { huCommand } from "../commands/hu.js";
27
27
  import { worktreeCommand } from "../commands/worktree.js";
28
28
  import { addAdr, listAdrs } from "../environment/adr.js";
29
29
  import { formatAdvancedIndex } from "../commands/advanced.js";
30
+ import { policyAddCommand } from "../policy/add.js";
30
31
  import { withConfig } from "./_shared.js";
31
32
  import { existsSync } from "node:fs";
32
33
  import { join } from "node:path";
@@ -285,7 +286,7 @@ export function registerMeta(program, { pkgVersion }) {
285
286
  });
286
287
 
287
288
  // KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
288
- const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado) — PL-A: modo warn");
289
+ 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");
289
290
  policyCmd.command("eval")
290
291
  .description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
291
292
  .option("--role <role>", "Rol del agente", "coder")
@@ -298,9 +299,39 @@ export function registerMeta(program, { pkgVersion }) {
298
299
  process.exitCode = await policyCommand({ action: "eval", config, flags });
299
300
  });
300
301
  });
302
+ policyCmd.command("grant")
303
+ .description("Concede una excepción PERMANENTE con caducidad a una regla NO-security: quién (identidad), regla exacta, justificación y hasta cuándo — GOV-B")
304
+ .option("--rule <rule_id>", "Regla exacta (p.ej. roles.coder.write.deny)")
305
+ .option("--until <iso>", "Caducidad ISO-8601 (p.ej. 2026-09-01T00:00:00Z)")
306
+ .option("--reason <text>", "Justificación escrita en el momento")
307
+ .action(async (flags) => {
308
+ await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
309
+ const { policyCommand } = await import("../commands/policy.js");
310
+ process.exitCode = await policyCommand({ action: "grant", config, flags });
311
+ });
312
+ });
313
+ policyCmd.command("add")
314
+ .description("Traduce una regla hablada al vocabulario cerrado de policy.yml — propone el diff y SOLO escribe con --yes (PL-C)")
315
+ .argument("<text...>", "La regla en lenguaje natural")
316
+ .option("--yes", "Aplicar el diff propuesto")
317
+ .action(async (textParts, flags) => {
318
+ await withConfig(pkgVersion, "policy-add", flags, async ({ config, logger }) => {
319
+ process.exitCode = await policyAddCommand({ text: textParts.join(" "), config, flags, logger });
320
+ });
321
+ });
322
+ policyCmd.command("anchor")
323
+ .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")
324
+ .action(async (flags) => {
325
+ await withConfig(pkgVersion, "policy-anchor", flags, async ({ config }) => {
326
+ const { policyCommand } = await import("../commands/policy.js");
327
+ process.exitCode = await policyCommand({ action: "anchor", config, flags });
328
+ });
329
+ });
301
330
  policyCmd.command("check")
302
- .description("Comprueba el diff STAGED contra la policy (write-deny + invariantes) — siempre warn en PL-A; exit 1 solo si policy.yml es inválido")
331
+ .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)")
303
332
  .option("--role <role>", "Rol del agente", "coder")
333
+ .option("--range <ref>", "Evalúa un rango git (p.ej. origin/main...HEAD) en vez del staged (CI)")
334
+ .option("--strict", "Exit 2 si hay violación con enforcement=deny")
304
335
  .option("--json", "Machine-readable")
305
336
  .action(async (flags) => {
306
337
  await withConfig(pkgVersion, "policy-check", flags, async ({ config }) => {
@@ -1,5 +1,5 @@
1
1
  import { rolesCommand } from "../commands/roles.js";
2
- import { agentsCommand } from "../commands/agents.js";
2
+ import { agentsCommand, ASSIGNABLE_ROLES, VALID_PROVIDERS } from "../commands/agents.js";
3
3
  import { withConfig } from "./_shared.js";
4
4
 
5
5
  /**
@@ -30,6 +30,22 @@ export function registerRolesSkills(program, { pkgVersion }) {
30
30
  .command("agents [subcommand] [role] [provider]")
31
31
  .description("List or change AI agent assignments per role (e.g. kj agents set coder gemini)")
32
32
  .option("--global", "Persist change to kj.config.yml (default for CLI)")
33
+ // KJC-TSK-0755: el --help enumera valores REALES (de las constantes, no
34
+ // duplicados a mano) — la firma genérica obligaba a preguntar.
35
+ .addHelpText("after", [
36
+ "",
37
+ "Subcommands:",
38
+ " list (default) Show the provider assigned to each role",
39
+ " set Change one: kj agents set <role> <provider>",
40
+ "",
41
+ `Roles: ${ASSIGNABLE_ROLES.join(", ")}`,
42
+ `Providers: ${VALID_PROVIDERS.join(", ")} (kj doctor shows which are installed)`,
43
+ "",
44
+ "Examples:",
45
+ " kj agents # current assignments",
46
+ " kj agents set reviewer codex",
47
+ " kj agents set solomon agy --global",
48
+ ].join("\n"))
33
49
  .action(async (subcommand, role, provider, flags) => {
34
50
  await withConfig(pkgVersion, "agents", {}, async ({ config }) => {
35
51
  await agentsCommand({ config, subcommand: subcommand || "list", role, provider, global: flags.global });
@@ -1,12 +1,14 @@
1
1
  import { loadConfig, writeConfig, getConfigPath, getProjectConfigPath, loadProjectConfig, resolveRole } from "../config.js";
2
2
  import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
3
3
 
4
- const ASSIGNABLE_ROLES = [
4
+ // Exportados para que el --help se autoalimente de las constantes reales
5
+ // (KJC-TSK-0755, hallazgo de campo: la firma genérica obligaba a preguntar).
6
+ export const ASSIGNABLE_ROLES = [
5
7
  "coder", "reviewer", "planner", "refactorer", "triage",
6
8
  "researcher", "tester", "security", "solomon"
7
9
  ];
8
10
 
9
- const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
11
+ export const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
10
12
 
11
13
  export function listAgents(config, sessionOverrides = {}, projectConfig = null) {
12
14
  return ASSIGNABLE_ROLES.map((role) => {
@@ -13,6 +13,7 @@
13
13
 
14
14
  import readline from "node:readline";
15
15
  import { runChecks as runCheckPipeline, toLegacyShape } from "../checks/runner.js";
16
+ import { collectGuaranteeFacts } from "../checks/guarantee-level.js";
16
17
  import { STATUS } from "../checks/types.js";
17
18
  import { getSystemChecks } from "../checks/system.js";
18
19
  import { getConfigFileChecks } from "../checks/config-files.js";
@@ -140,16 +141,31 @@ export async function runChecks({ config }) {
140
141
  */
141
142
  export async function doctorCommand({ config, checkOnly = false, yes = false, json = false, verbose = false, projectOnly = false }) {
142
143
  const report = await runDoctor({ config, checkOnly, yes, projectOnly });
144
+ // KJC-TSK-0756 (campo, Pedro): el guarantee level del host actual se
145
+ // DECLARA en runtime — la doctrina no vive solo en el README.
146
+ report.guarantee = collectGuaranteeFacts({ projectDir: config?.projectDir || process.cwd() });
143
147
 
144
148
  if (json) {
145
149
  console.log(JSON.stringify(report, null, 2));
146
150
  } else {
147
151
  printHuman(report, { verbose });
152
+ printGuaranteeLevel(report.guarantee);
148
153
  }
149
154
 
150
155
  return hasBlockingFailure(report) ? 1 : 0;
151
156
  }
152
157
 
158
+ // KJC-TSK-0756: la seccion de guarantee level — consola es el medio del
159
+ // doctor (excepcion no-console de commands).
160
+ function printGuaranteeLevel(g) {
161
+ const mark = (t) => (t.active ? "ACTIVE " : "OFF ");
162
+ console.log();
163
+ console.log(`Guarantee level (host: ${g.host ?? "desconocido"}) — kj jamas finge una supervision que no puede aplicar:`);
164
+ console.log(` ${mark(g.tierA)} A tool-time (Sentinel en el turno): ${g.tierA.reason}`);
165
+ console.log(` ${mark(g.tierB)} B commit-time (git gates, el SUELO): ${g.tierB.reason}`);
166
+ console.log(` ${mark(g.tierC)} C merge-time (re-check en CI): ${g.tierC.reason}`);
167
+ }
168
+
153
169
  function hasBlockingFailure(report) {
154
170
  return report.checks.some((c) => c.status === STATUS.FAIL || c.status === STATUS.TIMEOUT);
155
171
  }
@@ -8,20 +8,28 @@
8
8
  */
9
9
  import { execFile } from "node:child_process";
10
10
  import { promisify } from "node:util";
11
+ import { createHash } from "node:crypto";
12
+ import { readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
11
14
  import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
15
+ import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
16
+ import { verifyDecisionChain } from "@karajan-family/governance";
12
17
 
13
18
  const execFileAsync = promisify(execFile);
14
19
 
15
- async function stagedFacts(projectDir, gitFn) {
20
+ async function stagedFacts(projectDir, gitFn, range = null) {
16
21
  const run =
17
22
  gitFn ||
18
23
  (async (args) => (await execFileAsync("git", args, { cwd: projectDir, maxBuffer: 16 * 1024 * 1024 })).stdout);
19
- const files = (await run(["diff", "--cached", "--name-only"]))
24
+ // PL-C (KJC-TSK-0735): en CI no hay staged — con --range se evalúa
25
+ // base...head, el MISMO motor sobre el diff del PR (tier C del ADR 0001).
26
+ const base = range ? ["diff", range] : ["diff", "--cached"];
27
+ const files = (await run([...base, "--name-only"]))
20
28
  .split("\n")
21
29
  .map((s) => s.trim())
22
30
  .filter(Boolean);
23
31
  let net = 0;
24
- for (const line of (await run(["diff", "--cached", "--numstat"])).split("\n")) {
32
+ for (const line of (await run([...base, "--numstat"])).split("\n")) {
25
33
  const [a, r] = line.trim().split(/\s+/);
26
34
  if (a && a !== "-") net += Number(a) || 0;
27
35
  if (r && r !== "-") net -= Number(r) || 0;
@@ -38,6 +46,83 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
38
46
  return 1;
39
47
  }
40
48
 
49
+ // GOV-C2 (KJC-TSK-0749): anclaje temporal sin blockchain — verificar la
50
+ // cadena ENTERA y sellar su head-hash en un fichero TRACKEADO por git:
51
+ // reescribir el log de ayer exige reescribir el repo de ayer. Un anchor
52
+ // previo con más entradas que el log actual = truncado ⇒ no se re-sella.
53
+ if (action === "anchor") {
54
+ let raw;
55
+ try {
56
+ raw = readFileSync(join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8");
57
+ } catch {
58
+ logger.info?.("policy anchor: sin decisiones que anclar — el log nace con el primer deny/excepción/commit-allow");
59
+ return 0;
60
+ }
61
+ const lines = raw.split("\n").filter((l) => l.trim());
62
+ // Fichero vacío o solo whitespace = sin decisiones (catch de codex:
63
+ // lines.at(-1) undefined reventaba el hash en vez de salir limpio).
64
+ if (lines.length === 0) {
65
+ logger.info?.("policy anchor: sin decisiones que anclar — el log nace con el primer deny/excepción/commit-allow");
66
+ return 0;
67
+ }
68
+ const chain = verifyDecisionChain(lines);
69
+ if (!chain.ok) {
70
+ logger.error?.(`✗ policy anchor: cadena rota en la entrada ${chain.at} (${chain.reason}) — el log ha sido manipulado; NO se sella`);
71
+ return 1;
72
+ }
73
+ const anchorFile = join(projectDir, ".karajan", "policy-anchor.json");
74
+ try {
75
+ const prev = JSON.parse(readFileSync(anchorFile, "utf8"));
76
+ if (Number.isFinite(prev.length) && prev.length > chain.length) {
77
+ logger.error?.(`✗ policy anchor: el log retrocede (anchor previo=${prev.length} entradas, actual=${chain.length}) — truncado tras el último sello; NO se re-sella`);
78
+ return 1;
79
+ }
80
+ } catch { /* sin anchor previo (o ilegible): primer sello */ }
81
+ const head = createHash("sha256").update(lines.at(-1), "utf8").digest("hex");
82
+ writeFileSync(anchorFile, `${JSON.stringify({ head, length: chain.length, ts: new Date().toISOString() }, null, 2)}\n`, "utf8");
83
+ logger.info?.(`✓ policy anchor: cadena íntegra (${chain.length} decisiones) — head ${head.slice(0, 12)} sellado en .karajan/policy-anchor.json; committéalo para anclarlo en la historia de git`);
84
+ return 0;
85
+ }
86
+
87
+ // GOV-B (KJC-TSK-0746): conceder una excepción PERMANENTE con el modelo
88
+ // probatorio completo — quién (identidad), regla exacta, justificación en
89
+ // el momento y caducidad obligatoria. Los defaults.* del consumidor son
90
+ // inexcepcionables: no se conceden ni desde aquí.
91
+ if (action === "grant") {
92
+ const { rule, until, reason } = flags;
93
+ if (!rule || !until || !reason?.trim()) {
94
+ logger.error?.("policy grant: --rule, --until (ISO) y --reason son obligatorios — una excepción sin quién/por qué/hasta cuándo no es una excepción, es un agujero");
95
+ return 1;
96
+ }
97
+ // Inexcepcionable = inexcepcionable TAMBIÉN al conceder (catch de codex):
98
+ // defaults.*, cualquier cap con class security, y una policy inválida
99
+ // (sin policy legible no se puede probar que la regla NO es security).
100
+ const m = /^roles\.([^.]+)\.(write|shell)/.exec(rule);
101
+ if (rule.startsWith("defaults.") || errors.length > 0 || (m && policy.roles?.[m[1]]?.[m[2]]?.class === "security")) {
102
+ logger.error?.(`policy grant: "${rule}" es inexcepcionable (default del proyecto o clase security) o la policy no es verificable — no se concede, ni con razón`);
103
+ return 1;
104
+ }
105
+ try {
106
+ // GOV-E (KJC-TSK-0750, idea del lector): la renovación es SEÑAL — una
107
+ // regla re-concedida N veces es la política real pidiendo que la
108
+ // cambien por su cauce. Se cuenta contra TODAS las permanentes previas
109
+ // (vencidas incluidas: precisamente esas son la sedimentación).
110
+ const previous = loadStandingExceptions(projectDir).standing.filter((e) => e.rule_id === rule).length;
111
+ const rec = recordPolicyException({
112
+ projectDir,
113
+ entry: { rule_id: rule, justification: reason.trim(), scopeKind: "permanente", expiresAt: until },
114
+ });
115
+ logger.info?.(`✓ excepción permanente registrada: [${rec.rule_id}] hasta ${rec.expiresAt} — concedida por ${rec.who?.git ?? "?"} (${rec.who?.grade ?? "?"})`);
116
+ if (previous >= 1) {
117
+ logger.warn?.(`⚠ ${previous + 1}ª concesión sobre esta regla — una excepción que se renueva ya no es una excepción: considera cambiar la política por su cauce (PR a .karajan/policy.yml)`);
118
+ }
119
+ return 0;
120
+ } catch (err) {
121
+ logger.error?.(`policy grant: ${err.message}`);
122
+ return 1;
123
+ }
124
+ }
125
+
41
126
  if (action === "eval") {
42
127
  let input;
43
128
  try {
@@ -51,20 +136,29 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
51
136
  return verdict.decision === "deny" && flags.strict ? 2 : 0;
52
137
  }
53
138
 
54
- // check — sobre el diff staged (único modo en PL-A), SIEMPRE warn.
55
- const facts = await stagedFacts(projectDir, deps.gitFn);
139
+ // check — staged por defecto, base...head con --range (CI). Warn salvo
140
+ // --strict (PL-C): con --strict, una violación enforcement=deny devuelve
141
+ // exit 2 nombrando la regla — el contrato merge-blocking del tier C.
142
+ const facts = await stagedFacts(projectDir, deps.gitFn, flags.range || null);
56
143
  const violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
144
+ const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
57
145
  if (flags.json) {
58
- logger.info?.(JSON.stringify({ mode: "warn", violations }));
59
- return 0;
146
+ logger.info?.(JSON.stringify({ mode: flags.strict ? "strict" : "warn", violations }));
147
+ return hard.length > 0 ? 2 : 0;
60
148
  }
61
149
  if (violations.length === 0) {
62
150
  logger.info?.("policy check: limpio");
63
151
  return 0;
64
152
  }
65
153
  for (const v of violations) {
66
- logger.warn?.(`⚠ policy [${v.rule_id}] ${v.reason}${v.file ? ` (${v.file})` : ""}`);
154
+ const where = v.file ? ` (${v.file})` : "";
155
+ const mark = flags.strict && v.enforcement === "deny" ? "✗" : "⚠";
156
+ logger.warn?.(`${mark} policy [${v.rule_id}] ${v.reason}${where}`);
157
+ }
158
+ if (hard.length > 0) {
159
+ logger.error?.(`policy check: ${hard.length} violación(es) con enforcement=deny — el merge no procede (--strict)`);
160
+ return 2;
67
161
  }
68
- logger.warn?.(`policy check: ${violations.length} aviso(s) — modo warn (PL-A): no bloquea; PL-B le dará dientes`);
162
+ logger.warn?.(`policy check: ${violations.length} aviso(s) — check es modo warn, no bloquea; los deny los aplican kj review --staged y el pre-commit (PL-B)`);
69
163
  return 0;
70
164
  }