karajan-code 4.33.0 → 4.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.33.0",
3
+ "version": "4.34.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",
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Recovery for a command that runs once (KJC-BUG-0194).
3
+ *
4
+ * The pipeline can afford to wait: a quota wall means hibernate, persist the
5
+ * session and resume when the window reopens. A one-shot command cannot.
6
+ * Sleeping five hours inside `kj code` or `kj audit` is worse than the failure
7
+ * it is handling, and an MCP caller gets no answer at all.
8
+ *
9
+ * So here a quota wall takes the next declared candidate AT ONCE, and with
10
+ * none left it stops and says what it tried. Everything else keeps the
11
+ * pipeline's behaviour: a retired model still moves on immediately, provider
12
+ * 500s still back off, an auth failure still aborts.
13
+ */
14
+ import { DEFAULT_RECOVERY_POLICY } from "./with-brain-recovery.js";
15
+ import { ERROR_CLASS } from "./agent-error-classifier.js";
16
+
17
+ const takeTheNextOne = { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true };
18
+
19
+ export const ONE_SHOT_POLICY = Object.freeze({
20
+ ...DEFAULT_RECOVERY_POLICY,
21
+ classes: {
22
+ ...DEFAULT_RECOVERY_POLICY.classes,
23
+ [ERROR_CLASS.QUOTA_EXHAUSTED_DAILY]: takeTheNextOne,
24
+ [ERROR_CLASS.QUOTA_EXHAUSTED_MONTHLY]: takeTheNextOne,
25
+ },
26
+ });
@@ -121,7 +121,10 @@ export async function withBrainRecovery({
121
121
 
122
122
  while (true) {
123
123
  const result = await effectiveAgent.runTask(taskArgs);
124
- if (result?.ok) return result;
124
+ // KJC-TSK-0873: quien HA CORRIDO viaja con el resultado. Tras una caída al
125
+ // fallback, el llamante creía que había escrito el provider declarado, y
126
+ // con eso el recuento del panel contaba trabajo ajeno como propio.
127
+ if (result?.ok) return { ...result, provider: effectiveProvider };
125
128
 
126
129
  const cls = classifyAgentError({
127
130
  provider: effectiveProvider,
@@ -106,6 +106,42 @@ async function packPrivacyCheck(projectDir, pkg) {
106
106
  }
107
107
  }
108
108
 
109
+ /**
110
+ * KJC-BUG-0204 — an item may declare WHICH command repairs it (`remedied_by`),
111
+ * so the gate stops blocking the one action that would turn it green. It was
112
+ * the landing: the check demanded a deployed site and the guard blocked the
113
+ * deploy, leaving `KJ_ALLOW_RELEASE=1` — switching the gate off entirely — as
114
+ * the only way to reach the publish that came next.
115
+ */
116
+ const remedy = (item) => (typeof item?.remedied_by === "string" && item.remedied_by.trim() ? { remediedBy: item.remedied_by.trim() } : {});
117
+
118
+ /**
119
+ * The words of a verb must appear in the command IN ORDER, as whole words:
120
+ * `firebase deploy` covers `firebase --account a@b --project p deploy --only x`,
121
+ * and `deploy firebase` is a different command. Separators (`;`, `|`, `&`,
122
+ * parentheses) split words, so `(npm publish)` is still a publication.
123
+ * A word is read by its basename, so `/usr/bin/npm publish` is the same
124
+ * publication as `npm publish` (review catch: an absolute path hid one).
125
+ */
126
+ const wordsOf = (text) => String(text).split(/[^A-Za-z0-9_.@:/-]+/).filter(Boolean).map((w) => w.slice(w.lastIndexOf("/") + 1)).filter(Boolean);
127
+
128
+ export function remediesCommand(remediedBy, command) {
129
+ const want = wordsOf(remediedBy);
130
+ if (want.length === 0) return false;
131
+ let i = 0;
132
+ for (const word of wordsOf(command)) if (word === want[i] && ++i === want.length) return true;
133
+ return false;
134
+ }
135
+
136
+ /**
137
+ * A publication is irreversible: no declared item may excuse one. Detected with
138
+ * the SAME tolerance as a remedy (review catch): matching only adjacent words
139
+ * left `npm --registry https://r.example publish` undetected, so an item could
140
+ * have lifted a red check while the publication went ahead.
141
+ */
142
+ const PUBLICATION_VERBS = ["npm publish", "gh release create"];
143
+ const isPublication = (command) => PUBLICATION_VERBS.some((verb) => remediesCommand(verb, command));
144
+
109
145
  async function declaredItems(projectDir, config, version) {
110
146
  const items = config?.release_check?.items;
111
147
  if (!Array.isArray(items)) return [];
@@ -116,13 +152,16 @@ async function declaredItems(projectDir, config, version) {
116
152
  const p = isAbsolute(item.file_contains.path) ? item.file_contains.path : join(projectDir, item.file_contains.path);
117
153
  const needle = String(item.file_contains.pattern).replaceAll("{version}", version ?? "");
118
154
  const ok = existsSync(p) && readFileSync(p, "utf8").includes(needle);
119
- checks.push({ name, ok, detail: ok ? `"${needle}" found in ${item.file_contains.path}` : `"${needle}" NOT found in ${item.file_contains.path}` });
155
+ checks.push({ name, ok, ...remedy(item), detail: ok ? `"${needle}" found in ${item.file_contains.path}` : `"${needle}" NOT found in ${item.file_contains.path}` });
120
156
  } else if (item?.command) {
121
157
  try {
122
158
  const res = await runCommand("sh", ["-c", String(item.command).replaceAll("{version}", version ?? "")], { cwd: projectDir });
123
- checks.push({ name, ok: res.exitCode === 0, detail: res.exitCode === 0 ? "command exited 0" : `command exited ${res.exitCode}` });
159
+ checks.push({ name, ok: res.exitCode === 0, ...remedy(item), detail: res.exitCode === 0 ? "command exited 0" : `command exited ${res.exitCode}` });
124
160
  } catch (err) {
125
- checks.push({ name, ok: false, detail: `command failed to run: ${err.message}` });
161
+ // El remedio viaja tambien cuando el item no se pudo ni ejecutar
162
+ // (catch de la review): si no, el comando que lo repara queda
163
+ // bloqueado justo cuando el check no ha podido comprobar nada.
164
+ checks.push({ name, ok: false, ...remedy(item), detail: `command failed to run: ${err.message}` });
126
165
  }
127
166
  } else {
128
167
  checks.push({ name, ok: false, detail: "unknown item shape — use file_contains {path, pattern} or command" });
@@ -160,10 +199,17 @@ export async function dualPublishCheck(projectDir, pkg, run = runCommand) {
160
199
  }
161
200
  }
162
201
 
163
- export async function runReleaseCheck({ projectDir = process.cwd(), config = {} } = {}) {
202
+ export async function runReleaseCheck({ projectDir = process.cwd(), config = {}, forCommand = null } = {}) {
164
203
  const { checks, version, pkg } = await genericChecks(projectDir);
165
204
  const pack = await packPrivacyCheck(projectDir, pkg);
166
205
  const dual = await dualPublishCheck(projectDir, pkg);
167
206
  checks.push(...(pack ? [pack] : []), ...(dual ? [dual] : []), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
168
- return { ok: checks.every((c) => c.ok), version, checks };
207
+ if (forCommand === null) return { ok: checks.every((c) => c.ok), version, checks };
208
+ // A lifted check stays RED in the report: the fact is not falsified, it just
209
+ // stops blocking the command that exists to repair it. The mark travels ON
210
+ // the check (review catch): two items may share a name, and a name-keyed
211
+ // list would have exempted a red check nothing remedies.
212
+ const liftable = !isPublication(forCommand);
213
+ const marked = checks.map((c) => (liftable && !c.ok && c.remediedBy && remediesCommand(c.remediedBy, forCommand) ? { ...c, lifted: true } : c));
214
+ return { ok: marked.every((c) => c.ok || c.lifted === true), version, checks: marked, lifted: marked.filter((c) => c.lifted).map((c) => c.name) };
169
215
  }
@@ -40,6 +40,9 @@ import { existsSync } from "node:fs";
40
40
  import { join } from "node:path";
41
41
  import { spawnSync } from "node:child_process";
42
42
  import { verifySentinelScripts, resolveSentinelRoot } from "../harden/sentinel-hooks.js";
43
+ import { boardGate } from "../review/board-pending.js";
44
+ import { panelDeviation } from "../environment/panel.js";
45
+ import { detectHostAgent } from "../utils/agent-detect.js";
43
46
 
44
47
  /**
45
48
  * Register the "meta" / single-role / housekeeping commands: pre-pipeline
@@ -337,12 +340,16 @@ export function registerMeta(program, { pkgVersion }) {
337
340
  release.command("check")
338
341
  .description("Verify the release checklist deterministically (manifest vs CHANGELOG vs tags, privacy scan of publishable files, plus the project's release_check.items); exit 1 lists exactly what is missing")
339
342
  .option("--json", "Machine-readable result")
343
+ // KJC-BUG-0204: el guard pregunta POR un comando. Un item que declara
344
+ // remedied_by no bloquea el comando que lo repara; publicar nunca se exime.
345
+ .option("--for-command <cmd>", "Evaluate the checklist for this command: red items whose remedied_by is this command do not block it")
340
346
  .action(async (flags) => {
341
347
  await withConfig(pkgVersion, "release-check", flags, async ({ config }) => {
342
- const res = await runReleaseCheck({ projectDir: config?.projectDir || process.cwd(), config });
348
+ const res = await runReleaseCheck({ projectDir: config?.projectDir || process.cwd(), config, forCommand: flags.forCommand ?? null });
343
349
  if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
344
350
  else {
345
351
  for (const c of res.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`);
352
+ if (res.lifted?.length) console.log(` ℓ no bloquean este comando porque es su remedio: ${res.lifted.join(", ")}`);
346
353
  console.log(res.ok ? `release check: ready to release ${res.version ?? ""}`.trim() : "release check: NOT ready — fix the red items first");
347
354
  }
348
355
  process.exitCode = res.ok ? 0 : 1;
@@ -419,8 +426,12 @@ export function registerMeta(program, { pkgVersion }) {
419
426
  });
420
427
  policyCmd.command("seal")
421
428
  .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")
422
- .requiredOption("--escape <name>", "Escape usado (p.ej. KJ_ALLOW_BOARD)")
429
+ .option("--escape <name>", "Escape usado (p.ej. KJ_ALLOW_BOARD)")
423
430
  .option("--tool <tool>", "Tool que lo usó (Bash, Edit…)")
431
+ // KJC-TSK-0868: el panel se sella igual, con su propio chokepoint.
432
+ .option("--panel <coder>", "Coder declarado cuando el anfitrión escribió el código")
433
+ .option("--host <agent>", "Anfitrión que escribió, para el asiento del panel")
434
+ .option("--honoured", "El panel se CUMPLIÓ: escribió el coder declarado (KJC-TSK-0873)")
424
435
  .action(async (flags) => {
425
436
  await withConfig(pkgVersion, "policy-seal", flags, async ({ config }) => {
426
437
  process.exitCode = await policyCommand({ action: "seal", config, flags });
@@ -466,9 +477,38 @@ export function registerMeta(program, { pkgVersion }) {
466
477
  .action((flags) => {
467
478
  const res = verifySentinelScripts();
468
479
  if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
480
+ // KJC-BUG-0197: manipulación y desfase son hechos distintos y se leen
481
+ // distinto. Un desfase no es un defecto: los guardias que corren están
482
+ // sellados, solo son más viejos que las plantillas de este árbol.
483
+ else if (res.drift?.length && res.ok) console.log(`sentinel verify: scripts sellados, con desfase (${res.drift.join(", ")}) — ${res.reason}`);
469
484
  else console.log(res.ok ? "sentinel verify: scripts intactos" : `sentinel verify: modificados fuera de kj harden: ${res.mismatched.join(", ")} — restaura con kj harden`);
470
485
  process.exitCode = res.ok ? 0 : 1;
471
486
  });
487
+ // KJC-TSK-0868: el anfitrión escribiendo con otro coder declarado no se
488
+ // bloquea, se registra. El Stop gate pregunta una vez por sesión.
489
+ sentinel.command("panel-check")
490
+ .description("Record in the decision log that the host wrote code while another coder is declared (chokepoint panel); prints nothing and records nothing when the panel was honoured")
491
+ .requiredOption("--session <id>", "Host session id, for the record")
492
+ .action(async (flags) => {
493
+ await withConfig(pkgVersion, "sentinel-panel-check", flags, async ({ config }) => {
494
+ const dev = panelDeviation(config, detectHostAgent());
495
+ if (!dev) return;
496
+ process.exitCode = await policyCommand({ action: "seal", config, flags: { panel: dev.coder, host: dev.host }, logger: { info: () => {}, error: console.error } });
497
+ if (process.exitCode === 0) console.log(`ℓ panel: el coder declarado es ${dev.coder} y ha escrito ${dev.host} — registrado como excepción consciente (kj report lo cuenta)`);
498
+ });
499
+ });
500
+ // KJC-BUG-0198: el guard pregunta, kj decide con un hecho comprobable (otra
501
+ // PR abierta de la card), y exit 2 = bloquea.
502
+ sentinel.command("board-gate")
503
+ .description("Decide which pending board moves actually block: a card still being delivered in another open PR is carried, everything else blocks; exit 2 = blocked")
504
+ .requiredOption("--session <id>", "Host session id whose pending moves are evaluated")
505
+ .option("--json", "Machine-readable result")
506
+ .action((flags) => {
507
+ const res = boardGate({ projectDir: resolveSentinelRoot(), sessionId: flags.session });
508
+ if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
509
+ else for (const c of res.carried) console.log(`ℓ board-sync: ${c.card} ${c.why} — se moverá al cerrarla`);
510
+ process.exitCode = res.blocking.length > 0 ? 2 : 0;
511
+ });
472
512
 
473
513
  // KJC-TSK-0704 — the outbound privacy boundary: audit before anything ships.
474
514
  const privacy = program.command("privacy").description("Personal-data (PII) auditing of outbound boundaries: staged diffs, build outputs, docs trees");
@@ -5,23 +5,13 @@ import { buildCoderPrompt } from "../prompts/coder.js";
5
5
  import { resolveCardContext } from "../prompts/card-context.js";
6
6
  import { composeTask, resolveRagContext } from "../prompts/session-context.js";
7
7
  import { resolveRole } from "../config.js";
8
- import { withBrainRecovery, DEFAULT_RECOVERY_POLICY } from "../brain/with-brain-recovery.js";
9
- import { buildRoleFallbackChain } from "../brain/role-fallback-chain.js";
10
- import { ERROR_CLASS } from "../brain/agent-error-classifier.js";
11
-
12
8
  // KJC-TSK-0859: the agents no longer substitute a dead model behind your back,
13
- // so the command needs the declared chain. A one-shot CLI must not inherit the
14
- // pipeline's standby either: sleeping five hours inside `kj code` would be
15
- // worse than the failure. A quota wall takes the next candidate at once, and
16
- // with none left it stops and says what it tried.
17
- const ONE_SHOT_POLICY = Object.freeze({
18
- ...DEFAULT_RECOVERY_POLICY,
19
- classes: {
20
- ...DEFAULT_RECOVERY_POLICY.classes,
21
- [ERROR_CLASS.QUOTA_EXHAUSTED_DAILY]: { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true },
22
- [ERROR_CLASS.QUOTA_EXHAUSTED_MONTHLY]: { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true },
23
- },
24
- });
9
+ // so the command needs the declared chain, with the one-shot policy a command
10
+ // deserves (KJC-BUG-0194 moved it to its own module: audit needs the same).
11
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
12
+ import { buildRoleFallbackChain } from "../brain/role-fallback-chain.js";
13
+ import { ONE_SHOT_POLICY } from "../brain/one-shot-policy.js";
14
+ import { sealPanel } from "../environment/panel.js";
25
15
  import { withCliRunLog } from "../utils/cli-run-log.js";
26
16
  import { createCliProgressReporter } from "../utils/cli-progress.js";
27
17
 
@@ -89,6 +79,18 @@ export async function codeCommand({ task, config, logger, flags = {} }) {
89
79
  logger.warn(result.error);
90
80
  }
91
81
  logger.info(`Coder completed (exit ${result.exitCode})`);
82
+ // KJC-TSK-0873: aqui es donde el panel SE CUMPLE — el coder declarado ha
83
+ // escrito. Queda asentado junto a las desviaciones para que el recuento de
84
+ // kj report signifique algo: casi siempre y casi nunca no se ven igual.
85
+ // Solo cuenta como cumplido si el coder declarado TERMINO bien: ni una
86
+ // salida distinta de cero ni una caida al fallback son "escribio el coder
87
+ // que elegiste" (dos catches de la review). Si escribio otro, se asienta
88
+ // como desviacion nombrandolo.
89
+ const ranBy = result.provider || coderRole.provider;
90
+ if (result.exitCode === 0) {
91
+ try { sealPanel({ projectDir: config.projectDir, coder: coderRole.provider, host: ranBy, honoured: ranBy === coderRole.provider }); }
92
+ catch (err) { logger.warn(`panel: no se pudo sellar quién escribió (${err.message}) — el trabajo del coder no se toca`); }
93
+ }
92
94
  // The work is in the tree, not committed: the gate is the next step and a
93
95
  // DIFFERENT AI runs it. Saying so here is what keeps the panel honest.
94
96
  logger.info(`The work is in the tree. Review it before committing: kj review --staged (a different AI than ${coderRole.provider}).`);
@@ -153,7 +153,10 @@ export async function hardenCommand({
153
153
  result.harnessHooks = hh.wired ? "wired" : "script-only";
154
154
  // KJC-TSK-0713 — the Sentinel: method state + Stop gate (turn cannot
155
155
  // end red). Same Claude-only harness surface as the tool gate.
156
- const sh = installSentinelHooks({ projectDir, logger });
156
+ // KJC-BUG-0193: solo un humano avanza lo que un humano selló. Sin
157
+ // --commit, un guardia sellado se queda como está en lugar de quedarse
158
+ // sin sello, que es como una sesión se dejaba sin herramientas.
159
+ const sh = installSentinelHooks({ projectDir, logger, human: commitSupervisor });
157
160
  result.sentinelHooks = sh.wired ? "wired" : "script-only";
158
161
  }
159
162
  } catch (err) {
@@ -195,7 +198,11 @@ export async function hardenCommand({
195
198
  logger.info?.(` • ${c.file}: ${c.action}${note}`);
196
199
  }
197
200
  for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
198
- for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
201
+ for (const g of out.guidelines) {
202
+ logger.info?.(` • ${g.file}: ${g.action}`);
203
+ // KJC-BUG-0206: quitar reglas no puede leerse como "updated" y nada más.
204
+ if (g.shrank) logger.warn?.(` ⚠ ${g.file} queda con MENOS reglas que antes — revisa si el lenguaje detectado es el que crees (kj check lo dice)`);
205
+ }
199
206
  if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
200
207
  // IDN-A (KJC-TSK-0762): a hardened clone declares who works it. Captured
201
208
  // only with a human confirming; headless runs get the pending command.
@@ -15,6 +15,8 @@ import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
15
15
  import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
16
16
  import { loadExceptionRecords, loadGlobalExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
17
17
  import { buildPolicyReport } from "../policy/report.js";
18
+ import { budgetedNet } from "../review/loc-budget.js";
19
+ import { sealPanel } from "../environment/panel.js";
18
20
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
19
21
  import { readIdentity } from "../identity/store.js";
20
22
  import { verifyDecisionChain } from "@karajan-family/governance";
@@ -32,12 +34,10 @@ async function stagedFacts(projectDir, gitFn, range = null) {
32
34
  .split("\n")
33
35
  .map((s) => s.trim())
34
36
  .filter(Boolean);
35
- let net = 0;
36
- for (const line of (await run([...base, "--numstat"])).split("\n")) {
37
- const [a, r] = line.trim().split(/\s+/);
38
- if (a && a !== "-") net += Number(a) || 0;
39
- if (r && r !== "-") net -= Number(r) || 0;
40
- }
37
+ // KJC-BUG-0208: el presupuesto lo decide un solo modulo. Este sitio sumaba el
38
+ // numstat entero, asi que el job policy-check de CI contaba la documentacion
39
+ // y el gate bloqueante no: la misma regla con dos respuestas.
40
+ const { net } = budgetedNet(await run([...base, "--numstat"]));
41
41
  return { files, netLinesAdded: net };
42
42
  }
43
43
 
@@ -209,18 +209,23 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
209
209
  // identidad DECLARADA del clon (identity.local.yml), para que el informe
210
210
  // y el anchor la vean. Lo llama el Sentinel; cualquiera puede auditarlo.
211
211
  if (action === "seal") {
212
- if (!flags.escape) {
213
- logger.error?.("policy seal: --escape <KJ_ALLOW_X> es obligatorio — un escape sin nombre no es auditable");
212
+ // KJC-TSK-0868: el mismo asiento sirve para el panel — el anfitrión escribió
213
+ // teniendo otro coder declarado. Mismo log encadenado, otro chokepoint.
214
+ if (!flags.escape && !flags.panel) {
215
+ logger.error?.("policy seal: --escape <KJ_ALLOW_X> o --panel <coder> es obligatorio — un asiento sin nombre no es auditable");
214
216
  return 1;
215
217
  }
216
218
  try {
217
219
  const who = readIdentity(projectDir);
218
- const rec = recordGateDecision(projectDir, {
219
- decision: "exempt", chokepoint: "tool", escape: flags.escape, tool: flags.tool ?? null,
220
- who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
221
- policy_hash: policyFileHash(projectDir),
222
- });
223
- logger.info?.(`✓ escape ${rec.escape} sellado (chokepoint tool${rec.tool ? `, ${rec.tool}` : ""})`);
220
+ const rec = flags.panel
221
+ ? sealPanel({ projectDir, coder: flags.panel, host: flags.host ?? null, honoured: Boolean(flags.honoured) })
222
+ : recordGateDecision(projectDir, {
223
+ decision: "exempt", chokepoint: "tool", escape: flags.escape, tool: flags.tool ?? null,
224
+ who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
225
+ policy_hash: policyFileHash(projectDir),
226
+ });
227
+ if (rec.chokepoint === "panel") logger.info?.(`✓ panel sellado: el coder declarado es ${rec.coder} y escribió ${rec.host ?? "el anfitrión"}`);
228
+ else logger.info?.(`✓ escape ${rec.escape} sellado (chokepoint tool${rec.tool ? `, ${rec.tool}` : ""})`);
224
229
  return 0;
225
230
  } catch (err) {
226
231
  logger.error?.(`policy seal: ${err.message}`);
@@ -238,6 +243,8 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
238
243
  const sup = liftSealedSupervisorViolations({ projectDir, violations });
239
244
  if (sup.lifted > 0) {
240
245
  logger.info?.(`✓ policy: ${sup.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009)`);
246
+ // KJC-BUG-0212: lo que este sitio no ha podido juzgar se dice, no se calla.
247
+ if (sup.note) logger.info?.(`ℓ policy: ${sup.note}`);
241
248
  violations = sup.violations;
242
249
  }
243
250
  const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
@@ -226,6 +226,26 @@ export function formatSentinelEscapes(state) {
226
226
  return `Sentinel escapes used (${events.length}):\n${lines.join("\n")}`;
227
227
  }
228
228
 
229
+ /**
230
+ * KJC-TSK-0873 — the panel tally. The user chose who writes; this says how
231
+ * often it happened. One number alone would not: a project where the panel is
232
+ * honoured almost always and one where it almost never is look identical until
233
+ * both sides are counted.
234
+ */
235
+ export function formatPanelTally(lines) {
236
+ let honoured = 0;
237
+ let host = 0;
238
+ for (const line of lines) {
239
+ let rec;
240
+ try { rec = JSON.parse(line); } catch { continue; }
241
+ if (rec?.chokepoint !== "panel") continue;
242
+ if (rec.decision === "ok") honoured += 1;
243
+ else host += 1;
244
+ }
245
+ if (honoured + host === 0) return null;
246
+ return `Panel (who wrote): declared coder ${honoured}, host ${host}`;
247
+ }
248
+
229
249
  // The sentinel state is a PROJECT artifact (<project>/.karajan/harness), not
230
250
  // a session artifact: the `dir` the report handlers receive is the GLOBAL
231
251
  // sessions root (~/.karajan/sessions) and never contains it. `kj report` is
@@ -241,6 +261,14 @@ async function printSentinelEscapes({ projectDir = process.cwd(), format } = {})
241
261
  console.log(text);
242
262
  }
243
263
  } catch { /* no sentinel state in this project — nothing to report */ }
264
+ try {
265
+ const log = await fs.readFile(path.join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8");
266
+ const tally = formatPanelTally(log.split("\n").filter((l) => l.trim()));
267
+ if (tally) {
268
+ console.log("");
269
+ console.log(tally);
270
+ }
271
+ } catch { /* no decision log — no panel to tally */ }
244
272
  }
245
273
 
246
274
  function formatDuration(ms) {
@@ -23,6 +23,7 @@ import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
23
23
  import { checkTestsWithCode } from "../review/tests-with-code.js";
24
24
  import { loadPrivacyList, scanText } from "../privacy/scan.js";
25
25
  import { isGeneratedPath, splitAddedByFile } from "../privacy/diff-scope.js";
26
+ import { budgetedAdded, budgetedNet } from "../review/loc-budget.js";
26
27
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
27
28
  import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
28
29
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
@@ -203,14 +204,17 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
203
204
  const sizeSource = locInv ? `policy [${locInv.id}]` : "method_gates";
204
205
  if (sizeWarn > 0) {
205
206
  const numstat = await rawDiff(flags.range, ["--numstat"]);
206
- const added = numstat.split("\n").reduce((acc, l) => acc + (Number(l.split("\t")[0]) || 0), 0);
207
+ // KJC-BUG-0205: the budget caps CODE growth, so generated output and human
208
+ // documentation are exempt — the same rule CI applies. Counting everything
209
+ // here warned 234 lines on a doc-only PR that CI counted at 104, and two
210
+ // implementations of one rule that disagree teach people to trust neither.
211
+ const { added, exempt, testAdded } = budgetedAdded(numstat);
212
+ // Said out loud: an exemption nobody sees is indistinguishable from a gate
213
+ // that is not looking.
214
+ if (exempt > 0) console.log(`ℓ pr-size: ${exempt} line(s) exempt from the budget (generated output and human docs; AI-rule files always count)`);
207
215
  if (added > sizeWarn) {
208
216
  // KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
209
217
  // partitioning is a decision, and it must never split code from its tests.
210
- const testAdded = numstat.split("\n").reduce((acc, l) => {
211
- const [a, , ...f] = l.split("\t");
212
- return acc + (/\/tests?\/|__tests__\/|\.test\.|\.spec\./.test(`/${f.join("\t")}`) ? Number(a) || 0 : 0);
213
- }, 0);
214
218
  const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
215
219
  const sizePolicy = config?.method_gates?.pr_size || "warn";
216
220
  if (process.env.KJ_ALLOW_LARGE_PR === "1") {
@@ -289,12 +293,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
289
293
  // hooks. enforcement=warn avisa; deny cierra salvo excepción probatoria
290
294
  // (KJ_ALLOW_POLICY=1 + KJ_POLICY_REASON, registrada con identidad y hash
291
295
  // del diff); class=security cierra sin escape y sin arbitraje.
292
- let net = 0;
293
- for (const l of (await rawDiff(flags.range, ["--numstat"])).split("\n")) {
294
- const [a, r] = l.trim().split(/\s+/);
295
- if (a && a !== "-") net += Number(a) || 0;
296
- if (r && r !== "-") net -= Number(r) || 0;
297
- }
296
+ // KJC-BUG-0208: el mismo modulo que decide el presupuesto decide el metrico
297
+ // del invariante. Sumar aqui el numstat entero daba dos cifras para la misma
298
+ // regla en dos lineas seguidas del mismo comando.
299
+ const { net } = budgetedNet(await rawDiff(flags.range, ["--numstat"]));
298
300
  // GOV-B (KJC-TSK-0746): las permanentes concedidas (kj policy grant)
299
301
  // eximen su regla mientras viven; el descarte de líneas corruptas se dice.
300
302
  const std = loadStandingExceptions(projectDir);
@@ -11,6 +11,8 @@
11
11
  * code, and then it says so instead of staying quiet.
12
12
  */
13
13
  import { resolveRole } from "../config/role-resolver.js";
14
+ import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
15
+ import { readIdentity } from "../identity/store.js";
14
16
 
15
17
  /** Roles worth announcing: who writes, who reviews, who arbitrates. */
16
18
  export const PANEL_ROLES = ["coder", "reviewer", "solomon"];
@@ -42,6 +44,37 @@ export function panelLine(config) {
42
44
  ].join("\n");
43
45
  }
44
46
 
47
+ /**
48
+ * KJC-TSK-0868: the host writing the code while another coder is declared is
49
+ * not an offence to block, it is a fact to record. Announcing at session start
50
+ * is not enough: the user chose who writes and finds out it did not happen by
51
+ * accident, one turn at a time.
52
+ *
53
+ * @returns {{coder: string, host: string}|null} null when there is nothing to
54
+ * record: no coder declared, no host detected, or the host IS the coder.
55
+ */
56
+ export function panelDeviation(config, host) {
57
+ const { coder } = resolvePanel(config);
58
+ if (!coder || !host || coder === host) return null;
59
+ return { coder, host };
60
+ }
61
+
62
+ /**
63
+ * KJC-TSK-0873: the log holds both sides. A deviation alone cannot say whether
64
+ * the panel is honoured almost always or almost never, and those are different
65
+ * situations. The compliance seat is written where the declared coder is
66
+ * actually invoked (`kj code`); the deviation one when the turn ends.
67
+ */
68
+ export function sealPanel({ projectDir, coder, host = null, honoured = false }) {
69
+ const who = readIdentity(projectDir);
70
+ return recordGateDecision(projectDir, {
71
+ decision: honoured ? "ok" : "exempt", chokepoint: "panel", escape: null, tool: null,
72
+ coder, host: honoured ? coder : host,
73
+ who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
74
+ policy_hash: policyFileHash(projectDir),
75
+ });
76
+ }
77
+
45
78
  /** Human-facing one-liner for `kj check` and friends. */
46
79
  export function panelSummary(config) {
47
80
  const { coder, reviewer, solomon } = resolvePanel(config);
@@ -28,6 +28,8 @@ export function stripDevHooksBlock(source) {
28
28
  return { content: `${before}${joiner}${after}`, migrated: true };
29
29
  }
30
30
 
31
+ const countLines = (text) => String(text || "").split("\n").filter((l) => l.trim()).length;
32
+
31
33
  export function installGuidelines({ projectDir = process.cwd(), dryRun = false, language = null } = {}) {
32
34
  // KJC-BUG-0199 (issue #1773): the rules that enter the agent's context must
33
35
  // be the rules of THIS project's language. kj harden already detects it for
@@ -45,8 +47,12 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
45
47
  body,
46
48
  style: "html",
47
49
  });
50
+ // KJC-BUG-0206: harden removing rules is a fact worth saying. It silently
51
+ // dropped six lines from this repo's own files because the detected language
52
+ // had no block, and "updated" read like an improvement.
53
+ const shrank = countLines(content) < countLines(raw);
48
54
  if (!dryRun && (migrated || action !== "unchanged")) writeFileSync(target, content);
49
- results.push({ file, action: migrated ? "migrated" : action });
55
+ results.push({ file, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true } : {}) });
50
56
  }
51
57
  return { dryRun, guidelines: results };
52
58
  }
@@ -76,9 +76,18 @@ export const LANGUAGE_GUIDELINES = {
76
76
  ],
77
77
  };
78
78
 
79
+ /**
80
+ * Languages that share another's block (KJC-BUG-0206). TypeScript is the same
81
+ * ecosystem as JavaScript, and the block already says so in its heading; but the
82
+ * stack detector reports `typescript` for a repo with a tsconfig, so the lookup
83
+ * missed and harden ERASED the rules a project already had.
84
+ */
85
+ const ALIASES = { typescript: "javascript" };
86
+
79
87
  /** The guidelines body for a language (unknown or absent ⇒ the core alone). */
80
88
  export function guidelinesBody(language = null) {
81
- const extra = LANGUAGE_GUIDELINES[String(language || "").toLowerCase()] ?? [];
89
+ const key = String(language || "").toLowerCase();
90
+ const extra = LANGUAGE_GUIDELINES[ALIASES[key] ?? key] ?? [];
82
91
  return [...CORE, ...extra].join("\n");
83
92
  }
84
93
 
@@ -44,6 +44,12 @@ process.stdin.on("end", () => {
44
44
  `;
45
45
 
46
46
  /** Write a managed hook script under .karajan/harness/ and return its path. */
47
+ /**
48
+ * KJC-BUG-0211: el cuerpo canonico de los guardias que instala ESTE modulo,
49
+ * para que quien verifica un sello pueda RECOMPUTAR en vez de fiarse del hash.
50
+ */
51
+ export const HARNESS_BODIES = { "pretooluse.mjs": SCRIPT_BODY };
52
+
47
53
  export function writeHarnessScript(projectDir, name, body) {
48
54
  const dir = join(projectDir, ".karajan", "harness");
49
55
  mkdirSync(dir, { recursive: true });