karajan-code 4.32.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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/agents/aider-agent.js +2 -12
  3. package/src/agents/base-agent.js +9 -20
  4. package/src/agents/claude-agent.js +2 -12
  5. package/src/agents/codex-agent.js +2 -12
  6. package/src/agents/dead-models.js +74 -0
  7. package/src/agents/gemini-agent.js +2 -12
  8. package/src/agents/model-errors.js +35 -0
  9. package/src/agents/opencode-agent.js +2 -12
  10. package/src/brain/agent-error-classifier.js +19 -1
  11. package/src/brain/one-shot-policy.js +26 -0
  12. package/src/brain/role-fallback-chain.js +100 -0
  13. package/src/brain/with-brain-recovery.js +63 -4
  14. package/src/checks/action-pins.js +131 -0
  15. package/src/checks/project-checks.js +6 -0
  16. package/src/checks/release-check.js +51 -5
  17. package/src/cli/register-meta.js +42 -2
  18. package/src/cli/register-pipeline.js +11 -1
  19. package/src/commands/check.js +5 -0
  20. package/src/commands/code.js +59 -4
  21. package/src/commands/env.js +2 -1
  22. package/src/commands/harden.js +12 -3
  23. package/src/commands/policy.js +21 -14
  24. package/src/commands/report.js +28 -0
  25. package/src/commands/review-gate.js +25 -15
  26. package/src/environment/panel.js +85 -0
  27. package/src/environment/playbook.js +7 -6
  28. package/src/harden/config-templates.js +7 -1
  29. package/src/harden/guidelines-engine.js +14 -4
  30. package/src/harden/guidelines-templates.js +69 -10
  31. package/src/harden/harness-hooks.js +6 -0
  32. package/src/harden/hook-templates.js +4 -1
  33. package/src/harden/sentinel-hooks.js +227 -24
  34. package/src/harden/supervisor-commit.js +41 -1
  35. package/src/harden/workflow-templates.js +9 -3
  36. package/src/policy/supervisor-verify.js +45 -11
  37. package/src/privacy/diff-scope.js +58 -0
  38. package/src/privacy/scan.js +18 -0
  39. package/src/prompts/card-context.js +57 -0
  40. package/src/prompts/session-context.js +68 -0
  41. package/src/review/board-pending.js +68 -0
  42. package/src/review/loc-budget.js +97 -0
  43. package/src/roles/agent-role.js +11 -2
  44. package/src/roles/audit-role.js +28 -2
