karajan-code 4.29.1 → 4.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.29.1",
3
+ "version": "4.31.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -32,7 +32,11 @@ const CLAUDE_JSON_PATH = path.join(os.homedir(), ".claude.json");
32
32
  // KJC-BUG-0179 (#1730): a home INSIDE the npm package is the old default
33
33
  // this script wrote itself — wiped on every reinstall, never where `kj init`
34
34
  // stores kj.config.yml. Such a value is stale, not a user choice.
35
- const isPackageDirHome = (home) => /node_modules[\\/]karajan-code[\\/]\.karajan[\\/]?$/.test(home);
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");
36
40
 
37
41
  /** The home a previous registration carried, if the user (not this script) set it. */
38
42
  function homeFromExistingEntry(entry) {
@@ -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
+ }
@@ -34,6 +34,7 @@ export function registerPipeline(program, { pkgVersion }) {
34
34
  .option("--no-squeezr", "Skip the Squeezr auto-install (context compression). Karajan still runs but burns more tokens")
35
35
  .option("--no-qmd", "Skip the QMD auto-install + collection registration (semantic wiki over docs/, .reviews/ and plans/)")
36
36
  .option("--no-harden", "Skip the quality harness (git hooks, lint/commit config, CI gates, agent guidelines)")
37
+ .option("--no-sonar", "Do not start the SonarQube container (Docker). The review gate still needs it: start it later with 'kj sonar start'")
37
38
  .option("--json", "Emit a machine-readable summary of what init did (AB-B: for host agents)")
38
39
  .action(async (flags) => {
39
40
  await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
@@ -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
  }
@@ -115,6 +115,18 @@ export async function envInstallCommand({ config = null, logger = null, flags =
115
115
  try {
116
116
  const h = await hardenCommand({ projectDir, logger: console });
117
117
  if (h?.ok === false) throw new Error(h.error || "kj harden failed");
118
+ // KJC-BUG-0188: a headless harden never binds an identity by itself, and
119
+ // it must not. Blocking here beats the Sentinel denying every git and gh
120
+ // call later, with nothing on screen tying that back to this install.
121
+ if (h?.identityPending) {
122
+ result.exitCode = PENDING_EXIT_CODE;
123
+ result.pendingBlock = renderPendingBlock(
124
+ [{ tool: "identity", action: "needs-user", reason: h.identityPending, command: h.identityCommand }],
125
+ { retry: "kj env install", why: "this clone has no declared identity, and kj never binds one on its own" },
126
+ );
127
+ console.log(result.pendingBlock);
128
+ return result;
129
+ }
118
130
  await reviewGateCommand({ config, flags: { installGate: true } });
119
131
  console.log("✓ enforcement active: git hooks + cross-AI verdict gate — a commit outside the method is rejected, not narrated");
120
132
  } catch (err) {
@@ -13,6 +13,7 @@ import { spawn } from "node:child_process";
13
13
  import { checkBinary } from "../utils/agent-detect.js";
14
14
  import { createCliAskQuestion } from "../utils/cli-ask-question.js";
15
15
  import { envInstallCommand } from "./env.js";
16
+ import { ensureGitRepo } from "./init.js";
16
17
  import { boardCommand } from "./board.js";
17
18
  // The interactive launchers a muggle can live inside (v1: the two the epic
18
19
  // names). Auth heuristics are the same cheap file checks reviewer-fallback
@@ -44,7 +45,7 @@ export function buildGoPrompt() {
44
45
  ].join("\n");
45
46
  }
46
47
  async function defaultPrepare({ config, logger }) {
47
- await envInstallCommand({ config, logger, flags: { yes: true } });
48
+ return envInstallCommand({ config, logger, flags: { yes: true } });
48
49
  }
49
50
  export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
50
51
  const port = config.hu_board?.port || 4000;
@@ -95,7 +96,21 @@ export async function goCommand({ config = {}, logger = console, flags = {}, dep
95
96
  // Prepare ONCE: decisions already taken are never re-asked.
96
97
  if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
97
98
  logger.info?.("Preparando tu proyecto (solo la primera vez)…");
98
- await (deps.prepare ?? defaultPrepare)({ config, logger });
99
+ // KJC-BUG-0191: empezar en una carpeta vacía es lo natural, y kj env
100
+ // install se niega a correr sin repositorio. Lo creamos nosotros.
101
+ if (!ensureGitRepo({ projectDir, logger })) {
102
+ logger.error?.("No he podido preparar el control de versiones de tu proyecto (git). Instálalo y vuelve a escribir: kj go");
103
+ process.exitCode = 1;
104
+ return 1;
105
+ }
106
+ const prepared = await (deps.prepare ?? defaultPrepare)({ config, logger });
107
+ // Algo depende de tus manos (arriba tienes el detalle). Sin eso el
108
+ // proyecto quedaría a medias, así que paramos aquí en vez de seguir.
109
+ if (prepared?.exitCode) {
110
+ logger.error?.("Falta una cosa que solo puedes hacer tú, la tienes justo arriba. Cuando la hagas, vuelve a escribir: kj go");
111
+ process.exitCode = 1;
112
+ return 1;
113
+ }
99
114
  }
100
115
  const prompt = (deps.prompt ?? buildGoPrompt)();
101
116
  // --window (MGL-E, ADR 0008): la conversación vive DENTRO del board — el
@@ -200,7 +200,10 @@ export async function hardenCommand({
200
200
  if (!dryRun) {
201
201
  const id = await ensureIdentity({ projectDir, logger });
202
202
  out.identity = id.declared ? id.identity : null;
203
- if (id.pending) out.identityPending = id.pending;
203
+ if (id.pending) {
204
+ out.identityPending = id.pending;
205
+ out.identityCommand = id.command;
206
+ }
204
207
  }
205
208
  // KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
206
209
  // del supervisor con procedencia sellada. Acto humano — el guard vive en
@@ -121,6 +121,13 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
121
121
  console.warn(`⚠ possible duplicate${plural} — check before working it:\n${candidates}`);
122
122
  }
123
123
  const plan = await backlogPlan(projectDir);
124
+ /** Lowest free HU-#### in this plan — ids are permanent, never reused. */
125
+ const nextHuRef = (p) => {
126
+ const taken = new Set((p.hus || []).map((h) => h.short_id).filter(Boolean));
127
+ let n = 1;
128
+ while (taken.has(`HU-${String(n).padStart(4, "0")}`)) n += 1;
129
+ return `HU-${String(n).padStart(4, "0")}`;
130
+ };
124
131
  // KJC-TSK-0669 (absolute rule): cards are permanent. The delete-and-
125
132
  // recreate "fix" an agent improvises loses history and duplicates ids —
126
133
  // refuse it with the norm spelled out.
@@ -145,7 +152,12 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
145
152
  .flatMap((s) => String(s).split("\n")).map((s) => s.trim()).filter(Boolean);
146
153
  const hu = addHu(plan, {
147
154
  title,
148
- short_id: flags.id || null,
155
+ // KJC-BUG-0189: the card-first gate demands a branch that references a
156
+ // card, and it is this command the gate sends the agent to. The
157
+ // canonical plan id (hu_plan-<stamp>-<rand>_001) does not match
158
+ // CARD_REF_RE, so without an explicit --id the card kj just created
159
+ // could not satisfy kj's own gate. Sequential HU-#### does.
160
+ short_id: flags.id || nextHuRef(plan),
149
161
  acceptance_criteria: [...splitLines(flags.ac), ...splitLines(flags.criteria)],
150
162
  acceptance_tests: splitLines(flags.tests),
151
163
  scope: flags.scope || null,