karajan-code 4.21.0 → 4.23.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.
Files changed (54) hide show
  1. package/README.md +6 -3
  2. package/package.json +5 -2
  3. package/packages/hu-board/public/app.js +1 -0
  4. package/packages/hu-board/public/index.html +2 -0
  5. package/packages/hu-board/public/styles.css +22 -0
  6. package/packages/hu-board/public/utils/governance-view.js +149 -0
  7. package/packages/hu-board/src/auth.js +29 -1
  8. package/packages/hu-board/src/routes/governance.js +140 -0
  9. package/packages/hu-board/src/server.js +2 -0
  10. package/scripts/install.js +4 -3
  11. package/scripts/postinstall.js +4 -3
  12. package/scripts/toml-value.js +18 -0
  13. package/src/audit/basal-cost.js +4 -0
  14. package/src/audit/dead-exports.js +52 -1
  15. package/src/audit/deterministic-summary.js +18 -3
  16. package/src/audit/member-reachability.js +158 -0
  17. package/src/audit/osv-findings.js +1 -0
  18. package/src/checks/ai-trash.js +1 -1
  19. package/src/checks/mcp-health.js +1 -1
  20. package/src/checks/native-build.js +2 -2
  21. package/src/checks/release-check.js +62 -2
  22. package/src/claims/cross-check.js +86 -0
  23. package/src/claims/extract.js +56 -0
  24. package/src/claims/turn.js +54 -0
  25. package/src/cli/advanced-commands.js +1 -1
  26. package/src/cli/register-meta.js +66 -5
  27. package/src/commands/claims.js +73 -0
  28. package/src/commands/hu.js +3 -1
  29. package/src/commands/init.js +1 -1
  30. package/src/commands/policy.js +84 -3
  31. package/src/commands/privacy.js +6 -2
  32. package/src/commands/resume.js +4 -0
  33. package/src/commands/review-gate.js +34 -9
  34. package/src/commands/steward.js +146 -0
  35. package/src/config/defaults.js +6 -1
  36. package/src/environment/playbook.js +2 -1
  37. package/src/guards/duplicate-members.js +86 -0
  38. package/src/harden/sentinel-hooks.js +188 -22
  39. package/src/harden/workflow-engine.js +8 -2
  40. package/src/harden/workflow-templates.js +38 -1
  41. package/src/policy/exceptions.js +14 -4
  42. package/src/policy/report.js +99 -0
  43. package/src/privacy/scan.js +23 -1
  44. package/src/review/card-first.js +4 -1
  45. package/src/review/one-shot-review.js +10 -4
  46. package/src/review/parser.js +9 -0
  47. package/src/review/sonar-pregate.js +38 -3
  48. package/src/review/tests-with-code.js +12 -1
  49. package/src/review/unparseable-verdict.js +50 -0
  50. package/src/roles/audit-role.js +19 -1
  51. package/src/sonar/scanner.js +34 -8
  52. package/src/steward/invariants.js +190 -0
  53. package/src/steward/phantom-coverage.js +137 -0
  54. package/src/steward/proposed-work.js +68 -0
@@ -5,6 +5,9 @@ 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, claimsGateCommand } from "../commands/claims.js";
10
+ import { stewardSweepCommand } from "../commands/steward.js";
8
11
  import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
9
12
  import { qmdQueryCommand } from "../commands/qmd.js";
10
13
  import { ragMcpCommand } from "../commands/rag-mcp.js";
@@ -311,6 +314,34 @@ export function registerMeta(program, { pkgVersion }) {
311
314
  });
312
315
 
313
316
  // KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
