karajan-code 4.30.0 → 4.31.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.30.0",
3
+ "version": "4.31.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",
@@ -34,6 +34,7 @@ export function registerPipeline(program, { pkgVersion }) {
34
34
  .option("--no-squeezr", "Skip the Squeezr auto-install (context compression). Karajan still runs but burns more tokens")
35
35
  .option("--no-qmd", "Skip the QMD auto-install + collection registration (semantic wiki over docs/, .reviews/ and plans/)")
36
36
  .option("--no-harden", "Skip the quality harness (git hooks, lint/commit config, CI gates, agent guidelines)")
37
+ .option("--no-sonar", "Do not start the SonarQube container (Docker). The review gate still needs it: start it later with 'kj sonar start'")
37
38
  .option("--json", "Emit a machine-readable summary of what init did (AB-B: for host agents)")
38
39
  .action(async (flags) => {
39
40
  await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
@@ -115,6 +115,18 @@ export async function envInstallCommand({ config = null, logger = null, flags =
115
115
  try {
116
116
  const h = await hardenCommand({ projectDir, logger: console });
117
117
  if (h?.ok === false) throw new Error(h.error || "kj harden failed");
118
+ // KJC-BUG-0188: a headless harden never binds an identity by itself, and
119
+ // it must not. Blocking here beats the Sentinel denying every git and gh
120
+ // call later, with nothing on screen tying that back to this install.
121
+ if (h?.identityPending) {
122
+ result.exitCode = PENDING_EXIT_CODE;
123
+ result.pendingBlock = renderPendingBlock(
124
+ [{ tool: "identity", action: "needs-user", reason: h.identityPending, command: h.identityCommand }],
125
+ { retry: "kj env install", why: "this clone has no declared identity, and kj never binds one on its own" },
126
+ );
127
+ console.log(result.pendingBlock);
128
+ return result;
129
+ }
118
130
  await reviewGateCommand({ config, flags: { installGate: true } });
119
131
  console.log("✓ enforcement active: git hooks + cross-AI verdict gate — a commit outside the method is rejected, not narrated");
120
132
  } catch (err) {
@@ -13,6 +13,7 @@ import { spawn } from "node:child_process";
13
13
  import { checkBinary } from "../utils/agent-detect.js";
14
14
  import { createCliAskQuestion } from "../utils/cli-ask-question.js";
15
15
  import { envInstallCommand } from "./env.js";
16
+ import { ensureGitRepo } from "./init.js";
16
17
  import { boardCommand } from "./board.js";
17
18
  // The interactive launchers a muggle can live inside (v1: the two the epic
18
19
  // names). Auth heuristics are the same cheap file checks reviewer-fallback
@@ -44,7 +45,7 @@ export function buildGoPrompt() {
44
45
  ].join("\n");
45
46
  }
46
47
  async function defaultPrepare({ config, logger }) {
47
- await envInstallCommand({ config, logger, flags: { yes: true } });
48
+ return envInstallCommand({ config, logger, flags: { yes: true } });
48
49
  }
49
50
  export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
50
51
  const port = config.hu_board?.port || 4000;
@@ -95,7 +96,21 @@ export async function goCommand({ config = {}, logger = console, flags = {}, dep
95
96
  // Prepare ONCE: decisions already taken are never re-asked.
96
97
  if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
97
98
  logger.info?.("Preparando tu proyecto (solo la primera vez)…");
98
- await (deps.prepare ?? defaultPrepare)({ config, logger });
99
+ // KJC-BUG-0191: empezar en una carpeta vacía es lo natural, y kj env
100
+ // install se niega a correr sin repositorio. Lo creamos nosotros.
101
+ if (!ensureGitRepo({ projectDir, logger })) {
102
+ logger.error?.("No he podido preparar el control de versiones de tu proyecto (git). Instálalo y vuelve a escribir: kj go");
103
+ process.exitCode = 1;
104
+ return 1;
105
+ }
106
+ const prepared = await (deps.prepare ?? defaultPrepare)({ config, logger });
107
+ // Algo depende de tus manos (arriba tienes el detalle). Sin eso el
108
+ // proyecto quedaría a medias, así que paramos aquí en vez de seguir.
109
+ if (prepared?.exitCode) {
110
+ logger.error?.("Falta una cosa que solo puedes hacer tú, la tienes justo arriba. Cuando la hagas, vuelve a escribir: kj go");
111
+ process.exitCode = 1;
112
+ return 1;
113
+ }
99
114
  }
100
115
  const prompt = (deps.prompt ?? buildGoPrompt)();
101
116
  // --window (MGL-E, ADR 0008): la conversación vive DENTRO del board — el
@@ -200,7 +200,10 @@ export async function hardenCommand({
200
200
  if (!dryRun) {
201
201
  const id = await ensureIdentity({ projectDir, logger });
202
202
  out.identity = id.declared ? id.identity : null;
203
- if (id.pending) out.identityPending = id.pending;
203
+ if (id.pending) {
204
+ out.identityPending = id.pending;
205
+ out.identityCommand = id.command;
206
+ }
204
207
  }
205
208
  // KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
206
209
  // del supervisor con procedencia sellada. Acto humano — el guard vive en
@@ -121,6 +121,13 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
121
121
  console.warn(`⚠ possible duplicate${plural} — check before working it:\n${candidates}`);
122
122
  }
123
123
  const plan = await backlogPlan(projectDir);
124
+ /** Lowest free HU-#### in this plan — ids are permanent, never reused. */
125
+ const nextHuRef = (p) => {
126
+ const taken = new Set((p.hus || []).map((h) => h.short_id).filter(Boolean));
127
+ let n = 1;
128
+ while (taken.has(`HU-${String(n).padStart(4, "0")}`)) n += 1;
129
+ return `HU-${String(n).padStart(4, "0")}`;
130
+ };
124
131
  // KJC-TSK-0669 (absolute rule): cards are permanent. The delete-and-
125
132
  // recreate "fix" an agent improvises loses history and duplicates ids —
126
133
  // refuse it with the norm spelled out.
@@ -145,7 +152,12 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
145
152
  .flatMap((s) => String(s).split("\n")).map((s) => s.trim()).filter(Boolean);
146
153
  const hu = addHu(plan, {
147
154
  title,
148
- short_id: flags.id || null,
155
+ // KJC-BUG-0189: the card-first gate demands a branch that references a
156
+ // card, and it is this command the gate sends the agent to. The
157
+ // canonical plan id (hu_plan-<stamp>-<rand>_001) does not match
158
+ // CARD_REF_RE, so without an explicit --id the card kj just created
159
+ // could not satisfy kj's own gate. Sequential HU-#### does.
160
+ short_id: flags.id || nextHuRef(plan),
149
161
  acceptance_criteria: [...splitLines(flags.ac), ...splitLines(flags.criteria)],
150
162
  acceptance_tests: splitLines(flags.tests),
151
163
  scope: flags.scope || null,
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import fs from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -743,6 +744,25 @@ export async function resolveConfigScope({ flags, interactive }) {
743
744
  return { configPath: getConfigPath(), scope: "global" };
744
745
  }
745
746
 
747
+ /**
748
+ * KJC-BUG-0187: the enforcement gates live in git hooks, so a project without
749
+ * a repo gets no harness at all. Init creates it, exactly like `kj run` does
750
+ * (src/orchestrator/config-init.js), and reports failure instead of carrying
751
+ * on half-installed. No seed commit: that is the bootstrap commit's job.
752
+ */
753
+ export function ensureGitRepo({ projectDir, logger, gitFn = null }) {
754
+ if (isGitRepo(projectDir)) return true;
755
+ const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, stdio: "pipe" }));
756
+ try {
757
+ run(["init"]);
758
+ logger?.info?.("Initialized git repository (no seed commit) — the quality harness needs it");
759
+ return true;
760
+ } catch (err) {
761
+ logger?.warn?.(`git init failed: ${err.message}`);
762
+ return false;
763
+ }
764
+ }
765
+
746
766
  export async function initCommand({ logger, flags = {} }) {
747
767
  // AB-B (KJC-TSK-0656): with --json, stdout is a machine contract — every
748
768
  // human log moves to stderr so the only stdout line is the summary object.
@@ -888,20 +908,29 @@ export async function initCommand({ logger, flags = {} }) {
888
908
  }
889
909
  }
890
910
 
891
- await setupSonarQube(config, logger, { interactive });
911
+ // KJC-BUG-0184: the container is a heavy side effect — opt-out: --no-sonar.
912
+ // Only the bootstrap is skipped; the review gate still demands Sonar.
913
+ const skipSonar = flags?.noSonar === true || flags?.sonar === false;
914
+ if (skipSonar) {
915
+ logger.info("SonarQube container not started (--no-sonar). Start it later with 'kj sonar start'.");
916
+ } else {
917
+ await setupSonarQube(config, logger, { interactive });
918
+ }
892
919
  await scaffoldCiGateway(config, flags, logger);
893
920
 
894
921
  // Install the quality harness (git hooks, lint/format/commit config, CI
895
922
  // quality gates, agent guidelines) through the SAME engine as `kj harden`,
896
923
  // so init and harden never drift. Opt-out: --no-harden. Needs a git repo.
897
924
  const skipHarden = flags?.noHarden === true || flags?.harden === false;
925
+ let hardened = false;
898
926
  if (skipHarden) {
899
927
  logger.info("Quality harness skipped (--no-harden).");
900
- } else if (isGitRepo(process.cwd())) {
928
+ } else if (ensureGitRepo({ projectDir: process.cwd(), logger })) {
901
929
  logger.info("Installing quality harness (kj harden)...");
902
930
  await hardenCommand({ projectDir: process.cwd(), logger });
931
+ hardened = true;
903
932
  } else {
904
- logger.info("Not a git repository — run `kj harden` after `git init` to add the quality harness.");
933
+ logger.warn("Quality harness NOT installed: this is not a git repository and `git init` failed — Karajan's guarantees live in git hooks. Fix git, then run `kj harden`.");
905
934
  }
906
935
 
907
936
  // Persist any changes setupSonarQube made (token, etc.) back to disk.
@@ -925,7 +954,7 @@ export async function initCommand({ logger, flags = {} }) {
925
954
  if (flags.json) {
926
955
  console.log(JSON.stringify({
927
956
  ok: true, configPath, scope, interactive,
928
- skipped: ["ollama", "rtk", "squeezr", "qmd", "harden"].filter((t) => flags[t] === false),
957
+ skipped: ["ollama", "rtk", "squeezr", "qmd", "sonar", "harden"].filter((t) => flags[t] === false),
929
958
  }));
930
959
  return;
931
960
  }
@@ -933,7 +962,11 @@ export async function initCommand({ logger, flags = {} }) {
933
962
  // Clear close so a first-time user knows setup finished and what to do next,
934
963
  // instead of being left at the end of a wall of detection logs (KJC-BUG-0088).
935
964
  logger.info("");
936
- logger.info("✓ Karajan is set up in this project.");
965
+ // KJC-BUG-0187: never declare a setup that did not happen. Without the
966
+ // harness there are no gates, and saying otherwise is the worst outcome.
967
+ logger.info(hardened || skipHarden
968
+ ? "✓ Karajan is set up in this project."
969
+ : "⚠ Karajan is set up EXCEPT the quality harness — without it there are no gates.");
937
970
  logger.info(' Next: kj run "describe what to build or fix"');
938
971
  logger.info(" More: kj --help · kj doctor (check your setup)");
939
972
  }
@@ -16,6 +16,8 @@ const SCRIPT_BODY = `#!/usr/bin/env node
16
16
  // kj tool gate (KJC-TSK-0710) — managed by \`kj harden\`. Exit 2 blocks the
17
17
  // tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
18
18
  // so a gate bug never bricks the session.
19
+ import console from "node:console";
20
+ import process from "node:process";
19
21
  import { existsSync } from "node:fs";
20
22
  let raw = "";
21
23
  process.stdin.on("data", (d) => { raw += d; });
@@ -60,8 +60,9 @@ function chainToGlobal(hook, globalHooksDir) {
60
60
  function baseBranchGuard(baseBranch) {
61
61
  if (!baseBranch) return [];
62
62
  return [
63
- "# Branch-first guard — the base branch only moves via PR.",
64
- 'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ]; then',
63
+ "# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
64
+ "# the bootstrap commit is exempt, there is no commit to branch from yet.",
65
+ 'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ] && git rev-parse --verify HEAD >/dev/null 2>&1; then',
65
66
  ' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
66
67
  ` if [ "$current_branch" = "${baseBranch}" ]; then`,
67
68
  ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR (KJ_ALLOW_BASE_COMMIT=1 to override)'; exit 1`,
@@ -28,6 +28,7 @@ export function resolveSentinelRoot(dir = process.cwd()) {
28
28
  const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
29
29
  // Single source for every sentinel script: state, branch, classification,
30
30
  // violations, and escape recording.
31
+ import console from "node:console";
31
32
  import { readFileSync, writeFileSync } from "node:fs";
32
33
  import { execSync, spawnSync } from "node:child_process";
33
34
  import { dirname, join } from "node:path";
@@ -71,9 +72,18 @@ export const foreignLane = (targetDir) => {
71
72
  // the tracker. The method does not advance until the board is true.
72
73
  export const pendingMoves = (s) => (s && Array.isArray(s.pending_moves) ? s.pending_moves : []);
73
74
  export const pendingText = (p) => (p.card ? p.card : "card sin identificar") + " (PR #" + p.pr + ") mergeada sin mover en el tracker — muevela a To Validate / Fixed con sus commits (update_card del PG o kj hu move)";
75
+ // KJC-BUG-0185: bootstrap phase = the repo has no commit yet. There is no base
76
+ // branch to protect and no coverage to demand over a tree that does not exist,
77
+ // and \`git diff HEAD\` cannot even run. The phase is a verifiable FACT, never a
78
+ // flag, and it ends at the first commit.
79
+ export const bootstrapPhase = () => {
80
+ try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
81
+ catch { return true; }
82
+ };
74
83
  export const violations = (s, branch) => {
75
84
  const v = pendingMoves(s).map(pendingText);
76
85
  if (!s || !(s.edited_sources || []).length) return v;
86
+ if (bootstrapPhase()) return v;
77
87
  if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
78
88
  else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
79
89
  if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
@@ -120,9 +130,10 @@ const POST_BODY = `#!/usr/bin/env node
120
130
  // kj sentinel state writer (KJC-TSK-0713) — managed by \`kj harden\`.
121
131
  // Records deterministic method facts per session; never blocks, never fails
122
132
  // a tool call (PostToolUse, always exit 0).
133
+ import process from "node:process";
123
134
  import { relative } from "node:path";
124
135
  import { spawnSync } from "node:child_process";
125
- import { doc, CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
136
+ import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
126
137
  const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG"];
127
138
  let raw = "";
128
139
  process.stdin.on("data", (d) => { raw += d; });
@@ -151,10 +162,15 @@ process.stdin.on("end", () => {
151
162
  // repo-relative sources it returned. Neither the CLI nor the MCP know the
152
163
  // host's session id: the harness records, they do not. Hits outside the
153
164
  // repo (another indexed project) are not ours and are dropped.
154
- const recordRag = (query, sources) => {
165
+ // KJC-BUG-0190: an index with nothing in it (a brand new project) answers
166
+ // every question with silence, so the gate would demand what no query can
167
+ // deliver. kj says so explicitly, in both its human and json forms.
168
+ const EMPTY_INDEX = /No chunks indexed yet|"empty"\\s*:\\s*true/;
169
+ const recordRag = (query, sources, answer = "") => {
155
170
  const state = load();
156
171
  const s = session(state, sid);
157
172
  s.at = Date.now();
173
+ if (EMPTY_INDEX.test(String(answer))) s.rag_index_empty = true;
158
174
  const hits = [];
159
175
  for (const src of sources) {
160
176
  const rel = relative(ROOT, String(src)).split(String.fromCharCode(92)).join("/");
@@ -176,7 +192,7 @@ process.stdin.on("end", () => {
176
192
  const parseJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
177
193
  if (/__kj_rag_query$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
178
194
  const parts = Array.isArray(response?.content) ? response.content : [];
179
- recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))));
195
+ recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))), parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
180
196
  process.exit(0);
181
197
  }
182
198
  if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
@@ -213,7 +229,7 @@ process.stdin.on("end", () => {
213
229
  .filter((l) => l.includes("score=") && l.includes("] "))
214
230
  .map((l) => l.slice(l.lastIndexOf("] ") + 2).trim())
215
231
  .filter(Boolean);
216
- recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))));
232
+ recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))), out);
217
233
  process.exit(0);
218
234
  }
219
235
  // gh prints "merged pull request #N" only on a TTY: under a tool call the
@@ -275,6 +291,8 @@ const STOP_BODY = `#!/usr/bin/env node
275
291
  // one with its remediation). Fails OPEN on corrupt state, git errors, or
276
292
  // after 3 unresolved blocks — a sentinel bug never bricks the session — and
277
293
  // the fail-open is recorded in the state. \`--status\` prints, never blocks.
294
+ import console from "node:console";
295
+ import process from "node:process";
278
296
  import { spawnSync } from "node:child_process";
279
297
  import { readFileSync } from "node:fs";
280
298
  import { homedir } from "node:os";
@@ -382,6 +400,8 @@ const PRETOOL_BODY = `#!/usr/bin/env node
382
400
  // before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
383
401
  // remediation); read-only tools are never wired here; every honored escape
384
402
  // is recorded as an auditable event. Fails OPEN on anything unexpected.
403
+ import console from "node:console";
404
+ import process from "node:process";
385
405
  import { dirname, join, relative, resolve } from "node:path";
386
406
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
387
407
  import { spawnSync } from "node:child_process";
@@ -705,7 +725,7 @@ process.stdin.on("end", () => {
705
725
  // LITERALES (tambien dentro de sustituciones) los escanea el bucle
706
726
  // de tokens de abajo; el residuo (expansion anidada en segmentos
707
727
  // runner) queda documentado como en la regla de ficheros PROTECTED.
708
- if (/>{1,2}[ \\t]*["']?[\\$\`]/.test(cmd)) {
728
+ if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
709
729
  if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
710
730
  else {
711
731
  console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
@@ -780,7 +800,7 @@ process.stdin.on("end", () => {
780
800
  }
781
801
  const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
782
802
  const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
783
- if (!segs.every((s) => !/[\\$]/.test(s) || SAFE_EXP_SEG.test(s))) {
803
+ if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
784
804
  if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
785
805
  else {
786
806
  console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
@@ -890,7 +910,9 @@ process.stdin.on("end", () => {
890
910
  const fresh = !existsSync(String(file));
891
911
  // A path outside this tree is another lane's business (the lane guard
892
912
  // above rules on it) and the ledger only holds in-repo paths.
893
- const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || (fresh && (rs.rag_queries || []).length > 0);
913
+ // KJC-BUG-0190: with nothing indexed, no query can cover anything —
914
+ // the gate asks for kj rag index instead of denying the impossible.
915
+ const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || (fresh && (rs.rag_queries || []).length > 0) || rs.rag_index_empty === true;
894
916
  if (!covered) {
895
917
  if (escOn("KJ_ALLOW_NO_RAG")) {
896
918
  // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
@@ -28,7 +28,7 @@ export async function ensureIdentity({ projectDir, logger = null, deps = {} }) {
28
28
  const why = !gh || !email ? "no active gh session or git email" : "non-interactive run";
29
29
  const pending = `identity not declared for this clone (${why}) — run: ${hint}`;
30
30
  logger?.warn?.(`kj identity: ${pending}`);
31
- return { declared: false, identity: null, pending };
31
+ return { declared: false, identity: null, pending, command: hint };
32
32
  }
33
33
 
34
34
  const wizard = (deps.makeWizard || createWizard)();
@@ -37,7 +37,7 @@ export async function ensureIdentity({ projectDir, logger = null, deps = {} }) {
37
37
  if (!ok) {
38
38
  const pending = `identity not declared (declined) — run: ${hint}`;
39
39
  logger?.warn?.(`kj identity: ${pending}`);
40
- return { declared: false, identity: null, pending };
40
+ return { declared: false, identity: null, pending, command: hint };
41
41
  }
42
42
  } finally {
43
43
  wizard.close();
@@ -43,9 +43,14 @@ export function checkRagRequirement({ config = {}, stagedFiles = [], newFiles =
43
43
  const ignored = Object.keys(env || {}).filter((k) => k.startsWith("KJ_ALLOW_") && env[k] === "1");
44
44
  const overrideNote = ignored.length > 0 ? ` (${ignored.join(", ")} is not honoured here)` : "";
45
45
  const head = `The RAG must have answered about code before it is reviewed (${sources.length} staged source${sources.length === 1 ? "" : "s"})${overrideNote}`;
46
- // No Sentinel in this tree = no host session that could keep a ledger: the
47
- // requirement does not apply here, and the verdict says so (mode).
48
- if (ledger?.harness === false) return { ok: true, mode: "no-harness", sources };
46
+ // KJC-BUG-0192: no Sentinel in this tree used to PASS, which made "do not
47
+ // harden" the comfortable way around the gate. Nobody recording the session
48
+ // is a reason to install the harness, or to ask for a grant, never a silent
49
+ // exemption: the proof of the method cannot depend on the project choosing
50
+ // to install whoever records it.
51
+ if (ledger?.harness === false) {
52
+ return { ok: false, mode: "block", sources, reason: `${head} — no Sentinel harness in this tree, so no session ledger can exist: install it with \`kj harden\`. ${GRANT_HINT}` };
53
+ }
49
54
  // A harness that does not match the installed kj (edited, emptied, or older
50
55
  // than the ledger) cannot vouch for anything: fail closed, like the tamper
51
56
  // check does, until the human regenerates it.
@@ -82,8 +87,12 @@ export function checkRagVerdict({ config = {}, stagedFiles = [], rag, harness =
82
87
  if (sources.length === 0) return { ok: true, mode: "docs-only" };
83
88
  const grant = liveGrant(standingExceptions, now);
84
89
  if (grant) return { ok: true, mode: "granted", grant, sources };
85
- if (!harness) return { ok: true, mode: "no-harness", sources };
86
90
  const head = `The RAG must have answered about code before it is committed (${sources.length} staged source${sources.length === 1 ? "" : "s"})`;
91
+ // KJC-BUG-0192: same rule on the --check side, so the commit gate cannot be
92
+ // opened by removing the supervisor either.
93
+ if (!harness) {
94
+ return { ok: false, mode: "block", sources, reason: `${head} — no Sentinel harness in this tree, so no session ledger can exist: install it with \`kj harden\`. ${GRANT_HINT}` };
95
+ }
87
96
  if (!verified) {
88
97
  return { ok: false, mode: "block", sources, reason: `${head} — the Sentinel harness does not match the installed kj (${mismatched.join(", ") || "scripts missing"}): the human runs \`kj harden\` to regenerate it. ${GRANT_HINT}` };
89
98
  }
@@ -64,12 +64,15 @@ export function collectPending(results, { tty = Boolean(process.stdin.isTTY), ye
64
64
  }
65
65
 
66
66
  /** The PENDING USER ACTION block: exact commands for THIS OS, then wait. */
67
- export function renderPendingBlock(pending, { platform = process.platform, retry = "kj install-tools" } = {}) {
67
+ export function renderPendingBlock(pending, { platform = process.platform, retry = "kj install-tools", why = null } = {}) {
68
68
  const lines = [
69
69
  "════ PENDING USER ACTION ════════════════════════════════════════",
70
- "kj could not finish this install by itself (sudo or a platform",
71
- "installer is required). Karajan needs a COMPLETE environment —",
72
- "do not continue degraded.",
70
+ // `why` names the real cause when it is not an install (KJC-BUG-0188:
71
+ // an undeclared identity needs the human, not a package manager).
72
+ ...(why
73
+ ? [`kj could not finish by itself: ${why}.`]
74
+ : ["kj could not finish this install by itself (sudo or a platform", "installer is required)."]),
75
+ "Karajan needs a COMPLETE environment — do not continue degraded.",
73
76
  "",
74
77
  "Run in YOUR terminal:",
75
78
  "",