@@ -0,0 +1,131 @@
1
+ /**
2
+ * KJC-TSK-0870 — the maintenance that pinning moved onto kj.
3
+ *
4
+ * Issue #1374 was right: `kj harden` generated workflows with moving tags
5
+ * (`actions/checkout@v4`), and kj's own `kj audit --security` flagged them, so
6
+ * five of the project's seven warnings came from files kj had just written.
7
+ * They are pinned to commit SHAs now.
8
+ *
9
+ * Pinning trades one risk for another: nobody can move the tag under you, and
10
+ * nobody delivers the upstream patch either. A pin that nobody revisits is a
11
+ * vulnerability with a long shelf life, and since kj writes those lines, kj is
12
+ * the one that has to notice. That is this check.
13
+ *
14
+ * It lives in `kj doctor` (a person asking about their environment) and NOT in
15
+ * `kj check` (a gate that CI runs): a gate that needs the network is a gate
16
+ * that fails on a plane, and turning red over an unreachable API is how a gate
17
+ * loses its credibility.
18
+ */
19
+
20
+ import { execFile } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+
23
+ import { PINNED_ACTIONS } from "../harden/workflow-templates.js";
24
+
25
+ const execFileAsync = promisify(execFile);
26
+ // Nobody waits for a diagnostic. Both clients are capped, and the pins are
27
+ // asked in parallel, so an unreachable GitHub costs one timeout and not one
28
+ // per action.
29
+ const CALL_TIMEOUT_MS = 5_000;
30
+
31
+ const STRATEGY_MANUAL = "manual";
32
+ const API = "https://api.github.com/repos";
33
+
34
+ /** `owner/repo@sha # tag` as written in the templates. */
35
+ export function parsePin(pin) {
36
+ const m = /^([^@\s]+)@([0-9a-f]{40})\s*#\s*(\S+)$/i.exec(String(pin || "").trim());
37
+ return m ? { action: m[1], sha: m[2].toLowerCase(), tag: m[3] } : null;
38
+ }
39
+
40
+ const SHA_RE = /^[0-9a-f]{40}$/;
41
+ const clean = (s) => String(s || "").trim().toLowerCase();
42
+
43
+ /**
44
+ * The SHA a tag points at today, asked through `gh` when it is there.
45
+ *
46
+ * Measured, not assumed: plain unauthenticated `fetch` answered 403 on the
47
+ * first real run, because GitHub allows 60 calls an hour PER IP and any shared
48
+ * or NAT-ed address burns that between everyone behind it. A check that
49
+ * answers "could not check" almost always is decorative. `gh` carries the
50
+ * user's own credentials (5000/hour) and kj already relies on it elsewhere;
51
+ * `fetch` stays as the fallback for whoever has no gh.
52
+ *
53
+ * @returns {Promise<string|null>} null when the answer cannot be trusted.
54
+ */
55
+ export async function currentSha(action, tag, { fetchFn = fetch, ghFn = ghSha } = {}) {
56
+ const viaGh = await ghFn(action, tag);
57
+ if (viaGh) return viaGh;
58
+ const res = await fetchFn(`${API}/${action}/commits/${encodeURIComponent(tag)}`, {
59
+ headers: { Accept: "application/vnd.github.sha", "User-Agent": "karajan-code" },
60
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
61
+ });
62
+ if (!res?.ok) return null;
63
+ const body = clean(await res.text());
64
+ return SHA_RE.test(body) ? body : null;
65
+ }
66
+
67
+ /** @returns {Promise<string|null>} null when gh is absent, unauthenticated or unhappy. */
68
+ async function ghSha(action, tag) {
69
+ try {
70
+ const { stdout } = await execFileAsync("gh", ["api", `repos/${action}/commits/${tag}`, "--jq", ".sha"], {
71
+ encoding: "utf8",
72
+ timeout: CALL_TIMEOUT_MS,
73
+ });
74
+ const sha = clean(stdout);
75
+ return SHA_RE.test(sha) ? sha : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** @internal Exported for dynamic import from tests. */
82
+ export function createActionPinsCheck({ pins = PINNED_ACTIONS, deps = {} } = {}) {
83
+ return {
84
+ name: "action-pins",
85
+ label: "actions fijadas por SHA",
86
+ strategy: STRATEGY_MANUAL,
87
+ describe: "Detect a pinned GitHub Action whose tag has moved on without it",
88
+ async detect() {
89
+ const entries = Object.entries(pins).map(([key, pin]) => ({ key, ...(parsePin(pin) || {}) }));
90
+ const malformed = entries.filter((e) => !e.sha);
91
+ const asked = await Promise.all(
92
+ entries.filter((x) => x.sha).map(async (e) => {
93
+ // offline, rate limited, DNS down, gh missing: not an answer
94
+ const latest = await currentSha(e.action, e.tag, deps).catch(() => null);
95
+ return { ...e, latest };
96
+ }),
97
+ );
98
+ const stale = asked.filter((e) => e.latest && e.latest !== e.sha);
99
+ const unchecked = asked.filter((e) => !e.latest).length;
100
+
101
+ if (malformed.length > 0) {
102
+ return {
103
+ ok: false,
104
+ severity: "warn",
105
+ detail: `pin mal formado: ${malformed.map((m) => m.key).join(", ")} — un pin que no se puede leer no se puede comprobar`,
106
+ fix: "revisa PINNED_ACTIONS en src/harden/workflow-templates.js: owner/repo@<sha de 40> # <tag>",
107
+ };
108
+ }
109
+ if (stale.length > 0) {
110
+ return {
111
+ ok: false,
112
+ severity: "warn",
113
+ detail: stale.map((s) => `${s.action} ${s.tag} apunta hoy a ${s.latest.slice(0, 12)} y kj fija ${s.sha.slice(0, 12)}`).join(" · "),
114
+ fix: stale.map((s) => `${s.action}@${s.latest} # ${s.tag}`).join(" · "),
115
+ };
116
+ }
117
+ // Never claim what was not looked at (the card's own acceptance
118
+ // criterion): an unreachable API is said, not passed off as green.
119
+ if (unchecked > 0) {
120
+ return {
121
+ ok: true,
122
+ severity: "info",
123
+ detail: unchecked === entries.length
124
+ ? "no se pudo comprobar ninguna action (¿sin red o con la API de GitHub limitando?)"
125
+ : `${unchecked} action(s) sin comprobar (¿sin red o con la API de GitHub limitando?)`,
126
+ };
127
+ }
128
+ return { ok: true, severity: "info", detail: `${entries.length} action(s) fijadas y al día` };
129
+ },
130
+ };
131
+ }
@@ -24,6 +24,7 @@ import { checkBinary } from "../utils/agent-detect.js";
24
24
  import { getInstallCommand } from "../utils/os-detect.js";
25
25
  import { STRATEGY } from "./types.js";
26
26
  import { createRepoStateCheck } from "./repo-state.js";
27
+ import { createActionPinsCheck } from "./action-pins.js";
27
28
 
28
29
  /**
29
30
  * Detect signals del proyecto. Devuelve un set de tags con qué hay en el
@@ -246,5 +247,10 @@ export function getProjectChecks({ projectDir }) {
246
247
  // check looks at — the order it was set up in, whether the contract
247
248
  // travels, whether this clone declares who works it.
248
249
  createRepoStateCheck({ projectDir }),
250
+ // KJC-TSK-0870: pinning the actions by SHA (issue #1374) moved their
251
+ // maintenance onto kj. Doctor asks upstream whether a tag has moved on
252
+ // without its pin; `kj check` deliberately does not, because a gate that
253
+ // needs the network is a gate that fails on a plane.
254
+ createActionPinsCheck(),
249
255
  ];
250
256
  }
@@ -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");
@@ -179,11 +179,21 @@ export function registerPipeline(program, { pkgVersion }) {
179
179
  .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
180
180
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
181
181
  .option("--coder <name>")
182
+ // KJC-TSK-0864: --coder is the pipeline's word for it, --agent is the
183
+ // session's. Same knob, two vocabularies; naming both and disagreeing is
184
+ // an error, never a silent precedence.
185
+ .option("--agent <name>", "The agent that writes (alias of --coder)")
186
+ .option("--card <ref>", "Card/HU the work belongs to — its statement and criteria reach the coder")
182
187
  .option("--coder-model <name>")
183
188
  .action(async (task, flags) => {
189
+ if (flags.agent && flags.coder && flags.agent !== flags.coder) {
190
+ console.error(`kj code: --agent ${flags.agent} and --coder ${flags.coder} name different agents — pick one`);
191
+ process.exit(2);
192
+ }
193
+ if (flags.agent) flags.coder = flags.agent;
184
194
  await withConfig(pkgVersion, "code", flags, async ({ config, logger }) => {
185
195
  const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
186
- await codeCommand({ task: resolvedTask, config, logger });
196
+ await codeCommand({ task: resolvedTask, config, logger, flags });
187
197
  });
188
198
  });
189
199
 
@@ -9,6 +9,7 @@ import { collectMethodStats, formatMethodStats } from "../checks/method.js";
9
9
  import { createRagCoverageCheck } from "../checks/rag-coverage.js";
10
10
  import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
11
11
  import { detectObservedAgents } from "../utils/agent-detect.js";
12
+ import { panelSummary } from "../environment/panel.js";
12
13
  import { loadConfig } from "../config.js";
13
14
 
14
15
  export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
@@ -44,6 +45,10 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
44
45
  if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
45
46
  logger.info?.(` ${ragCoverage.ok ? "✓" : "✗"} rag-coverage: ${ragCoverage.detail}`);
46
47
  if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
48
+ // KJC-TSK-0865: who writes and who reviews is the user's choice, so it is
49
+ // said out loud next to everything else that describes the project. Never
50
+ // a check: it has no ok/fail, it informs.
51
+ logger.info?.(` ${panelSummary(config)}`);
47
52
  if (ok) logger.info?.("Harness OK.");
48
53
  else if (result.ok) logger.info?.("RAG index drift detected — a gate cannot protect what it cannot see.");
49
54
  else logger.info?.("Harness drift detected — run `kj harden` to repair.");
@@ -2,13 +2,25 @@ import fs from "node:fs/promises";
2
2
  import { createAgent } from "../agents/index.js";
3
3
  import { assertAgentsAvailable } from "../agents/availability.js";
4
4
  import { buildCoderPrompt } from "../prompts/coder.js";
5
+ import { resolveCardContext } from "../prompts/card-context.js";
6
+ import { composeTask, resolveRagContext } from "../prompts/session-context.js";
5
7
  import { resolveRole } from "../config.js";
8
+ // KJC-TSK-0859: the agents no longer substitute a dead model behind your back,
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";
6
15
  import { withCliRunLog } from "../utils/cli-run-log.js";
7
16
  import { createCliProgressReporter } from "../utils/cli-progress.js";
8
17
 
9
- export async function codeCommand({ task, config, logger }) {
18
+ export async function codeCommand({ task, config, logger, flags = {} }) {
10
19
  return withCliRunLog("code", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
11
20
  const coderRole = resolveRole(config, "coder");
21
+ // KJC-TSK-0864: no quota, not authenticated or simply absent? This THROWS
22
+ // with the agent named. The one thing it never does is quietly fall back
23
+ // to whoever happens to be reachable.
12
24
  await assertAgentsAvailable([coderRole.provider]);
13
25
  logger.info(`Coder (${coderRole.provider}) starting...`);
14
26
  runLog.logText(`[coder] provider=${coderRole.provider}`);
@@ -21,11 +33,39 @@ export async function codeCommand({ task, config, logger }) {
21
33
  try { coderRules = await fs.readFile("coder-rules.md", "utf8"); } catch { /* no coder rules file */ }
22
34
  }