317
+ // CLM-B (KJC-TSK-0802): the data the AI states, checked against what actually ran.
318
+ const stewardCmd = program.command("steward").description("El Steward: gobierna el ESTADO del proyecto — invariantes con caducidad y cuatro veredictos (épica claims/steward)");
319
+ stewardCmd.command("sweep")
320
+ .description("Barrido read-only de los invariantes: deja el informe versionado en .karajan/steward/ (md + json) y sale con 1 solo si algo está ROTO — unknown y not-observable informan con su remedio")
321
+ .option("--if-stale <days>", "Solo barre si el informe tiene más de N días — retomar trabajo con informe fresco no re-barre")
322
+ .option("--json", "Machine-readable")
323
+ .action(async (flags) => {
324
+ await withConfig(pkgVersion, "steward-sweep", flags, async ({ config }) => {
325
+ process.exitCode = await stewardSweepCommand({ flags, config });
326
+ });
327
+ });
328
+ const claimsCmd = 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)");
329
+ claimsCmd.command("check")
330
+ .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")
331
+ .requiredOption("--transcript <path>", "Ruta del transcript de la sesión (la que pasa el hook)")
332
+ .option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
333
+ .option("--json", "Machine-readable")
334
+ .action(async (flags) => { process.exitCode = await claimsCommand({ flags }); });
335
+ claimsCmd.command("gate")
336
+ .description("El mismo cruce, gobernado por method_gates.claims del proyecto (off|warn|block) — lo invocan los hooks; off = silencio, block = exit 2 solo con un dato desmentido")
337
+ .requiredOption("--transcript <path>", "Ruta del transcript de la sesión")
338
+ .option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
339
+ .action(async (flags) => {
340
+ await withConfig(pkgVersion, "claims-gate", flags, async ({ config }) => {
341
+ process.exitCode = await claimsGateCommand({ flags, config });
342
+ });
343
+ });
344
+
314
345
  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
346
  policyCmd.command("eval")
316
347
  .description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
@@ -320,7 +351,6 @@ export function registerMeta(program, { pkgVersion }) {
320
351
  .option("--strict", "Exit 2 si deny")
321
352
  .action(async (flags) => {
322
353
  await withConfig(pkgVersion, "policy-eval", flags, async ({ config }) => {
323
- const { policyCommand } = await import("../commands/policy.js");
324
354
  process.exitCode = await policyCommand({ action: "eval", config, flags });
325
355
  });
326
356
  });
@@ -331,7 +361,6 @@ export function registerMeta(program, { pkgVersion }) {
331
361
  .option("--reason <text>", "Justificación escrita en el momento")
332
362
  .action(async (flags) => {
333
363
  await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
334
- const { policyCommand } = await import("../commands/policy.js");
335
364
  process.exitCode = await policyCommand({ action: "grant", config, flags });
336
365
  });
337
366
  });
@@ -348,10 +377,27 @@ export function registerMeta(program, { pkgVersion }) {
348
377
  .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
378
  .action(async (flags) => {
350
379
  await withConfig(pkgVersion, "policy-anchor", flags, async ({ config }) => {
351
- const { policyCommand } = await import("../commands/policy.js");
352
380
  process.exitCode = await policyCommand({ action: "anchor", config, flags });
353
381
  });
354
382
  });
383
+ policyCmd.command("seal")
384
+ .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")
385
+ .requiredOption("--escape <name>", "Escape usado (p.ej. KJ_ALLOW_BOARD)")
386
+ .option("--tool <tool>", "Tool que lo usó (Bash, Edit…)")
387
+ .action(async (flags) => {
388
+ await withConfig(pkgVersion, "policy-seal", flags, async ({ config }) => {
389
+ process.exitCode = await policyCommand({ action: "seal", config, flags });
390
+ });
391
+ });
392
+ policyCmd.command("report")
393
+ .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)")
394
+ .option("--soon <days>", "Días para considerar una concesión 'próxima a vencer'", "7")
395
+ .option("--json", "Machine-readable")
396
+ .action(async (flags) => {
397
+ await withConfig(pkgVersion, "policy-report", flags, async ({ config }) => {
398
+ process.exitCode = await policyCommand({ action: "report", config, flags });
399
+ });
400
+ });
355
401
  policyCmd.command("check")
356
402
  .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
403
  .option("--role <role>", "Rol del agente", "coder")
