karajan-code 4.29.1 → 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.
@@ -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
  }
@@ -14,6 +14,8 @@ import { runSolomonArbitration } from "../review/solomon-arbitration.js";
14
14
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
15
  import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
16
16
  import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
17
+ import { checkRagRequirement, checkRagVerdict, ragBlock, RAG_RULE_ID } from "../review/rag-requirement.js";
18
+ import { readRagLedger } from "../review/rag-ledger.js";
17
19
  import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
18
20
  import { checkCardFirst } from "../review/card-first.js";
19
21
  import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
@@ -62,6 +64,13 @@ const formatSonarGrant = (g) => {
62
64
  return `⚠ sonar requirement lifted by a HUMAN grant [${SONAR_RULE_ID}]${scope} until ${g.expiresAt} — ${who}: ${g.justification || "sin justificación"}`;
63
65
  };
64
66
 
67
+ // KJC-TSK-0849 (ADR 0010): same for the rag requirement.
68
+ const formatRagGrant = (g) => {
69
+ const scope = g.origin === "global" ? " GLOBAL" : "";
70
+ const who = g.who?.git ?? "?";
71
+ return `⚠ rag requirement lifted by a HUMAN grant [${RAG_RULE_ID}]${scope} until ${g.expiresAt} — ${who}: ${g.justification || "sin justificación"}`;
72
+ };
73
+
65
74
  // KJC-TSK-0813 (AC3): la exención dice su PROCEDENCIA — un standing global
66
75
  // (concedido para toda la máquina) no pasa por uno del proyecto. El texto
67
76
  // de las de proyecto queda EXACTAMENTE como estaba.
@@ -355,13 +364,29 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
355
364
  if (!req.ok) res = { ok: false, verdict: res.verdict, reason: req.reason };
356
365
  else if (req.mode === "granted") console.log(formatSonarGrant(req.grant));
357
366
  }
367
+ // KJC-TSK-0849 (ADR 0010, RAG-C): the verdict's rag block is the evidence
368
+ // that the session consulted the RAG about every staged source. The one
369
+ // place the rule cannot be verified is a pipeline verdict: the headless
370
+ // pipeline keeps no session ledger yet. It WARNS and the allow seals the
371
+ // warn under the rule id, so the exception is auditable (kj policy report)
372
+ // and gains teeth with data — never accepted in silence.
373
+ const warnIds = gate.warns.map((w) => w.rule_id);
374
+ if (res.ok && res.verdict.host === "kj-pipeline") {
375
+ console.log(`⚠ rag [${RAG_RULE_ID}]: the pipeline verdict carries no session ledger — the headless pipeline does not consult the RAG yet; sealed as a warn`);
376
+ if (!warnIds.includes(RAG_RULE_ID)) warnIds.push(RAG_RULE_ID);
377
+ } else if (res.ok) {
378
+ const { harness, verified, mismatched } = readRagLedger(projectDir);
379
+ const rreq = checkRagVerdict({ config, stagedFiles: changedFiles, rag: res.verdict.rag, harness, verified, mismatched, standingExceptions: std.standing });
380
+ if (!rreq.ok) res = { ok: false, verdict: res.verdict, reason: rreq.reason };
381
+ else if (rreq.mode === "granted" && rreq.grant) console.log(formatRagGrant(rreq.grant));
382
+ }
358
383
  console.log(res.ok
359
384
  ? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
360
385
  : `✗ ${res.reason}`);
361
386
  // GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella. PL-E
362
387
  // (KJC-TSK-0767): con las reglas que AVISARON, para que "nace avisando y
363
388
  // gana dientes" se decida con datos (kj policy report), no a ciegas.
364
- if (res.ok) seal("allow", gate.warns.length > 0 ? { warn_rule_ids: gate.warns.map((w) => w.rule_id) } : {});
389
+ if (res.ok) seal("allow", warnIds.length > 0 ? { warn_rule_ids: warnIds } : {});
365
390
  process.exitCode = res.ok ? 0 : 1;
366
391
  return res;
367
392
  }
@@ -434,6 +459,29 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
434
459
  // human grant) — the method report reads it back.
435
460
  sonarRecord.mode = sonarReq.mode;
436
461
 
