@karajan-family/code 5.0.3 → 5.1.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-family/code",
3
- "version": "5.0.3",
3
+ "version": "5.1.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",
@@ -33,6 +33,7 @@ export function formatDeterministicSummary(ctx) {
33
33
  const lines = ["## Deterministic Findings", ""];
34
34
  lines.push("These are the rule-based findings (no LLM). They cost zero tokens and are always factually accurate. The interactive prompt below asks if you want to extend this with an LLM analysis (which will cost tokens).");
35
35
  lines.push("");
36
+ lines.push(...formatCoverageBlock(auditCoverage(ctx)));
36
37
 
37
38
  if (ctx.stack) lines.push(...formatStackBlock(ctx.stack));
38
39
  if (ctx.basalCost) lines.push(...formatBasalCostBlock(ctx.basalCost, ctx.growthDelta));
@@ -51,6 +52,31 @@ export function formatDeterministicSummary(ctx) {
51
52
  return lines.join("\n");
52
53
  }
53
54
 
55
+ // KJC-TSK-1381 (#3208): a green audit that skipped half its checks must not read as complete.
56
+ const CHECK_NAMES = {
57
+ sonarFindings: "Sonar", osvFindings: "osv-scanner", semgrepFindings: "semgrep",
58
+ injectionFindings: "prompt-injection", infraFindings: "checkov", npmSignatures: "npm signatures",
59
+ circularDeps: "madge", deadExports: "knip", aiSlop: "ai-slop", envKeys: "env keys", webperf: "webperf",
60
+ };
61
+
62
+ /** Checks attempted (a section skipped by a flag is null and does not count), and those that did not run. */
63
+ export function auditCoverage(ctx) {
64
+ const attempted = Object.entries(CHECK_NAMES).filter(([key]) => ctx[key]);
65
+ const notRun = attempted.filter(([key]) => ctx[key].available === false)
66
+ .map(([key, name]) => ({ name, reason: ctx[key].reason || "did not run" }));
67
+ return { ran: attempted.length - notRun.length, total: attempted.length, notRun };
68
+ }
69
+
70
+ function formatCoverageBlock({ ran, total, notRun }) {
71
+ const lines = [`**Coverage:** ${ran} of ${total} checks ran.`];
72
+ for (const n of notRun) lines.push(`- not run, ${n.name}: ${n.reason}`);
73
+ if (notRun.some((n) => /not installed|not found/i.test(n.reason))) {
74
+ lines.push("- Install the missing tools with `kj install-tools`: until then this audit covers less than it can.");
75
+ }
76
+ lines.push("");
77
+ return lines;
78
+ }
79
+
54
80
  const MAX_SAMPLE_ENV_KEYS = 8;
55
81
 
56
82
  // KJC-TSK-0845: the same key resolved with two rules in two modules, and a
@@ -87,6 +87,7 @@ export function registerPipeline(program, { pkgVersion }) {
87
87
  .option("--position <text>", "Why the brain disagrees with the reviewer")
88
88
  .option("--position-file <path>", "Read the position from a file (no quoting to get right)")
89
89
  .option("--range <range>", "Arbitrate a git range instead of the staged diff")
90
+ .option("--finding <id>", "Arbitrate only this finding of the verdict; the others keep blocking")
90
91
  .action(async (flags) => {
91
92
  await withConfig(pkgVersion, "solomon", flags, async ({ config, logger }) => {
92
93
  await solomonCommand({ config, logger, flags });
@@ -176,8 +176,11 @@ export async function solomonCommand({ config, logger = null, flags = {} }) {
176
176
  process.exitCode = 1;
177
177
  return { ruling: "reject", reasoning: "security-class policy denial — not arbitrable" };
178
178
  }
179
- const res = await runSolomonArbitration({ diff, position, config, logger, projectDir });
180
- if (res.ruling === "approve") {
179
+ const res = await runSolomonArbitration({ diff, position, config, logger, projectDir, findingId: flags.finding || null });
180
+ if (res.ruling === "approve" && res.remaining?.length > 0) {
181
+ // KJC-TSK-1379: one finding arbitrated; the rest still block the gate.
182
+ console.log(`⚖ Solomon (${res.solomon}) rules for the brain on ${flags.finding}; still blocking: ${res.remaining.join(", ")}.`);
183
+ } else if (res.ruling === "approve") {
181
184
  console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
182
185
  } else {
183
186
  const who = res.solomon ? ` (${res.solomon})` : "";
@@ -74,6 +74,7 @@ const B = {
74
74
  ...(config?.method_gates?.mutation
75
75
  ? ["Green is not proof: run `kj mutate --since <base>` after the suite passes and BEFORE review — kill surviving mutants (weak asserts) first."]
76
76
  : []),
77
+ "Once green, simplify your change before `kj review --staged`: same behavior, no test weakened, within the line budget. The verdict binds to the exact diff, so a cleanup after it costs a second review.",
77
78
  ],
78
79
  "green suite + the list of new/changed tests and what each one pins.");
79
80
  },
@@ -64,10 +64,10 @@ Invariants (the git gates enforce these — they are not suggestions):
64
64
  \`kj_rag_query\` tool: reach for it before grepping by hand. Questions about
65
65
  the project's docs, notes, ADRs or plans: ask qmd (its MCP) first.
66
66
  - ${trackingLine(stateBackend, boardName)}
67
- - Tests prove behavior: the failing test exists first (TDD), and the suite
68
- is never left red. Green is not proof: with \`method_gates.mutation\`
69
- declared, run \`kj mutate --since <base>\` after green and BEFORE the
70
- review — a surviving mutant is a weak assert; kill it first.
67
+ - Tests prove behavior: failing test first (TDD), suite never left red.
68
+ Green is not proof: with \`method_gates.mutation\` declared, run
69
+ \`kj mutate --since <base>\` and kill surviving mutants (weak asserts); then
70
+ simplify (same behavior, no test weakened). Both BEFORE the review.
71
71
  - Every diff is reviewed by a DIFFERENT AI before it is committed
72
72
  (\`kj review --staged\`): verdicts bind to the exact diff — change the code
73
73
  and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
@@ -35,7 +35,7 @@ const SERENA_INSTRUCTIONS = [
35
35
  * on it; the per-review content (task context + git diff, up to 12 KB
36
36
  * and always different) goes last.
37
37
  */
38
- export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, domainContext = null, projectDir = null, language = "en", provider = null, roleSkillBlock = null }) {
38
+ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, domainContext = null, projectDir = null, language = "en", provider = null, roleSkillBlock = null, factsBlock = null }) {
39
39
  const { body: clippedDiff, note: clipNote } = clipDiff(diff);
40
40
  // KJC-BUG-0138: full diff on purpose — the moved-to side survives clipping.
41
41
  const splitSignal = buildSplitSignal(diff);
@@ -61,7 +61,9 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
61
61
  section("A claim that a tool, platform or API does not support something the diff uses is not a blocking issue on its own: your training data may predate the feature. Block only when the diff or the task context shows it fails; otherwise put it in non_blocking_suggestions.", STABLE),
62
62
  section("Return only one valid JSON object and nothing else.", STABLE),
63
63
  section("JSON schema:", STABLE),
64
- section('{"approved":boolean,"blocking_issues":[{"id":string,"severity":"critical|high|medium|low","category":"security|correctness|performance|style|other","file":string,"line":number,"description":string,"suggested_fix":string}],"non_blocking_suggestions":[string],"summary":string,"confidence":number}', STABLE),
64
+ section('{"approved":boolean,"blocking_issues":[{"id":string,"severity":"critical|high|medium|low","category":"security|correctness|performance|style|other","file":string|null,"line":number|null,"description":string,"suggested_fix":string}],"non_blocking_suggestions":[string],"summary":string,"confidence":number}', STABLE),
65
+ // KJC-TSK-1379 (#3207): a finding that names no line is hard to apply and hard to arbitrate.
66
+ section("Every blocking issue names the file and the line of the diff it is about; use null only for a finding about the whole diff.", STABLE),
65
67
  section(serenaEnabled ? SERENA_INSTRUCTIONS : null, STABLE),
66
68
  section(rtkSnippet, STABLE),
67
69
  section(productContext ? `## Product Context\n${productContext}` : null, STABLE),
@@ -69,6 +71,8 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
69
71
  section(`Review rules:\n${reviewRules}`, STABLE),
70
72
  section(skillSection, STABLE),
71
73
  section(`Task context:\n${task}`, VOLATILE),
74
+ // KJC-TSK-1380 (#3207): facts computed without a model, before the diff they describe.
75
+ section(factsBlock || null, VOLATILE),
72
76
  section(`Git diff:\n${clippedDiff}`, VOLATILE),
73
77
  section(clipNote, VOLATILE),
74
78
  section(splitSignal, VOLATILE),
@@ -0,0 +1,110 @@
1
+ /**
2
+ * KJC-TSK-1380 (#3207): facts about a diff computed without a model, each with its
3
+ * file:line where it has one. The reviewer starts from them and keeps the judgement;
4
+ * they inform, they never block on their own.
5
+ */
6
+ import { scanText, loadPrivacyList } from "../privacy/scan.js";
7
+
8
+ const MAX_SIZE_FACTS = 10;
9
+ const TEST_FILE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$/;
10
+ const CODE_FILE = /\.([cm]?[jt]sx?|py|go|rb|java|rs|php|cs|kt|swift)$/;
11
+ const DYNAMIC_IMPORT = /await\s+import\(\s*["'`]([^"'`]+)["'`]\s*\)/;
12
+ const STATIC_IMPORT = /^\s*import\s[^;]*?from\s+["']([^"']+)["']/gm;
13
+ const DEP_LINE = /^\s*"([^"]+)":\s*"([^"]*)"\s*,?\s*$/;
14
+ const DEP_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
15
+ const NOT_DEPS = new Set(["name", "version", "description", "main", "module", "types", "license", "type", "node", "npm"]);
16
+
17
+ /** Added lines (with their line in the new file) and removed counts, per file. */
18
+ function parseDiff(diff) {
19
+ const files = new Map();
20
+ let current = null;
21
+ let line = 0;
22
+ let name;
23
+ for (const raw of String(diff).split("\n")) {
24
+ if (raw.startsWith("diff --git ")) {
25
+ name = / b\/(.+)$/.exec(raw)?.[1] ?? null;
26
+ current = name ? { added: [], removed: 0 } : null;
27
+ if (current) files.set(name, current);
28
+ line = 1;
29
+ } else if (raw.startsWith("+++ ")) {
30
+ continue; // file header, not an added line (the file was named by "diff --git")
31
+ } else if (raw.startsWith("@@")) {
32
+ line = Number(/\+(\d+)/.exec(raw)?.[1] ?? 1);
33
+ } else if (current && raw.startsWith("+")) {
34
+ current.added.push({ line, text: raw.slice(1) });
35
+ line += 1;
36
+ } else if (current && raw.startsWith("-") && !raw.startsWith("---")) {
37
+ current.removed += 1;
38
+ } else if (current && raw.startsWith(" ")) {
39
+ line += 1;
40
+ }
41
+ }
42
+ return files;
43
+ }
44
+
45
+ function declaredDeps(readFile, name) {
46
+ try {
47
+ const pkg = JSON.parse(readFile(name));
48
+ return new Set(DEP_SECTIONS.flatMap((s) => Object.keys(pkg[s] || {})));
49
+ } catch { return null; } // unreadable: fall back to the shape of the line
50
+ }
51
+
52
+ function dependencyFacts(name, added, readFile) {
53
+ if (!/(^|\/)package\.json$/.test(name)) return [];
54
+ const deps = readFile ? declaredDeps(readFile, name) : null;
55
+ return added.flatMap(({ line, text }) => {
56
+ const m = DEP_LINE.exec(text);
57
+ if (!m) return [];
58
+ const isDep = deps ? deps.has(m[1]) : !NOT_DEPS.has(m[1]) && /^[\^~<>=*\d]|^(npm|workspace|file|git)/.test(m[2]);
59
+ return isDep ? [{ kind: "new-dependency", file: name, line, text: `adds dependency ${m[1]}@${m[2]}` }] : [];
60
+ });
61
+ }
62
+
63
+ function dynamicImportFacts(name, added, readFile) {
64
+ if (!readFile) return [];
65
+ let content;
66
+ try { content = readFile(name); } catch { return []; }
67
+ const staticSpecs = new Set([...content.matchAll(STATIC_IMPORT)].map((m) => m[1]));
68
+ return added.flatMap(({ line, text }) => {
69
+ const spec = DYNAMIC_IMPORT.exec(text)?.[1];
70
+ return spec && staticSpecs.has(spec) ? [{ kind: "redundant-dynamic-import", file: name, line, text: `await import("${spec}") of a module this file already imports statically` }] : [];
71
+ });
72
+ }
73
+
74
+ function secretFacts(name, added, list) {
75
+ return added.flatMap(({ line, text }) => scanText(text, { list, source: name })
76
+ .filter((f) => f.severity === "block" || f.type === "secret-assignment")
77
+ .map((f) => ({ kind: "secret-shape", file: name, line, text: `${f.type}: ${f.masked}` })));
78
+ }
79
+
80
+ /**
81
+ * @param {string} diff unified diff
82
+ * @param {{ readFile?: (path: string) => string, list?: object }} [opts] readFile reads a file as it is after the diff
83
+ */
84
+ export function diffFacts(diff, { readFile = null, list } = {}) {
85
+ const files = parseDiff(diff);
86
+ const names = [...files.keys()];
87
+ const privacy = list ?? loadPrivacyList(); // read once, not per added line
88
+ const facts = [...files]
89
+ .toSorted(([, a], [, b]) => (b.added.length + b.removed) - (a.added.length + a.removed))
90
+ .slice(0, MAX_SIZE_FACTS)
91
+ .map(([name, f]) => ({ kind: "size", file: name, text: `+${f.added.length} / -${f.removed}` }));
92
+ const code = names.filter((n) => CODE_FILE.test(n) && !TEST_FILE.test(n));
93
+ if (code.length > 0 && !names.some((n) => TEST_FILE.test(n))) {
94
+ facts.push({ kind: "no-tests", text: `code changes in ${code.length} file(s) and no test file is touched` });
95
+ }
96
+ for (const [name, f] of files) {
97
+ facts.push(...dependencyFacts(name, f.added, readFile), ...dynamicImportFacts(name, f.added, readFile),
98
+ ...secretFacts(name, f.added, privacy));
99
+ }
100
+ return facts;
101
+ }
102
+
103
+ /** The facts as a prompt block; empty when there are none. */
104
+ export function formatDiffFacts(facts) {
105
+ if (!facts?.length) return "";
106
+ const where = (f) => (f.file ? `${f.file}${f.line ? `:${f.line}` : ""} ` : "");
107
+ return ["## Verified facts (computed without a model)",
108
+ "Checked mechanically on this diff. Start from them; judging what they mean is yours.",
109
+ ...facts.map((f) => `- ${where(f)}${f.text}`)].join("\n");
110
+ }
@@ -17,6 +17,9 @@ import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js
17
17
  import { accountLabel } from "../utils/agent-home.js";
18
18
  import { saveVerdict, diffHash } from "./verdict-store.js";
19
19
  import { summarizeGeneratedFiles } from "./prompt-diff.js";
20
+ import { diffFacts, formatDiffFacts } from "./diff-facts.js";
21
+ import { readFileSync } from "node:fs";
22
+ import { join } from "node:path";
20
23
  import { reportUnparseableVerdict } from "./unparseable-verdict.js";
21
24
  import { detectWorkspace } from "./workspace.js";
22
25
  import { branchOf } from "./correction-rounds.js";
@@ -107,11 +110,14 @@ export async function runOneShotReview({
107
110
  }
108
111
 
109
112
  const { rules } = await resolveReviewProfile({ mode: "standard", projectDir });
113
+ // KJC-TSK-1380 (#3207): facts computed without a model; the reviewer starts from them.
114
+ const facts = diffFacts(diff, { readFile: (p) => readFileSync(join(projectDir || process.cwd(), p), "utf8") });
110
115
  const attempt = async (who, cfg = config) => {
111
116
  const prompt = await buildReviewerPrompt({
112
117
  task: task || "Review the following diff for correctness, security and maintainability.",
113
118
  // KJC-BUG-0347: generated files summarized; the verdict still binds to the full diff.
114
119
  diff: summarizeGeneratedFiles(diff), reviewRules: rules, mode: "standard", provider: who, projectDir, roleSkillBlock: skills,
120
+ factsBlock: formatDiffFacts(facts),
115
121
  });
116
122
  // KJC-TSK-1344: with two subscriptions per CLI, say which account reviews.
117
123
  const account = accountLabel(who, { env });
@@ -139,6 +145,7 @@ export async function runOneShotReview({
139
145
  }
140
146
 
141
147
  const record = await verdictRecord(parsed, { reviewer: activeReviewer, hostAgent, projectDir, sonar, rag, ui });
148
+ if (facts.length > 0) record.facts = facts;
142
149
  return persist ? saveVerdict(projectDir, diff, record) : record;
143
150
  }
144
151
 
@@ -43,7 +43,7 @@ export async function pickThirdParty({ config, hostAgent, reviewerAgent, detectA
43
43
  }
44
44
 
45
45
  export async function runSolomonArbitration({
46
- diff, position, config, logger, projectDir,
46
+ diff, position, config, logger, projectDir, findingId = null,
47
47
  hostAgent = detectHostAgent(),
48
48
  createAgentFn = createAgent,
49
49
  detectAgents = detectAvailableAgents,
@@ -56,8 +56,15 @@ export async function runSolomonArbitration({
56
56
  throw new Error("nothing to arbitrate — there is no rejected verdict for the current diff (run `kj review --staged` first)");
57
57
  }
58
58
 
59
+ // KJC-TSK-1379 (#3207): with findingId only that finding is disputed; the rest keep blocking.
60
+ const allIssues = verdict.issues || [];
61
+ const disputed = findingId ? allIssues.filter((issue) => issue.id === findingId) : allIssues;
62
+ if (findingId && disputed.length === 0) {
63
+ throw new Error(`no finding "${findingId}" in the rejected verdict; its findings are: ${allIssues.map((i) => i.id).join(", ") || "none"}`);
64
+ }
65
+
59
66
  // ABSOLUTE RULE: security findings are never overridable.
60
- const securityIssues = (verdict.issues || []).filter((issue) => securityReason(issue));
67
+ const securityIssues = disputed.filter((issue) => securityReason(issue));
61
68
  if (securityIssues.length > 0) {
62
69
  const named = securityIssues.map(describeSecurityIssue).join("; ");
63
70
  const record = await saveVerdict(projectDir, diff, {
@@ -79,8 +86,9 @@ export async function runSolomonArbitration({
79
86
  "The reviewer REJECTED a diff; the orchestrating brain DISAGREES. Decide who is right.",
80
87
  "Never approve to save time or effort — only if the reviewer's objections are genuinely wrong or immaterial for this diff.",
81
88
  'Return only one JSON object: {"ruling":"approve"|"reject","reasoning":string}. "approve" = the brain is right, the diff may ship as-is. "reject" = the reviewer is right, the brain must fix the issues.',
89
+ ...(findingId ? ["Rule on this one finding only: the reviewer's other findings are not in dispute."] : []),
82
90
  "", "## Reviewer's blocking issues",
83
- JSON.stringify(verdict.issues || [], null, 2),
91
+ JSON.stringify(disputed, null, 2),
84
92
  "", "## Brain's position", position,
85
93
  // KJC-BUG-0347: generated files summarized; the ruling still binds to the full diff.
86
94
  "", "## The diff under dispute", summarizeGeneratedFiles(diff),
@@ -115,12 +123,17 @@ export async function runSolomonArbitration({
115
123
  // for this exact hash and travels with the overriding verdict — without it
116
124
  // `kj review --check` refuses every arbitrated diff.
117
125
  const evidence = { ...(verdict.sonar ? { sonar: verdict.sonar } : {}), ...(verdict.rag ? { rag: verdict.rag } : {}) };
118
- const record = parsed.ruling === "approve"
119
- ? await saveVerdict(projectDir, diff, {
126
+ if (findingId) arbitration.findingId = findingId;
127
+ const remaining = parsed.ruling === "approve" ? allIssues.filter((issue) => !disputed.includes(issue)) : allIssues;
128
+ let record;
129
+ if (parsed.ruling !== "approve") record = await saveVerdict(projectDir, diff, { ...verdict, arbitration });
130
+ else if (remaining.length > 0) record = await saveVerdict(projectDir, diff, { ...verdict, issues: remaining, arbitration });
131
+ else {
132
+ record = await saveVerdict(projectDir, diff, {
120
133
  verdict: "approved", reviewer: `solomon:${solomon}`, host: hostAgent || null,
121
134
  issues: [], summary: `Arbitration overrode ${verdict.reviewer}'s rejection: ${parsed.reasoning || ""}`.trim(), arbitration, ...evidence,
122
- })
123
- : await saveVerdict(projectDir, diff, { ...verdict, arbitration });
135
+ });
136
+ }
124
137
 
125
- return { ruling: parsed.ruling, reasoning: parsed.reasoning || "", solomon, record };
138
+ return { ruling: parsed.ruling, reasoning: parsed.reasoning || "", solomon, record, remaining: remaining.map((issue) => issue.id) };
126
139
  }