@@ -360,7 +406,6 @@ export function registerMeta(program, { pkgVersion }) {
360
406
  .option("--json", "Machine-readable")
361
407
  .action(async (flags) => {
362
408
  await withConfig(pkgVersion, "policy-check", flags, async ({ config }) => {
363
- const { policyCommand } = await import("../commands/policy.js");
364
409
  process.exitCode = await policyCommand({ action: "check", config, flags });
365
410
  });
366
411
  });
@@ -599,9 +644,10 @@ export function registerMeta(program, { pkgVersion }) {
599
644
 
600
645
  program
601
646
  .command("board [action]")
602
- .description("Manage HU Board (start|stop|status|open|cleanup)")
647
+ .description("Manage HU Board (start|stop|status|open|cleanup) — SIN acción arranca un servidor persistente en segundo plano (equivale a `start`)")
603
648
  .option("--port <number>", "Port (default: 4000)", "4000")
604
649
  .option("--bind <host>", "Bind host (default: 127.0.0.1; use 0.0.0.0 to expose on LAN — token auth auto-enforced)")
650
+ .option("--force", "Arranca aunque hu_board.enabled sea false en kj.config.yml")
605
651
  .action(async (action = "start", opts) => {
606
652
  await withConfig(pkgVersion, "board", opts, async ({ config, logger }) => {
607
653
  // KJC-TSK-0684 (issue #1287): an external board is the source of
@@ -612,6 +658,21 @@ export function registerMeta(program, { pkgVersion }) {
612
658
  console.log(`⚠ this project's board lives in ${name} (state_backend: external) — kj does not run a parallel HU Board here.`);
613
659
  return;
614
660
  }
661
+ // KJC-BUG-0152 (issue #1427): `kj board` with no action starts a persistent server.
662
+ // Doing that while hu_board.enabled is false contradicts kj doctor, which reports the
663
+ // board as skipped — two commands saying opposite things about the same config. The
664
+ // system works or fails loudly; it never does the opposite of what the config says.
665
+ // Only `start` is gated (the bare command defaults to it): stop, status, cleanup and open
666
+ // never bring a server up, and blocking them would take away the way to tidy up.
667
+ if (config?.hu_board?.enabled === false && !opts.force && action === "start") {
668
+ logger.error(
669
+ `hu_board.enabled es false en kj.config.yml — no arranco el HU Board (kj doctor ya lo reporta como omitido).\n` +
670
+ ` Para arrancarlo igualmente: kj board ${action} --force\n` +
671
+ ` Para dejarlo activado siempre: pon hu_board.enabled: true en kj.config.yml`
672
+ );
673
+ process.exitCode = 1;
674
+ return;
675
+ }
615
676
  const port = Number(opts.port) || config.hu_board?.port || 4000;
616
677
  const bind = opts.bind || config.hu_board?.bind || "127.0.0.1";
617
678
  await boardCommand({ action, port, bind, logger });
@@ -0,0 +1,73 @@
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 { readFileSync } from "node:fs";
13
+ import { readTurn } from "../claims/turn.js";
14
+ import { crossCheck, formatClaimReport } from "../claims/cross-check.js";
15
+
16
+ /**
17
+ * `kj claims gate` — the same check, run by the Stop hook with the PROJECT's
18
+ * say-so. The hook stays policy-free: kj reads `method_gates.claims` and
19
+ * decides. "off" (default: adoption is explicit) exits 0 in silence; "warn"
20
+ * reports and never blocks; "block" refuses only a datum DENIED by its own
21
+ * source — unbacked data is reported either way, per the accepted ADR:
22
+ * inform always, block almost never.
23
+ */
24
+ export async function claimsGateCommand({ flags = {}, config = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
25
+ const mode = config?.method_gates?.claims ?? "off";
26
+ if (mode !== "warn" && mode !== "block") return 0;
27
+ let turn;
28
+ try {
29
+ turn = readTurnFn(flags.transcript);
30
+ // CLM-C: with --file the ARTIFACT is what gets checked — a PR body, a card, a
31
+ // note — against the same turn's outputs. The final message is what outlives
32
+ // the turn least; the artifact is what outlives it most.
33
+ if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
34
+ } catch {
35
+ return 0; // not observable: a gate that cannot read its inputs gets out of the way
36
+ }
37
+ const result = crossCheck(turn);
38
+ if (result.denied.length && mode === "block") {
39
+ logger.error(formatClaimReport(result));
40
+ return 2;
41
+ }
42
+ if (result.denied.length || result.unbacked.length) logger.error(formatClaimReport(result));
43
+ return 0;
44
+ }
45
+
46
+ export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
47
+ const path = flags.transcript;
48
+ if (!path) {
49
+ logger.error("kj claims check: --transcript <path> is required");
50
+ return 1;
51
+ }
52
+ let turn;
53
+ try {
54
+ turn = readTurnFn(path);
55
+ if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
56
+ } catch (err) {
57
+ // Not observable: the transcript could not be read. Never reported as clean.
58
+ const note = `kj claims: transcript not readable (${err.message}) — nothing checked`;
59
+ if (flags.json) logger.log(JSON.stringify({ ok: true, checked: false, reason: note }));
60
+ else logger.error(note);
61
+ return 0;
62
+ }
63
+
64
+ const result = crossCheck(turn);
65
+ if (flags.json) {
66
+ logger.log(JSON.stringify({ ok: true, checked: true, denied: result.denied, unbacked: result.unbacked, claims: result.claims.length }));
67
+ } else if (result.denied.length || result.unbacked.length) {
68
+ logger.error(formatClaimReport(result));
69
+ } else {
70
+ logger.log(`kj claims: ${result.claims.length} dato(s) comprobado(s), todos con respaldo en este turno`);
71
+ }
72
+ return result.denied.length ? 2 : 0;
73
+ }
@@ -43,7 +43,9 @@ export function findTitleMatches(title, allHus) {
43
43
  return { identical, similar };
44
44
  }
45
45
 
46
- async function backlogPlan(projectDir) {
46
+ // Exported for the Steward's proposed-work sync (KJC-TSK-0792): broken
47
+ // invariants land in the same backlog the brain already consumes.
48
+ export async function backlogPlan(projectDir) {
47
49
  const plans = await listPlans(projectDir);
48
50
  const existing = plans.find((p) => p.alias === BACKLOG_NAME || p.name === BACKLOG_NAME);
49
51
  if (existing) return loadPlan(projectDir, existing.planId);
@@ -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
@@ -28,9 +28,12 @@ export async function privacyScanCommand({ paths = [], flags = {}, logger = cons
28
28
  const warns = findings.filter((f) => f.severity === "warn");
29
29
  const ok = blocks.length === 0;
30
30
  process.exitCode = ok ? 0 : 1;
31
+ // KJC-TSK-0797 AC4: "nothing found" and "found but explained by context"
32
+ // are different truths — the report says which one it is.
33
+ const discarded = findings.discardedByContext || 0;
31
34
  // --json keeps stdout machine-clean: exactly one JSON document, no prose.
32
35
  if (flags.json) {
33
- process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, findings })}\n`);
36
+ process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, discardedByContext: discarded, findings })}\n`);
34
37
  return { ok, findings };
35
38
  }
36
39
  for (const f of blocks) logger.error?.(`✗ BLOCK [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
@@ -38,8 +41,9 @@ export async function privacyScanCommand({ paths = [], flags = {}, logger = cons
38
41
  if (!list.present) {
39
42
  logger.info?.(`hint: no personal denylist found — create ${privacyConfigPath()} (personal: [...], allow: [...]) so YOUR data blocks, not just warns`);
40
43
  }
44
+ const context = discarded > 0 ? `; ${discarded} candidate(s) discarded by context — git SHAs / documentation domains` : "";
41
45
  logger.info?.(ok
42
- ? `privacy scan: clean of denylist hits (${warns.length} generic warning(s))`
46
+ ? `privacy scan: clean of denylist hits (${warns.length} generic warning(s)${context})`
43
47
  : `privacy scan: ${blocks.length} personal-data hit(s) — this must not ship`);
44
48
  return { ok, findings };
45
49
  }
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { resumeFlow } from "../orchestrator.js";
3
+ import { sweepOnResume } from "./steward.js";
3
4
  import { createActivityLog } from "../activity-log.js";
4
5
  import { printEvent } from "../utils/display/event-handlers.js";
5
6
  import { withCliRunLog } from "../utils/cli-run-log.js";
@@ -9,6 +10,9 @@ import { createCliAskQuestion } from "../utils/cli-ask-question.js";
9
10
  export async function resumeCommand({ sessionId, answer, config, logger, flags }) {
10
11
  const jsonMode = flags?.json;
11
12
  const quietMode = config.output?.quiet !== false;
13
+ // STW-E (KJC-TSK-0793): resuming work is when someone is in front to review
14
+ // the state — sweep if the report went stale; adoption stays explicit.
15
+ await sweepOnResume({ config, logger });
12
16
 
13
17
  // Same wrapper as every other CLI command — without it `.kj/run.log`
14
18
  // is never opened during the resume and `kj-tail` stays silent
@@ -12,7 +12,7 @@ import { checkVerdict, diffHash } from "../review/verdict-store.js";
12
12
  import { runOneShotReview } from "../review/one-shot-review.js";
13
13
  import { runSolomonArbitration } from "../review/solomon-arbitration.js";
14
14
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
- import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
15
+ import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
16
16
  import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
17
17
  import { checkCardFirst } from "../review/card-first.js";
18
18
  import { checkTestsWithCode } from "../review/tests-with-code.js";
@@ -162,16 +162,23 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
162
162
  const numstat = await rawDiff(flags.range, ["--numstat"]);
163
163
  const added = numstat.split("\n").reduce((acc, l) => acc + (Number(l.split("\t")[0]) || 0), 0);
164
164
  if (added > sizeWarn) {
165
+ // KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
166
+ // partitioning is a decision, and it must never split code from its tests.
167
+ const testAdded = numstat.split("\n").reduce((acc, l) => {
168
+ const [a, , ...f] = l.split("\t");
169
+ return acc + (/\/tests?\/|__tests__\/|\.test\.|\.spec\./.test(`/${f.join("\t")}`) ? Number(a) || 0 : 0);
170
+ }, 0);
171
+ const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
165
172
  const sizePolicy = config?.method_gates?.pr_size || "warn";
166
173
  if (process.env.KJ_ALLOW_LARGE_PR === "1") {
167
174
  console.log(`⚠ pr-size exempt: ${added} lines added — KJ_ALLOW_LARGE_PR=1 (explicit escape hatch)`);
168
175
  } else if (sizePolicy === "block") {
169
- const reason = `${added} lines added exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
176
+ const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
170
177
  console.log(`✗ pr-size gate: ${reason}`);
171
178
  process.exitCode = 1;
172
179
  return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
173
180
  } else {
174
- console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
181
+ console.log(`⚠ pr-size: ${added} lines added${split} (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
175
182
  }
176
183
  }
177
184
  }
@@ -213,7 +220,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
213
220
  }
214
221
  }
215
222
 
216
- const tests = checkTestsWithCode({ config, stagedFiles: changedFiles });
223
+ // KJC-TSK-0795 AC1: hand the gate the numbers so a delete-only diff is
224
+ // exempt — deleting code adds no behavior to test.
225
+ const numstat = (await rawDiff(flags.range, ["--numstat"])).split("\n").map((l) => l.trim()).filter(Boolean)
226
+ .map((l) => { const [a, r, ...f] = l.split(/\s+/); return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 }; });
227
+ const tests = checkTestsWithCode({ config, stagedFiles: changedFiles, numstat });
228
+ if (tests.mode === "delete-only") console.log(`⚠ tests-with-code: exempt — ${tests.reason}`);
217
229
  if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
218
230
  if (tests.mode === "exempt") console.log(`⚠ tests-with-code exempt: ${tests.reason}`);
219
231
  if (!tests.ok) {
@@ -289,8 +301,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
289
301
  console.log(res.ok
290
302
  ? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
291
303
  : `✗ ${res.reason}`);
292
- // GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella.
293
- if (res.ok) seal("allow");
304
+ // GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella. PL-E
305
+ // (KJC-TSK-0767): con las reglas que AVISARON, para que "nace avisando y
306
+ // gana dientes" se decida con datos (kj policy report), no a ciegas.
307
+ if (res.ok) seal("allow", gate.warns.length > 0 ? { warn_rule_ids: gate.warns.map((w) => w.rule_id) } : {});
294
308
  process.exitCode = res.ok ? 0 : 1;
295
309
  return res;
296
310
  }
@@ -301,15 +315,26 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
301
315
  // the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
302
316
  let task = flags.task;
303
317
  if (flags.sonar !== false) {
304
- const pre = await runSonarPregate({ config, stagedFiles: changedFiles, logger });
318
+ // KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
319
+ const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
320
+ const pre = await runSonarPregate({ config, stagedFiles: changedFiles, touchedLines, logger });
305
321
  if (!pre.available) {
306
- console.log(`⚠ sonar pre-gate skipped: ${pre.reason}`);
322
+ // KJC-BUG-0156 (issue #1543): a scan that FAILED must not read like one
323
+ // more config warning — it hid a dead quality gate for 8 straight PRs.
324
+ // Still fail-open (a laptop without Docker still commits), but SAID.
325
+ const chosen = /disabled in config/.test(pre.reason || "");
326
+ console.log(chosen
327
+ ? `⚠ sonar pre-gate skipped: ${pre.reason}`
328
+ : `✗ sonar pre-gate UNAVAILABLE — the quality gate did NOT run on this diff: ${pre.reason}`);
307
329
  } else {
308
330
  const found = [...pre.blocking, ...pre.advisory];
309
331
  if (found.length > 0) {
310
- console.log(`Sonar on the changed files — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
332
+ console.log(`Sonar on the changed lines — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
311
333
  for (const f of found) console.log(` - ${formatSonarFinding(f)}`);
312
334
  }
335
+ if ((pre.preexisting || []).length > 0) {
336
+ console.log(`⚠ sonar trend: ${pre.preexisting.length} preexisting issue(s) on untouched lines of these files — not this PR's veto, but the debt is real`);
337
+ }
313
338
  if (pre.blocking.length > 0) {
314
339
  console.log("✗ REJECTED by sonar (deterministic) — fix the blocking findings; the cross-AI reviewer was not invoked.");
315
340
  process.exitCode = 1;
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `kj steward sweep` (STW-B, KJC-TSK-0790, epic KJC-PCS-0081) — a READ-ONLY
3
+ * pass over the Steward invariants that leaves the verdict IN THE REPO:
4
+ * `.karajan/steward/report.md` (a person: verdict, last evidence, and the
5
+ * command that renews each invariant) and `report.json` (a machine — and the
6
+ * BASELINE the next sweep compares against: the versioned report is the
7
+ * shared memory, never a record on one machine). Exit 1 only when something
8
+ * is BROKEN — unknown and not-observable inform with their remedy.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import {
14
+ VERDICTS, resolveFreshness, evaluateMainCi, evaluateSecurityAudit,
15
+ evaluateVulnAging, evaluateDeadCodeTrend, evaluateCoverageConfig,
16
+ evaluatePhantomCoverage, runInvariants,
17
+ } from "../steward/invariants.js";
18
+ import { loadPreviousAudit } from "../audit/basal-cost.js";
19
+ import { collectOsvFindings } from "../audit/osv-findings.js";
20
+ import { recordGateDecision } from "../policy/decisions.js";
21
+ import { syncProposedWork } from "../steward/proposed-work.js";
22
+
23
+ const stewardDir = (projectDir) => path.join(projectDir, ".karajan", "steward");
24
+
25
+ /** `gh run list` for push runs on the base branch — the sweep's default probe. */
26
+ const ghRuns = (projectDir, baseBranch) => () => {
27
+ const out = execFileSync("gh", ["run", "list", "--branch", baseBranch, "--event", "push", "--limit", "30", "--json", "name,conclusion,createdAt"], { cwd: projectDir, encoding: "utf8", timeout: 30_000 });
28
+ return JSON.parse(out).map((r) => ({ workflow: r.name, conclusion: r.conclusion, createdAt: r.createdAt }));
29
+ };
30
+
31
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
32
+
33
+ export async function stewardSweepCommand({ flags = {}, config = {}, logger = console, probes = {} } = {}) {
34
+ const projectDir = config.projectDir || process.cwd();
35
+ const baseBranch = config.base_branch || "main";
36
+ // STW-E (KJC-TSK-0793): --if-stale <days> — resuming work with a fresh
37
+ // report does not re-sweep; the report in the repo stays the ONE source.
38
+ if (flags.ifStale !== undefined) {
39
+ const prev = readJson(path.join(stewardDir(projectDir), "report.json"));
40
+ const ageMs = prev?.sweptAt ? (probes.nowMs ?? Date.now()) - Date.parse(prev.sweptAt) : Infinity;
41
+ if (ageMs < Number(flags.ifStale) * 86_400_000) {
42
+ logger.info?.(`steward: report is fresh (swept ${prev.sweptAt}) — not re-sweeping`);
43
+ return 0;
44
+ }
45
+ }
46
+ const freshness = resolveFreshness(config);
47
+ const nowMs = probes.nowMs ?? Date.now();
48
+ const runsFn = probes.runsFn ?? ghRuns(projectDir, baseBranch);
49
+
50
+ // Live osv probe (best-effort): unavailable is null → the invariant answers
51
+ // unknown with its remedy, never a clean bill.
52
+ let vulns = probes.vulns ?? null;
53
+ if (vulns === null) {
54
+ try {
55
+ const osv = await (probes.osvFn ?? collectOsvFindings)(projectDir, logger);
56
+ if (osv?.available) vulns = (osv.vulnerabilities || []).map((v) => ({ id: v.id, severity: v.severity, publishedAt: v.publishedAt ?? null }));
57
+ } catch { /* stays null — unknown */ }
58
+ }
59
+
60
+ // Dead-code baseline: the PREVIOUS report in the repo — shared memory.
61
+ const prevReport = readJson(path.join(stewardDir(projectDir), "report.json"));
62
+ const snapshot = await loadPreviousAudit(projectDir).catch(() => null);
63
+ const deadNow = typeof snapshot?.knipDeadExports?.exports === "number"
64
+ ? { deadExports: snapshot.knipDeadExports.exports + (snapshot.knipDeadExports.files || 0) }
65
+ : Array.isArray(snapshot?.deadExports) ? { deadExports: snapshot.deadExports.length } : null;
66
+
67
+ const invariants = [
68
+ { id: "main-ci", renew: "push to the base branch (or fix the red run)", evaluate: () => evaluateMainCi({ projectDir, baseBranch, freshness: freshness.values, runsFn, nowMs }) },
69
+ { id: "security-audit", renew: "kj audit --security", evaluate: () => evaluateSecurityAudit({ projectDir, freshness: freshness.values, nowMs }) },
70
+ { id: "vulnerable-deps", renew: "kj audit (osv-scanner)", evaluate: () => evaluateVulnAging({ vulns, freshness: freshness.values, nowMs }) },
71
+ { id: "dead-code-trend", renew: "kj audit (knip inventory)", evaluate: () => evaluateDeadCodeTrend({ current: deadNow, previous: prevReport?.deadCodeBaseline ?? null }) },
72
+ { id: "coverage-config", renew: "configure a coverage threshold", evaluate: () => evaluateCoverageConfig({ projectDir }) },
73
+ { id: "phantom-coverage", renew: "KJC-TSK-0800 ships the detectors", evaluate: () => evaluatePhantomCoverage() },
74
+ ];
75
+ const results = runInvariants(invariants, {}).map((r, i) => ({ ...r, renew: invariants[i].renew }));
76
+ const broken = results.filter((r) => r.verdict === VERDICTS.BROKEN);
77
+
78
+ const report = {
79
+ sweptAt: new Date(nowMs).toISOString(),
80
+ baseBranch,
81
+ freshness: { ...freshness.values, declared: freshness.declared },
82
+ invariants: results,
83
+ deadCodeBaseline: deadNow ? { ...deadNow, timestamp: new Date(nowMs).toISOString() } : (prevReport?.deadCodeBaseline ?? null),
84
+ };
85
+ const mark = { [VERDICTS.OK]: "✓", [VERDICTS.BROKEN]: "✗", [VERDICTS.UNKNOWN]: "?", [VERDICTS.NOT_OBSERVABLE]: "∅" };
86
+ const md = [
87
+ "# Steward report",
88
+ "", `Last swept: ${report.sweptAt}`, "",
89
+ `Freshness: ${freshness.declared ? "declared by the project" : "calibrated defaults (the project declared none)"} — ${Object.entries(freshness.values).map(([k, v]) => `${k}=${v}`).join(", ")}`, "",
90
+ ...results.map((r) => [
91
+ `## ${mark[r.verdict] || "?"} ${r.id} — ${r.verdict}`,
92
+ `- evidence: ${r.evidence || "(none)"}`,
93
+ ...(r.remedy ? [`- remedy: ${r.remedy}`] : []),
94
+ `- renews with: ${r.renew}`, "",
95
+ ]).flat(),
96
+ ].join("\n");
97
+
98
+ fs.mkdirSync(stewardDir(projectDir), { recursive: true });
99
+ fs.writeFileSync(path.join(stewardDir(projectDir), "report.md"), md);
100
+ fs.writeFileSync(path.join(stewardDir(projectDir), "report.json"), `${JSON.stringify(report, null, 2)}\n`);
101
+
102
+ // Every sweep is SEALED in the decision chain — the same criterion policy
103
+ // decisions already meet: recorded and verifiable, not just notified.
104
+ try {
105
+ const counts = {};
106
+ for (const r of results) counts[r.verdict] = (counts[r.verdict] || 0) + 1;
107
+ recordGateDecision(projectDir, { kind: "steward-sweep", verdicts: { ok: counts[VERDICTS.OK] || 0, broken: broken.length, unknown: counts[VERDICTS.UNKNOWN] || 0, "not-observable": counts[VERDICTS.NOT_OBSERVABLE] || 0 }, broken_ids: broken.map((b) => b.id) });
108
+ } catch (err) { logger.warn?.(`⚠ steward: the sweep could not be sealed in the decision chain (${err.message})`); }
109
+
110
+ // STW-D (KJC-TSK-0792): every break is PROPOSED work on the board the brain
111
+ // already consumes. AFTER the report and the seal: a board failure never
112
+ // costs either, and it is said loudly — never swallowed.
113
+ try {
114
+ const sync = await syncProposedWork({ projectDir, config, results, sweptAt: report.sweptAt });
115
+ if (!sync.synced) logger.warn?.(`⚠ steward: broken invariants not carded — ${sync.reason}`);
116
+ else if (sync.created || sync.updated || sync.resolved) logger.info?.(`steward board: ${sync.created} card(s) proposed, ${sync.updated} updated, ${sync.resolved} resolved`);
117
+ } catch (err) { logger.warn?.(`⚠ steward: carding the broken invariants failed (${err.message}) — the report and the seal stand`); }
118
+
119
+ // A report nobody can see is not shared state — say it, do not fix their tree.
120
+ try {
121
+ execFileSync("git", ["check-ignore", "-q", path.join(".karajan", "steward", "report.md")], { cwd: projectDir });
122
+ logger.warn?.("⚠ steward: the report path is gitignored — untrack .karajan/steward from .gitignore so the state is shared");
123
+ } catch { /* not ignored — good */ }
124
+
125
+ if (flags.json) {
126
+ process.stdout.write(`${JSON.stringify(report)}\n`);
127
+ } else {
128
+ for (const r of results) logger.info?.(`${mark[r.verdict]} ${r.id}: ${r.verdict}${r.verdict === VERDICTS.OK ? "" : ` — ${r.remedy || r.evidence}`}`);
129
+ logger.info?.(broken.length ? `steward: ${broken.length} invariant(s) BROKEN — the report names them (.karajan/steward/report.md)` : "steward: nothing broken — the full state is in .karajan/steward/report.md");
130
+ }
131
+ process.exitCode = broken.length ? 1 : 0;
132
+ return broken.length ? 1 : 0;
133
+ }
134
+
135
+ /**
136
+ * STW-E — the on-resume mode: when work resumes there is someone in front to
137
+ * review the report, which is the requirement. Only for projects that ADOPTED
138
+ * the Steward (a report exists, or method_gates.steward is declared): the
139
+ * default imposes nothing. Best-effort: a failed sweep never stops a resume.
140
+ */
141
+ export async function sweepOnResume({ config = {}, logger = console, sweepFn = stewardSweepCommand } = {}) {
142
+ const projectDir = config.projectDir || process.cwd();
143
+ const adopted = fs.existsSync(path.join(stewardDir(projectDir), "report.json")) || Boolean(config.method_gates?.steward);
144
+ if (!adopted) return;
145
+ try { await sweepFn({ flags: { ifStale: 1 }, config, logger }); } catch (err) { logger.warn?.(`⚠ steward: on-resume sweep failed (${err.message}) — resuming anyway`); }
146
+ }
@@ -80,7 +80,12 @@ const DEFAULTS = {
80
80
  tests_with_code: "warn",
81
81
  // MG-C: informative nudge when the staged diff exceeds this many
82
82
  // added lines (0 disables). The project's CI owns any hard budget.
83
- pr_size_warn: 150
83
+ pr_size_warn: 150,
84
+ // CLM-B (claims-with-evidence ADR): the Stop gate crosses the hard data of
85
+ // the turn's final message against that turn's tool outputs. "off" (adoption
86
+ // is explicit) | "warn" (report only) | "block" (a datum DENIED by its own
87
+ // source refuses to end the turn; unbacked data is still only reported).
88
+ claims: "off"
84
89
  },
85
90
  review_rules: "./.karajan/review-rules.md",
86
91
  coder_rules: "./.karajan/coder-rules.md",