karajan-code 3.15.3 → 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.3",
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",
@@ -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);
@@ -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":
@@ -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) {