karajan-code 4.68.0 → 4.69.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.68.0",
3
+ "version": "4.69.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",
@@ -267,10 +267,12 @@ export function registerPipeline(program, { pkgVersion }) {
267
267
  .option("--lens <lens>", "With --third-party: what the reviewer looks at; qa = test coverage, weak assertions, flakiness, test data")
268
268
  .option("--verifier <provider>", "With --third-party: an AI of another family confirms, downgrades or rejects each finding (#2369)")
269
269
  .option("--verifier-model <model>", "With --verifier: the verifier's model")
270
+ .option("--reviewers <a,b>", "With --third-party: two reviewers of different families review on their own and verify each other (#2623)")
271
+ .option("--reviewer-models <a=model,b=model>", "With --reviewers: each reviewer's model")
270
272
  .action(async (task, flags) => {
271
273
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
272
- if ((flags.verifier || flags.verifierModel) && !flags.thirdParty) {
273
- logger.error("--verifier and --verifier-model work with --third-party only");
274
+ if ((flags.verifier || flags.verifierModel || flags.reviewers || flags.reviewerModels) && !flags.thirdParty) {
275
+ logger.error("--verifier, --verifier-model, --reviewers and --reviewer-models work with --third-party only");
274
276
  process.exitCode = 1;
275
277
  return;
276
278
  }
package/src/cli.js CHANGED
@@ -14,6 +14,10 @@ import { registerStandby } from "./cli/register-standby.js";
14
14
  import { printUpdateNotice } from "./utils/update-check.js";
15
15
  import { printWelcomeScreen } from "./utils/welcome.js";
16
16
  import { migrateKjToKarajan } from "./utils/home-migration.js";
17
+ import { pinGitDiffFormat } from "./utils/git-diff-format.js";
18
+
19
+ // KJC-BUG-0408: every git kj runs prints a/ and b/, whatever the user's gitconfig says.
20
+ pinGitDiffFormat();
17
21
 
18
22
  const PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
19
23
  const PKG_VERSION = JSON.parse(readFileSync(PKG_PATH, "utf8")).version;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * KJC-BUG-0414 (#2606, from #2564): Solomon told the coder to drop a file from the commit as a
3
+ * mirror of another, but the other was a symlink to it: the link would be committed dangling.
4
+ * kj names the in-repo targets of the symlinks in a change, and a condition that takes one of
5
+ * them out of the commit is dropped before it reaches the coder.
6
+ */
7
+ import { lstatSync, readlinkSync } from "node:fs";
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
9
+
10
+ /** The in-repo target of each symlink among `paths` (relative to projectDir). */
11
+ export function symlinkTargets(projectDir, paths) {
12
+ const root = resolve(projectDir);
13
+ return paths.flatMap((link) => {
14
+ try {
15
+ const abs = join(root, link);
16
+ if (!lstatSync(abs).isSymbolicLink()) return [];
17
+ const target = relative(root, resolve(dirname(abs), readlinkSync(abs)));
18
+ const outside = target === ".." || target.startsWith(`..${sep}`) || isAbsolute(target);
19
+ return target && !outside ? [{ link, target: target.split(sep).join("/") }] : [];
20
+ } catch {
21
+ return [];
22
+ }
23
+ });
24
+ }
25
+
26
+ // A `*` matches within one path segment, as in the shell.
27
+ const segmentMatches = (pattern, name) => {
28
+ const parts = pattern.split("*");
29
+ if (parts.length === 1) return pattern === name;
30
+ if (!name.startsWith(parts[0]) || !name.endsWith(parts.at(-1))) return false;
31
+ let at = parts[0].length;
32
+ for (const part of parts.slice(1, -1)) {
33
+ const found = name.indexOf(part, at);
34
+ if (found < 0) return false;
35
+ at = found + part.length;
36
+ }
37
+ return at <= name.length - parts.at(-1).length;
38
+ };
39
+ const globMatches = (pattern, path) => {
40
+ const p = pattern.split("/");
41
+ const t = path.split("/");
42
+ return p.length === t.length && p.every((seg, i) => segmentMatches(seg, t[i]));
43
+ };
44
+ const REMOVES = /\b(unstage|remove|drop|exclude|delete|restore|reset|revert|rm|untrack|quitar|sacar|eliminar|excluir|borrar)\b/i;
45
+ // Quoted strings are one token (a path may hold spaces); bare tokens lose surrounding punctuation.
46
+ const TOKENS = /"([^"]+)"|'([^']+)'|`([^`]+)`|(\S+)/g;
47
+ const tokensOf = (text) => [...String(text).matchAll(TOKENS)]
48
+ .map((m) => (m[1] ?? m[2] ?? m[3] ?? m[4]).replace(/^[([{]+/, "").replace(/[)\]}:;,]+$/, ""));
49
+ // Named literally anywhere in the text (any character git allows), or by a glob token.
50
+ const names = (condition, target) => String(condition).includes(target) || tokensOf(condition).some((t) => globMatches(t, target));
51
+
52
+ /** Splits Solomon's conditions: the ones that would take a symlink's target out of the commit go to `dropped`. */
53
+ export function keepSymlinkTargets(conditions, targets) {
54
+ const takesTargetOut = (condition) => REMOVES.test(condition) && targets.some(({ target }) => names(condition, target));
55
+ const dropped = targets.length ? conditions.filter(takesTargetOut) : [];
56
+ return { kept: conditions.filter((c) => !dropped.includes(c)), dropped };
57
+ }
package/src/mcp/server.js CHANGED
@@ -7,6 +7,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
8
8
  import { tools } from "./tools.js";
9
9
  import { handleToolCall, responseText, enrichedFailPayload } from "./server-handlers.js";
10
+ import { pinGitDiffFormat } from "../utils/git-diff-format.js";
11
+
12
+ // KJC-BUG-0408: every git the MCP server runs prints a/ and b/, whatever the user's gitconfig says.
13
+ pinGitDiffFormat();
10
14
 
11
15
  const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
12
16
  const PKG_PATH = path.resolve(MODULE_DIR, "../../package.json");
@@ -3,12 +3,35 @@ import { addCheckpoint, pauseSession, saveSession } from "../session/store.js";
3
3
  import { setSuggestions } from "../session/mutators.js";
4
4
  import { emitProgress, makeEvent } from "../utils/events.js";
5
5
  import { msg, getLang } from "../utils/messages.js";
6
+ import { listPendingPaths } from "../utils/git.js";
7
+ import { keepSymlinkTargets, symlinkTargets } from "../git/symlink-targets.js";
6
8
 
7
9
  /**
8
10
  * @typedef {import("../types/solomon.js").Conflict} Conflict
9
11
  * @typedef {import("../types/solomon.js").SolomonResult} SolomonResult
10
12
  */
11
13
 
14
+ // KJC-BUG-0414 (#2606): the symlinks among the pending paths, so Solomon never advises taking a
15
+ // link's target out of the commit. Unlistable is said, and Solomon goes on without the fact.
16
+ async function changeSymlinks(config, logger) {
17
+ const projectDir = config.projectDir || process.cwd();
18
+ try {
19
+ return symlinkTargets(projectDir, await listPendingPaths(projectDir));
20
+ } catch (err) {
21
+ logger.warn(`Solomon: the change's symlinks could not be listed (${err.message})`);
22
+ return [];
23
+ }
24
+ }
25
+
26
+ function dropSymlinkTargetConditions(ruling, symlinks, logger) {
27
+ const conditions = ruling?.result?.conditions;
28
+ if (!Array.isArray(conditions) || symlinks.length === 0) return;
29
+ const { kept, dropped } = keepSymlinkTargets(conditions, symlinks);
30
+ if (dropped.length === 0) return;
31
+ ruling.result.conditions = kept;
32
+ logger.warn(`Solomon: dropped ${dropped.length} condition(s) that took a symlink's target out of the commit: ${dropped.join(" | ")}`);
33
+ }
34
+
12
35
  /**
13
36
  * Architectural invariant (DO NOT BREAK):
14
37
  * `invokeSolomon` MUST NOT be called directly from stages, rules or agent
@@ -147,12 +170,15 @@ export async function invokeSolomon({ config, logger, emitter, eventBase, stage,
147
170
  try {
148
171
  // Inject Solomon's own history so it doesn't repeat failed strategies
149
172
  const solomonHistory = extractSolomonHistory(session);
173
+ const symlinks = await changeSymlinks(config, logger);
150
174
  const enrichedConflict = {
151
175
  ...conflict,
152
176
  ...(pendingSuggestions && { hostSuggestions: pendingSuggestions }),
153
- ...(solomonHistory.length > 0 && { previousSolomonRulings: solomonHistory })
177
+ ...(solomonHistory.length > 0 && { previousSolomonRulings: solomonHistory }),
178
+ ...(symlinks.length > 0 && { symlinkTargets: symlinks })
154
179
  };
155
180
  ruling = await solomon.run({ conflict: enrichedConflict });
181
+ dropSymlinkTargetConditions(ruling, symlinks, logger);
156
182
  } catch (err) {
157
183
  logger.warn(`Solomon threw: ${err.message}`);
158
184
  return escalateToHuman({
@@ -50,6 +50,9 @@ export function splitAddedByFile(diff) {
50
50
  out.push(current);
51
51
  continue;
52
52
  }
53
+ // KJC-BUG-0408: a file header in another shape (mnemonic or no prefix) would leave its lines
54
+ // unscanned and the gate passing. A diff that cannot be read fails closed.
55
+ if (line.startsWith("diff --git ")) throw new Error(`cannot read the file headers of this diff (${line}): refusing to scan it as empty`);
53
56
  if (!current) continue;
54
57
  // `+++ b/x` is the header, not content; `+` alone is an added blank line.
55
58
  if (line.startsWith("+") && !line.startsWith("+++")) current.lines.push(line.slice(1));
@@ -0,0 +1,73 @@
1
+ /**
2
+ * KJC-TSK-1247 (#2623): the dual third-party review. Two reviewers of different families review
3
+ * the same range on their own, and each one's findings are verified by the other family (the
4
+ * --verifier of #2369). This module reconciles both into one result: every finding says who
5
+ * found it and how it was verified, the ones both found rank first, the rejected are listed
6
+ * apart (never dropped), and there is one verdict.
7
+ */
8
+
9
+ const RANK = { low: 0, medium: 1, high: 2, critical: 3 };
10
+ const rank = (f) => RANK[f.severity] ?? -1;
11
+ // Within a few lines of each other in the same file, two reviewers are talking about the same thing.
12
+ const NEAR_LINES = 3;
13
+ const sameSpot = (a, b) => a.file === b.file
14
+ && (a.line == null || b.line == null ? a.line == null && b.line == null : Math.abs(a.line - b.line) <= NEAR_LINES);
15
+ const isBlocker = (f) => f.category === "security" || rank(f) >= RANK.high;
16
+ // Security is never overridable; otherwise the other family confirmed it, or both reviewers found it.
17
+ const isConfirmed = (f) => f.category === "security" || f.found_by.length === 2
18
+ || Object.values(f.verification).some((o) => o === "confirmed" || o === "kept_by_rule");
19
+ const where = (f) => `${f.file ?? "?"}:${f.line ?? "?"}`;
20
+
21
+ /** Each side's kept findings, tagged with who found them and how the other family judged them. */
22
+ function tagged(side) {
23
+ const verifier = side.verification?.verifier;
24
+ return side.findings.map((finding, i) => ({
25
+ ...finding,
26
+ found_by: [side.reviewer],
27
+ verification: verifier ? { [verifier]: side.outcomes?.[i] ?? "unverified" } : {},
28
+ }));
29
+ }
30
+
31
+ /** One list: a finding both reviewers made is merged (the higher severity wins) and ranks first. */
32
+ function mergeFindings(a, b) {
33
+ const rest = tagged(b);
34
+ const merged = tagged(a).map((fa) => {
35
+ const at = rest.findIndex((fb) => sameSpot(fa, fb));
36
+ if (at < 0) return fa;
37
+ const [fb] = rest.splice(at, 1);
38
+ const top = rank(fb) > rank(fa) ? fb : fa;
39
+ return { ...top, found_by: [...fa.found_by, ...fb.found_by], verification: { ...fa.verification, ...fb.verification } };
40
+ });
41
+ return [...merged, ...rest].toSorted((x, y) => y.found_by.length - x.found_by.length || rank(y) - rank(x));
42
+ }
43
+
44
+ function verdictOf(sides, findings, coverage) {
45
+ const blockers = findings.filter((f) => isBlocker(f) && isConfirmed(f));
46
+ const summary = sides.map((s) => s.verdict?.summary).filter(Boolean).join("\n");
47
+ if (blockers.length) return { status: "rejected", approved: false, reason: `confirmed blocker(s): ${blockers.map(where).join(", ")}`, summary };
48
+ const rejecting = sides.filter((s) => !s.verdict?.approved).map((s) => s.reviewer);
49
+ if (rejecting.length === 0 && !coverage.truncated) return { status: "approved", approved: true, summary };
50
+ const reason = rejecting.length
51
+ ? `${rejecting.join(" and ")} rejected, but no blocking finding was confirmed by the other family`
52
+ : "not every file was reviewed";
53
+ return { status: "partial", approved: false, reason, summary };
54
+ }
55
+
56
+ /** The two sides (reviewer, model, verdict, coverage, findings, outcomes, verification) as one result. */
57
+ export function reconcileReviews([a, b]) {
58
+ const findings = mergeFindings(a, b);
59
+ const coverage = { ...a.coverage, truncated: Boolean(a.coverage?.truncated || b.coverage?.truncated) };
60
+ const counts = ({ reviewer, verification: v = {} }) => ({
61
+ reviewer, verifier: v.verifier ?? null, confirmed: v.confirmed ?? 0, downgraded: v.downgraded ?? 0,
62
+ unverified: v.unverified ?? 0, kept_by_rule: v.kept_by_rule ?? 0, ...(v.error && { error: v.error }),
63
+ });
64
+ const rejected = [a, b].flatMap((s) => (s.verification?.rejected ?? [])
65
+ .map((r) => ({ ...r, found_by: s.reviewer, rejected_by: s.verification.verifier })));
66
+ return {
67
+ reviewers: [a, b].map((s) => ({ reviewer: s.reviewer, model: s.model ?? null, verdict: s.verdict })),
68
+ verdict: verdictOf([a, b], findings, coverage),
69
+ coverage,
70
+ findings,
71
+ verification: { by: [a, b].map(counts), rejected },
72
+ };
73
+ }
@@ -65,6 +65,9 @@ export function applyVerification(findings, verdicts, { verifier, model = null }
65
65
  if (Number.isInteger(v?.index) && VERDICTS.has(v.verdict) && !byIndex.has(v.index)) byIndex.set(v.index, v);
66
66
  }
67
67
  const kept = [];
68
+ // KJC-TSK-1247: how each kept finding came out, parallel to `kept`, for the dual review.
69
+ const outcomes = [];
70
+ const keep = (finding, outcome) => { kept.push(finding); outcomes.push(outcome); };
68
71
  const rejected = [];
69
72
  let confirmed = 0;
70
73
  let downgraded = 0;
@@ -74,19 +77,19 @@ export function applyVerification(findings, verdicts, { verifier, model = null }
74
77
  const v = byIndex.get(i);
75
78
  if (v && v.verdict !== "confirmed" && isProtected(finding)) {
76
79
  keptByRule += 1;
77
- kept.push(finding);
80
+ keep(finding, "kept_by_rule");
78
81
  } else if (v?.verdict === "rejected") {
79
82
  rejected.push({ finding, reason: oneLine(v.reason) });
80
83
  } else if (v?.verdict === "confirmed") {
81
84
  confirmed += 1;
82
- kept.push(finding);
85
+ keep(finding, "confirmed");
83
86
  } else if (v?.verdict === "downgraded" && isLower(v.severity, finding.severity)) {
84
87
  downgraded += 1;
85
- kept.push({ ...finding, severity: v.severity });
88
+ keep({ ...finding, severity: v.severity }, "downgraded");
86
89
  } else {
87
90
  unverified += 1;
88
- kept.push(finding);
91
+ keep(finding, "unverified");
89
92
  }
90
93
  });
91
- return { findings: kept, verification: { verifier, model, confirmed, downgraded, rejected, unverified, kept_by_rule: keptByRule } };
94
+ return { findings: kept, outcomes, verification: { verifier, model, confirmed, downgraded, rejected, unverified, kept_by_rule: keptByRule } };
92
95
  }
@@ -27,13 +27,13 @@ export function parseVerdicts(text) {
27
27
  }
28
28
 
29
29
  /** Throws when the verifier is the reviewer's family; warns when either family cannot be told. */
30
- export function assertCrossFamily({ reviewer, reviewerModel = null, verifier, verifierModel = null, logger }) {
30
+ export function assertCrossFamily({ reviewer, reviewerModel = null, verifier, verifierModel = null, logger, flag = "--verifier" }) {
31
31
  const theirs = familyOf(reviewer, reviewerModel);
32
32
  const mine = familyOf(verifier, verifierModel);
33
33
  if (theirs && theirs === mine) {
34
- throw new Error(`--verifier ${verifier} is the same family (${mine}) as the reviewer ${reviewer}: it would repeat its blind spots; pick another family`);
34
+ throw new Error(`${flag} ${verifier} is the same family (${mine}) as the reviewer ${reviewer}: it would repeat its blind spots; pick another family`);
35
35
  }
36
- if (!theirs || !mine) logger?.warn?.(`--verifier: the family of ${mine ? reviewer : verifier} cannot be told; make sure it differs from the reviewer's`);
36
+ if (!theirs || !mine) logger?.warn?.(`${flag}: the family of ${mine ? reviewer : verifier} cannot be told; make sure it differs from the reviewer's`);
37
37
  }
38
38
 
39
39
  /** The findings after the verifier, and the verification block. */
@@ -19,6 +19,7 @@ import { thirdPartyTask } from "./untrusted-context.js";
19
19
  import { readTaskFile } from "../utils/task-file.js";
20
20
  import { DIFF_CLIP_CHARS } from "../prompts/diff-clip.js";
21
21
  import { assertCrossFamily, verifyFindings } from "./run-verifier.js";
22
+ import { reconcileReviews } from "./dual-review.js";
22
23
 
23
24
  // KJC-BUG-0378: a reviewer call per batch; past this many, files are left out and named.
24
25
  const MAX_REVIEW_BATCHES = 10;
@@ -36,10 +37,12 @@ const finding = (i) => ({
36
37
  });
37
38
 
38
39
  /** The review as data: verdict, findings, suggestions, security checks and, apart, the house requirements. */
39
- export async function thirdPartyReview({ range, config, projectDir, logger, task = null, gitDiff = gitDiffOf, run = runOneShotReview, requirements = prRequirementChecks, security = prSecurityChecks, sonar = sonarCheck, limit = DIFF_CLIP_CHARS, maxBatches = MAX_REVIEW_BATCHES, sizeLimit = null, verifier = null, verifierModel = null, verify = verifyFindings }) {
40
+ export async function thirdPartyReview({ range, config, projectDir, logger, task = null, gitDiff = gitDiffOf, run = runOneShotReview, requirements = prRequirementChecks, security = prSecurityChecks, sonar = sonarCheck, limit = DIFF_CLIP_CHARS, maxBatches = MAX_REVIEW_BATCHES, sizeLimit = null, verifier = null, verifierModel = null, verify = verifyFindings, reviewers = null }) {
40
41
  // KJC-TSK-1195: a verifier of the reviewer's own family is refused before any token is spent.
41
42
  const reviewerRole = resolveRole(config, "reviewer");
42
43
  if (verifier) assertCrossFamily({ reviewer: reviewerRole.provider, reviewerModel: reviewerRole.model, verifier, verifierModel, logger });
44
+ // KJC-TSK-1248 (#2623): two reviewers of the same family would share their blind spots.
45
+ if (reviewers) assertCrossFamily({ reviewer: reviewers[0].provider, reviewerModel: reviewers[0].model, verifier: reviewers[1].provider, verifierModel: reviewers[1].model, logger, flag: "--reviewers" });
43
46
  const diff = await gitDiff(projectDir, range);
44
47
  // KJC-TSK-1076/1077: the PR's requirements and its security, with no tokens; they never stop the review.
45
48
  // KJC-BUG-0379 (#2263): the house requirements (size, commits, attribution, tests) travel apart:
@@ -51,10 +54,16 @@ export async function thirdPartyReview({ range, config, projectDir, logger, task
51
54
  const provider = reviewerRole.provider || null;
52
55
  const { batches, coverage } = batchDiff(diff, { limit, maxBatches });
53
56
  if (coverage.files.total === 0) throw new Error(`nothing to review — ${range} has no file changes`);
54
- const records = [];
55
- for (const batch of batches) {
56
- records.push(await run({ diff: batch.text, task, config, logger, projectDir, reviewerOverride: provider, persist: false }));
57
+ const reviewWith = async (who, cfg) => {
58
+ const recs = [];
59
+ for (const batch of batches) recs.push(await run({ diff: batch.text, task, config: cfg, logger, projectDir, reviewerOverride: who, persist: false }));
60
+ return recs;
61
+ };
62
+ if (reviewers) {
63
+ const dual = await dualReview({ reviewers, reviewWith, verify, diff, coverage, config, logger });
64
+ return { mode: "third-party", range, ...dual, checks, policyChecks, advice: adviceFor(diff, sizeLimit ?? prSizeLimit({ projectDir, config }).limit) };
57
65
  }
66
+ const records = await reviewWith(provider, config);
58
67
  const findings = records.flatMap((r) => (r.issues ?? []).map(finding));
59
68
  // KJC-TSK-1195 (#2369): with a verifier, the findings are the verified ones and the block says what changed.
60
69
  const verified = verifier ? await verify({ findings, diff, verifier, model: verifierModel, config, logger }) : null;
@@ -73,6 +82,23 @@ export async function thirdPartyReview({ range, config, projectDir, logger, task
73
82
  };
74
83
  }
75
84
 
85
+ /**
86
+ * KJC-TSK-1248 (#2623): both reviewers review the whole range on their own, in parallel, each
87
+ * with its model; each one's findings are verified by the other family; one reconciled result.
88
+ */
89
+ async function dualReview({ reviewers, reviewWith, verify, diff, coverage, config, logger }) {
90
+ const withModel = ({ provider, model }) => ({ ...config, roles: { ...config?.roles, reviewer: { ...config?.roles?.reviewer, provider, model } } });
91
+ const records = await Promise.all(reviewers.map((r) => reviewWith(r.provider, withModel(r))));
92
+ const sides = await Promise.all(reviewers.map(async (me, i) => {
93
+ const other = reviewers[1 - i];
94
+ const found = records[i].flatMap((r) => (r.issues ?? []).map(finding));
95
+ const verified = await verify({ findings: found, diff, verifier: other.provider, model: other.model, config, logger });
96
+ return { reviewer: me.provider, model: me.model, coverage, verdict: verdictOf(records[i], coverage), ...verified };
97
+ }));
98
+ const reconciled = reconcileReviews(sides);
99
+ return { reviewer: reviewers.map((r) => r.provider).join("+"), ...reconciled, suggestions: records.flat().flatMap((r) => r.suggestions ?? []) };
100
+ }
101
+
76
102
  /** KJC-TSK-1101: over the size budget, how to split the PR; advice only, never a finding. */
77
103
  function adviceFor(diff, budget) {
78
104
  const files = splitByFile(diff).filter((f) => !isGenerated(f.file));
@@ -92,6 +118,19 @@ function verdictOf(records, coverage) {
92
118
  // KJC-TSK-1153: flags of the own-work review that this mode would otherwise ignore in silence.
93
119
  const OWN_WORK_FLAGS = { staged: "--staged", check: "--check", installGate: "--install-gate", prune: "--prune", dryRun: "--dry-run", publishPr: "--publish-pr", verifyPr: "--verify-pr", baseRef: "--base-ref", walkedWith: "--walked-with" };
94
120
 
121
+ /** KJC-TSK-1248: --reviewers a,b [--reviewer-models a=model,b=model] as two reviewers, or null. */
122
+ function reviewersOf(flags) {
123
+ if (!flags.reviewers) {
124
+ if (flags.reviewerModels) throw new Error("--reviewer-models needs --reviewers <a,b>");
125
+ return null;
126
+ }
127
+ if (flags.verifier) throw new Error("--reviewers already verifies each reviewer with the other: drop --verifier");
128
+ const providers = String(flags.reviewers).split(",").map((p) => p.trim()).filter(Boolean);
129
+ if (providers.length !== 2) throw new Error("--reviewers takes exactly two providers of different families, e.g. claude,codex");
130
+ const models = Object.fromEntries(String(flags.reviewerModels ?? "").split(",").filter(Boolean).map((pair) => pair.split("=").map((s) => s.trim())));
131
+ return providers.map((provider) => ({ provider, model: models[provider] || null }));
132
+ }
133
+
95
134
  function assertThirdPartyFlags(flags) {
96
135
  const used = Object.keys(OWN_WORK_FLAGS).filter((k) => flags[k]).map((k) => OWN_WORK_FLAGS[k]);
97
136
  if (flags.walked?.length) used.push("--walked");
@@ -120,7 +159,8 @@ export async function thirdPartyReviewCommand({ flags, config, out = process.std
120
159
  const context = flags.taskFile ? await readTask(flags.taskFile, { projectDir }) : flags.task;
121
160
  const task = await lensTask(flags.lens ?? null, thirdPartyTask(context ?? null));
122
161
  if (flags.verifierModel && !flags.verifier) throw new Error("--verifier-model needs --verifier <provider>");
123
- print(await review({ range: flags.range, config, projectDir, logger, task, verifier: flags.verifier ?? null, verifierModel: flags.verifierModel ?? null }));
162
+ const reviewers = reviewersOf(flags);
163
+ print(await review({ range: flags.range, config, projectDir, logger, task, verifier: flags.verifier ?? null, verifierModel: flags.verifierModel ?? null, ...(reviewers && { reviewers }) }));
124
164
  return 0;
125
165
  } catch (e) {
126
166
  print({ error: e.message });
@@ -144,6 +144,12 @@ function buildPrompt({ conflict, task, instructions }) {
144
144
  sections.push(`## Blocking issues\n${issueList}`);
145
145
  }
146
146
 
147
+ // KJC-BUG-0414 (#2606): taking a symlink's target out of the commit leaves the link dangling.
148
+ if (conflict?.symlinkTargets?.length) {
149
+ const links = conflict.symlinkTargets.map(({ link, target }) => `- ${link} -> ${target}`).join("\n");
150
+ sections.push(`## Symlinks in this change\n${links}\nEach target stays in the commit: never propose unstaging, removing or restoring it, or the link is committed dangling.`);
151
+ }
152
+
147
153
  if (task) {
148
154
  sections.push(`## Original task\n${task}`);
149
155
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * KJC-BUG-0408 (#2560): kj reads the diffs git prints for it (privacy, sonar, review, guards),
3
+ * and every parser expects `a/` and `b/`. A user's diff.mnemonicPrefix (c/, i/, w/), noprefix or
4
+ * srcPrefix changed those headers: the suite went red on that machine and the privacy gate saw
5
+ * no file and passed. kj pins the format of the git it runs through the environment (the
6
+ * GIT_CONFIG_COUNT protocol, git >= 2.31), so every child inherits it and no parser depends on
7
+ * the user's gitconfig. The pins are appended after whatever config the environment carries.
8
+ */
9
+
10
+ // KJC-BUG-0415 (#2622): a verdict binds to the hash of the diff text, so every option that
11
+ // changes that text is pinned to git's default too, or the hash kj review --staged records on a
12
+ // machine with diff.algorithm=histogram is not the one CI recomputes.
13
+ const PINS = [
14
+ ["diff.mnemonicPrefix", "false"],
15
+ ["diff.noprefix", "false"],
16
+ ["diff.srcPrefix", "a/"],
17
+ ["diff.dstPrefix", "b/"],
18
+ ["diff.algorithm", "myers"],
19
+ ["diff.context", "3"],
20
+ ["diff.interHunkContext", "0"],
21
+ ["diff.indentHeuristic", "true"],
22
+ ["diff.suppressBlankEmpty", "false"],
23
+ ["diff.renames", "true"],
24
+ ["diff.relative", "false"],
25
+ ["color.diff", "false"],
26
+ ];
27
+ const PINNED = "KJ_GIT_DIFF_PINNED";
28
+
29
+ /** Pin git's diff headers to a/ and b/ for every git that inherits `env`; idempotent. */
30
+ export function pinGitDiffFormat(env = process.env) {
31
+ if (env[PINNED]) return env;
32
+ const count = Number.parseInt(env.GIT_CONFIG_COUNT ?? "0", 10) || 0;
33
+ PINS.forEach(([key, value], i) => {
34
+ env[`GIT_CONFIG_KEY_${count + i}`] = key;
35
+ env[`GIT_CONFIG_VALUE_${count + i}`] = value;
36
+ });
37
+ env.GIT_CONFIG_COUNT = String(count + PINS.length);
38
+ env[PINNED] = "1";
39
+ return env;
40
+ }