23
35
  }
24
- const prompt = await buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology || "tdd" });
36
+ const card = await resolveCardContext({ projectDir: config.projectDir, ref: flags.card || null });
37
+ if (card?.external) logger.warn(`card ${card.huId} lives on a board kj cannot read — passing the reference, not its text`);
38
+ else if (card) logger.info(`Card ${card.huId}: ${card.title}`);
39
+ // The method's first invariant is that the RAG answers before you assume.
40
+ // The session asks on the coder's behalf: a subprocess coder cannot run
41
+ // `kj rag query` itself, so it used to guess the codebase instead.
42
+ const rag = await resolveRagContext({ task, config, logger });
43
+ // The pipeline always passed projectDir (it arms the directory-boundary
44
+ // rule and the skills section) and the provider. `kj code` did not, so the
45
+ // declared coder got a weaker prompt from the session than from `kj run`.
46
+ const prompt = await buildCoderPrompt({
47
+ task: composeTask(task, [card?.section, rag?.section]),
48
+ coderRules,
49
+ methodology: config.development?.methodology || "tdd",
50
+ projectDir: config.projectDir,
51
+ provider: coderRole.provider,
52
+ huId: card?.huId ?? null,
53
+ acceptanceTests: card?.acceptanceTests ?? null,
54
+ serenaEnabled: Boolean(config.serena?.enabled),
55
+ rtkAvailable: Boolean(config.rtk?.available),
56
+ });
25
57
  const progress = createCliProgressReporter({ role: "coder" });