462
+ // KJC-TSK-0849 (ADR 0010, RAG-C): the session's RAG ledger must show the
463
+ // RAG answered about every staged source (file or sibling), and the TWINS
464
+ // it returned that the diff does not touch travel to the reviewer by name —
465
+ // the 16-sep bugs were exactly the twin nobody touched.
466
+ const ledger = readRagLedger(projectDir);
467
+ const newFiles = (await rawDiff(flags.range, ["--name-only", "--diff-filter=A"])).split("\n").map((f) => f.trim()).filter(Boolean);
468
+ const ragReq = checkRagRequirement({ config, stagedFiles: changedFiles, newFiles, ledger, standingExceptions: std.standing });
469
+ if (!ragReq.ok) {
470
+ console.log(`✗ ${ragReq.reason}`);
471
+ process.exitCode = 1;
472
+ return { verdict: "rejected", reviewer: "rag-first", issues: [{ severity: "high", file: undefined, description: ragReq.reason }] };
473
+ }
474
+ if (ragReq.mode === "granted") console.log(formatRagGrant(ragReq.grant));
475
+ if (ragReq.mode === "no-harness") console.log("⚠ rag: no Sentinel harness in this tree — no session ledger to check (kj harden installs it)");
476
+ const ragRecord = ragBlock(ragReq, ledger);
477
+ if (ragRecord.twinsUntouched.length > 0) {
478
+ const twins = ragRecord.twinsUntouched;
479
+ console.log(`ℹ rag twins: this session's queries also returned ${twins.length} file(s) the diff does not touch — ${twins.slice(0, 5).join(", ")}${twins.length > 5 ? "…" : ""}`);
480
+ task = `${task || "Review the following diff for correctness, security and maintainability."}\n\n`
481
+ + "The session's RAG queries also returned these files, which the diff does NOT touch — the same concept may live there, so check the change is complete, not only correct:\n"
482
+ + twins.map((t) => `- ${t}`).join("\n");
483
+ }
484
+
437
485
  // MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
438
486
  // SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
439
487
  // el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
@@ -456,7 +504,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
456
504
  }
457
505
  }
458
506
 
459
- const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord });
507
+ const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord });
460
508
  printVerdict(record);
461
509
  process.exitCode = record.verdict === "approved" ? 0 : 1;
462
510
  return record;
@@ -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,10 +130,11 @@ 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";
126
- 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"];
136
+ import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
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; });
129
140
  process.stdin.on("end", () => {
@@ -146,6 +157,44 @@ process.stdin.on("end", () => {
146
157
  if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
147
158
  save(state);
148
159
  };
160
+ // KJC-TSK-0847 (ADR 0010, RAG-A): the ledger of what the RAG answered in
161
+ // this session — every kj_rag_query (MCP) and kj rag query (CLI) with the
162
+ // repo-relative sources it returned. Neither the CLI nor the MCP know the
163
+ // host's session id: the harness records, they do not. Hits outside the
164
+ // repo (another indexed project) are not ours and are dropped.
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 = "") => {
170
+ const state = load();
171
+ const s = session(state, sid);
172
+ s.at = Date.now();
173
+ if (EMPTY_INDEX.test(String(answer))) s.rag_index_empty = true;
174
+ const hits = [];
175
+ for (const src of sources) {
176
+ const rel = relative(ROOT, String(src)).split(String.fromCharCode(92)).join("/");
177
+ if (!rel || rel.startsWith("..") || rel.startsWith("/") || hits.includes(rel)) continue;
178
+ hits.push(rel);
179
+ }
180
+ (s.rag_queries ||= []).push({ ts: Date.now(), text: String(query || "").slice(0, 200), hits });
181
+ const all = (s.rag_hits ||= []);
182
+ for (const h of hits) if (!all.includes(h)) all.push(h);
183
+ save(state);
184
+ };
185
+ // Sources are read STRUCTURALLY, never through the stringified response:
186
+ // a JSON document (the MCP {content:[{text}]} parts, the CLI --json array)
187
+ // is parsed and its hits[].source / [].source collected.
188
+ const sourcesOf = (doc) => {
189
+ const list = Array.isArray(doc) ? doc : Array.isArray(doc?.hits) ? doc.hits : [];
190
+ return list.map((h) => h?.source).filter((x) => typeof x === "string");
191
+ };
192
+ const parseJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
193
+ if (/__kj_rag_query$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
194
+ const parts = Array.isArray(response?.content) ? response.content : [];
195
+ recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))), parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
196
+ process.exit(0);
197
+ }
149
198
  if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
150
199
  // The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
151
200
  // stringified, the inner quotes are ESCAPED — match both forms (found live:
