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 +1 -1
- package/scripts/postinstall.js +39 -15
- package/scripts/verify-pack-mcp.mjs +76 -0
- package/scripts/verify-pack.mjs +29 -0
- package/src/audit/deterministic-summary.js +24 -0
- package/src/audit/env-key-findings.js +162 -0
- package/src/checks/method.js +37 -2
- package/src/checks/rag-coverage.js +50 -0
- package/src/commands/check.js +16 -5
- package/src/commands/review-gate.js +50 -2
- package/src/harden/sentinel-hooks.js +67 -2
- package/src/harden/workflow-engine.js +6 -1
- package/src/harden/workflow-templates.js +1 -2
- package/src/rag/coverage.js +48 -0
- package/src/review/one-shot-review.js +3 -0
- package/src/review/rag-ledger.js +41 -0
- package/src/review/rag-requirement.js +118 -0
- package/src/review/solomon-arbitration.js +6 -1
- package/src/roles/audit-role.js +12 -2
package/package.json
CHANGED
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
66
|
+
return path.join(os.homedir(), ".karajan");
|
|
47
67
|
}
|
|
48
68
|
|
|
49
|
-
async function
|
|
50
|
-
const claudeJsonPath = path.join(os.homedir(), ".claude.json");
|
|
51
|
-
let config = {};
|
|
69
|
+
async function readClaudeConfig() {
|
|
52
70
|
try {
|
|
53
|
-
|
|
71
|
+
return await readJson(CLAUDE_JSON_PATH);
|
|
54
72
|
} catch {
|
|
55
|
-
|
|
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: {
|
|
87
|
+
env: { ...userEnv, KARAJAN_HOME: kjHome }
|
|
65
88
|
};
|
|
66
89
|
|
|
67
|
-
await writeJson(
|
|
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
|
-
`
|
|
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
|
|
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
|
+
}
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/checks/method.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/check.js
CHANGED
|
@@ -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
|
|
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 (
|
|
38
|
-
else logger.info?.("
|
|
39
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
|
package/src/roles/audit-role.js
CHANGED
|
@@ -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),
|