26
58
  let result;
27
59
  try {
28
- result = await coder.runTask({ prompt, onOutput: progress.onOutput, role: "coder" });
60
+ result = await withBrainRecovery({
61
+ agent: { runTask: (args) => coder.runTask(args), provider: coderRole.provider, model: coderRole.model },
62
+ taskArgs: { prompt, onOutput: progress.onOutput, role: "coder" },
63
+ role: "coder",
64
+ provider: coderRole.provider,
65
+ logger,
66
+ policy: ONE_SHOT_POLICY,
67
+ fallback: buildRoleFallbackChain({ config, role: "coder", logger }),
68
+ });
29
69
  progress.finish(result.ok ? "done" : "failed");
30
70
  } catch (err) { progress.finish("failed"); throw err; }
31
71
  if (!result.ok) {
@@ -39,7 +79,22 @@ export async function codeCommand({ task, config, logger }) {
39
79
  logger.warn(result.error);
40
80
  }
41
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
+ }
94
+ // The work is in the tree, not committed: the gate is the next step and a
95
+ // DIFFERENT AI runs it. Saying so here is what keeps the panel honest.
96
+ logger.info(`The work is in the tree. Review it before committing: kj review --staged (a different AI than ${coderRole.provider}).`);
42
97
  runLog.logText(`[coder] finished (exit=${result.exitCode})`);