@@ -171,6 +220,18 @@ process.stdin.on("end", () => {
171
220
  }
172
221
  if (tool === "Bash") {
173
222
  const cmdText = String(input.command || "");
223
+ // CLI form of the ledger: the human output lists one hit per line as
224
+ // "[kind · label · score=…] <path>"; --json prints an array with "source".
225
+ const ragCmd = /kj +rag +query/.exec(cmdText);
226
+ if (ragCmd) {
227
+ const out = typeof response === "string" ? response : String(response.stdout || "") + String(response.stderr || "");
228
+ const fromLines = out.split(String.fromCharCode(10))
229
+ .filter((l) => l.includes("score=") && l.includes("] "))
230
+ .map((l) => l.slice(l.lastIndexOf("] ") + 2).trim())
231
+ .filter(Boolean);
232
+ recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))), out);
233
+ process.exit(0);
234
+ }
174
235
  // gh prints "merged pull request #N" only on a TTY: under a tool call the
175
236
  // success line is ABSENT (found live on the first dogfood merge). The
176
237
  // authoritative signal is the PR state itself: one gh pr view per merge.
@@ -230,6 +291,8 @@ const STOP_BODY = `#!/usr/bin/env node
230
291
  // one with its remediation). Fails OPEN on corrupt state, git errors, or
231
292
  // after 3 unresolved blocks — a sentinel bug never bricks the session — and
232
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";
233
296
  import { spawnSync } from "node:child_process";
234
297
  import { readFileSync } from "node:fs";
235
298
  import { homedir } from "node:os";
@@ -250,7 +313,7 @@ if (process.argv.includes("--status")) {
250
313
  const sessions = Object.entries(st.sessions || {});
251
314
  if (!sessions.length) console.log("sentinel: sin actividad registrada en esta sesion");
252
315
  for (const [sid, s] of sessions) {
253
- console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : ""));
316
+ console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : "") + " rag=" + (s.rag_queries || []).length + " queries/" + (s.rag_hits || []).length + " sources");
254
317
  for (const x of violations(s, branch)) console.log(" ROJO: " + x);
255
318
  }
256
319
  process.exit(0);
@@ -337,6 +400,8 @@ const PRETOOL_BODY = `#!/usr/bin/env node
337
400
  // before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
338
401
  // remediation); read-only tools are never wired here; every honored escape
339
402
  // is recorded as an auditable event. Fails OPEN on anything unexpected.
403
+ import console from "node:console";
404
+ import process from "node:process";
340
405
  import { dirname, join, relative, resolve } from "node:path";
341
406
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
342
407
  import { spawnSync } from "node:child_process";
