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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * KJC-TSK-0849 (ADR 0010, RAG-C) — a diff that touches source files enters
3
+ * the review only if the session's RAG ledger (RAG-A) shows the RAG answered
4
+ * about every staged source: the file itself or a sibling of its directory.
5
+ * A file NEW in the diff cannot have been returned yet: it counts as covered
6
+ * once the session consulted at all (the ADR's fallback A, same as the
7
+ * Sentinel gate). Docs-only diffs are exempt. The only other way through is
8
+ * a live HUMAN grant on `method.rag.code` — never an env var: an escape the
9
+ * agent can set for itself is the hole this closes.
10
+ *
11
+ * The block also names the TWINS: sources the RAG returned for the same
12
+ * concepts that are not in the diff. That list travels to the reviewer,
13
+ * because the bugs of 2026-09-16 were exactly the twin nobody touched.
14
+ */
15
+ import { sourceFilesOf } from "./tests-with-code.js";
16
+
17
+ export const RAG_RULE_ID = "method.rag.code";
18
+
19
+ const GRANT_HINT = `Only a human grant lifts this: kj policy grant --rule ${RAG_RULE_ID} --until <iso> --reason "<why>"`;
20
+ const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
21
+
22
+ function liveGrant(standingExceptions, now) {
23
+ return standingExceptions.find((e) => e.rule_id === RAG_RULE_ID && new Date(e.expiresAt).getTime() > now.getTime()) || null;
24
+ }
25
+
26
+ /**
27
+ * @param {object} args
28
+ * @param {string[]} args.stagedFiles - every path of the diff
29
+ * @param {string[]} [args.newFiles] - paths ADDED by the diff (not in HEAD)
30
+ * @param {{available: boolean, reason?: string, queries: object[], hits: string[]}} [args.ledger]
31
+ * @returns {{ok: boolean, mode: "docs-only"|"pass"|"granted"|"block", reason?: string, grant?: object, sources?: string[], queries?: number, covered?: string[], uncovered?: string[], twinsUntouched?: string[]}}
32
+ */
33
+ export function checkRagRequirement({ config = {}, stagedFiles = [], newFiles = [], ledger, standingExceptions = [], now = new Date(), env = {} }) {
34
+ const { sources } = sourceFilesOf(config, stagedFiles);
35
+ if (sources.length === 0) return { ok: true, mode: "docs-only" };
36
+
37
+ const grant = liveGrant(standingExceptions, now);
38
+ if (grant) return { ok: true, mode: "granted", grant, sources };
39
+
40
+ // An env override is REJECTED here, explicitly: whatever KJ_ALLOW_* the
41
+ // process carries is named in the reason and changes nothing. The Sentinel
42
+ // escape opens an edit; it never opens the review.
43
+ const ignored = Object.keys(env || {}).filter((k) => k.startsWith("KJ_ALLOW_") && env[k] === "1");
44
+ const overrideNote = ignored.length > 0 ? ` (${ignored.join(", ")} is not honoured here)` : "";
45
+ const head = `The RAG must have answered about code before it is reviewed (${sources.length} staged source${sources.length === 1 ? "" : "s"})${overrideNote}`;
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
+ }
54
+ // A harness that does not match the installed kj (edited, emptied, or older
55
+ // than the ledger) cannot vouch for anything: fail closed, like the tamper
56
+ // check does, until the human regenerates it.
57
+ if (ledger?.harness === true && ledger?.verified === false) {
58
+ return { ok: false, mode: "block", sources, reason: `${head} — the Sentinel harness does not match the installed kj (${(ledger.mismatched || []).join(", ") || "scripts missing"}): the human runs \`kj harden\` to regenerate it before code is reviewed. ${GRANT_HINT}` };
59
+ }
60
+ if (!ledger?.available) {
61
+ return { ok: false, mode: "block", sources, reason: `${head} — ${ledger?.reason || "no session ledger"}: consult the RAG about the change (kj_rag_query / kj rag query) and run \`kj review --staged\` again. ${GRANT_HINT}` };
62
+ }
63
+ const hits = ledger.hits || [];
64
+ const queries = (ledger.queries || []).length;
65
+ const fresh = new Set(newFiles);
66
+ const answered = (f) => hits.includes(f) || hits.some((h) => dirOf(h) === dirOf(f)) || (fresh.has(f) && queries > 0);
67
+ const covered = sources.filter(answered);
68
+ const uncovered = sources.filter((f) => !answered(f));
69
+ const staged = new Set(stagedFiles);
70
+ const twinsUntouched = hits.filter((h) => !staged.has(h));
71
+ const evidence = { queries, covered, uncovered, twinsUntouched };
72
+ if (uncovered.length > 0) {
73
+ const list = `${uncovered.slice(0, 5).join(", ")}${uncovered.length > 5 ? "…" : ""}`;
74
+ return { ok: false, mode: "block", sources, ...evidence, reason: `${head} — no query of this session returned ${uncovered.length} of them (${list}): ask what they do and where else that concept lives, then run \`kj review --staged\` again. ${GRANT_HINT}` };
75
+ }
76
+ return { ok: true, mode: "pass", sources, ...evidence };
77
+ }
78
+
79
+ /**
80
+ * The pre-commit side (`kj review --check`): the verdict's `rag` block is the
81
+ * evidence, recomputed against the CURRENT staged sources like the sonar
82
+ * block — the diff hash pins the file set, so a source missing from
83
+ * `covered` was never answered about, whatever the block claims.
84
+ */
85
+ export function checkRagVerdict({ config = {}, stagedFiles = [], rag, harness = true, verified = true, mismatched = [], standingExceptions = [], now = new Date() }) {
86
+ const { sources } = sourceFilesOf(config, stagedFiles);
87
+ if (sources.length === 0) return { ok: true, mode: "docs-only" };
88
+ const grant = liveGrant(standingExceptions, now);
89
+ if (grant) return { ok: true, mode: "granted", grant, sources };
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
+ }
96
+ if (!verified) {
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}` };
98
+ }
99
+ if (!rag) {
100
+ return { ok: false, mode: "block", sources, reason: `${head} — the verdict carries no rag block; run \`kj review --staged\` again. ${GRANT_HINT}` };
101
+ }
102
+ // The block's mode is a claim, never an authorization: a grant or a missing
103
+ // harness only count when they hold NOW (checked above). So the block can
104
+ // only prove coverage — a "granted" or "no-harness" it recorded has lapsed.
105
+ if (rag.mode !== "pass" || !Array.isArray(rag.covered)) {
106
+ return { ok: false, mode: "block", sources, reason: `${head} — the verdict's rag block is malformed (mode ${JSON.stringify(rag.mode ?? null)}, covered ${Array.isArray(rag.covered) ? "list" : typeof rag.covered}); run \`kj review --staged\` again. ${GRANT_HINT}` };
107
+ }
108
+ const coveredSet = new Set(rag.covered);
109
+ const uncovered = sources.filter((f) => !coveredSet.has(f));
110
+ if (uncovered.length > 0) {
111
+ const list = `${uncovered.slice(0, 5).join(", ")}${uncovered.length > 5 ? "…" : ""}`;
112
+ return { ok: false, mode: "block", sources, reason: `${head} — the session ledger never covered ${uncovered.length} of them (${list}); consult the RAG and run \`kj review --staged\` again. ${GRANT_HINT}` };
113
+ }
114
+ return { ok: true, mode: "pass", sources };
115
+ }
116
+
117
+ /** The `rag` block stored in the verdict, bound to the diff hash like `sonar`. */
118
+ export function ragBlock(req, ledger) {
119
+ return {
120
+ mode: req.mode,
121
+ sessionId: ledger?.sessionId || null,
122
+ queries: req.queries ?? (ledger?.queries || []).length,
123
+ covered: req.covered || [],
124
+ uncovered: req.uncovered || [],
125
+ twinsUntouched: req.twinsUntouched || [],
126
+ };
127
+ }
@@ -102,10 +102,15 @@ export async function runSolomonArbitration({
102
102
  position, ruling: parsed.ruling, reasoning: parsed.reasoning || "", solomon,
103
103
  originalVerdict: { reviewer: verdict.reviewer, issues: verdict.issues },
104
104
  };
105
+ // KJC-BUG-0183: the arbiter judges the reviewer's objections, not the
106
+ // evidence bound to the diff (sonar, rag blocks). That evidence was computed
107
+ // for this exact hash and travels with the overriding verdict — without it
108
+ // `kj review --check` refuses every arbitrated diff.
109
+ const evidence = { ...(verdict.sonar ? { sonar: verdict.sonar } : {}), ...(verdict.rag ? { rag: verdict.rag } : {}) };
105
110
  const record = parsed.ruling === "approve"
106
111
  ? await saveVerdict(projectDir, diff, {
107
112
  verdict: "approved", reviewer: `solomon:${solomon}`, host: hostAgent || null,
108
- issues: [], summary: `Arbitration overrode ${verdict.reviewer}'s rejection: ${parsed.reasoning || ""}`.trim(), arbitration,
113
+ issues: [], summary: `Arbitration overrode ${verdict.reviewer}'s rejection: ${parsed.reasoning || ""}`.trim(), arbitration, ...evidence,
109
114
  })
110
115
  : await saveVerdict(projectDir, diff, { ...verdict, arbitration });
111
116
 
@@ -14,6 +14,7 @@ import { collectCircularDeps } from "../audit/circular-deps.js";
14
14
  import { collectDeadExports } from "../audit/dead-exports.js";
15
15
  import { collectInjectionFindings } from "../audit/injection-findings.js";
16
16
  import { collectAiSlop } from "../audit/ai-slop-findings.js";
17
+ import { collectEnvKeyFindings } from "../audit/env-key-findings.js";
17
18
 
18
19
  function parseDimensions(dimensionsStr) {
19
20
  if (!dimensionsStr || dimensionsStr === "all") return null;
@@ -77,6 +78,7 @@ export class AuditRole extends AgentRole {
77
78
  let injectionFindings = null;
78
79
  let infraFindings = null;
79
80
  let aiSlop = null;
81
+ let envKeys = null;
80
82
  if (!securityOnly) {
81
83
  try {
82
84
  basalCost = await measureBasalCost(projectDir);
@@ -154,13 +156,20 @@ export class AuditRole extends AgentRole {
154
156
  aiSlop = await collectAiSlop(projectDir);
155
157
  } catch { /* ai-slop scan is best-effort */ }
156
158
  }
159
+ // KJC-TSK-0845: one key, two resolution rules = a migration left half-way
160
+ // (KJ_HOME → KARAJAN_HOME). Deterministic, offline, best-effort.
161
+ if (!securityOnly) {
162
+ try {
163
+ envKeys = await collectEnvKeyFindings(projectDir);
164
+ } catch { /* env-key scan is best-effort */ }
165
+ }
157
166
  // STW-A (KJC-TSK-0789 AC5): record that the security surface was looked
158
167
  // at, so the Steward can age it — GREBLA went 79 days with "never".
159
168
  try {
160
169
  mkdirSync(dirname(securityAuditMarkerPath(projectDir)), { recursive: true });
161
170
  writeFileSync(securityAuditMarkerPath(projectDir), JSON.stringify({ at: new Date().toISOString(), mode: securityOnly ? "security" : "full" }));
162
171
  } catch { /* recording is best-effort — the audit itself already ran */ }
163
- return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings, infraFindings, aiSlop };
172
+ return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings, infraFindings, aiSlop, envKeys };
164
173
  }
165
174
 
166
175
  /**
@@ -176,7 +185,7 @@ export class AuditRole extends AgentRole {
176
185
  const context = typeof input === "object" ? input?.context || null : null;
177
186
  const dimensions = typeof rawDimensions === "string" ? parseDimensions(rawDimensions) : rawDimensions;
178
187
 
179
- const { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports } = deterministicCtx;
188
+ const { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, envKeys } = deterministicCtx;
180
189
 
181
190
  const provider = this.resolveProvider();
182
191
  const agent = this.createAgentInstance(provider);
@@ -217,6 +226,7 @@ export class AuditRole extends AgentRole {
217
226
  semgrepFindings: semgrepFindings?.available ? semgrepFindings : undefined,
218
227
  circularDeps: circularDeps?.available ? circularDeps : undefined,
219
228
  deadExports: deadExports?.available ? deadExports : undefined,
229
+ envKeys: envKeys?.available ? envKeys : undefined,
220
230
  provider
221
231
  },
222
232
  summary: buildSummary(parsed),
@@ -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
  "",