43
- return { ok: true };
98
+ return { ok: true, provider: coderRole.provider, card: card?.huId ?? null, ragSources: rag?.sources ?? [] };
44
99
  });
45
100
  }
@@ -71,6 +71,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
71
71
  projectDir, target: flags.target || "all",
72
72
  stateBackend: config?.state_backend || "hu-board",
73
73
  boardName: config?.board?.name || null,
74
+ config, // KJC-TSK-0865: the panel travels with the method
74
75
  });
75
76
  console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
76
77
 
@@ -213,7 +214,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
213
214
  // CLAUDE.md, but the running session loaded its context BEFORE — nobody
214
215
  // re-reads it. Print the method so it enters THIS conversation now.
215
216
  console.log("\n— The Karajan method below is IN EFFECT from this very message. If your session started before this install, apply it from now on:\n");
216
- console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null }));
217
+ console.log(renderPlaybook({ stateBackend: config?.state_backend || "hu-board", boardName: config?.board?.name || null, config }));
217
218
  }
218
219
  return result;
219
220
  }
@@ -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) {
@@ -171,7 +174,9 @@ export async function hardenCommand({
171
174
  ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, profile, mutation, dryRun })
172
175
  : null;
173
176
  const withGuidelines = guidelines && profile !== "minimal";
174
- const gl = withGuidelines ? installGuidelines({ projectDir, dryRun }) : null;
177
+ // KJC-BUG-0199 (issue #1773): the same detected language the workflows and
178
+ // the configs already use — a Python project must not be told to use `const`.
179
+ const gl = withGuidelines ? installGuidelines({ projectDir, language: roots[0]?.language ?? null, dryRun }) : null;
175
180
  const out = {
176
181
  ok: true,
177
182
  ...result,
@@ -193,7 +198,11 @@ export async function hardenCommand({
193
198
  logger.info?.(` • ${c.file}: ${c.action}${note}`);
194
199
  }
195
200
  for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
196
- 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
+ }
197
206
  if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
198
207
  // IDN-A (KJC-TSK-0762): a hardened clone declares who works it. Captured
199
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) {