karajan-code 3.15.2 → 4.0.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/README.md CHANGED
@@ -23,10 +23,20 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v3.7.1 released** — Patch: fixes a `kj init` crash when configuring the Plan B fallback, and makes pnpm installs safe — `kj doctor` now flags when pnpm skipped `better-sqlite3`'s native build and tells you exactly how to fix it (`pnpm approve-builds better-sqlite3`). The release gate checks pnpm too. Built on **v3.7.0 — Autonomous delivery**: `kj autorun <spec>` chains spec → plan → run every user story → outcome in one command, unattended, with an **Arbiter** that resolves agent conflicts by picking the least-bad call; autonomy is opt-in, interactive runs are unchanged. Full notes in [CHANGELOG.md](CHANGELOG.md).
26
+ > **v4.0.0 released — Karajan Environment.** The host agent orchestrates, Karajan governs. Work with Claude Code or Codex as your orchestrator; Karajan installs the method (`kj env install`), routes every diff through a review by a DIFFERENT AI (`kj review --staged`), and enforces it with a git pre-commit gate: without an approved cross-AI verdict tied to the exact staged diff, **the commit does not enter**. A false green becomes structurally impossible. The classic subprocess pipeline continues as the headless mode with the same gates. Full notes in [CHANGELOG.md](CHANGELOG.md).
27
27
 
28
28
  You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
29
29
 
30
+ ## v4: the Karajan Environment
31
+
32
+ Since v4, Karajan attaches to the agent you already work with instead of driving everything by subprocess:
33
+
34
+ 1. **`kj env install`** — writes the Karajan method into your agent's rule file (CLAUDE.md for Claude Code, AGENTS.md for Codex, same single source): query the project RAG before coding, card first, TDD, cross-AI review before committing, security checklist. It also builds the project's RAG index if missing.
35
+ 2. **`kj review --staged`** — your diff is reviewed by an AI **different from your orchestrator** (Claude orchestrates → Codex reviews, and vice versa). The verdict is recorded, tied to the sha256 of the exact diff: change the code and it must be reviewed again.
36
+ 3. **`kj review --install-gate`** — enables the pre-commit gate. From then on, commits without an approved cross-AI verdict are rejected by git itself. The marker is tracked, so the whole team inherits the contract.
37
+
38
+ This repo runs under its own environment: every commit to karajan-code carries a cross-AI verdict.
39
+
30
40
  ## What is Karajan?
31
41
 
32
42
  Karajan is a local coding orchestrator. It runs on your machine, uses your existing AI providers (Claude, Codex, Gemini, Aider, OpenCode), and coordinates a pipeline of specialized agents that work together on your code.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.15.2",
3
+ "version": "4.0.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",
@@ -85,7 +85,8 @@
85
85
  "mcp": "node src/mcp/server.js",
86
86
  "audit:test-diet": "node scripts/audit-test-diet.mjs",
87
87
  "verify-pack": "node scripts/verify-pack.mjs",
88
- "prepublishOnly": "node scripts/verify-pack.mjs"
88
+ "prepublishOnly": "node scripts/verify-pack.mjs",
89
+ "verify-quickstart": "node scripts/quickstart-gate.mjs"
89
90
  },