@@ -660,7 +725,7 @@ process.stdin.on("end", () => {
660
725
  // LITERALES (tambien dentro de sustituciones) los escanea el bucle
661
726
  // de tokens de abajo; el residuo (expansion anidada en segmentos
662
727
  // runner) queda documentado como en la regla de ficheros PROTECTED.
663
- if (/>{1,2}[ \\t]*["']?[\\$\`]/.test(cmd)) {
728
+ if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
664
729
  if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
665
730
  else {
666
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"));
@@ -735,7 +800,7 @@ process.stdin.on("end", () => {
735
800
  }
736
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]*$/;
737
802
  const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
738
- if (!segs.every((s) => !/[\\$]/.test(s) || SAFE_EXP_SEG.test(s))) {
803
+ if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
739
804
  if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
740
805
  else {
741
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"));
@@ -835,6 +900,28 @@ process.stdin.on("end", () => {
835
900
  : "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first"));
836
901
  process.exit(2);
837
902
  }
903
+ // KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
904
+ // zone before the session touches it. Covered = the session ledger
905
+ // (RAG-A) holds this file or a sibling of its directory; a NEW file only
906
+ // needs the session to have consulted at all. Same file set as card-first.
907
+ const rs = load().sessions?.[sid] || {};
908
+ const hits = rs.rag_hits || [];
909
+ const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
910
+ const fresh = !existsSync(String(file));
911
+ // A path outside this tree is another lane's business (the lane guard
912
+ // above rules on it) and the ledger only holds in-repo paths.
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;
916
+ if (!covered) {
917
+ if (escOn("KJ_ALLOW_NO_RAG")) {
918
+ // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
919
+ if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
920
+ } else {
921
+ console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
922
+ process.exit(2);
923
+ }
924
+ }
838
925
  }
839
926
  }
840
927
  if (tool === "Bash") {
@@ -102,11 +102,16 @@ export function installWorkflows({
102
102
  results.push({ file: label, action: "skipped" });
103
103
  continue;
104
104
  }
105
+ // KJC-BUG-0182: prettier's YAML printer puts a blank line between the last
106
+ // line of a block scalar (`run: |`) and a comment at column 0 — which is
107
+ // exactly our closing marker. Every template ends with a blank line, so the
108
+ // file harden writes is the file prettier would write (the pre-commit's
109
+ // format:check must accept its own harness).
105
110
  const { content, action } = upsertManagedBlock({
106
111
  source,
107
112
  blockId: wf.blockId,
108
113
  version: BLOCK_VERSION,
109
- body: wf.body,
114
+ body: wf.body.endsWith("\n") ? wf.body : `${wf.body}\n`,
110
115
  style: "hash",
111
116
  });
112
117
  if (!dryRun && action !== "unchanged") {
@@ -89,7 +89,6 @@ export const policyWorkflowFor = (kjVersion, kjName = "karajan-code") => [
89
89
  " run: |",
90
90
  ` if [ -f bin/kj.js ]; then npm ci --ignore-scripts && KJ="node bin/kj.js"; else KJ="npx --yes ${kjName}@${kjVersion}"; fi`,
91
91
  ' $KJ policy check --range "origin/${BASE_REF}...HEAD" --strict',
92
- "", // línea en blanco pre-marcador: prettier la exige tras un block scalar
93
92
  ].join("\n");
94
93
 
95
94
  const header = (steps) =>
@@ -263,7 +262,7 @@ export function mutationWorkflowFor(language, pm = "npm") {
263
262
  "name: Mutation (nightly)",
264
263
  "on:",
265
264
  " schedule:",
266
- " - cron: '0 4 * * 1'",
265
+ ' - cron: "0 4 * * 1"',
267
266
  " workflow_dispatch:",
268
267
  "permissions:",
269
268
  " contents: read",
@@ -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();
@@ -0,0 +1,48 @@
1
+ /**
2
+ * KJC-TSK-0850 (ADR 0010, RAG-D) — index coverage as a defect. A gate built
3
+ * on the RAG protects nothing it cannot see, so the sources the indexer's
4
+ * OWN matchers would take (src/, scripts/, bin/, packages/*: the walker
5
+ * takes the whole repo minus the skip segments) are compared against the
6
+ * chunks the store holds for this project, and against the commit the
7
+ * index was stamped with. No config declares the directories twice: the
8
+ * matchers are the single truth, here and in the indexer.
9
+ */
10
+ import { execa } from "execa";
11
+ import { isAbsolute, relative } from "node:path";
12
+ import { detectAdaptersForProject, buildMatchers } from "../lang/registry.js";
13
+ import { getLastIndexedCommit, projectSlug } from "./vec-store.js";
14
+
15
+ /**
16
+ * @param {string} projectDir
17
+ * @param {{db: object}} deps - an open vec store
18
+ * @returns {Promise<{project: string, total: number, indexed: number, missing: string[], stale: string[], lastIndexedCommit: string|null, absent: boolean}>}
19
+ */
20
+ export async function ragIndexCoverage(projectDir, { db }) {
21
+ const project = projectSlug(projectDir);
22
+ const matchers = buildMatchers(detectAdaptersForProject(projectDir));
23
+ const { stdout } = await execa("git", ["-C", projectDir, "ls-files", "-z"]);
24
+ const sources = stdout.split("\0").filter((p) => p && matchers.isCodeFile(p) && !matchers.shouldSkip(p));
25
+
26
+ const rows = db.prepare("SELECT DISTINCT source FROM chunks WHERE kind = 'code' AND project_slug = ?").all(project);
27
+ const indexed = new Set(rows.map((r) => (isAbsolute(r.source) ? relative(projectDir, r.source) : r.source)));
28
+ const missing = sources.filter((p) => !indexed.has(p));
29
+
30
+ const lastIndexedCommit = getLastIndexedCommit(db, project);
31
+ let stale = [];
32
+ if (lastIndexedCommit) {
33
+ // Files the repo changed after the stamp: the delta update has not seen
34
+ // them yet, so what the store says about them is old.
35
+ // The stamp must be an ANCESTOR of HEAD: a commit that still exists but
36
+ // sits on a rewritten branch would diff fine and report selective
37
+ // staleness, when in truth freshness is unknown. Fail closed.
38
+ const ancestor = await execa("git", ["-C", projectDir, "merge-base", "--is-ancestor", lastIndexedCommit, "HEAD"], { reject: false });
39
+ if (ancestor.exitCode !== 0) {
40
+ throw new Error(`the RAG index stamp ${lastIndexedCommit.slice(0, 9)} is not reachable from HEAD — kj rag index --with-sources`);
41
+ }
42
+ const diff = await execa("git", ["-C", projectDir, "diff", "--name-only", lastIndexedCommit, "HEAD"]);
43
+ const changed = new Set(diff.stdout.split("\n").filter(Boolean));
44
+ stale = sources.filter((p) => indexed.has(p) && changed.has(p));
45
+ }
46
+
47
+ return { project, total: sources.length, indexed: sources.length - missing.length, missing, stale, lastIndexedCommit, absent: indexed.size === 0 };
48
+ }
@@ -45,6 +45,8 @@ export async function runOneShotReview({
45
45
  diff, task, config, logger, projectDir,
46
46
  // KJC-TSK-0838: what the sonar pre-gate saw, persisted with the verdict.
47
47
  sonar = null,
48
+ // KJC-TSK-0849 (ADR 0010): what the session's RAG ledger proved, same place.
49
+ rag = null,
48
50
  hostAgent = detectHostAgent(),
49
51
  createAgentFn = createAgent,
50
52
  detectAgents = detectAvailableAgents,
@@ -126,6 +128,7 @@ export async function runOneShotReview({
126
128
  suggestions: parsed.non_blocking_suggestions || [],
127
129
  summary: parsed.summary || parsed.raw_summary || "",
128
130
  ...(sonar ? { sonar } : {}),
131
+ ...(rag ? { rag } : {}),
129
132
  confidence: parsed.confidence ?? null,
130
133
  });
131
134
  }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * KJC-TSK-0849 (ADR 0010, RAG-C) — the session's RAG ledger as the review
3
+ * sees it. The Sentinel records every RAG consultation per host session
4
+ * (RAG-A) in `.karajan/harness/sentinel-state.json`. `kj review` runs as a
5
+ * CLI and does not know the host's session id, so it takes the session with
6
+ * the most recent activity (`at`): MONO-0 forbids two sessions mutating one
7
+ * tree, so the last one that acted is the one now reviewing. Said here and
8
+ * in the verdict (`sessionId`), never assumed in silence.
9
+ */
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { verifySentinelScripts } from "../harden/sentinel-hooks.js";
13
+
14
+ /**
15
+ * @param {string} projectDir
16
+ * @returns {{harness: boolean, verified: boolean, mismatched: string[], available: boolean, reason?: string, sessionId?: string, queries: object[], hits: string[]}}
17
+ * `harness` false = no `.karajan/harness` in this tree: no host session can
18
+ * keep a ledger, so the requirement does not apply (said, not assumed).
19
+ * `verified` = the scripts on disk are byte-identical to what the INSTALLED
20
+ * kj writes (the tamper check's root of trust): an empty directory, an
21
+ * edited hook or one that predates the ledger all read as unverified, and
22
+ * the requirement fails closed until the human re-runs `kj harden`.
23
+ */
24
+ export function readRagLedger(projectDir) {
25
+ const harnessDir = path.join(projectDir, ".karajan", "harness");
26
+ const harness = existsSync(harnessDir);
27
+ const check = harness ? verifySentinelScripts({ projectDir }) : { ok: false, mismatched: [] };
28
+ const base = { harness, verified: harness && check.ok === true, mismatched: check.mismatched || [], queries: [], hits: [] };
29
+ let state;
30
+ try {
31
+ state = JSON.parse(readFileSync(path.join(harnessDir, "sentinel-state.json"), "utf8"));
32
+ } catch {
33
+ return { ...base, available: false, reason: harness ? "no session activity recorded by the Sentinel yet" : "no Sentinel harness in this tree (kj harden installs it)" };
34
+ }
35
+ const sessions = Object.entries(state?.sessions || {});
36
+ if (sessions.length === 0) {
37
+ return { ...base, available: false, reason: "the sentinel state holds no session" };
38
+ }
39
+ const [sessionId, s] = sessions.sort((a, b) => (b[1]?.at || 0) - (a[1]?.at || 0))[0];
40
+ return { ...base, available: true, sessionId, queries: Array.isArray(s?.rag_queries) ? s.rag_queries : [], hits: Array.isArray(s?.rag_hits) ? s.rag_hits : [] };
41
+ }