karajan-code 4.0.0 → 4.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-code",
3
- "version": "4.0.0",
3
+ "version": "4.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",
@@ -27,13 +27,13 @@ export const META_COMMANDS = ["advanced", "help"];
27
27
  * so a newly-registered command can never silently vanish from `kj advanced`.
28
28
  */
29
29
  export const ADVANCED_GROUPS = [
30
- { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "scan"] },
31
- { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard"] },
30
+ { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan"] },
31
+ { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
32
32
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
33
33
  { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar"] },
34
34
  { title: "Sesión / board", commands: ["resume", "report", "board", "undo", "standby"] },
35
35
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
36
- { title: "Mantenimiento", commands: ["clean", "sync", "telemetry"] },
36
+ { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
37
37
  ];
38
38
 
39
39
  /** Flat set of every advanced command name (for fast lookup / filtering). */
@@ -19,7 +19,9 @@ import { checkCommand } from "../commands/check.js";
19
19
  import { mutateCommand } from "../commands/mutate.js";
20
20
  import { hardenCommand } from "../commands/harden.js";
21
21
  import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/telemetry.js";
22
- import { envInstallCommand } from "../commands/env.js";
22
+ import { envInstallCommand, briefCommand } from "../commands/env.js";
23
+ import { agentRunCommand } from "../commands/agent-run.js";
24
+ import { reportIssueCommand } from "../commands/report-issue.js";
23
25
  import { formatAdvancedIndex } from "../commands/advanced.js";
24
26
  import { withConfig } from "./_shared.js";
25
27
 
@@ -126,8 +128,8 @@ export function registerMeta(program, { pkgVersion }) {
126
128
  // orchestrates, Karajan installs the method it must follow.
127
129
  const env = program.command("env").description("Karajan Environment (v4) — playbook for host agents");
128
130
  env.command("install")
129
- .description("Install/refresh the Karajan playbook in CLAUDE.md (Claude) and AGENTS.md (Codex)")
130
- .option("--target <target>", "claude | codex | both", "both")
131
+ .description("Install/refresh the Karajan playbook for any host agent (CLAUDE.md, AGENTS.md, GEMINI.md)")
132
+ .option("--target <target>", "claude | codex | gemini | all", "all")
131
133
  .option("--no-rag", "Skip building the RAG index when the project has none (ENV-E1)")
132
134
  .action(async (flags) => {
133
135
  await withConfig(pkgVersion, "env-install", flags, async ({ config, logger }) => {
@@ -135,6 +137,45 @@ export function registerMeta(program, { pkgVersion }) {
135
137
  });
136
138
  });
137
139
 
140
+ // AB-D (KJC-TSK-0654): the brain's inter-agent bus.
141
+ const agentCmd = program.command("agent").description("Delegate work to another AI agent (the brain's bus)");
142
+ agentCmd.command("run <agent> <task>")
143
+ .description("Run a task on the named agent and print its output (exit 0/1)")
144
+ .option("--json", "Machine-readable output: {ok, agent, output, usage}")
145
+ .option("--timeout-minutes <n>", "Hard timeout for the delegated task")
146
+ .action(async (agent, task, flags) => {
147
+ await withConfig(pkgVersion, "agent-run", flags, async ({ config, logger }) => {
148
+ await agentRunCommand({ agent, task, config, logger, flags });
149
+ });
150
+ });
151
+
152
+ // AB-F (KJC-TSK-0655): self-healing — the brain files kj frictions upstream.
153
+ program
154
+ .command("report-issue")
155
+ .description("Report a kj bug/friction to the public repo (sanitized; publishing needs --publish)")
156
+ .requiredOption("--title <text>", "One-line summary")
157
+ .option("--description <text>", "What happened and what you expected")
158
+ .option("--command <cmd>", "The kj command involved")
159
+ .option("--error <text>", "The error output (it will be sanitized)")
160
+ .option("--publish", "Create the issue via the gh CLI (confirm with your user first)")
161
+ .option("--force", "Skip the similar-issues check")
162
+ .option("--json", "Machine-readable output")
163
+ .action(async (flags) => {
164
+ await reportIssueCommand({ logger: console, flags });
165
+ });
166
+
167
+ // AB-C (KJC-TSK-0652): role briefs for the brain (any host agent).
168
+ program
169
+ .command("brief")
170
+ .description("Show the distilled method of a pipeline role (for the host agent to execute)")
171
+ .argument("[role]", "triage | planner | researcher | architect | tester | security | audit")
172
+ .option("--json", "Machine-readable output")
173
+ .action(async (role, flags) => {
174
+ await withConfig(pkgVersion, "brief", flags, async ({ config }) => {
175
+ briefCommand({ config, flags, role });
176
+ });
177
+ });
178
+
138
179
  const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
139
180
  rag.command("index")
140
181
  .description("Index plans + onboarding (and optionally project sources) into the local vector store")
@@ -3,6 +3,7 @@ import { ollamaStartCommand, ollamaStopCommand, ollamaStatusCommand, ollamaPullC
3
3
  import { configCommand } from "../commands/config.js";
4
4
  import { codeCommand } from "../commands/code.js";
5
5
  import { reviewCommand } from "../commands/review.js";
6
+ import { reviewGateCommand, solomonCommand } from "../commands/review-gate.js";
6
7
  import { scanCommand } from "../commands/scan.js";
7
8
  import { installToolsCommand } from "../commands/install-tools.js";
8
9
  import { doctorCommand } from "../commands/doctor.js";
@@ -31,6 +32,7 @@ export function registerPipeline(program, { pkgVersion }) {
31
32
  .option("--no-squeezr", "Skip the Squeezr auto-install (context compression). Karajan still runs but burns more tokens")
32
33
  .option("--no-qmd", "Skip the QMD auto-install + collection registration (semantic wiki over docs/, .reviews/ and plans/)")
33
34
  .option("--no-harden", "Skip the quality harness (git hooks, lint/commit config, CI gates, agent guidelines)")
35
+ .option("--json", "Emit a machine-readable summary of what init did (AB-B: for host agents)")
34
36
  .action(async (flags) => {
35
37
  await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
36
38
  await initCommand({ logger, flags });
@@ -181,6 +183,18 @@ export function registerPipeline(program, { pkgVersion }) {
181
183
  });
182
184
  });
183
185
 
186
+ // AB-E (KJC-TSK-0651): third-AI arbitration for the v4 environment.
187
+ program
188
+ .command("solomon")
189
+ .description("Ask a third AI to arbitrate a rejected review verdict (brain ≠ reviewer ≠ solomon)")
190
+ .requiredOption("--position <text>", "Why the brain disagrees with the reviewer")
191
+ .option("--range <range>", "Arbitrate a git range instead of the staged diff")
192
+ .action(async (flags) => {
193
+ await withConfig(pkgVersion, "solomon", flags, async ({ config, logger }) => {
194
+ await solomonCommand({ config, logger, flags });
195
+ });
196
+ });
197
+
184
198
  program
185
199
  .command("review")
186
200
  .description("Run only reviewer (--staged/--check: v4 cross-AI gate with recorded verdict)")
@@ -198,7 +212,6 @@ export function registerPipeline(program, { pkgVersion }) {
198
212
  // ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
199
213
  // the exact diff so the pre-commit hook can enforce cross-AI review.
200
214
  if (flags.staged || flags.check || flags.range || flags.installGate) {
201
- const { reviewGateCommand } = await import("../commands/review-gate.js");
202
215
  await reviewGateCommand({ config, logger, flags: { ...flags, task } });
203
216
  return;
204
217
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `kj agent run <agent> "<task>"` (AB-D, KJC-TSK-0654) — the brain's
3
+ * inter-agent bus. The host agent decides WHO does WHAT; kj provides the
4
+ * plumbing it shouldn't have to fight: binary detection, subprocess
5
+ * workarounds (CLAUDECODE strip, stdin, silence timeouts), usage capture.
6
+ * The brain reads the output and decides — kj never interprets it.
7
+ */
8
+ import { createAgent, getAvailableAgents } from "../agents/index.js";
9
+ import { detectAvailableAgents } from "../utils/agent-detect.js";
10
+
11
+ export async function agentRunCommand({ agent, task, config = {}, logger = null, flags = {} }) {
12
+ if (!task || !task.trim()) {
13
+ throw new Error("kj agent run requires a task: kj agent run <agent> \"<task>\"");
14
+ }
15
+ const known = getAvailableAgents().map((a) => a.name);
16
+ const detected = await detectAvailableAgents();
17
+ const up = detected.filter((a) => a.available).map((a) => a.name).join(", ") || "none";
18
+ if (!known.includes(agent)) {
19
+ throw new Error(`unknown agent "${agent}" — supported: ${known.join(", ")}; available on this machine: ${up}`);
20
+ }
21
+ const entry = detected.find((a) => a.name === agent);
22
+ if (!entry?.available) {
23
+ throw new Error(`agent "${agent}" is not available on this machine (available: ${up}) — install its CLI or pick another`);
24
+ }
25
+
26
+ let timeoutMs;
27
+ if (flags.timeoutMinutes !== undefined) {
28
+ const minutes = Number(flags.timeoutMinutes);
29
+ if (!Number.isFinite(minutes) || minutes <= 0) {
30
+ throw new Error(`--timeout-minutes must be a positive number, got "${flags.timeoutMinutes}"`);
31
+ }
32
+ timeoutMs = minutes * 60000;
33
+ }
34
+
35
+ // --json must emit ONLY the serialized result — no informational lines,
36
+ // and a crash of the delegated subprocess still yields one JSON payload.
37
+ if (!flags.json) logger?.info?.(`kj agent run: delegating to ${agent}`);
38
+ const instance = createAgent(agent, config, logger);
39
+ let result;
40
+ try {
41
+ result = await instance.runTask({ prompt: task, role: "delegate", timeoutMs });
42
+ } catch (err) {
43
+ result = { ok: false, output: "", error: err.message };
44
+ }
45
+
46
+ const res = {
47
+ ok: Boolean(result?.ok),
48
+ agent,
49
+ output: result?.output || "",
50
+ error: result?.error || null,
51
+ usage: result?.tokens_out != null
52
+ ? { tokens_in: result.tokens_in ?? null, tokens_out: result.tokens_out, cached_tokens: result.cached_tokens ?? null }
53
+ : null,
54
+ };
55
+ if (flags.json) {
56
+ console.log(JSON.stringify(res));
57
+ } else {
58
+ if (res.output) console.log(res.output);
59
+ if (!res.ok && res.error) console.error(res.error);
60
+ }
61
+ process.exitCode = res.ok ? 0 : 1;
62
+ return res;
63
+ }
@@ -5,6 +5,7 @@
5
5
  * has no RAG index yet, build it (default ON, `--no-rag` opts out).
6
6
  */
7
7
  import { installPlaybook } from "../environment/playbook.js";
8
+ import { renderBrief, listBriefs } from "../environment/briefs.js";
8
9
  import { openVecStore, projectSlug, getLastIndexedCommit } from "../rag/vec-store.js";
9
10
  import { ragIndexCommand } from "./rag.js";
10
11
 
@@ -14,10 +15,28 @@ function hasRagIndex(config, projectDir) {
14
15
  finally { db.close(); }
15
16
  }
16
17
 
18
+ /**
19
+ * `kj brief [role]` (AB-C, KJC-TSK-0652) — the distilled method of a role,
20
+ * for the brain to execute or delegate. No role → list them.
21
+ */
22
+ export function briefCommand({ config = null, flags = {}, role = null }) {
23
+ if (!role) {
24
+ const list = listBriefs();
25
+ if (flags.json) { console.log(JSON.stringify(list, null, 2)); return list; }
26
+ console.log("Available role briefs (kj brief <role>):");
27
+ for (const { role: r, purpose } of list) console.log(` ${r.padEnd(11)} ${purpose}`);
28
+ return list;
29
+ }
30
+ const text = renderBrief(role, config || {});
31
+ if (flags.json) { console.log(JSON.stringify({ role, brief: text })); return { role, brief: text }; }
32
+ console.log(text);
33
+ return { role, brief: text };
34
+ }
35
+
17
36
  export async function envInstallCommand({ config = null, logger = null, flags = {} }) {
18
37
  const projectDir = config?.projectDir || process.cwd();
19
38
  const result = await installPlaybook({
20
- projectDir, target: flags.target || "both",
39
+ projectDir, target: flags.target || "all",
21
40
  stateBackend: config?.state_backend || "hu-board",
22
41
  });
23
42
  console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
@@ -9,6 +9,7 @@
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
+ import { loadConfig } from "../config.js";
12
13
  import { compareHarden, formatAdvisoryReport } from "../harden/advisory.js";
13
14
  import { interactiveHarden } from "../harden/interactive.js";
14
15
  import { createWizard, isTTY } from "../utils/wizard.js";
@@ -120,9 +121,16 @@ export async function hardenCommand({
120
121
 
121
122
  const roots = detectStackRoots(projectDir, { only: onlyDirs, exclude: excludeDirs });
122
123
  const cmds = await resolveCmds(projectDir, roots[0]?.language ?? null);
124
+ // KJC-TSK-0648: branch-first guard — the base branch only moves via PR.
125
+ // Resolved from the project's kj config (default "main"); best-effort so
126
+ // harden keeps working on repos that never ran kj init.
127
+ let baseBranch = "main";
128
+ try {
129
+ baseBranch = (await loadConfig(projectDir))?.base_branch || "main";
130
+ } catch { /* no kj config — default stands */ }
123
131
  let result;
124
132
  try {
125
- result = await installHooks({ projectDir, profile, cmds, dryRun });
133
+ result = await installHooks({ projectDir, profile, cmds, dryRun, baseBranch });
126
134
  } catch (err) {
127
135
  if (json) logger.info?.(JSON.stringify({ ok: false, error: err.message }));
128
136
  else logger.error?.(`kj harden: ${err.message}`);
@@ -736,11 +736,23 @@ export async function resolveConfigScope({ flags, interactive }) {
736
736
  }
737
737
 
738
738
  export async function initCommand({ logger, flags = {} }) {
739
+ // AB-B (KJC-TSK-0656): with --json, stdout is a machine contract — every
740
+ // human log moves to stderr so the only stdout line is the summary object.
741
+ if (flags.json) {
742
+ const toStderr = (...args) => console.error(...args);
743
+ logger = { ...logger, info: toStderr, warn: toStderr, error: toStderr };
744
+ }
739
745
  const karajanHome = getKarajanHome();
740
746
  await ensureDir(karajanHome);
741
747
  logger.info(`Ensured ${karajanHome} exists`);
742
748
 
743
749
  const interactive = flags.noInteractive !== true && isTTY();
750
+ // AB-B (KJC-TSK-0656): the agent is the UI. When there is no TTY the
751
+ // wizard silently used defaults — say so, so a brain (or a CI log
752
+ // reader) knows WHY nothing was asked and which flags override what.
753
+ if (!interactive && flags.noInteractive !== true) {
754
+ logger.info("No TTY detected — running non-interactive with defaults (pass flags to override; see kj init --help)");
755
+ }
744
756
  const { configPath, scope } = await resolveConfigScope({ flags, interactive });
745
757
  logger.info(`Config scope: ${scope} → ${configPath}`);
746
758
  const karajanDir = path.join(process.cwd(), ".karajan");
@@ -900,6 +912,16 @@ export async function initCommand({ logger, flags = {} }) {
900
912
  sendTelemetryEvent("install", { version }, config).catch(() => {});
901
913
  } catch { /* non-blocking */ }
902
914
 
915
+ // AB-B (KJC-TSK-0656): machine contract for brains — one JSON object with
916
+ // what init actually did, instead of parsing the human log.
917
+ if (flags.json) {
918
+ console.log(JSON.stringify({
919
+ ok: true, configPath, scope, interactive,
920
+ skipped: ["ollama", "rtk", "squeezr", "qmd", "harden"].filter((t) => flags[t] === false),
921
+ }));
922
+ return;
923
+ }
924
+
903
925
  // Clear close so a first-time user knows setup finished and what to do next,
904
926
  // instead of being left at the end of a wall of detection logs (KJC-BUG-0088).
905
927
  logger.info("");
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `kj report-issue` (AB-F 2/2, KJC-TSK-0655) — the brain files kj
3
+ * frictions to the public repo. Default output is a sanitized preview
4
+ * plus a prefilled new-issue URL: publishing stays a human decision.
5
+ * `--publish` uses the gh CLI; the playbook orders the brain to confirm
6
+ * with its user before passing it.
7
+ */
8
+ import os from "node:os";
9
+ import { readFileSync } from "node:fs";
10
+ import { fileURLToPath } from "node:url";
11
+ import { resolve } from "node:path";
12
+ import { runCommand } from "../utils/process.js";
13
+ import { composeIssue, findSimilarIssues, newIssueUrl, REPO } from "../environment/issue-report.js";
14
+
15
+ function kjVersion() {
16
+ try {
17
+ const pkg = resolve(fileURLToPath(import.meta.url), "../../../package.json");
18
+ return JSON.parse(readFileSync(pkg, "utf8")).version;
19
+ } catch { return "unknown"; }
20
+ }
21
+
22
+ export async function reportIssueCommand({ logger: _logger = null, flags = {}, deps = {} }) {
23
+ if (!flags.title || !flags.title.trim()) {
24
+ throw new Error("kj report-issue requires --title \"<one-line summary>\"");
25
+ }
26
+ const { fetchFn = globalThis.fetch, runCmd = runCommand } = deps;
27
+
28
+ const { title, body } = composeIssue({
29
+ title: flags.title,
30
+ description: flags.description || "",
31
+ command: flags.command || "",
32
+ error: flags.error || "",
33
+ env: { kjVersion: kjVersion(), nodeVersion: process.version, platform: `${os.platform()}-${os.arch()}` },
34
+ });
35
+ const url = newIssueUrl({ title, body });
36
+
37
+ const similar = await findSimilarIssues(title, { fetchFn });
38
+ if (similar.length > 0 && !flags.force) {
39
+ const res = { published: false, title, body, similar, url };
40
+ if (flags.json) console.log(JSON.stringify(res));
41
+ else {
42
+ console.log(`✗ ${similar.length} similar open issue(s) — comment there instead of duplicating (or re-run with --force):`);
43
+ for (const s of similar) console.log(` #${s.number} ${s.title}\n ${s.html_url}`);
44
+ }
45
+ process.exitCode = 1;
46
+ return res;
47
+ }
48
+
49
+ if (flags.publish) {
50
+ try {
51
+ const gh = await runCmd("gh", ["issue", "create", "--repo", REPO, "--title", title, "--body", body]);
52
+ if (gh.exitCode !== 0) throw new Error(gh.stderr?.trim() || `gh exited ${gh.exitCode}`);
53
+ const issueUrl = gh.stdout.trim().split("\n").pop();
54
+ const res = { published: true, title, url: issueUrl, similar };
55
+ if (flags.json) console.log(JSON.stringify(res));
56
+ else console.log(`✓ issue published: ${issueUrl}`);
57
+ process.exitCode = 0;
58
+ return res;
59
+ } catch (err) {
60
+ const res = { published: false, title, body, similar, url, error: err.message };
61
+ if (flags.json) console.log(JSON.stringify(res));
62
+ else console.log(`✗ could not publish via gh (${err.message}) — open it manually:\n${url}`);
63
+ process.exitCode = 1;
64
+ return res;
65
+ }
66
+ }
67
+
68
+ const res = { published: false, title, body, similar, url };
69
+ if (flags.json) console.log(JSON.stringify(res));
70
+ else {
71
+ console.log(`--- issue preview (sanitized) ---\n# ${title}\n\n${body}\n---`);
72
+ console.log(`Open (and edit) it here:\n${url}`);
73
+ console.log("Or publish directly with --publish (requires gh; confirm with your user first).");
74
+ }
75
+ process.exitCode = 0;
76
+ return res;
77
+ }
@@ -8,6 +8,7 @@
8
8
  import { runCommand } from "../utils/process.js";
9
9
  import { checkVerdict } from "../review/verdict-store.js";
10
10
  import { runOneShotReview } from "../review/one-shot-review.js";
11
+ import { runSolomonArbitration } from "../review/solomon-arbitration.js";
11
12
 
12
13
  // Raw git always — never a wrapped/compressing runner (KJC-BUG-0115).
13
14
  async function rawDiff(range) {
@@ -34,6 +35,25 @@ function printVerdict(record) {
34
35
  console.log("Fix the issues and run `kj review --staged` again — the verdict is tied to the exact diff.");
35
36
  }
36
37
 
38
+ /**
39
+ * `kj solomon --position "<why>"` (AB-E, KJC-TSK-0651) — the brain asks a
40
+ * third AI to arbitrate a rejected verdict it disagrees with. Exit 0 =
41
+ * approve (gate opens), 1 = reject (obey the reviewer and fix).
42
+ */
43
+ export async function solomonCommand({ config, logger = null, flags = {} }) {
44
+ const projectDir = config?.projectDir || process.cwd();
45
+ const diff = await rawDiff(flags.range);
46
+ const res = await runSolomonArbitration({ diff, position: flags.position, config, logger, projectDir });
47
+ if (res.ruling === "approve") {
48
+ console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
49
+ } else {
50
+ console.log(`⚖ Solomon${res.solomon ? ` (${res.solomon})` : ""} rules for the reviewer — obey and fix:`);
51
+ }
52
+ if (res.reasoning) console.log(` ${res.reasoning}`);
53
+ process.exitCode = res.ruling === "approve" ? 0 : 1;
54
+ return res;
55
+ }
56
+
37
57
  export async function reviewGateCommand({ config, logger = null, flags = {} }) {
38
58
  const projectDir = config?.projectDir || process.cwd();
39
59
 
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Role briefs (AB-C, KJC-TSK-0652; outcome-first rewrite AB-C2,
3
+ * KJC-TSK-0653) — the method of each role, exposed to the brain via
4
+ * `kj brief <role>`.
5
+ *
6
+ * Format is Mission + Invariants + Deliverable, never step scripts:
7
+ * frontier models choose better paths than a script would force, but they
8
+ * need the hard limits explicit. The real guarantee lives in the git
9
+ * gates, not in this text. Subprocess prompts stay in headless.
10
+ */
11
+
12
+ const brief = (title, mission, invariants, deliverable) => [
13
+ `# ${title} brief`, "",
14
+ `Mission: ${mission}`, "",
15
+ "Invariants:",
16
+ ...invariants.map((i) => `- ${i}`), "",
17
+ `Deliverable: ${deliverable}`,
18
+ ].join("\n");
19
+
20
+ const B = {
21
+ triage: {
22
+ purpose: "Size the task before touching anything",
23
+ render: () => brief("Triage",
24
+ "before any code exists, the task has a type (sw|infra|doc|add-tests|refactor), an honest size, its risk surfaces named (auth, persistence, external I/O, money, PII), and a DONE-statement that is falsifiable.",
25
+ [
26
+ "The RAG answers before you assume (`kj rag query`) — never size from guesses.",
27
+ "A task too big for one ~150-net-line PR is split into HUs BEFORE coding starts.",
28
+ ],
29
+ "type, size, risks, done-statement — or the HU split."),
30
+ },
31
+ planner: {
32
+ purpose: "Turn the task into an ordered, committable plan",
33
+ render: () => brief("Planner",
34
+ "the smallest ordered plan whose steps each fit one commit, name their files, and reach the done-statement — with an explicit out-of-scope list.",
35
+ [
36
+ "Behavior changes carry their test in the same step.",
37
+ "Data-model or API changes never share a step with anything else.",
38
+ "No PR beyond ~150 net lines — partition upfront, not at the gate.",
39
+ ],
40
+ "numbered-free plan: steps {description, files}, risks, out-of-scope."),
41
+ },
42
+ researcher: {
43
+ purpose: "Ground the task in how the codebase actually works",
44
+ render: () => brief("Researcher",
45
+ "the design starts from how this codebase actually does things: the existing pattern for this kind of change is found and mimicked, the load-bearing files and the tests that pin them are known.",
46
+ [
47
+ "Facts carry file:line references — a claim without one is a guess.",
48
+ "Unknowns are listed explicitly, never papered over.",
49
+ ],
50
+ "grounded facts with references + the open unknowns."),
51
+ },
52
+ architect: {
53
+ purpose: "Decide structure before code when the change is cross-cutting",
54
+ render: () => brief("Architect",
55
+ "for medium/complex changes, one design is chosen over named alternatives, existing ADRs and module boundaries are respected, and any data-model/API change has a migration and compatibility story.",
56
+ [
57
+ "Check the ADRs before deciding — never against an accepted one.",
58
+ "Prefer the design that deletes code over the one that adds layers.",
59
+ ],
60
+ "chosen design, discarded alternatives with reasons, affected boundaries."),
61
+ },
62
+ tester: {
63
+ purpose: "Prove the change does what DONE says",
64
+ render: (config) => {
65
+ const tdd = config?.development?.methodology !== "standard";
66
+ return brief("Tester",
67
+ "every behavior change is pinned by a test that fails without it, sad paths included (invalid input, empty state, boundaries), and the FULL suite is green.",
68
+ [
69
+ ...(tdd ? ["TDD is ON: the failing test exists FIRST, then the code."] : []),
70
+ "The suite is never left red, and never goes green by deleting or skipping tests.",
71
+ ],
72
+ "green suite + the list of new/changed tests and what each one pins.");
73
+ },
74
+ },
75
+ security: {
76
+ purpose: "The security pass the brain absorbs — non-negotiable",
77
+ render: () => brief("Security",
78
+ "when you finish, no user-controlled data reaches HTML, shell, SQL or file paths unsanitized; no secret, key or credential lives in code, config, logs or commits; and every new endpoint/action checks identity AND permission.",
79
+ [
80
+ "These findings are never overridable — not even by Solomon arbitration.",
81
+ "PII is never logged; new dependencies need a reason and a pinned version.",
82
+ ],
83
+ "findings with severity, category and file:line — or an explicit 'no security surface touched'."),
84
+ },
85
+ audit: {
86
+ purpose: "Final look before shipping — does the whole thing hold?",
87
+ render: () => brief("Audit",
88
+ "the done-statement from triage is literally true, the diff carries no debug leftovers or card-less TODOs, user-visible behavior changes have their minimal doc, and suite + lint + review gate are verified green.",
89
+ [
90
+ "Verified means you ran it — never assumed from an earlier state.",
91
+ "There is no 'ship with caveats': it ships or it has a must-fix list.",
92
+ ],
93
+ "SHIP, or the concrete must-fix list."),
94
+ },
95
+ };
96
+
97
+ export const BRIEF_ROLES = Object.keys(B);
98
+
99
+ export function listBriefs() {
100
+ return BRIEF_ROLES.map((role) => ({ role, purpose: B[role].purpose }));
101
+ }
102
+
103
+ export function renderBrief(role, config = {}) {
104
+ const entry = B[role];
105
+ if (!entry) {
106
+ throw new Error(`unknown role "${role}" — available: ${BRIEF_ROLES.join(", ")}`);
107
+ }
108
+ return entry.render(config);
109
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Issue reporting core (AB-F, KJC-TSK-0655) — the self-healing loop: any
3
+ * user's brain diagnoses a kj friction and reports it to the public repo,
4
+ * so field feedback reaches the maintainer's board without a human relay.
5
+ *
6
+ * A public issue is a privacy boundary. sanitize() strips home paths,
7
+ * usernames and emails; the composer only ever includes what the brain
8
+ * explicitly passed (command, error, description) plus kj/node/platform
9
+ * metadata — never project code.
10
+ */
11
+
12
+ export const REPO = "manufosela/karajan-code";
13
+
14
+ export function sanitize(text) {
15
+ return String(text || "")
16
+ // any /home/<user>/ or /Users/<user>/ prefix collapses to ~/
17
+ .replaceAll(/\/(?:home|Users)\/[^/\s]+/g, "~")
18
+ // emails never belong in a public issue
19
+ .replaceAll(/[\w.+-]+@[\w-]+\.[\w.]+/g, "[redacted-email]");
20
+ }
21
+
22
+ export function composeIssue({ title, description = "", command = "", error = "", env = {} }) {
23
+ const body = [
24
+ sanitize(description),
25
+ command ? `\n**Command:** \`${sanitize(command)}\`` : "",
26
+ error ? `\n**Error:**\n\`\`\`\n${sanitize(error)}\n\`\`\`` : "",
27
+ "\n**Environment:**",
28
+ `- kj ${env.kjVersion || "unknown"}`,
29
+ `- node ${env.nodeVersion || "unknown"}`,
30
+ `- ${env.platform || "unknown"}`,
31
+ "\n---",
32
+ "_Reported via `kj report-issue` (sanitized: no project code, paths or personal data)._",
33
+ ].filter(Boolean).join("\n");
34
+ return { title: sanitize(title), body };
35
+ }
36
+
37
+ const STOPWORDS = new Set(["the", "a", "an", "is", "are", "from", "with", "for", "and", "not", "kj", "karajan"]);
38
+
39
+ function meaningfulWords(title) {
40
+ return title.toLowerCase().split(/[^a-z0-9-]+/)
41
+ .filter((w) => w.length > 3 && !STOPWORDS.has(w));
42
+ }
43
+
44
+ /**
45
+ * Look for open issues that share meaningful title words (≥2 overlaps, or
46
+ * 1 when the title is short). Read-only public API — no auth needed. Any
47
+ * failure degrades to [] : dedup must never block reporting.
48
+ */
49
+ export async function findSimilarIssues(title, { fetchFn = globalThis.fetch } = {}) {
50
+ try {
51
+ const res = await fetchFn(`https://api.github.com/repos/${REPO}/issues?state=open&per_page=100`, {
52
+ headers: { accept: "application/vnd.github+json" },
53
+ });
54
+ if (!res.ok) return [];
55
+ const issues = await res.json();
56
+ const words = meaningfulWords(title);
57
+ const needed = Math.min(2, Math.max(1, words.length));
58
+ return issues.filter((issue) => {
59
+ const theirs = new Set(meaningfulWords(issue.title || ""));
60
+ const overlap = words.filter((w) => theirs.has(w)).length;
61
+ return overlap >= needed;
62
+ }).map(({ number, title: t, html_url }) => ({ number, title: t, html_url }));
63
+ } catch {
64
+ return [];
65
+ }
66
+ }
67
+
68
+ /** Prefilled new-issue URL — publishing stays a human click by default. */
69
+ export function newIssueUrl({ title, body }) {
70
+ const params = new URLSearchParams({ title, body });
71
+ return `https://github.com/${REPO}/issues/new?${params.toString().replaceAll("+", "%20")}`;
72
+ }
@@ -13,39 +13,56 @@ import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { upsertManagedBlock } from "../utils/managed-markers.js";
15
15
 
16
- export const PLAYBOOK_TARGETS = ["claude", "codex", "both"];
16
+ // AB-A (KJC-TSK-0650): any agent can be the brain. AGENTS.md is the
17
+ // emerging standard (Codex, Cursor and most new CLIs read it); GEMINI.md
18
+ // covers Gemini CLI. "both" is kept as a legacy alias of claude+codex.
19
+ export const PLAYBOOK_TARGETS = ["claude", "codex", "gemini", "both", "all"];
17
20
 
18
- const TARGET_FILES = { claude: ["CLAUDE.md"], codex: ["AGENTS.md"], both: ["CLAUDE.md", "AGENTS.md"] };
21
+ const TARGET_FILES = {
22
+ claude: ["CLAUDE.md"],
23
+ codex: ["AGENTS.md"],
24
+ gemini: ["GEMINI.md"],
25
+ both: ["CLAUDE.md", "AGENTS.md"],
26
+ all: ["CLAUDE.md", "AGENTS.md", "GEMINI.md"],
27
+ };
19
28
 
20
- // ENV-D1 (KJC-TSK-0642): step 2 names the CHOSEN state backend — a playbook
21
- // that says "board or PG" makes the host guess; the config already knows.
29
+ // ENV-D1 (KJC-TSK-0642): the tracking invariant names the CHOSEN state
30
+ // backend — a playbook that says "board or PG" makes the host guess.
22
31
  const BACKEND_TRACKING = {
23
- "hu-board": "no work without a tracked story/bug in the HU Board (`kj board`).",
24
- "planning-game": "no work without a tracked card in the Planning Game MCP (create it first, move it to In Progress when you start).",
32
+ "hu-board": "Every piece of work has a tracked story/bug in the HU Board (`kj board`) before it starts.",
33
+ "planning-game": "Every piece of work has a tracked card in the Planning Game MCP before it starts (In Progress while you work it).",
25
34
  };
26
35
 
36
+ // AB-C2 (KJC-TSK-0653): outcome-first — invariants over step scripts.
37
+ // Frontier models choose their own path best; what they need explicit are
38
+ // the limits. The git gates enforce these regardless of what any brain does.
27
39
  const playbookBody = (stateBackend) => `# Karajan method (v4)
28
40
 
29
- You are the orchestrator; Karajan governs the method. Follow this on every task:
41
+ You are the orchestrator; Karajan governs. A task is DONE when its
42
+ done-statement is literally true, the full suite is green, and every commit
43
+ carries a cross-AI verdict.
44
+
45
+ Invariants (the git gates enforce these — they are not suggestions):
46
+
47
+ - The project RAG answers before you assume: \`kj rag query\` — never guess
48
+ what the codebase does.
49
+ - ${BACKEND_TRACKING[stateBackend] || BACKEND_TRACKING["hu-board"]}
50
+ - Tests prove behavior: the failing test exists first (TDD), and the suite
51
+ is never left red.
52
+ - Every diff is reviewed by a DIFFERENT AI before it is committed
53
+ (\`kj review --staged\`): verdicts bind to the exact diff — change the code
54
+ and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
55
+ - Security findings are never overridable — not even by arbitration. You
56
+ absorb the security role: \`kj brief security\` states what must be true.
57
+ - Branch first: never commit on the base branch — every change reaches it
58
+ through an atomic PR (~150 net lines, Conventional Commits).
30
59
 
31
- 1. **Context first**: before writing code, query the project RAG:
32
- \`kj rag query "<what you need to know>"\` — never guess what the codebase does.
33
- 2. **Card first**: ${BACKEND_TRACKING[stateBackend] || BACKEND_TRACKING["hu-board"]}
34
- 3. **TDD**: write or extend the failing test FIRST, then the code. Run the suite
35
- after every significant change; never leave it red.
36
- 4. **Cross-AI review before committing**: stage your changes and run
37
- \`kj review --staged\` — a DIFFERENT AI reviews the diff and records a verdict.
38
- If rejected: fix the issues and review again (the verdict is tied to the exact
39
- diff, so any change requires a new one). The pre-commit hook enforces this
40
- when the project has the review gate enabled.
41
- 5. **Security checklist** (you absorb the security role): validate all inputs,
42
- never commit secrets or keys, no deprecated APIs, parameterized queries,
43
- sanitize anything user-controlled before it reaches HTML/shell/SQL.
44
- 6. **Ship small**: Conventional Commits, atomic PRs (~150 net lines), each PR
45
- compiles and passes tests on its own.
60
+ Commands: \`kj rag query\` · \`kj brief <role>\` (triage, planner, researcher,
61
+ architect, tester, security, audit) · \`kj review --staged\` · \`kj review --check\` ·
62
+ \`kj solomon --position\` · \`kj agent run <agent>\` · \`kj report\` · \`kj check\`
46
63
 
47
- Useful commands: \`kj rag query\` · \`kj review --staged\` · \`kj review --check\` ·
48
- \`kj report\` · \`kj check\`
64
+ Hit a kj bug or friction? Diagnose it and file it upstream with
65
+ \`kj report-issue\` (sanitized; ask your user before \`--publish\`).
49
66
  `;
50
67
 
51
68
  export function renderPlaybook({ stateBackend = "hu-board" } = {}) {
@@ -56,7 +73,7 @@ export function renderPlaybook({ stateBackend = "hu-board" } = {}) {
56
73
  * Install/refresh the playbook block in the target agent files.
57
74
  * User content outside the managed block is never touched.
58
75
  */
59
- export async function installPlaybook({ projectDir, target = "both", version = "1", stateBackend = "hu-board" }) {
76
+ export async function installPlaybook({ projectDir, target = "all", version = "1", stateBackend = "hu-board" }) {
60
77
  if (!PLAYBOOK_TARGETS.includes(target)) {
61
78
  throw new Error(`unknown target "${target}" — use one of: ${PLAYBOOK_TARGETS.join(", ")}`);
62
79
  }
@@ -33,11 +33,25 @@ export async function installHooks({
33
33
  profile = "standard",
34
34
  cmds = {},
35
35
  dryRun = false,
36
+ baseBranch = null,
36
37
  } = {}) {
37
38
  if (!isGitRepo(projectDir)) throw new Error(`Not a git repository: ${projectDir}`);
38
39
  const hooks = PROFILE_HOOKS[profile];
39
40
  if (!hooks) throw new Error(`Unknown harden profile: ${profile}`);
40
41
 
42
+ // KJC-TSK-0645: setting a repo-local hooksPath ECLIPSES the machine's
43
+ // global hooks dir — personal guards (AI-attribution commit-msg, protected
44
+ // branch pre-push) would silently stop applying here. Detect the previous
45
+ // global dir and have every generated hook chain to it. `~` is emitted as
46
+ // `$HOME` so the committed hook stays portable across machines (the -x
47
+ // guard silences it where the dir doesn't exist).
48
+ let globalHooksDir = null;
49
+ const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
50
+ const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
51
+ if (rawGlobal && rawGlobal !== HOOKS_DIR) {
52
+ globalHooksDir = rawGlobal.startsWith("~") ? `$HOME${rawGlobal.slice(1)}` : rawGlobal;
53
+ }
54
+
41
55
  const absHooksDir = join(projectDir, HOOKS_DIR);
42
56
  const results = [];
43
57
  for (const hook of hooks) {
@@ -47,7 +61,7 @@ export async function installHooks({
47
61
  source,
48
62
  blockId: `hook:${hook}`,
49
63
  version: BLOCK_VERSION,
50
- body: hookBody(hook, cmds),
64
+ body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
51
65
  style: "hash",
52
66
  });
53
67
  results.push({ hook, target, action });
@@ -21,10 +21,42 @@ export const PROFILE_HOOKS = {
21
21
  };
22
22
 
23
23
  /** Build the managed body for a single hook. */
24
- export function hookBody(hook, cmds = {}) {
24
+ // KJC-TSK-0645: `core.hooksPath .karajan/hooks` eclipses the user's global
25
+ // hooks dir, silently disabling personal guards. When a previous global dir
26
+ // is known, every generated hook ends by chaining its namesake there —
27
+ // guarded, so machines without it are unaffected.
28
+ function chainToGlobal(hook, globalHooksDir) {
29
+ if (!globalHooksDir) return [];
30
+ return [
31
+ "# Chain the machine's previous global hook (kj harden keeps it active).",
32
+ `if [ -x "${globalHooksDir}/${hook}" ]; then`,
33
+ ` "${globalHooksDir}/${hook}" "$@"; rc=$?`,
34
+ ' [ "$rc" -eq 0 ] || exit "$rc"',
35
+ "fi",
36
+ ];
37
+ }
38
+
39
+ // KJC-TSK-0648: branch-first, enforced. The first external session of the
40
+ // v4 environment committed on local main following literal instructions —
41
+ // the playbook now orders "branch first" and this guard backs it up.
42
+ function baseBranchGuard(baseBranch) {
43
+ if (!baseBranch) return [];
44
+ return [
45
+ "# Branch-first guard — the base branch only moves via PR.",
46
+ 'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ]; then',
47
+ ' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
48
+ ` if [ "$current_branch" = "${baseBranch}" ]; then`,
49
+ ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR (KJ_ALLOW_BASE_COMMIT=1 to override)'; exit 1`,
50
+ " fi",
51
+ "fi",
52
+ ];
53
+ }
54
+
55
+ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch = null } = {}) {
56
+ const chain = chainToGlobal(hook, globalHooksDir);
25
57
  switch (hook) {
26
58
  case "pre-commit": {
27
- const lines = ["# Lint + format the working tree with the project's native tools."];
59
+ const lines = [...baseBranchGuard(baseBranch), "# Lint + format the working tree with the project's native tools."];
28
60
  if (cmds.lint) lines.push(`${cmds.lint} || { echo 'kj harden: lint failed'; exit 1; }`);
29
61
  if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
30
62
  if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
@@ -38,7 +70,7 @@ export function hookBody(hook, cmds = {}) {
38
70
  " kj review --check || { echo 'kj: no approved cross-AI verdict for the staged diff — run `kj review --staged`'; exit 1; }",
39
71
  "fi"
40
72
  );
41
- return lines.join("\n");
73
+ return [...lines, ...chain].join("\n");
42
74
  }
43
75
  case "commit-msg":
44
76
  // Pure POSIX — Conventional Commits header, length cap, AI-attribution.
@@ -52,6 +84,7 @@ export function hookBody(hook, cmds = {}) {
52
84
  `if grep -qiE '${AI_ATTRIBUTION}' "$msg_file"; then`,
53
85
  " echo 'kj harden: AI attribution is not allowed in commit messages'; exit 1",
54
86
  "fi",
87
+ ...chain,
55
88
  ].join("\n");
56
89
  case "pre-push": {
57
90
  const lines = [
@@ -64,7 +97,7 @@ export function hookBody(hook, cmds = {}) {
64
97
  ];
65
98
  if (cmds.test) lines.push(`${cmds.test} || { echo 'kj harden: tests failed'; exit 1; }`);
66
99
  else lines.push("# (no test command detected for this stack)");
67
- return lines.join("\n");
100
+ return [...lines, ...chain].join("\n");
68
101
  }
69
102
  case "post-merge":
70
103
  return [
@@ -72,6 +105,7 @@ export function hookBody(hook, cmds = {}) {
72
105
  "if command -v kj >/dev/null 2>&1; then",
73
106
  " kj rag index --since auto >/dev/null 2>&1 || true",
74
107
  "fi",
108
+ ...chain,
75
109
  ].join("\n");
76
110
  default:
77
111
  throw new Error(`Unknown hook: ${hook}`);
@@ -52,7 +52,7 @@ export async function buildReviewerPromptLayout({ task, diff, reviewRules, mode,
52
52
  section("Only block approval for issues IN THE DIFF that are bugs, security vulnerabilities, or clear violations of the review rules.", STABLE),
53
53
  section("Return only one valid JSON object and nothing else.", STABLE),
54
54
  section("JSON schema:", STABLE),
55
- section('{"approved":boolean,"blocking_issues":[{"id":string,"severity":"critical|high|medium|low","file":string,"line":number,"description":string,"suggested_fix":string}],"non_blocking_suggestions":[string],"summary":string,"confidence":number}', STABLE),
55
+ 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),
56
56
  section(serenaEnabled ? SERENA_INSTRUCTIONS : null, STABLE),
57
57
  section(rtkSnippet, STABLE),
58
58
  section(productContext ? `## Product Context\n${productContext}` : null, STABLE),
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Solomon arbitration (AB-E, KJC-TSK-0651) — when the brain (host agent)
3
+ * disagrees with the reviewer's verdict, a THIRD AI arbitrates and its
4
+ * ruling is recorded in the verdict store, tied to the exact diff.
5
+ *
6
+ * Hard rules:
7
+ * - brain ≠ reviewer ≠ solomon. Two-agent machines get an actionable
8
+ * error — self-arbitration would void the whole point.
9
+ * - Security issues from the reviewer are NEVER overridable (inherited
10
+ * from the pipeline's Solomon): the arbiter is not even consulted.
11
+ * - An approve ruling writes an approved verdict (`solomon:<agent>`) for
12
+ * the same diff hash, so the pre-commit gate opens structurally; a
13
+ * reject keeps it closed. Both record the full conflict for audit.
14
+ */
15
+ import { createAgent } from "../agents/index.js";
16
+ import { parseMaybeJsonString } from "./parser.js";
17
+ import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
18
+ import { diffHash, loadVerdict, saveVerdict } from "./verdict-store.js";
19
+
20
+ const SECURITY_PATTERN = /injection|xss|csrf|secret|credential|password|token leak|auth(entication|orization)?|crypt|session|sanitiz|traversal|rce\b/i;
21
+
22
+ // Structured signal first (`category: "security"` — part of the reviewer's
23
+ // JSON schema since AB-E); the free-text pattern stays as a fail-closed net
24
+ // for verdicts recorded before the schema carried categories.
25
+ function isSecurityIssue(issue) {
26
+ return issue.category === "security"
27
+ || SECURITY_PATTERN.test(`${issue.id || ""} ${issue.description || ""}`);
28
+ }
29
+
30
+ export async function pickThirdParty({ config, hostAgent, reviewerAgent, detectAgents = detectAvailableAgents }) {
31
+ const excluded = new Set([hostAgent, reviewerAgent]);
32
+ const configured = config?.roles?.solomon?.provider;
33
+ const agents = await detectAgents();
34
+ const candidates = agents.filter((a) => a.available && !excluded.has(a.name)).map((a) => a.name);
35
+ if (configured && candidates.includes(configured)) return configured;
36
+ return candidates[0] || null;
37
+ }
38
+
39
+ export async function runSolomonArbitration({
40
+ diff, position, config, logger, projectDir,
41
+ hostAgent = detectHostAgent(),
42
+ createAgentFn = createAgent,
43
+ detectAgents = detectAvailableAgents,
44
+ }) {
45
+ if (!position || !position.trim()) {
46
+ throw new Error("kj solomon requires --position \"<why you disagree with the reviewer>\"");
47
+ }
48
+ const verdict = await loadVerdict(projectDir, diffHash(diff));
49
+ if (!verdict || verdict.verdict !== "rejected") {
50
+ throw new Error("nothing to arbitrate — there is no rejected verdict for the current diff (run `kj review --staged` first)");
51
+ }
52
+
53
+ // ABSOLUTE RULE: security findings are never overridable.
54
+ const securityIssues = (verdict.issues || []).filter(isSecurityIssue);
55
+ if (securityIssues.length > 0) {
56
+ const record = await saveVerdict(projectDir, diff, {
57
+ ...verdict,
58
+ arbitration: { position, ruling: "reject", reasoning: "security issues from the reviewer are never overridable", solomon: null },
59
+ });
60
+ return { ruling: "reject", reasoning: "security issues are never overridable — fix them", solomon: null, record };
61
+ }
62
+
63
+ const solomon = await pickThirdParty({ config, hostAgent, reviewerAgent: verdict.reviewer, detectAgents });
64
+ if (!solomon) {
65
+ throw new Error(
66
+ `arbitration requires a third AI distinct from the brain (${hostAgent || "unknown"}) and the reviewer (${verdict.reviewer}) — install a third agent CLI`
67
+ );
68
+ }
69
+
70
+ const prompt = [
71
+ "You are Solomon, the neutral arbiter between two AIs in a coding workflow.",
72
+ "The reviewer REJECTED a diff; the orchestrating brain DISAGREES. Decide who is right.",
73
+ "Never approve to save time or effort — only if the reviewer's objections are genuinely wrong or immaterial for this diff.",
74
+ '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.',
75
+ "", "## Reviewer's blocking issues",
76
+ JSON.stringify(verdict.issues || [], null, 2),
77
+ "", "## Brain's position", position,
78
+ "", "## The diff under dispute", diff,
79
+ ].join("\n");
80
+
81
+ logger?.info?.(`kj solomon: brain=${hostAgent || "?"} vs reviewer=${verdict.reviewer} → arbiter=${solomon}`);
82
+ const agent = createAgentFn(solomon, config, logger);
83
+ const result = await agent.reviewTask({ prompt, role: "solomon" });
84
+ if (!result?.ok) throw new Error(`solomon ${solomon} failed: ${result?.error || "no output"}`);
85
+ const parsed = parseMaybeJsonString(result.output);
86
+ if (!parsed || !["approve", "reject"].includes(parsed.ruling)) {
87
+ throw new Error(`solomon ${solomon} returned no parseable ruling`);
88
+ }
89
+
90
+ const arbitration = {
91
+ position, ruling: parsed.ruling, reasoning: parsed.reasoning || "", solomon,
92
+ originalVerdict: { reviewer: verdict.reviewer, issues: verdict.issues },
93
+ };
94
+ const record = parsed.ruling === "approve"
95
+ ? await saveVerdict(projectDir, diff, {
96
+ verdict: "approved", reviewer: `solomon:${solomon}`, host: hostAgent || null,
97
+ issues: [], summary: `Arbitration overrode ${verdict.reviewer}'s rejection: ${parsed.reasoning || ""}`.trim(), arbitration,
98
+ })
99
+ : await saveVerdict(projectDir, diff, { ...verdict, arbitration });
100
+
101
+ return { ruling: parsed.ruling, reasoning: parsed.reasoning || "", solomon, record };
102
+ }