karajan-code 4.29.0 → 4.30.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.29.0",
3
+ "version": "4.30.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",
@@ -27,12 +27,32 @@ async function writeJson(file, obj) {
27
27
  await fs.writeFile(file, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
28
28
  }
29
29
 
30
- function resolveKjHome() {
31
- if (process.env.KJ_HOME) return process.env.KJ_HOME;
32
- return path.join(ROOT_DIR, ".karajan");
30
+ const CLAUDE_JSON_PATH = path.join(os.homedir(), ".claude.json");
31
+
32
+ // KJC-BUG-0179 (#1730): a home INSIDE the npm package is the old default
33
+ // this script wrote itself — wiped on every reinstall, never where `kj init`
34
+ // stores kj.config.yml. Such a value is stale, not a user choice.
35
+ // KJC-BUG-0181: in a linked install (npm link, source tree) the package does
36
+ // not live under node_modules, so THIS package's own `.karajan` is the same
37
+ // stale default under another path — compared as a path, not a pattern.
38
+ const isPackageDirHome = (home) => /node_modules[\\/]karajan-code[\\/]\.karajan[\\/]?$/.test(home)
39
+ || path.resolve(home) === path.join(ROOT_DIR, ".karajan");
40
+
41
+ /** The home a previous registration carried, if the user (not this script) set it. */
42
+ function homeFromExistingEntry(entry) {
43
+ const previous = entry?.env?.KARAJAN_HOME || entry?.env?.KJ_HOME;
44
+ return previous && !isPackageDirHome(previous) ? previous : null;
33
45
  }
34
46
 
35
- async function resolveKjHomeFromRegistry() {
47
+ /**
48
+ * Precedence: KARAJAN_HOME → KJ_HOME (deprecated) → the existing entry's home
49
+ * → the instances registry → `~/.karajan` (where the CLI defaults, KJC-BUG-0179).
50
+ */
51
+ async function resolveKjHome(existingEntry) {
52
+ if (process.env.KARAJAN_HOME) return process.env.KARAJAN_HOME;
53
+ if (process.env.KJ_HOME) return process.env.KJ_HOME;
54
+ const previous = homeFromExistingEntry(existingEntry);
55
+ if (previous) return previous;
36
56
  try {
37
57
  const registry = await readJson(REGISTRY_PATH);
38
58
  const names = Object.keys(registry.instances || {});
@@ -43,28 +63,31 @@ async function resolveKjHomeFromRegistry() {
43
63
  } catch {
44
64
  // No registry yet — use default
45
65
  }
46
- return resolveKjHome();
66
+ return path.join(os.homedir(), ".karajan");
47
67
  }
48
68
 
49
- async function setupClaudeMcp(kjHome) {
50
- const claudeJsonPath = path.join(os.homedir(), ".claude.json");
51
- let config = {};
69
+ async function readClaudeConfig() {
52
70
  try {
53
- config = await readJson(claudeJsonPath);
71
+ return await readJson(CLAUDE_JSON_PATH);
54
72
  } catch {
55
- config = {};
73
+ return {};
56
74
  }
75
+ }
57
76
 
77
+ async function setupClaudeMcp(config, kjHome) {
58
78
  config.mcpServers = config.mcpServers || {};
79
+ // Keep every env key the user added to the entry; only the home is ours,
80
+ // and it is written under the current name (KJ_HOME is deprecated).
81
+ const { KJ_HOME: _stale, ...userEnv } = config.mcpServers["karajan-mcp"]?.env || {};
59
82
  config.mcpServers["karajan-mcp"] = {
60
83
  type: "stdio",
61
84
  command: "node",
62
85
  args: [path.join(ROOT_DIR, "src", "mcp", "server.js")],
63
86
  cwd: ROOT_DIR,
64
- env: { KJ_HOME: kjHome }
87
+ env: { ...userEnv, KARAJAN_HOME: kjHome }
65
88
  };
66
89
 
67
- await writeJson(claudeJsonPath, config);
90
+ await writeJson(CLAUDE_JSON_PATH, config);
68
91
  }
69
92
 
70
93
  function upsertCodexMcpBlock(toml, block) {
@@ -96,7 +119,7 @@ async function setupCodexMcp(kjHome) {
96
119
  `args = [${tomlPath(path.join(ROOT_DIR, "src", "mcp", "server.js"))}]`,
97
120
  `cwd = ${tomlPath(ROOT_DIR)}`,
98
121
  '[mcp_servers."karajan-mcp".env]',
99
- `KJ_HOME = ${tomlPath(kjHome)}`
122
+ `KARAJAN_HOME = ${tomlPath(kjHome)}`
100
123
  ].join("\n");
101
124
 
102
125
  const updated = upsertCodexMcpBlock(toml, block);
@@ -105,9 +128,10 @@ async function setupCodexMcp(kjHome) {
105
128
  }
106
129
 
107
130
  async function main() {
108
- const kjHome = await resolveKjHomeFromRegistry();
131
+ const claudeConfig = await readClaudeConfig();
132
+ const kjHome = await resolveKjHome(claudeConfig.mcpServers?.["karajan-mcp"]);
109
133
 
110
- await setupClaudeMcp(kjHome);
134
+ await setupClaudeMcp(claudeConfig, kjHome);
111
135
  await setupCodexMcp(kjHome);
112
136
 
113
137
  console.log("karajan-mcp registered in Claude Code and Codex.");
@@ -0,0 +1,76 @@
1
+ /**
2
+ * MCP smoke for verify-pack (KJC-TSK-0844) — the karajan-mcp server of the
3
+ * INSTALLED tarball is started over stdio with the SDK client and exercised
4
+ * with kj_status, kj_config and kj_review, no LLM involved. The MCP surface
5
+ * was only proved by tests with every seam mocked and dogfooded by no
6
+ * development session; KJC-BUG-0175 (taskFile promised, never read) and
7
+ * KJC-BUG-0176 (kjHome declared, ignored) both shipped through that gap.
8
+ *
9
+ * What counts as a failure: the server not answering, a tool missing from
10
+ * the list, or a handler that never reached its gate — "Missing required
11
+ * field", "Config file not found", "taskFile read failed", "Unknown tool".
12
+ * A rejection the gate itself hands back (no reviewer CLI on this machine,
13
+ * Sonar, base branch) is the expected answer of an agentless runner and is
14
+ * said explicitly, never hidden.
15
+ */
16
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
18
+
19
+ const FATAL = [/Missing required field/i, /Config file not found/i, /taskFile read failed/i, /Unknown tool/i];
20
+ const TOOLS = ["kj_status", "kj_config", "kj_review"];
21
+
22
+ /** The text of a tool answer, and whether it is a failure of the contract (not of the environment). */
23
+ export function classifyAnswer(tool, text) {
24
+ const fatal = FATAL.find((re) => re.test(text));
25
+ if (fatal) return { tool, ok: false, reason: `${tool} answered "${text.match(fatal)[0]}": the handler never reached its gate` };
26
+ let parsed = null;
27
+ try { parsed = JSON.parse(text); } catch { /* not JSON: plain text answer */ }
28
+ if (parsed && parsed.ok === false) return { tool, ok: true, gated: true, reason: String(parsed.error || "").slice(0, 200) };
29
+ return { tool, ok: true, gated: false };
30
+ }
31
+
32
+ /**
33
+ * @param {object} o
34
+ * @param {string} o.serverPath - src/mcp/server.js of the install under test
35
+ * @param {string} o.kjHome - a home holding kj.config.yml (passed as the tool's `kjHome` AND as KARAJAN_HOME)
36
+ * @param {string} o.projectDir - an initialised git repo with a task file
37
+ * @param {string} o.taskFile - path of the task (.md) relative to projectDir
38
+ * @returns {Promise<{ok: boolean, findings: string[], answers: object[]}>}
39
+ */
40
+ export async function mcpSmoke({ serverPath, kjHome, projectDir, taskFile, env = process.env, timeoutMs = 120_000 }) {
41
+ const findings = [];
42
+ const answers = [];
43
+ const transport = new StdioClientTransport({
44
+ command: process.execPath, args: [serverPath], cwd: projectDir, stderr: "pipe",
45
+ env: { ...env, KARAJAN_HOME: kjHome },
46
+ });
47
+ const client = new Client({ name: "kj-verify-pack", version: "0" });
48
+ const call = async (name, args) => {
49
+ const res = await client.callTool({ name, arguments: args }, undefined, { timeout: timeoutMs });
50
+ const text = (res.content || []).map((c) => c.text || "").join("\n");
51
+ const verdict = classifyAnswer(name, text);
52
+ answers.push(verdict);
53
+ if (!verdict.ok) findings.push(verdict.reason);
54
+ return verdict;
55
+ };
56
+ try {
57
+ await client.connect(transport);
58
+ const listed = new Set((await client.listTools()).tools.map((t) => t.name));
59
+ for (const t of TOOLS) if (!listed.has(t)) findings.push(`${t} is not listed by the server`);
60
+ if (findings.length > 0) return { ok: false, findings, answers };
61
+ // A project that never ran has no run log: that answer is the contract, not a refusal.
62
+ const status = await call("kj_status", { kjHome, projectDir, lines: 5 });
63
+ if (status.gated && !/no active run log/i.test(status.reason)) findings.push(`kj_status refused: ${status.reason}`);
64
+ const config = await call("kj_config", { kjHome, json: true });
65
+ if (config.gated) findings.push(`kj_config refused: ${config.reason}`);
66
+ // The task travels ONLY as a file: the handler must read it before any
67
+ // gate. The reviewer is a name no machine has, so the gate stops the call
68
+ // before a single LLM token, on a laptop with real agents as much as in CI.
69
+ await call("kj_review", { kjHome, projectDir, taskFile, reviewer: "kj-verify-stub" });
70
+ } catch (err) {
71
+ findings.push(`MCP session failed: ${err.message}`);
72
+ } finally {
73
+ await client.close().catch(() => {});
74
+ }
75
+ return { ok: findings.length === 0, findings, answers };
76
+ }
@@ -24,6 +24,8 @@ import os from "node:os";
24
24
  import path from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
 
27
+ import { mcpSmoke } from "./verify-pack-mcp.mjs";
28
+
27
29
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
30
  const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
29
31
  const expectedVersion = pkg.version;
@@ -220,6 +222,33 @@ try {
220
222
  }
221
223
  console.log("verify-pack: quickstart smoke (init + report, no-remote repo) ✓");
222
224
 
225
+ // 5.6 MCP smoke (KJC-TSK-0844) — the karajan-mcp server of the GLOBAL
226
+ // install, over stdio, against the quickstart repo and its home: kj_status,
227
+ // kj_config and kj_review with kjHome + taskFile, no LLM. The MCP surface
228
+ // shipped KJC-BUG-0175/0176 through tests that mocked every seam.
229
+ const gServer = path.join(
230
+ gTmp, ...(process.platform === "win32" ? ["node_modules"] : ["lib", "node_modules"]),
231
+ ...pkgName.split("/"), "src", "mcp", "server.js",
232
+ );
233
+ if (!fs.existsSync(gServer)) fail(`karajan-mcp server missing in the global install: ${gServer}`);
234
+ // A branch with a staged source and a task file: kj_review must READ the
235
+ // file and reach its gate (a refusal there is this agentless runner's answer).
236
+ run("git", ["checkout", "-q", "-b", "smoke/mcp"], { cwd: qsTmp });
237
+ fs.writeFileSync(path.join(qsTmp, "task.md"), "Add a greeting helper\n");
238
+ fs.writeFileSync(path.join(qsTmp, "hello.js"), "export const hi = () => 'hi';\n");
239
+ run("git", ["add", "hello.js"], { cwd: qsTmp });
240
+ console.log("verify-pack: MCP smoke (kj_status, kj_config, kj_review over stdio)…");
241
+ const mcp = await mcpSmoke({ serverPath: gServer, kjHome: qsEnv.KARAJAN_HOME, projectDir: qsTmp, taskFile: "task.md", env: qsEnv });
242
+ if (!mcp.ok) fail("the tarball's karajan-mcp server did not honour its own tool contract", mcp.findings.join("\n"));
243
+ for (const a of mcp.answers) if (a.gated) console.log(`verify-pack: ${a.tool} reached its gate and refused (expected here): ${a.reason}`);
244
+ // KJC-BUG-0179: the tarball's postinstall registered the MCP in the temp
245
+ // HOME under the current name, never the deprecated KJ_HOME.
246
+ const registered = JSON.parse(fs.readFileSync(path.join(homeTmp, ".claude.json"), "utf8")).mcpServers?.["karajan-mcp"]?.env || {};
247
+ if (!registered.KARAJAN_HOME || registered.KJ_HOME) {
248
+ fail("postinstall registered karajan-mcp without KARAJAN_HOME (or with the deprecated KJ_HOME)", JSON.stringify(registered));
249
+ }
250
+ console.log("verify-pack: MCP smoke ✓");
251
+
223
252
  // 6. pnpm install smoke (KJC-TSK-0580). pnpm's layout differs from npm's
224
253
  // (a symlinked virtual store), so it can break resolution of the bundled
225
254
  // karajan-core the way npm packaging breakage did before (KJC-BUG-0082/0086).
@@ -15,6 +15,7 @@ import { groupFindingsBySeverity as groupSemgrepBySeverity } from "./semgrep-fin
15
15
  import { groupCyclesBySeverity } from "./circular-deps.js";
16
16
  import { groupDeadExportsBySeverity } from "./dead-exports.js";
17
17
  import { groupInjectionBySeverity } from "./injection-findings.js";
18
+ import { groupEnvKeyFindingsBySeverity } from "./env-key-findings.js";
18
19
 
19
20
  const MAX_SAMPLE_DEAD_EXPORTS = 10;
20
21
  const MAX_SAMPLE_SONAR_PER_SEVERITY = 5;
@@ -43,11 +44,33 @@ export function formatDeterministicSummary(ctx) {
43
44
  if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
44
45
  if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
45
46
  if (ctx.aiSlop) lines.push(...formatAiSlopBlock(ctx.aiSlop));
47
+ if (ctx.envKeys) lines.push(...formatEnvKeysBlock(ctx.envKeys));
46
48
  if (ctx.webperf) lines.push(...formatWebperfBlock(ctx.webperf));
47
49
 
48
50
  return lines.join("\n");
49
51
  }
50
52
 
53
+ const MAX_SAMPLE_ENV_KEYS = 8;
54
+
55
+ // KJC-TSK-0845: the same key resolved with two rules in two modules, and a
56
+ // deprecated key read where its successor never is — every site named.
57
+ function formatEnvKeysBlock(envKeys) {
58
+ if (!envKeys.available) return ["### Env/config key resolution", `- Status: not available — ${envKeys.reason || "scan failed"}`, ""];
59
+ const { HIGH, MEDIUM } = groupEnvKeyFindingsBySeverity(envKeys);
60
+ const lines = ["### Env/config key resolution", `- Files scanned: ${envKeys.scanned ?? 0}`, `- Incomplete migrations: ${HIGH.length} · keys resolved with divergent rules: ${MEDIUM.length}`];
61
+ for (const i of HIGH.slice(0, MAX_SAMPLE_ENV_KEYS)) {
62
+ lines.push(` - HIGH ${i.file}:${i.line} reads \`${i.key}\` and never \`${i.successor}\``);
63
+ }
64
+ for (const d of MEDIUM.slice(0, MAX_SAMPLE_ENV_KEYS)) {
65
+ lines.push(` - MEDIUM [${d.kind}] \`${d.key}\` resolved ${d.rules.length} ways:`);
66
+ for (const r of d.rules) lines.push(` - \`${r.rule}\` ← ${r.sites.join(", ")}`);
67
+ }
68
+ const more = HIGH.length + MEDIUM.length - Math.min(HIGH.length, MAX_SAMPLE_ENV_KEYS) - Math.min(MEDIUM.length, MAX_SAMPLE_ENV_KEYS);
69
+ if (more > 0) lines.push(` - ... and ${more} more`);
70
+ lines.push("");
71
+ return lines;
72
+ }
73
+
51
74
  function formatAiSlopBlock(slop) {
52
75
  if (!slop.available) return ["### AI-slop tells (deterministic)", `- Status: not available — ${slop.reason || "scan failed"}`, ""];
53
76
  const lines = ["### AI-slop tells (deterministic)", `- Files scanned: ${slop.filesScanned ?? 0}`, `- Score: ${slop.score}/100 (100 = clean)`, `- Findings: ${slop.total ?? 0}`];
@@ -338,5 +361,6 @@ export function deterministicContextHasFindings(ctx) {
338
361
  if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
339
362
  if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
340
363
  if (ctx.aiSlop?.available && (ctx.aiSlop.total ?? 0) > 0) return true;
364
+ if (ctx.envKeys?.available && ((ctx.envKeys.divergent?.length ?? 0) + (ctx.envKeys.incomplete?.length ?? 0)) > 0) return true;
341
365
  return false;
342
366
  }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Env/config key resolution collector for `kj audit` (KJC-TSK-0845).
3
+ *
4
+ * The same key resolved by two modules with two rules is a migration left
5
+ * half-way: KJ_HOME → KARAJAN_HOME reached the runtime and not the installer
6
+ * (KJC-BUG-0179), and the MCP resolved the home unlike the CLI (KJC-BUG-0176).
7
+ * Zero tokens, lexical, best-effort — same shape as injection-findings:
8
+ * - divergent: one key, read with an explicit fallback chain (`||` / `??`)
9
+ * that differs between files. Plain reads consume a value and resolve
10
+ * nothing, so they never count: no false positive on a consumer.
11
+ * - incomplete-migration: a file reads a deprecated key and never names its
12
+ * successor.
13
+ */
14
+ import { readdir, readFile } from "node:fs/promises";
15
+ import path from "node:path";
16
+
17
+ export const DEPRECATED_KEYS = { KJ_HOME: "KARAJAN_HOME" };
18
+ const ROOTS = ["src", "scripts", "bin", "packages"];
19
+ const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", "tests", "test", "__tests__", "docs", "public"]);
20
+ const ENV_READ = /process\.env\.([A-Z][A-Z0-9_]*)|process\.env\[["']([A-Z][A-Z0-9_]*)["']\]/g;
21
+ const CONFIG_START = /\bconfig(?=\??\.[a-z])/g;
22
+ const SEGMENT = /^\??\.([a-z][a-z0-9_]*)/;
23
+ const STOP = new Set([" ", "\t", ";", ",", "|", "?", ":"]);
24
+
25
+ /** ` || rest` → { op, consumed } with the surrounding blanks, or null when no operator follows. */
26
+ function operatorAt(s) {
27
+ const t = s.trimStart();
28
+ const op = ["||", "??"].find((o) => t.startsWith(o)) ?? null;
29
+ if (!op) return null;
30
+ const afterOp = t.slice(op.length);
31
+ return { op, consumed: s.length - afterOp.trimStart().length };
32
+ }
33
+
34
+ /** `config.a?.b` → { key: "a.b", length } read from just after `config`, or null. */
35
+ function configPath(rest) {
36
+ const segments = [];
37
+ let s = rest;
38
+ for (let m = SEGMENT.exec(s); m; m = SEGMENT.exec(s)) {
39
+ segments.push(m[1]);
40
+ s = s.slice(m[0].length);
41
+ }
42
+ return segments.length > 0 ? { key: segments.join("."), length: rest.length - s.length } : null;
43
+ }
44
+
45
+ /** One fallback operand: a quoted literal, or an expression up to the next operator, with balanced brackets. */
46
+ function readOperand(s) {
47
+ const quote = s[0];
48
+ if (quote === '"' || quote === "'" || quote === "`") {
49
+ const end = s.indexOf(quote, 1);
50
+ return end === -1 ? null : s.slice(0, end + 1);
51
+ }
52
+ const n = operandLength(s);
53
+ return n > 0 ? s.slice(0, n) : null;
54
+ }
55
+
56
+ /** Length of the expression at the start of `s`: up to the next operator or unbalanced close. */
57
+ function operandLength(s) {
58
+ // Optional chaining (`a?.b`) is not an operator boundary: flatten it, same length.
59
+ const flat = s.replaceAll("?.", "..");
60
+ let depth = 0;
61
+ for (let i = 0; i < flat.length; i += 1) {
62
+ const c = flat[i];
63
+ if (c === "(" || c === "[") {
64
+ depth += 1;
65
+ } else if (c === ")" || c === "]") {
66
+ if (depth === 0) { return i; }
67
+ depth -= 1;
68
+ } else if (depth === 0 && STOP.has(c)) {
69
+ return i;
70
+ }
71
+ }
72
+ return flat.length;
73
+ }
74
+
75
+ async function listJsFiles(dir, depth = 0) {
76
+ if (depth > 8) return [];
77
+ let entries;
78
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
79
+ const out = [];
80
+ for (const e of entries) {
81
+ const p = path.join(dir, e.name);
82
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) out.push(...(await listJsFiles(p, depth + 1))); }
83
+ else if (/\.(m?js|cjs)$/.test(e.name)) out.push(p);
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** The fallback chain written after a subject on the same line, normalized. */
89
+ export function fallbackChain(rest) {
90
+ const chain = [];
91
+ let s = rest;
92
+ for (;;) {
93
+ const op = operatorAt(s);
94
+ if (!op) break;
95
+ const operand = readOperand(s.slice(op.consumed));
96
+ if (!operand) break;
97
+ // The operator is part of the rule: `|| "x"` and `?? "x"` resolve differently.
98
+ chain.push(`${op.op} ${operand.replaceAll(/\s+/g, "")}`);
99
+ s = s.slice(op.consumed + operand.length);
100
+ }
101
+ return chain;
102
+ }
103
+
104
+ /** Every subject read in one file: { key, kind, line, chain }. */
105
+ export function readsIn(text) {
106
+ const reads = [];
107
+ text.split("\n").forEach((line, i) => {
108
+ if (/^\s*(\/\/|\*)/.test(line)) return;
109
+ for (const m of line.matchAll(ENV_READ)) {
110
+ reads.push({ key: m[1] || m[2], kind: "env", line: i + 1, chain: fallbackChain(line.slice(m.index + m[0].length)) });
111
+ }
112
+ for (const m of line.matchAll(CONFIG_START)) {
113
+ const after = m.index + m[0].length;
114
+ const p = configPath(line.slice(after));
115
+ if (p) reads.push({ key: p.key, kind: "config", line: i + 1, chain: fallbackChain(line.slice(after + p.length)) });
116
+ }
117
+ });
118
+ return reads;
119
+ }
120
+
121
+ export async function collectEnvKeyFindings(projectDir, { roots = ROOTS } = {}) {
122
+ if (!projectDir) return { available: false, reason: "projectDir not provided" };
123
+ const files = (await Promise.all(roots.map((r) => listJsFiles(path.join(projectDir, r))))).flat();
124
+ const sites = new Map(); // key → [{ file, line, chain }] (only reads WITH a chain)
125
+ const incomplete = [];
126
+ for (const file of files) {
127
+ const text = await readFile(file, "utf8");
128
+ const rel = path.relative(projectDir, file);
129
+ const reads = readsIn(text);
130
+ for (const r of reads) {
131
+ if (r.chain.length === 0) continue;
132
+ // One subject per namespace: process.env.PORT and config.port never compare.
133
+ const id = `${r.kind}:${r.key}`;
134
+ if (!sites.has(id)) sites.set(id, []);
135
+ sites.get(id).push({ file: rel, line: r.line, chain: r.chain });
136
+ }
137
+ for (const [old, successor] of Object.entries(DEPRECATED_KEYS)) {
138
+ // The successor must be READ in the file (a comment naming it is not a migration).
139
+ const hit = reads.find((r) => r.key === old);
140
+ if (hit && !reads.some((r) => r.key === successor)) incomplete.push({ key: old, successor, file: rel, line: hit.line });
141
+ }
142
+ }
143
+ const divergent = [];
144
+ for (const [id, list] of sites) {
145
+ const [kind, key] = [id.slice(0, id.indexOf(":")), id.slice(id.indexOf(":") + 1)];
146
+ const rules = new Map();
147
+ for (const s of list) {
148
+ const rule = `${key} ${s.chain.join(" ")}`;
149
+ if (!rules.has(rule)) rules.set(rule, []);
150
+ rules.get(rule).push(s);
151
+ }
152
+ if (rules.size > 1 && new Set(list.map((s) => s.file)).size > 1) {
153
+ divergent.push({ key, kind, rules: [...rules].map(([rule, at]) => ({ rule, sites: at.map((s) => `${s.file}:${s.line}`) })) });
154
+ }
155
+ }
156
+ divergent.sort((a, b) => a.key.localeCompare(b.key));
157
+ return { available: true, scanned: files.length, divergent, incomplete };
158
+ }
159
+
160
+ export function groupEnvKeyFindingsBySeverity({ divergent = [], incomplete = [] } = {}) {
161
+ return { HIGH: incomplete, MEDIUM: divergent };
162
+ }
@@ -12,6 +12,7 @@ import { runCommand } from "../utils/process.js";
12
12
  import { checkTestsWithCode } from "../review/tests-with-code.js";
13
13
  import { CARD_REF_RE } from "../review/card-first.js";
14
14
  import { STRATEGY } from "./types.js";
15
+ import { createRagCoverageCheck } from "./rag-coverage.js";
15
16
 
16
17
  function recentVerdicts(projectDir, sample) {
17
18
  try {
@@ -40,9 +41,12 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
40
41
  const verdicts = recentVerdicts(projectDir, sample);
41
42
  const stamped = verdicts.filter((v) => v.workspace);
42
43
  const sonar = { proved: 0, docsOnly: 0, granted: 0, unproved: 0 };
44
+ const rag = { proved: 0, docsOnly: 0, granted: 0, noHarness: 0, unproved: 0 };
43
45
  for (const v of verdicts) {
44
46
  const kind = sonarProof(v);
45
47
  if (kind) sonar[kind] += 1;
48
+ const asked = ragProof(v);
49
+ if (asked) rag[asked] += 1;
46
50
  }
47
51
 
48
52
  return {
@@ -50,6 +54,7 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
50
54
  verdicts: { total: verdicts.length, stamped: stamped.length, root: stamped.filter((v) => v.workspace === "root").length },
51
55
  testless: { sampled: blocks.length, offenders },
52
56
  sonar,
57
+ rag,
53
58
  };
54
59
  }
55
60
 
@@ -74,9 +79,33 @@ export function sonarProof(v) {
74
79
  return "unproved";
75
80
  }
76
81
 
82
+ /**
83
+ * KJC-TSK-0849 (ADR 0010, RAG-C): what a verdict's rag block proves — the
84
+ * session asked the RAG about every source ("proved"), the diff was
85
+ * "docsOnly", a human "granted" the rule, the tree runs with "noHarness"
86
+ * (no session ledger can exist), or "unproved": an approved verdict for
87
+ * code whose block covers nothing, or was never written. A pipeline verdict
88
+ * carries no block (its warn is sealed at commit) and a verdict older than
89
+ * the requirement has none either: neither is retroactive.
90
+ */
91
+ export function ragProof(v) {
92
+ const r = v?.rag;
93
+ if (!r || v.verdict !== "approved" || !r.mode) return null;
94
+ if (r.mode === "docs-only") return "docsOnly";
95
+ if (r.mode === "granted") return "granted";
96
+ if (r.mode === "no-harness") return "noHarness";
97
+ // A pass proves something only with real coverage: both lists present,
98
+ // at least one source covered, none uncovered. Anything else is a claim.
99
+ const covered = Array.isArray(r.covered) ? r.covered : null;
100
+ const uncovered = Array.isArray(r.uncovered) ? r.uncovered : null;
101
+ if (r.mode === "pass" && covered?.length > 0 && uncovered?.length === 0) return "proved";
102
+ return "unproved";
103
+ }
104
+
77
105
  export function formatMethodStats(s) {
78
106
  const sonar = s.sonar ? ` · sonar proof: ${s.sonar.proved} proved, ${s.sonar.docsOnly} docs-only, ${s.sonar.granted} granted, ${s.sonar.unproved} unproved` : "";
79
- return `commits with card ref ${s.commits.withCard}/${s.commits.total} · verdict workspaces root ${s.verdicts.root}/${s.verdicts.stamped || 0} stamped (${s.verdicts.total} total) · source commits without tests ${s.testless.offenders}/${s.testless.sampled}${sonar}`;
107
+ const rag = s.rag ? ` · rag proof: ${s.rag.proved} proved, ${s.rag.docsOnly} docs-only, ${s.rag.granted} granted, ${s.rag.noHarness} no-harness, ${s.rag.unproved} unproved` : "";
108
+ return `commits with card ref ${s.commits.withCard}/${s.commits.total} · verdict workspaces root ${s.verdicts.root}/${s.verdicts.stamped || 0} stamped (${s.verdicts.total} total) · source commits without tests ${s.testless.offenders}/${s.testless.sampled}${sonar}${rag}`;
80
109
  }
81
110
 
82
111
  function createMethodCheck() {
@@ -92,6 +121,10 @@ function createMethodCheck() {
92
121
  if (stats.sonar.unproved > 0) {
93
122
  return { ok: false, severity: "fail", detail: `${stats.sonar.unproved} approved verdict(s) without sonar proof — ${detail}` };
94
123
  }
124
+ // KJC-TSK-0849 (ADR 0010): same for code the session never asked the RAG about.
125
+ if (stats.rag.unproved > 0) {
126
+ return { ok: false, severity: "fail", detail: `${stats.rag.unproved} approved verdict(s) without rag proof — ${detail}` };
127
+ }
95
128
  const drought = stats.commits.total >= 5 && stats.commits.withCard / stats.commits.total < 0.5;
96
129
  if (drought) {
97
130
  return { ok: false, severity: "warn", detail: `most recent commits carry no card reference — ${detail}` };
@@ -102,5 +135,7 @@ function createMethodCheck() {
102
135
  }
103
136
 
104
137
  export function getMethodChecks() {
105
- return [createMethodCheck()];
138
+ // KJC-TSK-0850 (ADR 0010): a source the RAG index cannot see is a defect
139
+ // of the method, not a preference — same family as sonar proof.
140
+ return [createMethodCheck(), createRagCoverageCheck()];
106
141
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * KJC-TSK-0850 (ADR 0010, RAG-D) — `kj check` / `kj doctor` item: the RAG
3
+ * index covers every source of the project and is not older than HEAD.
4
+ * Red only when an index EXISTS and is incomplete or stale: a machine with
5
+ * no index yet (CI, a fresh clone) gets the command, not a failure.
6
+ */
7
+ import { openVecStore } from "../rag/vec-store.js";
8
+ import { ragIndexCoverage } from "../rag/coverage.js";
9
+ import { STRATEGY } from "./types.js";
10
+
11
+ export function createRagCoverageCheck({ openStore = (dim) => openVecStore({ dim }) } = {}) {
12
+ return {
13
+ name: "rag-coverage",
14
+ label: "RAG index coverage (ADR 0010)",
15
+ strategy: STRATEGY.NONE,
16
+ async detect({ config = {}, projectDir = process.cwd() } = {}) {
17
+ const db = openStore(config?.rag?.embedder?.dim || 768);
18
+ try {
19
+ let c;
20
+ try {
21
+ c = await ragIndexCoverage(projectDir, { db });
22
+ } catch (err) {
23
+ // Corrupt store, unreachable stamp, git failure: a failing check,
24
+ // never a silent pass. The only tolerated non-red state is "no index".
25
+ return { ok: false, severity: "fail", detail: `rag coverage could not be evaluated: ${err.message}` };
26
+ }
27
+ if (c.absent) {
28
+ return { ok: true, severity: "info", detail: `no RAG index for ${c.project} yet (${c.total} sources) — kj rag index --with-sources` };
29
+ }
30
+ if (!c.lastIndexedCommit) {
31
+ // Chunks without a commit stamp: nothing says how old they are, so
32
+ // freshness cannot be established — that is a defect, not a pass.
33
+ return { ok: false, severity: "fail", detail: `the RAG index of ${c.project} carries no commit stamp, freshness unknown (${c.indexed}/${c.total} sources) — kj rag index --with-sources` };
34
+ }
35
+ const gaps = [...c.missing, ...c.stale];
36
+ if (gaps.length > 0) {
37
+ // --since only re-embeds files CHANGED since the stamp: a source the
38
+ // store never saw needs the full walk.
39
+ const stamp = c.lastIndexedCommit.slice(0, 9);
40
+ const fix = c.missing.length === 0 ? `kj rag index --since ${stamp}` : "kj rag index --with-sources";
41
+ const sample = gaps.slice(0, 3).join(", ") + (gaps.length > 3 ? ", …" : "");
42
+ return { ok: false, severity: "fail", detail: `${c.missing.length} source(s) outside the RAG index, ${c.stale.length} stale (${sample}) — ${fix}` };
43
+ }
44
+ return { ok: true, severity: "info", detail: `${c.indexed}/${c.total} sources indexed at ${c.lastIndexedCommit.slice(0, 9)}` };
45
+ } finally {
46
+ db.close();
47
+ }
48
+ },
49
+ };
50
+ }
@@ -6,14 +6,22 @@
6
6
 
7
7
  import { checkHarden } from "../harden/check.js";
8
8
  import { collectMethodStats, formatMethodStats } from "../checks/method.js";
9
+ import { createRagCoverageCheck } from "../checks/rag-coverage.js";
9
10
  import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
10
11
  import { detectObservedAgents } from "../utils/agent-detect.js";
12
+ import { loadConfig } from "../config.js";
11
13
 
12
14
  export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
13
15
  const result = await checkHarden({ projectDir, profile });
14
16
  // KJC-TSK-0689 (MG-D): method adherence is VISIBILITY, not a gate — it
15
17
  // rides along in check output but never affects the exit code.
16
18
  const method = await collectMethodStats({ projectDir }).catch(() => null);
19
+ // KJC-TSK-0850 (ADR 0010, RAG-D): index coverage IS a gate — an index that
20
+ // exists but misses or lags a source turns check red; no index = the command.
21
+ const { config } = await loadConfig(projectDir).catch(() => ({ config: {} }));
22
+ // An evaluation error (corrupt store, git failure) is a failing check, never
23
+ // a silent pass: the only tolerated non-red state is the explicit "no index".
24
+ const ragCoverage = await createRagCoverageCheck().detect({ config, projectDir }).catch((err) => ({ ok: false, severity: "fail", detail: `rag coverage could not be evaluated: ${err.message}` }));
17
25
  // KJC-TSK-0694: same deal for the MCP inventory — a nudge, never a gate.
18
26
  // KJC-TSK-0728: observed agent CLIs ride the same snapshot as "(cli)"
19
27
  // entries, so a newly-appeared agent binary trips the same drift question.
@@ -25,16 +33,19 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
25
33
  aiSurface = checkAiSurface({ projectDir, extraSurface: clis });
26
34
  } catch { /* inventory is best-effort */ }
27
35
 
36
+ const ok = result.ok && ragCoverage.ok;
28
37
  if (json) {
29
- logger.info?.(JSON.stringify({ ...result, method, aiSurface }));
30
- return result.ok ? 0 : 1;
38
+ logger.info?.(JSON.stringify({ ...result, ok, method, ragCoverage, aiSurface }));
39
+ return ok ? 0 : 1;
31
40
  }
32
41
 
33
42
  logger.info?.(`kj check (${profile})`);
34
43
  for (const c of result.checks) logger.info?.(` ${c.ok ? "✓" : "✗"} ${c.id}: ${c.detail}`);
35
44
  if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
45
+ logger.info?.(` ${ragCoverage.ok ? "✓" : "✗"} rag-coverage: ${ragCoverage.detail}`);
36
46
  if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
37
- if (!result.ok) logger.info?.("Harness drift detected — run `kj harden` to repair.");
38
- else logger.info?.("Harness OK.");
39
- return result.ok ? 0 : 1;
47
+ if (ok) logger.info?.("Harness OK.");
48
+ else if (result.ok) logger.info?.("RAG index drift detected — a gate cannot protect what it cannot see.");
49
+ else logger.info?.("Harness drift detected — run `kj harden` to repair.");
50
+ return ok ? 0 : 1;
40
51
  }
@@ -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;
@@ -123,7 +123,7 @@ const POST_BODY = `#!/usr/bin/env node
123
123
  import { relative } from "node:path";
124
124
  import { spawnSync } from "node:child_process";
125
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"];
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", "KJ_ALLOW_NO_RAG"];
127
127
  let raw = "";
128
128
  process.stdin.on("data", (d) => { raw += d; });
129
129
  process.stdin.on("end", () => {
@@ -146,6 +146,39 @@ process.stdin.on("end", () => {
146
146
  if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
147
147
  save(state);
148
148
  };
149
+ // KJC-TSK-0847 (ADR 0010, RAG-A): the ledger of what the RAG answered in
150
+ // this session — every kj_rag_query (MCP) and kj rag query (CLI) with the
151
+ // repo-relative sources it returned. Neither the CLI nor the MCP know the
152
+ // host's session id: the harness records, they do not. Hits outside the
153
+ // repo (another indexed project) are not ours and are dropped.
154
+ const recordRag = (query, sources) => {
155
+ const state = load();
156
+ const s = session(state, sid);
157
+ s.at = Date.now();
158
+ const hits = [];
159
+ for (const src of sources) {
160
+ const rel = relative(ROOT, String(src)).split(String.fromCharCode(92)).join("/");
161
+ if (!rel || rel.startsWith("..") || rel.startsWith("/") || hits.includes(rel)) continue;
162
+ hits.push(rel);
163
+ }
164
+ (s.rag_queries ||= []).push({ ts: Date.now(), text: String(query || "").slice(0, 200), hits });
165
+ const all = (s.rag_hits ||= []);
166
+ for (const h of hits) if (!all.includes(h)) all.push(h);
167
+ save(state);
168
+ };
169
+ // Sources are read STRUCTURALLY, never through the stringified response:
170
+ // a JSON document (the MCP {content:[{text}]} parts, the CLI --json array)
171
+ // is parsed and its hits[].source / [].source collected.
172
+ const sourcesOf = (doc) => {
173
+ const list = Array.isArray(doc) ? doc : Array.isArray(doc?.hits) ? doc.hits : [];
174
+ return list.map((h) => h?.source).filter((x) => typeof x === "string");
175
+ };
176
+ const parseJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
177
+ if (/__kj_rag_query$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
178
+ const parts = Array.isArray(response?.content) ? response.content : [];
179
+ recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))));
180
+ process.exit(0);
181
+ }
149
182
  if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
150
183
  // The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
151
184
  // stringified, the inner quotes are ESCAPED — match both forms (found live:
@@ -171,6 +204,18 @@ process.stdin.on("end", () => {
171
204
  }
172
205
  if (tool === "Bash") {
173
206
  const cmdText = String(input.command || "");
207
+ // CLI form of the ledger: the human output lists one hit per line as
208
+ // "[kind · label · score=…] <path>"; --json prints an array with "source".
209
+ const ragCmd = /kj +rag +query/.exec(cmdText);
210
+ if (ragCmd) {
211
+ const out = typeof response === "string" ? response : String(response.stdout || "") + String(response.stderr || "");
212
+ const fromLines = out.split(String.fromCharCode(10))
213
+ .filter((l) => l.includes("score=") && l.includes("] "))
214
+ .map((l) => l.slice(l.lastIndexOf("] ") + 2).trim())
215
+ .filter(Boolean);
216
+ recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))));
217
+ process.exit(0);
218
+ }
174
219
  // gh prints "merged pull request #N" only on a TTY: under a tool call the
175
220
  // success line is ABSENT (found live on the first dogfood merge). The
176
221
  // authoritative signal is the PR state itself: one gh pr view per merge.
@@ -250,7 +295,7 @@ if (process.argv.includes("--status")) {
250
295
  const sessions = Object.entries(st.sessions || {});
251
296
  if (!sessions.length) console.log("sentinel: sin actividad registrada en esta sesion");
252
297
  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 : ""));
298
+ 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
299
  for (const x of violations(s, branch)) console.log(" ROJO: " + x);
255
300
  }
256
301
  process.exit(0);
@@ -835,6 +880,26 @@ process.stdin.on("end", () => {
835
880
  : "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
881
  process.exit(2);
837
882
  }
883
+ // KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
884
+ // zone before the session touches it. Covered = the session ledger
885
+ // (RAG-A) holds this file or a sibling of its directory; a NEW file only
886
+ // needs the session to have consulted at all. Same file set as card-first.
887
+ const rs = load().sessions?.[sid] || {};
888
+ const hits = rs.rag_hits || [];
889
+ const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
890
+ const fresh = !existsSync(String(file));
891
+ // A path outside this tree is another lane's business (the lane guard
892
+ // 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);
894
+ if (!covered) {
895
+ if (escOn("KJ_ALLOW_NO_RAG")) {
896
+ // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
897
+ if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
898
+ } else {
899
+ 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"));
900
+ process.exit(2);
901
+ }
902
+ }
838
903
  }
839
904
  }
840
905
  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",
@@ -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
+ }
@@ -0,0 +1,118 @@
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
+ // 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 };
49
+ // A harness that does not match the installed kj (edited, emptied, or older
50
+ // than the ledger) cannot vouch for anything: fail closed, like the tamper
51
+ // check does, until the human regenerates it.
52
+ if (ledger?.harness === true && ledger?.verified === false) {
53
+ 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}` };
54
+ }
55
+ if (!ledger?.available) {
56
+ 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}` };
57
+ }
58
+ const hits = ledger.hits || [];
59
+ const queries = (ledger.queries || []).length;
60
+ const fresh = new Set(newFiles);
61
+ const answered = (f) => hits.includes(f) || hits.some((h) => dirOf(h) === dirOf(f)) || (fresh.has(f) && queries > 0);
62
+ const covered = sources.filter(answered);
63
+ const uncovered = sources.filter((f) => !answered(f));
64
+ const staged = new Set(stagedFiles);
65
+ const twinsUntouched = hits.filter((h) => !staged.has(h));
66
+ const evidence = { queries, covered, uncovered, twinsUntouched };
67
+ if (uncovered.length > 0) {
68
+ const list = `${uncovered.slice(0, 5).join(", ")}${uncovered.length > 5 ? "…" : ""}`;
69
+ 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}` };
70
+ }
71
+ return { ok: true, mode: "pass", sources, ...evidence };
72
+ }
73
+
74
+ /**
75
+ * The pre-commit side (`kj review --check`): the verdict's `rag` block is the
76
+ * evidence, recomputed against the CURRENT staged sources like the sonar
77
+ * block — the diff hash pins the file set, so a source missing from
78
+ * `covered` was never answered about, whatever the block claims.
79
+ */
80
+ export function checkRagVerdict({ config = {}, stagedFiles = [], rag, harness = true, verified = true, mismatched = [], standingExceptions = [], now = new Date() }) {
81
+ const { sources } = sourceFilesOf(config, stagedFiles);
82
+ if (sources.length === 0) return { ok: true, mode: "docs-only" };
83
+ const grant = liveGrant(standingExceptions, now);
84
+ if (grant) return { ok: true, mode: "granted", grant, sources };
85
+ if (!harness) return { ok: true, mode: "no-harness", sources };
86
+ const head = `The RAG must have answered about code before it is committed (${sources.length} staged source${sources.length === 1 ? "" : "s"})`;
87
+ if (!verified) {
88
+ 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
+ }
90
+ if (!rag) {
91
+ return { ok: false, mode: "block", sources, reason: `${head} — the verdict carries no rag block; run \`kj review --staged\` again. ${GRANT_HINT}` };
92
+ }
93
+ // The block's mode is a claim, never an authorization: a grant or a missing
94
+ // harness only count when they hold NOW (checked above). So the block can
95
+ // only prove coverage — a "granted" or "no-harness" it recorded has lapsed.
96
+ if (rag.mode !== "pass" || !Array.isArray(rag.covered)) {
97
+ 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}` };
98
+ }
99
+ const coveredSet = new Set(rag.covered);
100
+ const uncovered = sources.filter((f) => !coveredSet.has(f));
101
+ if (uncovered.length > 0) {
102
+ const list = `${uncovered.slice(0, 5).join(", ")}${uncovered.length > 5 ? "…" : ""}`;
103
+ 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}` };
104
+ }
105
+ return { ok: true, mode: "pass", sources };
106
+ }
107
+
108
+ /** The `rag` block stored in the verdict, bound to the diff hash like `sonar`. */
109
+ export function ragBlock(req, ledger) {
110
+ return {
111
+ mode: req.mode,
112
+ sessionId: ledger?.sessionId || null,
113
+ queries: req.queries ?? (ledger?.queries || []).length,
114
+ covered: req.covered || [],
115
+ uncovered: req.uncovered || [],
116
+ twinsUntouched: req.twinsUntouched || [],
117
+ };
118
+ }
@@ -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),