90
91
  "simple-git-hooks": {
91
92
  "pre-commit": "npx lint-staged"
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Quickstart gate (KJC-TSK-0635) — the landing's Quick Start, end to end,
4
+ * against the PACKED tarball, with a REAL coder run.
5
+ *
6
+ * Run manually before a release (`npm run verify-quickstart`). Opt-in
7
+ * because the coder run spends real subscription quota (~$0.5-1.5, 5-8
8
+ * minutes). What it catches that unit tests and verify-pack cannot:
9
+ * the exact first-contact sequence a new user follows — install → init →
10
+ * `kj run` on a no-remote repo → playable artifact (2026-07-19: that
11
+ * sequence surfaced KJC-BUG-0111/0112/0113 in one afternoon).
12
+ *
13
+ * Asserts: run exits 0 · report says approved · index.html exists with
14
+ * game markers · "skipping push/PR" logged · ZERO Solomon escalations.
15
+ */
16
+ import { execFileSync, spawnSync } from "node:child_process";
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+
21
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
22
+ const TASK = "Create a tic-tac-toe game in a single self-contained index.html — vanilla HTML, CSS and JavaScript, no server and no build step. The human plays X, the bot plays O and blocks or takes a winning move when it can. Detect wins and draws, and add a New Game button. Opening index.html in a browser must be enough to play.";
23
+
24
+ function fail(msg, detail) {
25
+ console.error(`\n✗ quickstart-gate: ${msg}`);
26
+ if (detail) console.error(String(detail).slice(-1200));
27
+ process.exitCode = 1;
28
+ throw new Error(msg);
29
+ }
30
+
31
+ let work = null;
32
+ try {
33
+ console.log("quickstart-gate: packing…");
34
+ const packOut = execFileSync("npm", ["pack", "--json", "--silent"], { cwd: repoRoot, encoding: "utf8" });
35
+ const tgz = path.join(repoRoot, JSON.parse(packOut)[0].filename);
36
+
37
+ work = fs.mkdtempSync(path.join(os.tmpdir(), "kj-qs-gate-"));
38
+ const prefix = path.join(work, "npm");
39
+ const project = path.join(work, "project");
40
+ fs.mkdirSync(project, { recursive: true });
41
+
42
+ console.log("quickstart-gate: installing the tarball (isolated prefix)…");
43
+ execFileSync("npm", ["install", "-g", tgz, "--no-audit", "--no-fund", "--silent", "--prefix", prefix], { encoding: "utf8" });
44
+ const kj = path.join(prefix, "bin", "kj");
45
+ fs.rmSync(tgz, { force: true });
46
+
47
+ // Isolated KARAJAN_HOME: faithful to a brand-new user (no global
48
+ // config) and never touches the maintainer's real ~/.karajan.
49
+ const env = { ...process.env, KARAJAN_HOME: path.join(work, "karajan-home") };
50
+ delete env.CLAUDECODE;
51
+ // If the maintainer's machine has a SonarQube server running, preflight
52
+ // (correctly) demands a token the isolated home doesn't have. Forward the
53
+ // maintainer's token so the gate exercises the sonar stage instead of
54
+ // dying in preflight. A machine with no sonar server is unaffected.
55
+ if (!env.KJ_SONAR_TOKEN) {
56
+ try {
57
+ const cfg = fs.readFileSync(path.join(os.homedir(), ".karajan", "kj.config.yml"), "utf8");
58
+ const m = cfg.match(/^\s*token:\s*(sqa_[A-Za-z0-9]+|squ_[A-Za-z0-9]+)\s*$/m);
59
+ if (m) env.KJ_SONAR_TOKEN = m[1];
60
+ } catch { /* no global config — brand-new machine, nothing to forward */ }
61
+ }
62
+
63
+ console.log("quickstart-gate: git init + kj init (unattended)…");
64
+ execFileSync("git", ["init", "-q", "-b", "main"], { cwd: project });
65
+ const init = spawnSync(kj, ["init", "--no-interactive", "--no-ollama", "--no-rtk", "--no-squeezr", "--no-qmd"], {
66
+ cwd: project, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 300000,
67
+ });
68
+ if (init.status !== 0) fail("kj init failed", init.stderr || init.stdout);
69
+
70
+ console.log("quickstart-gate: kj run (REAL coder — this spends quota, ~5-8 min)…");
71
+ const runRes = spawnSync(kj, ["run", "-y", TASK], {
72
+ cwd: project, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20 * 60 * 1000,
73
+ });
74
+ const runOut = `${runRes.stdout || ""}${runRes.stderr || ""}`;
75
+ fs.writeFileSync(path.join(work, "run.log"), runOut);
76
+
77
+ if (runRes.status !== 0) fail(`kj run exited ${runRes.status} (log: ${work}/run.log)`, runOut);
78
+ if (/escalating to Solomon/i.test(runOut)) fail(`Solomon escalation detected — the happy path must not consult Solomon (log: ${work}/run.log)`);
79
+ // KJC-BUG-0112 regression: a remote-less repo must never attempt (and
80
+ // fail) push/fetch automation. With init's defaults (auto_push: false)
81
+ // the "skipping push/PR" line is legitimately absent, so assert the
82
+ // absence of failure symptoms rather than the presence of the skip line.
83
+ if (/failed to push|fatal: .*origin|couldn't find remote/i.test(runOut)) fail(`push/fetch against a missing remote detected (log: ${work}/run.log)`);
84
+
85
+ const html = path.join(project, "index.html");
86
+ if (!fs.existsSync(html)) fail("index.html was not created");
87
+ const htmlText = fs.readFileSync(html, "utf8");
88
+ if (!/new game/i.test(htmlText)) fail("index.html has no New Game control — not the requested game");
89
+
90
+ const report = spawnSync(kj, ["report"], { cwd: project, env, encoding: "utf8", timeout: 60000 });
91
+ if (!/approved/i.test(`${report.stdout || ""}`)) fail("kj report does not say approved", report.stdout);
92
+
93
+ console.log("\n✓ quickstart-gate: install → init → run → playable index.html → approved. Ship it.");
94
+ } finally {
95
+ if (work && process.exitCode !== 1 && fs.existsSync(work)) fs.rmSync(work, { recursive: true, force: true });
96
+ if (work && process.exitCode === 1) console.error(`quickstart-gate: workdir kept for inspection: ${work}`);
97
+ }
@@ -52,6 +52,7 @@ let tgzPath = null;
52
52
  let tmpDir = null;
53
53
  let gTmp = null;
54
54
  let pnpmTmp = null;
55
+ let qsTmp = null;
55
56
  try {
56
57
  console.log(`verify-pack: packing karajan-code@${expectedVersion}…`);
57
58
  // --json gives us the exact filename without parsing human output.
@@ -146,6 +147,42 @@ try {
146
147
  }
147
148
  console.log(`verify-pack: global install + kj --version → ${gVersion} ✓`);
148
149
 
150
+ // 5.5 Quickstart smoke (KJC-TSK-0635) — the landing's Getting Started
151
+ // sequence against the INSTALLED tarball, no LLM involved. Born from
152
+ // 2026-07-19: following the docs to the letter caught 3 field bugs
153
+ // (silent 2.34.0 install, Solomon loop on no-remote repos, retired
154
+ // gemini CLI) that 6000 unit tests never saw. Aux bootstraps (ollama,
155
+ // rtk, squeezr, qmd, harden) are skipped — this validates kj's own
156
+ // init + config on the canonical no-remote scenario, not the network.
157
+ qsTmp = fs.mkdtempSync(path.join(os.tmpdir(), "kj-verify-qs-"));
158
+ console.log(`verify-pack: quickstart smoke (init on a no-remote repo) in ${qsTmp}…`);
159
+ run("git", ["init", "-q", "-b", "main"], { cwd: qsTmp });
160
+ // KARAJAN_HOME points at an isolated dir: faithful to a brand-new user
161
+ // (no pre-existing global config — `--local` without one is rejected by
162
+ // design, which is exactly what this smoke caught on its first CI run)
163
+ // AND hermetic (never touches the real ~/.karajan).
164
+ const qsEnv = { ...childEnv, KARAJAN_HOME: path.join(qsTmp, "karajan-home") };
165
+ delete qsEnv.CLAUDECODE;
166
+ const initRes = spawnSync(gBin, ["init", "--no-interactive", "--no-ollama", "--no-rtk", "--no-squeezr", "--no-qmd", "--no-harden"], {
167
+ encoding: "utf8", cwd: qsTmp, env: qsEnv, timeout: 180000,
168
+ });
169
+ if (initRes.status !== 0) {
170
+ fail("`kj init --no-interactive` failed on a fresh no-remote repo", (initRes.stderr || initRes.stdout || "").slice(-800));
171
+ }
172
+ const qsCfgLocal = path.join(qsTmp, ".karajan", "kj.config.yml");
173
+ const qsCfgGlobal = path.join(qsTmp, "karajan-home", "kj.config.yml");
174
+ const qsCfg = fs.existsSync(qsCfgLocal) ? qsCfgLocal : qsCfgGlobal;
175
+ if (!fs.existsSync(qsCfg)) fail(`kj init did not write ${qsCfg}`);
176
+ if (!/coder:\s*\S+/.test(fs.readFileSync(qsCfg, "utf8"))) {
177
+ fail("generated kj.config.yml has no coder assignment");
178
+ }
179
+ const reportRes = spawnSync(gBin, ["report"], { encoding: "utf8", cwd: qsTmp, env: qsEnv, timeout: 60000 });
180
+ const reportOut = `${reportRes.stdout || ""}${reportRes.stderr || ""}`;
181
+ if (reportRes.status !== 0 && !/no session|sin sesi|not found/i.test(reportOut)) {
182
+ fail("`kj report` crashed on a project with no sessions", reportOut.slice(-400));
183
+ }
184
+ console.log("verify-pack: quickstart smoke (init + report, no-remote repo) ✓");
185
+
149
186
  // 6. pnpm install smoke (KJC-TSK-0580). pnpm's layout differs from npm's
150
187
  // (a symlinked virtual store), so it can break resolution of the bundled
151
188
  // karajan-core the way npm packaging breakage did before (KJC-BUG-0082/0086).
@@ -195,4 +232,5 @@ try {
195
232
  if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
196
233
  if (gTmp && fs.existsSync(gTmp)) fs.rmSync(gTmp, { recursive: true, force: true });
197
234
  if (pnpmTmp && fs.existsSync(pnpmTmp)) fs.rmSync(pnpmTmp, { recursive: true, force: true });
235
+ if (qsTmp && fs.existsSync(qsTmp)) fs.rmSync(qsTmp, { recursive: true, force: true });
198
236
  }
@@ -32,7 +32,7 @@ export const ADVANCED_GROUPS = [
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
- { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents"] },
35
+ { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
36
36
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry"] },
37
37
  ];
38
38
 
@@ -19,6 +19,7 @@ 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
23
  import { formatAdvancedIndex } from "../commands/advanced.js";
23
24
  import { withConfig } from "./_shared.js";
24
25
 
@@ -121,6 +122,19 @@ export function registerMeta(program, { pkgVersion }) {
121
122
  });
122
123
  });
123
124
 
125
+ // ENV-A1 (KJC-TSK-0639) — Karajan Environment v4: the host agent
126
+ // orchestrates, Karajan installs the method it must follow.
127
+ const env = program.command("env").description("Karajan Environment (v4) — playbook for host agents");
128
+ 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
+ .option("--no-rag", "Skip building the RAG index when the project has none (ENV-E1)")
132
+ .action(async (flags) => {
133
+ await withConfig(pkgVersion, "env-install", flags, async ({ config, logger }) => {
134
+ await envInstallCommand({ config, logger, flags });
135
+ });
136
+ });
137
+
124
138
  const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
125
139
  rag.command("index")
126
140
  .description("Index plans + onboarding (and optionally project sources) into the local vector store")
@@ -142,6 +156,7 @@ export function registerMeta(program, { pkgVersion }) {
142
156
  });
143
157
  rag.command("query <text>")
144
158
  .description("Run a semantic query against the indexed RAG corpus")
159
+ .option("--no-rag-update", "Skip the pre-query drift delta-update (ENV-E1)")
145
160
  .option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
146
161
  .option("--top-k <n>", "Number of hits to return (default: 5)", "5")
147
162
  .option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project. Default: cwd basename")
@@ -183,14 +183,25 @@ export function registerPipeline(program, { pkgVersion }) {
183
183
 
184
184
  program
185
185
  .command("review")
186
- .description("Run only reviewer")
186
+ .description("Run only reviewer (--staged/--check: v4 cross-AI gate with recorded verdict)")
187
187
  .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
188
188
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
189
189
  .option("--reviewer <name>")
190
190
  .option("--reviewer-model <name>")
191
191
  .option("--base-ref <ref>")
192
+ .option("--staged", "Review the staged diff with a cross-AI reviewer and record the verdict")
193
+ .option("--check", "Verify the recorded verdict matches the staged diff (exit 0/1, hook-friendly)")
194
+ .option("--range <range>", "Review a git range (e.g. main..HEAD) instead of the staged diff")
195
+ .option("--install-gate", "Enable the pre-commit review gate for this project (creates .karajan/review-gate)")
192
196
  .action(async (task, flags) => {
193
197
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
198
+ // ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
199
+ // the exact diff so the pre-commit hook can enforce cross-AI review.
200
+ if (flags.staged || flags.check || flags.range || flags.installGate) {
201
+ const { reviewGateCommand } = await import("../commands/review-gate.js");
202
+ await reviewGateCommand({ config, logger, flags: { ...flags, task } });
203
+ return;
204
+ }
194
205
  const { resolveTaskInput } = await import("../utils/task-file.js");
195
206
  const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
196
207
  await reviewCommand({ task: resolvedTask, config, logger, baseRef: flags.baseRef });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `kj env install` (ENV-A1/E1, KJC-TSK-0639/0640) — install/refresh the
3
+ * Karajan Environment playbook as a managed block in the host agents' rule
4
+ * files (CLAUDE.md, AGENTS.md), and make its step 1 real: when the project
5
+ * has no RAG index yet, build it (default ON, `--no-rag` opts out).
6
+ */
7
+ import { installPlaybook } from "../environment/playbook.js";
8
+ import { openVecStore, projectSlug, getLastIndexedCommit } from "../rag/vec-store.js";
9
+ import { ragIndexCommand } from "./rag.js";
10
+
11
+ function hasRagIndex(config, projectDir) {
12
+ const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
13
+ try { return Boolean(getLastIndexedCommit(db, projectSlug(projectDir))); }
14
+ finally { db.close(); }
15
+ }
16
+
17
+ export async function envInstallCommand({ config = null, logger = null, flags = {} }) {
18
+ const projectDir = config?.projectDir || process.cwd();
19
+ const result = await installPlaybook({
20
+ projectDir, target: flags.target || "both",
21
+ stateBackend: config?.state_backend || "hu-board",
22
+ });
23
+ console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
24
+
25
+ // ENV-E1: RAG-first — the playbook orders "query the RAG before coding",
26
+ // so installing the environment guarantees the index exists. An indexing
27
+ // failure is reported but never blocks the playbook install.
28
+ if (flags.rag !== false) {
29
+ try {
30
+ if (hasRagIndex(config, projectDir)) {
31
+ console.log("✓ RAG index present");
32
+ } else {
33
+ console.log("⏳ no RAG index for this project — building it (first time only)…");
34
+ await ragIndexCommand({ config, logger, flags: { withSources: true } });
35
+ }
36
+ } catch (err) {
37
+ result.ragError = err.message;
38
+ console.log(`⚠ RAG index could not be built (${err.message}) — run \`kj rag index --with-sources\` later`);
39
+ }
40
+ }
41
+ console.log(" The host agent now follows the method: RAG first, TDD, cross-AI review before commit.");
42
+ return result;
43
+ }
@@ -6,7 +6,7 @@ import { openVecStore, countChunks, projectSlug, getLastIndexedCommit, setLastIn
6
6
  import { makeEmbedder } from "../rag/embedders/factory.js";
7
7
  import { indexProject, indexProjectDelta } from "../rag/indexer.js";
8
8
  import { query } from "../rag/retriever.js";
9
- import { installPostMergeHook } from "../rag/auto-update.js";
9
+ import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
10
10
  import { loadGoldenQueries, runEval } from "../rag/eval.js";
11
11
  import { getKarajanHome } from "../utils/paths.js";
12
12
 
@@ -73,6 +73,11 @@ export async function ragInstallHooksCommand({ config, logger, flags = {} }) {
73
73
 
74
74
  export async function ragQueryCommand({ text, config, logger, flags = {} }) {
75
75
  if (!text) throw new Error("kj rag query: text argument required");
76
+ // ENV-E1 (KJC-TSK-0640): never serve stale code — delta-update on drift
77
+ // before searching. Same escape hatches as the pre-run check
78
+ // (--no-rag-update / config.rag.autoUpdate); failures degrade to a warn
79
+ // inside maybeAutoUpdate and the query proceeds with the current index.
80
+ await maybeAutoUpdate({ projectDir: config?.projectDir || process.cwd(), config, logger, flags });
76
81
  const db = openDb(config);
77
82
  try {
78
83
  const topK = Math.max(1, Number(flags.topK) || 5);
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { exists } from "../utils/fs.js";
4
4
  import { getSessionRoot } from "../utils/paths.js";
5
5
  import { loadConfig } from "../config.js";
6
+ import { DEFAULTS } from "../config/defaults.js";
6
7
 
7
8
  function parseBudgetFromActivityLog(logText) {
8
9
  if (!logText) {
@@ -131,6 +132,14 @@ async function buildReport(dir, sessionId) {
131
132
 
132
133
  const sonar = summarizeSonar(checkpoints);
133
134
  const budget = parseBudgetFromActivityLog(activityLog);
135
+ // KJC-BUG-0114: activity logs written when max_budget_usd was null carry
136
+ // a phantom "$0.00" ceiling (Number(null) === 0). Resolve the effective
137
+ // ceiling from the session's config snapshot, falling back to the
138
+ // shipped default; an explicit 0 means "no ceiling" and drops the suffix.
139
+ if (budget.limit_usd === 0) {
140
+ const snapBudget = session.config_snapshot?.max_budget_usd;
141
+ budget.limit_usd = snapBudget == null ? DEFAULTS.max_budget_usd : (Number(snapBudget) || null);
142
+ }
134
143
  const commits = summarizeCommits(session, checkpoints);
135
144
 
136
145
  const budgetTrace = Array.isArray(session.budget?.trace) ? session.budget.trace : [];
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `kj review --staged | --check | --range` (ENV-B1, KJC-TSK-0637) — the v4
3
+ * cross-AI review gate. Unlike the legacy task-review mode, this reviews a
4
+ * raw git diff with an AI DIFFERENT from the host agent and records the
5
+ * verdict tied to the exact diff (verdict-store), so the pre-commit hook
6
+ * (ENV-C) can verify it. Exit code 0 = approved, 1 = rejected/stale.
7
+ */
8
+ import { runCommand } from "../utils/process.js";
9
+ import { checkVerdict } from "../review/verdict-store.js";
10
+ import { runOneShotReview } from "../review/one-shot-review.js";
11
+
12
+ // Raw git always — never a wrapped/compressing runner (KJC-BUG-0115).
13
+ async function rawDiff(range) {
14
+ const args = range ? ["diff", range] : ["diff", "--cached"];
15
+ const res = await runCommand("git", args);
16
+ if (res.exitCode !== 0) {
17
+ throw new Error(res.stderr?.trim() || `git ${args.join(" ")} failed`);
18
+ }
19
+ return res.stdout;
20
+ }
21
+
22
+ function printVerdict(record) {
23
+ if (record.verdict === "approved") {
24
+ console.log(`✓ APPROVED by ${record.reviewer} (diff ${record.diffHash.slice(0, 12)})`);
25
+ if (record.summary) console.log(` ${record.summary}`);
26
+ return;
27
+ }
28
+ console.log(`✗ REJECTED by ${record.reviewer} — ${record.issues.length} blocking issue(s):`);
29
+ for (const issue of record.issues) {
30
+ const where = issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "";
31
+ console.log(` - (${issue.severity || "high"})${where} ${issue.description || issue.id}`);
32
+ if (issue.suggested_fix) console.log(` fix: ${issue.suggested_fix}`);
33
+ }
34
+ console.log("Fix the issues and run `kj review --staged` again — the verdict is tied to the exact diff.");
35
+ }
36
+
37
+ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
38
+ const projectDir = config?.projectDir || process.cwd();
39
+
40
+ if (flags.installGate) {
41
+ const fs = await import("node:fs/promises");
42
+ const path = await import("node:path");
43
+ const marker = path.join(projectDir, ".karajan", "review-gate");
44
+ await fs.mkdir(path.dirname(marker), { recursive: true });
45
+ await fs.writeFile(marker, "# Cross-AI review gate enabled (ENV-C1). Commit this file so the whole team inherits the gate.\n");
46
+ console.log("✓ review gate enabled — commits now require an approved cross-AI verdict (kj review --staged)");
47
+ const hookProbe = await runCommand("git", ["config", "core.hooksPath"]);
48
+ if (!hookProbe.stdout?.trim()) {
49
+ console.log("⚠ no core.hooksPath configured — run `kj harden` so the pre-commit hook enforces the gate");
50
+ }
51
+ return { installed: true };
52
+ }
53
+
54
+ const diff = await rawDiff(flags.range);
55
+
56
+ if (flags.check) {
57
+ const res = await checkVerdict(projectDir, diff);
58
+ console.log(res.ok
59
+ ? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
60
+ : `✗ ${res.reason}`);
61
+ process.exitCode = res.ok ? 0 : 1;
62
+ return res;
63
+ }
64
+
65
+ const record = await runOneShotReview({ diff, task: flags.task, config, logger, projectDir });
66
+ printVerdict(record);
67
+ process.exitCode = record.verdict === "approved" ? 0 : 1;
68
+ return record;
69
+ }
@@ -59,6 +59,9 @@ const DEFAULTS = {
59
59
  // runaway run must never drain a subscription quota unattended. Explicit
60
60
  // null in the user's config opts out (no cap).
61
61
  max_budget_usd: 5,
62
+ // ENV-D1 (KJC-TSK-0642): where work items live. The HU Board ships with
63
+ // kj; "planning-game" routes the v4 playbook to the user's PG MCP.
64
+ state_backend: "hu-board",
62
65
  review_rules: "./.karajan/review-rules.md",
63
66
  coder_rules: "./.karajan/coder-rules.md",
64
67
  base_branch: "main",
@@ -235,6 +235,12 @@ export const ConfigSchema = v.looseObject({
235
235
  v.number(),
236
236
  v.minValue(0, "max_budget_usd must be >= 0")
237
237
  ))),
238
+ // ENV-D1 (KJC-TSK-0642): where work items live — the integrated HU Board
239
+ // or the user's Planning Game. The v4 playbook renders per backend.
240
+ state_backend: v.optional(v.picklist(
241
+ ["hu-board", "planning-game"],
242
+ "state_backend must be \"hu-board\" or \"planning-game\""
243
+ )),
238
244
  review_rules: v.optional(v.string()),
239
245
  coder_rules: v.optional(v.string()),
240
246
  base_branch: v.optional(v.string()),
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Karajan Environment playbook (ENV-A1, KJC-TSK-0639).
3
+ *
4
+ * ONE source renders the method for every host agent: a managed block in
5
+ * CLAUDE.md (Claude Code) and AGENTS.md (Codex). The body is data here —
6
+ * not a template file — so it never counts as a repo AI-rule file and can
7
+ * be parameterized later (rag/gate state, backend) without forking copies.
8
+ *
9
+ * The playbook is agent context: every line costs attention on every turn,
10
+ * so it stays under 60 lines by test. Method over prose.
11
+ */
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { upsertManagedBlock } from "../utils/managed-markers.js";
15
+
16
+ export const PLAYBOOK_TARGETS = ["claude", "codex", "both"];
17
+
18
+ const TARGET_FILES = { claude: ["CLAUDE.md"], codex: ["AGENTS.md"], both: ["CLAUDE.md", "AGENTS.md"] };
19
+
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.
22
+ 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).",
25
+ };
26
+
27
+ const playbookBody = (stateBackend) => `# Karajan method (v4)
28
+
29
+ You are the orchestrator; Karajan governs the method. Follow this on every task:
30
+
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.
46
+
47
+ Useful commands: \`kj rag query\` · \`kj review --staged\` · \`kj review --check\` ·
48
+ \`kj report\` · \`kj check\`
49
+ `;
50
+
51
+ export function renderPlaybook({ stateBackend = "hu-board" } = {}) {
52
+ return playbookBody(stateBackend);
53
+ }
54
+
55
+ /**
56
+ * Install/refresh the playbook block in the target agent files.
57
+ * User content outside the managed block is never touched.
58
+ */
59
+ export async function installPlaybook({ projectDir, target = "both", version = "1", stateBackend = "hu-board" }) {
60
+ if (!PLAYBOOK_TARGETS.includes(target)) {
61
+ throw new Error(`unknown target "${target}" — use one of: ${PLAYBOOK_TARGETS.join(", ")}`);
62
+ }
63
+ const files = [];
64
+ for (const file of TARGET_FILES[target]) {
65
+ const fullPath = path.join(projectDir, file);
66
+ let source = "";
67
+ try { source = await fs.readFile(fullPath, "utf8"); } catch { /* new file */ }
68
+ const { content, action } = upsertManagedBlock({
69
+ source, blockId: "playbook", version, body: renderPlaybook({ stateBackend }), style: "html",
70
+ note: "do not edit: regenerated by kj env install",
71
+ });
72
+ if (action !== "unchanged") await fs.writeFile(fullPath, content);
73
+ files.push(file);
74
+ }
75
+ return { files, target };
76
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { addCheckpoint } from "../session/store.js";
7
+ import { stampStagedVerdict } from "../review/verdict-store.js";
7
8
  import {
8
9
  ensureGitRepo,
9
10
  currentBranch,
@@ -268,7 +269,16 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
268
269
  let committed = false;
269
270
  const commits = [];
270
271
  if (config.git.auto_commit) {
271
- const commitResult = await commitAll(commitMsg);
272
+ // ENV-F1: this path only runs after the pipeline's reviewer approved,
273
+ // so stamp that verdict for the staged diff — the v4 pre-commit gate
274
+ // (when the repo opted in) accepts the pipeline's own commit.
275
+ const commitResult = await commitAll(commitMsg, null, {
276
+ beforeCommit: () => stampStagedVerdict({
277
+ projectDir: config?.projectDir || process.cwd(),
278
+ reviewer: config?.reviewer || "pipeline-reviewer",
279
+ summary: `kj run session ${session?.id || ""}: reviewer approved`.trim(),
280
+ }),
281
+ });
272
282
  committed = commitResult.committed;
273
283
  if (commitResult.commit) {
274
284
  commits.push(commitResult.commit);
@@ -28,6 +28,16 @@ export function hookBody(hook, cmds = {}) {
28
28
  if (cmds.lint) lines.push(`${cmds.lint} || { echo 'kj harden: lint failed'; exit 1; }`);
29
29
  if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
30
30
  if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
31
+ lines.push(
32
+ "# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
33
+ "# a staged diff only enters with a recorded cross-AI approved verdict.",
34
+ "if [ -f .karajan/review-gate ]; then",
35
+ " if ! command -v kj >/dev/null 2>&1; then",
36
+ " echo 'kj: review gate is enabled but kj is not installed — see karajancode.com/docs/getting-started/installation'; exit 1",
37
+ " fi",
38
+ " kj review --check || { echo 'kj: no approved cross-AI verdict for the staged diff — run `kj review --staged`'; exit 1; }",
39
+ "fi"
40
+ );
31
41
  return lines.join("\n");
32
42
  }
33
43
  case "commit-msg":
@@ -12,6 +12,7 @@ import { resolveRole } from "../config.js";
12
12
  import { emitProgress, makeEvent } from "../utils/events.js";
13
13
  import { getTemplatesRoot } from "../utils/templates-root.js";
14
14
  import { BudgetTracker, extractUsageMetrics } from "../utils/budget.js";
15
+ import { DEFAULTS } from "../config/defaults.js";
15
16
  import { computeKjComparison } from "../budget/comparison.js";
16
17
  import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
17
18
  import { projectSlug } from "../plan/plan-store.js";
@@ -303,8 +304,13 @@ export async function handleDryRun({ task, config, flags, emitter, pipelineFlags
303
304
 
304
305
  export function createBudgetManager({ config, emitter, eventBase, getCompressionStats = null }) {
305
306
  const budgetTracker = new BudgetTracker({ pricing: config?.budget?.pricing });
306
- const budgetLimit = Number(config?.max_budget_usd);
307
- const hasBudgetLimit = Number.isFinite(budgetLimit) && budgetLimit >= 0;
307
+ // KJC-BUG-0114: Number(null) === 0, so a config that reached us with
308
+ // max_budget_usd: null produced a phantom "$X / $0.00" ceiling (and a
309
+ // permanent warn state). null/undefined fall back to the shipped
310
+ // default ceiling; an explicit 0 means "no ceiling".
311
+ const rawBudget = config?.max_budget_usd;
312
+ const budgetLimit = rawBudget == null ? DEFAULTS.max_budget_usd : Number(rawBudget);
313
+ const hasBudgetLimit = Number.isFinite(budgetLimit) && budgetLimit > 0;
308
314
  const warnThresholdPct = Number(config?.budget?.warn_threshold_pct ?? 80);
309
315
  let stageCounter = 0;
310
316
 
@@ -21,9 +21,8 @@ import { prepareGitAutomation } from "../../git/automation.js";
21
21
  import { CoderRole } from "../../roles/coder-role.js";
22
22
  import { PipelineContext } from "../pipeline-context.js";
23
23
  import { detectRtk } from "../../utils/rtk-detect.js";
24
- import { createRtkRunner, RtkSavingsTracker } from "../../utils/rtk-wrapper.js";
25
- import { setRunner as setDiffRunner, setProjectDir as setDiffProjectDir } from "../../review/diff-generator.js";
26
- import { setRunner as setGitRunner } from "../../utils/git.js";
24
+ import { RtkSavingsTracker } from "../../utils/rtk-wrapper.js";
25
+ import { setProjectDir as setDiffProjectDir } from "../../review/diff-generator.js";
27
26
  import {
28
27
  loadProductContext, resolvePipelineFlags, createBudgetManager,
29
28
  initializeSession, autoInit,
@@ -116,19 +115,19 @@ export async function initFlowContext({ task, config, logger, emitter, askQuesti
116
115
  const rtkResult = await detectRtk();
117
116
  if (rtkResult.available) {
118
117
  config = { ...config, rtk: { available: true, version: rtkResult.version } };
119
- const rtkTracker = new RtkSavingsTracker();
120
- const rtkRunner = createRtkRunner(true, rtkTracker);
121
- // TSK-0338: install rtkRunner into the per-run context; module-level
122
- // setters are the back-compat path for runs outside a withRunContext scope.
123
- if (runCtx) runCtx.runner = rtkRunner;
124
- else {
125
- setDiffRunner(rtkRunner);
126
- setGitRunner(rtkRunner);
127
- }
128
- ctx.rtkTracker = rtkTracker;
129
- logger.info(`RTK detected (${rtkResult.version}) — wrapping internal git/diff commands with rtk`);
118
+ // KJC-BUG-0115: rtk MUST NOT wrap the pipeline's internal git/diff
119
+ // commands. `rtk git diff` emits a compressed summary without
120
+ // `diff --git` headers, so every consumer that parses the output
121
+ // (tdd-policy extractChangedFiles, reviewer diffs, status checks)
122
+ // sees an empty change set — in the field this made the TDD gate
123
+ // fail forever with "(2 src, 0 test)" while real tests existed.
124
+ // RTK still saves tokens where it belongs: inside the coder agent's
125
+ // own shell, via its Claude Code hook. Detection is kept so the
126
+ // config advertises availability to agents.
127
+ ctx.rtkTracker = new RtkSavingsTracker();
128
+ logger.info(`RTK detected (${rtkResult.version}) — available to agents (internal git/diff stay unwrapped)`);
130
129
  emitProgress(emitter, makeEvent("rtk:detected", ctx.eventBase, {
131
- message: "RTK detected — internal commands wrapped for token optimization",
130
+ message: "RTK detected — available to agents",
132
131
  detail: { version: rtkResult.version, executorType: "local" }
133
132
  }));
134
133
  }
@@ -253,6 +253,7 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
253
253
  }
254
254
  if (iterResult.action === "retry") { i -= 1; }
255
255
  else {
256
+ await ensureIterationRecorded(ctx, i, logger);
256
257
  // Iteration gate (KJC-TSK-0628): opt-in pause with a report before the
257
258
  // next iteration; free-text answers become directives for the coder.
258
259
  const gate = await handleIterationGate({
@@ -306,6 +307,7 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
306
307
  if (iterResult.action === "return") return iterResult.result;
307
308
  if (iterResult.action === "retry") { i -= 1; }
308
309
  else {
310
+ await ensureIterationRecorded(ctx, i, logger);
309
311
  // Same iteration gate on Solomon-extended iterations (KJC-TSK-0628).
310
312
  const gate = await handleIterationGate({
311
313
  enabled: ctx.config.session?.iteration_gate === true,
@@ -323,9 +325,56 @@ export async function runIterationLoop(ctx, { task: loopTask, askQuestion, emitt
323
325
 
324
326
  // Extended iterations also exhausted — final Solomon call
325
327
  const finalResult = await handleMaxIterationsReached({ session: ctx.session, budgetSummary: ctx.budgetSummary, emitter, eventBase: ctx.eventBase, config: ctx.config, stageResults: ctx.stageResults, logger, askQuestion, task: loopTask, rtkTracker: ctx.rtkTracker, brainCtx: ctx.brainCtx });
326
- return finalResult;
328
+ return finalizeMaxIterationsApproval(ctx, finalResult, { task: loopTask, askQuestion, emitter, logger, i });
327
329
  }
328
330
 
329
- return maxIterResult;
331
+ return finalizeMaxIterationsApproval(ctx, maxIterResult, { task: loopTask, askQuestion, emitter, logger, i });
332
+ }
333
+
334
+ // KJC-BUG-0117: iterations that end before the reviewer (TDD/sonar
335
+ // "continue", guard retries) never reached the recordIteration call at
336
+ // the end of runSingleIteration, so session._journalIterations stayed
337
+ // empty and the journal read "Iterations: 0" after a 5-iteration run.
338
+ export async function ensureIterationRecorded(ctx, i, logger) {
339
+ if (!ctx.journalIterations) return;
340
+ if ((ctx.session._journalIterations?.length || 0) >= i) return;
341
+ try {
342
+ const { recordIteration, extractIterationData } = await import("../../session/journal/iteration-logger.js");
343
+ recordIteration(ctx.session, extractIterationData({
344
+ iteration: i, durationMs: 0, stageResults: ctx.stageResults, session: ctx.session,
345
+ }));
346
+ } catch (err) {
347
+ logger.warn(`Iteration journal record failed (non-blocking): ${err.message}`);
348
+ }
349
+ }
350
+
351
+ // KJC-BUG-0116: an "approved" verdict at max_iterations (brain_approved,
352
+ // brain_solomon_approved, solomon_approved) used to be returned as-is,
353
+ // skipping the entire post-loop (tester/security/audit) AND git
354
+ // automation (push/PR) that every reviewer-approved session gets — the
355
+ // session read "approved" with zero verification and zero push. Route it
356
+ // through the same handleApprovedReview path instead.
357
+ export async function finalizeMaxIterationsApproval(ctx, maxIterResult, { task, askQuestion, emitter, logger, i }) {
358
+ if (maxIterResult?.approved !== true) return maxIterResult;
359
+
360
+ const review = {
361
+ approved: true,
362
+ raw_summary: `Finalized at max_iterations (${maxIterResult.reason || "approved"})`,
363
+ blocking_issues: []
364
+ };
365
+ const fin = await handleApprovedReview({
366
+ config: ctx.config, session: ctx.session, emitter, eventBase: ctx.eventBase,
367
+ coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, i, task,
368
+ stageResults: ctx.stageResults, pipelineFlags: ctx.pipelineFlags, askQuestion, logger,
369
+ gitCtx: ctx.gitCtx, budgetSummary: ctx.budgetSummary, pgCard: ctx.pgCard, pgProject: ctx.pgProject,
370
+ review, rtkTracker: ctx.rtkTracker, brainCtx: ctx.brainCtx
371
+ });
372
+ if (fin.action === "return") return fin.result;
373
+
374
+ // Post-loop demanded another coder pass but iterations are exhausted —
375
+ // report honestly instead of a false green.
376
+ logger.warn("Post-loop stages rejected the work at max_iterations — session NOT approved");
377
+ await markSessionStatus(ctx.session, "failed");
378
+ return { approved: false, sessionId: ctx.session.id, reason: "post_loop_rejected_at_max_iterations" };
330
379
  }
331
380
 
@@ -389,16 +389,19 @@ async function handleTddFailure({ tddEval, config, logger, emitter, eventBase, s
389
389
  return { action: "continue" };
390
390
  }
391
391
 
392
- // Brain: when enabled, skip Solomon — Brain handles via max_iterations
392
+ // Brain: at the sub-loop limit the TDD gate must stop eating iterations.
393
+ // KJC-BUG-0115: returning "continue" here short-circuited runSingleIteration
394
+ // before the reviewer gate — 5/5 iterations ended without any review. The
395
+ // failure is already queued as feedback; "proceed" lets the reviewer run.
393
396
  if (brainCtx?.enabled) {
394
- logger.info("Brain: TDD sub-loop limit reached — Brain will handle via max_iterations (Solomon bypassed)");
397
+ logger.info("Brain: TDD sub-loop limit reached — proceeding to reviewer with TDD failure as pending feedback");
395
398
  emitProgress(emitter, makeEvent("brain:tdd-retry-limit", { ...eventBase, stage: "tdd" }, {
396
- message: `TDD sub-loop limit reached (${session.repeated_issue_count}/${config.session.fail_fast_repeats}) — Brain handling`,
399
+ message: `TDD sub-loop limit reached (${session.repeated_issue_count}/${config.session.fail_fast_repeats}) — proceeding to reviewer`,
397
400
  detail: { subloop: "tdd", retryCount: session.repeated_issue_count, reason: tddEval.reason }
398
401
  }));
399
402
  resetRetryCount(session, "repeated_issue");
400
403
  await saveSession(session);
401
- return { action: "continue" };
404
+ return { action: "proceed" };
402
405
  }
403
406
 
404
407
  emitProgress(
@@ -119,7 +119,14 @@ async function listFiles(dir, predicate) {
119
119
  * it as `--with-sources`.
120
120
  */
121
121
  export async function indexProject(projectDir, { db, embedder, karajanHome, logger = console, withSources = false } = {}) {
122
- const totals = { indexed: 0, failed: 0, files: 0 };
122
+ // KJC-TSK-0640: a full index must stamp HEAD too — without it,
123
+ // last_indexed_commit stayed null after the FIRST index, so the drift
124
+ // delta-update (maybeAutoUpdate) never engaged until a manual --since.
125
+ const totals = { indexed: 0, failed: 0, files: 0, head: null };
126
+ try {
127
+ const { stdout } = await execa("git", ["-C", projectDir, "rev-parse", "HEAD"]);
128
+ totals.head = stdout.trim();
129
+ } catch { /* not a git repo (or zero commits) — nothing to stamp */ }
123
130
  const slug = projectDir.split("/").pop()?.replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
124
131
  await prepareAdapters(detectAdaptersForProject(projectDir), { logger });
125
132
  const planRoot = join(karajanHome, PLANS_DIR, slug);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * One-shot cross-AI review (ENV-B1, KJC-TSK-0637) — the v4 primitive.
3
+ *
4
+ * The HOST agent (Claude Code, Codex) orchestrates the work; Karajan
5
+ * routes the review to a DIFFERENT AI and records the verdict tied to
6
+ * the exact diff (verdict-store). No cross-AI reviewer available is an
7
+ * error, never a silent fallback to the host: without a second pair of
8
+ * eyes there is no verdict, and without a verdict the pre-commit gate
9
+ * (ENV-C) keeps the commit out.
10
+ */
11
+ import { createAgent } from "../agents/index.js";
12
+ import { resolveRole } from "../config/role-resolver.js";
13
+ import { buildReviewerPrompt } from "../prompts/reviewer.js";
14
+ import { resolveReviewProfile } from "./profiles.js";
15
+ import { parseMaybeJsonString } from "./parser.js";
16
+ import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
+ import { saveVerdict } from "./verdict-store.js";
18
+
19
+ // Cross-AI preference when the configured reviewer IS the host.
20
+ const CROSS_ORDER = ["codex", "claude", "gemini", "opencode", "aider"];
21
+
22
+ /**
23
+ * Pick a reviewer that is NOT the host agent.
24
+ * @returns {Promise<string|null>} provider name, or null if none exists.
25
+ */
26
+ export async function pickCrossReviewer({ config, hostAgent, detectAgents = detectAvailableAgents }) {
27
+ const configured = resolveRole(config, "reviewer").provider;
28
+ if (configured && configured !== hostAgent) return configured;
29
+
30
+ const agents = await detectAgents();
31
+ const candidates = agents
32
+ .filter((a) => a.available && a.name !== hostAgent)
33
+ .map((a) => a.name);
34
+ return CROSS_ORDER.find((name) => candidates.includes(name)) || candidates[0] || null;
35
+ }
36
+
37
+ /**
38
+ * Review a raw diff with a cross-AI reviewer and persist the verdict.
39
+ * @returns {Promise<object>} the stored verdict record.
40
+ */
41
+ export async function runOneShotReview({
42
+ diff, task, config, logger, projectDir,
43
+ hostAgent = detectHostAgent(),
44
+ createAgentFn = createAgent,
45
+ detectAgents = detectAvailableAgents,
46
+ }) {
47
+ if (!diff || !diff.trim()) {
48
+ throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
49
+ }
50
+
51
+ const reviewer = await pickCrossReviewer({ config, hostAgent, detectAgents });
52
+ if (!reviewer) {
53
+ throw new Error(
54
+ `cross-AI review requires an agent other than the host (${hostAgent || "unknown"}) — install codex, claude or another supported CLI`
55
+ );
56
+ }
57
+
58
+ const { rules } = await resolveReviewProfile({ mode: "standard", projectDir });
59
+ const prompt = await buildReviewerPrompt({
60
+ task: task || "Review the following diff for correctness, security and maintainability.",
61
+ diff, reviewRules: rules, mode: "standard", provider: reviewer, projectDir,
62
+ });
63
+
64
+ logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${reviewer} (cross-AI)`);
65
+ const agent = createAgentFn(reviewer, config, logger);
66
+ const result = await agent.reviewTask({ prompt, role: "reviewer" });
67
+ if (!result?.ok) {
68
+ throw new Error(`reviewer ${reviewer} failed: ${result?.error || "no output"}`);
69
+ }
70
+
71
+ const parsed = parseMaybeJsonString(result.output);
72
+ if (!parsed || typeof parsed.approved !== "boolean") {
73
+ throw new Error(`reviewer ${reviewer} returned no parseable verdict`);
74
+ }
75
+
76
+ return saveVerdict(projectDir, diff, {
77
+ verdict: parsed.approved ? "approved" : "rejected",
78
+ reviewer,
79
+ host: hostAgent || null,
80
+ issues: parsed.blocking_issues || [],
81
+ suggestions: parsed.non_blocking_suggestions || [],
82
+ summary: parsed.summary || parsed.raw_summary || "",
83
+ confidence: parsed.confidence ?? null,
84
+ });
85
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Verdict store (ENV-B1, KJC-TSK-0637) — persists cross-AI review verdicts
3
+ * keyed by the sha256 of the RAW diff they reviewed.
4
+ *
5
+ * The hash is the contract: a verdict only counts for byte-identical
6
+ * content, so any change after the review voids it and forces a new one
7
+ * (resolve-until-pass by construction). The pre-commit hook (ENV-C)
8
+ * calls checkVerdict() with the staged diff to decide whether the
9
+ * commit may enter. Diffs must be raw git output — never rtk-compressed
10
+ * (KJC-BUG-0115).
11
+ */
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { runCommand } from "../utils/process.js";
16
+
17
+ const STORE_DIR = path.join(".karajan", "reviews");
18
+
19
+ export function diffHash(diff) {
20
+ // trimEnd: runners differ on the final newline (execa strips it, raw
21
+ // git keeps it) — trailing whitespace must not void a verdict.
22
+ return crypto.createHash("sha256").update(diff.trimEnd(), "utf8").digest("hex");
23
+ }
24
+
25
+ function verdictPath(projectDir, hash) {
26
+ return path.join(projectDir, STORE_DIR, `${hash}.json`);
27
+ }
28
+
29
+ export async function saveVerdict(projectDir, diff, verdict) {
30
+ const hash = diffHash(diff);
31
+ const record = {
32
+ ...verdict,
33
+ diffHash: hash,
34
+ timestamp: new Date().toISOString(),
35
+ };
36
+ const file = verdictPath(projectDir, hash);
37
+ await fs.mkdir(path.dirname(file), { recursive: true });
38
+ await fs.writeFile(file, `${JSON.stringify(record, null, 2)}\n`);
39
+ return record;
40
+ }
41
+
42
+ export async function loadVerdict(projectDir, hash) {
43
+ try {
44
+ return JSON.parse(await fs.readFile(verdictPath(projectDir, hash), "utf8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * ENV-F1 (KJC-TSK-0643): headless pipeline sessions call this after staging
52
+ * and before committing. Their reviewer ALREADY cross-AI-reviewed the work,
53
+ * so the verdict is recorded for the staged diff and the v4 pre-commit gate
54
+ * accepts the pipeline's commit. No gate marker → no-op (zero overhead for
55
+ * repos that never opted in). Raw git only — never a wrapped runner
56
+ * (KJC-BUG-0115).
57
+ * @returns {Promise<{stamped: boolean}>}
58
+ */
59
+ export async function stampStagedVerdict({ projectDir, reviewer, summary = "" }) {
60
+ const dir = projectDir || process.cwd();
61
+ try {
62
+ await fs.access(path.join(dir, ".karajan", "review-gate"));
63
+ } catch {
64
+ return { stamped: false };
65
+ }
66
+ const res = await runCommand("git", ["diff", "--cached"], { cwd: dir });
67
+ if (res.exitCode !== 0 || !res.stdout?.trim()) return { stamped: false };
68
+ await saveVerdict(dir, res.stdout, {
69
+ verdict: "approved", reviewer, host: "kj-pipeline", issues: [], summary,
70
+ });
71
+ return { stamped: true };
72
+ }
73
+
74
+ /**
75
+ * Is there an APPROVED verdict for exactly this diff?
76
+ * @returns {Promise<{ok: boolean, verdict?: object, reason?: string}>}
77
+ */
78
+ export async function checkVerdict(projectDir, diff) {
79
+ const verdict = await loadVerdict(projectDir, diffHash(diff));
80
+ if (!verdict) {
81
+ return { ok: false, reason: "no verdict recorded for the current diff — run `kj review`" };
82
+ }
83
+ if (verdict.verdict !== "approved") {
84
+ return { ok: false, verdict, reason: `review was rejected by ${verdict.reviewer} — fix the issues and run \`kj review\` again` };
85
+ }
86
+ return { ok: true, verdict };
87
+ }
@@ -1,4 +1,5 @@
1
1
  import { BaseRole } from "./base-role.js";
2
+ import { stampStagedVerdict } from "../review/verdict-store.js";
2
3
  import {
3
4
  ensureGitRepo,
4
5
  currentBranch,
@@ -57,7 +58,15 @@ export class CommiterRole extends BaseRole {
57
58
  }
58
59
 
59
60
  const msg = commitMessage || buildCommitMessage(task);
60
- await commitAll(msg);
61
+ // ENV-F1 (KJC-TSK-0643): CommiterRole runs after the HU review passed —
62
+ // stamp the pipeline's verdict so the v4 gate accepts this commit.
63
+ await commitAll(msg, null, {
64
+ beforeCommit: () => stampStagedVerdict({
65
+ projectDir: this.config?.projectDir || process.cwd(),
66
+ reviewer: this.config?.reviewer || "pipeline-reviewer",
67
+ summary: "kj pipeline (commiter role): review passed",
68
+ }),
69
+ });
61
70
  const commitHash = await revParse("HEAD");
62
71
 
63
72
  // KJC-BUG-0112: no `origin` remote (quickstart scenario) → skip
package/src/utils/git.js CHANGED
@@ -229,11 +229,15 @@ function isNothingToCommit(message) {
229
229
  return NOTHING_TO_COMMIT_PATTERNS.some((re) => re.test(m));
230
230
  }
231
231
 
232
- export async function commitAll(message, cwd = null) {
232
+ export async function commitAll(message, cwd = null, { beforeCommit = null } = {}) {
233
233
  const opts = cwd ? { cwd } : {};
234
234
  await runGit(["add", "-A"], opts);
235
235
  const changed = await hasChanges(cwd);
236
236
  if (!changed) return { committed: false };
237
+ // ENV-F1 (KJC-TSK-0643): runs between staging and committing — the only
238
+ // window where the staged diff is exactly what the commit will contain
239
+ // (used to stamp the pipeline's review verdict for the v4 gate).
240
+ if (beforeCommit) await beforeCommit();
237
241
  try {
238
242
  await runGit(["commit", "-m", message], opts);
239
243
  } catch (err) {
@@ -15,7 +15,13 @@ const CAPABILITY_TIERS = {
15
15
  // Role requirements: what capability level is ideal for each role
16
16
  const ROLE_PREFERENCES = {
17
17
  brain: { minTier: 4, prefer: "claude", description: "Karajan Brain (orchestrator)" },
18
- solomon: { minTier: 3, prefer: "gemini", description: "Solomon (judge/arbiter)" },
18
+ // KJC-BUG-0113: Solomon preferred gemini, but Google retired the Gemini
19
+ // Code Assist CLI for individuals — a binary that answers --version yet
20
+ // dies with IneligibleTierError on every real call. Any machine with the
21
+ // stale CLI on PATH got a judge that could never rule. Claude matches
22
+ // the brain default; diversifyReviewer still picks a different agent
23
+ // when more than one healthy option exists.
24
+ solomon: { minTier: 3, prefer: "claude", description: "Solomon (judge/arbiter)" },
19
25
  coder: { minTier: 2, prefer: "claude", description: "Coder" },
20
26
  reviewer: { minTier: 3, prefer: "codex", description: "Reviewer" },
21
27
  planner: { minTier: 4, prefer: "claude", description: "Planner" },