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 +11 -1
- package/package.json +3 -2
- package/scripts/quickstart-gate.mjs +97 -0
- package/scripts/verify-pack.mjs +38 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +15 -0
- package/src/cli/register-pipeline.js +12 -1
- package/src/commands/env.js +43 -0
- package/src/commands/rag.js +6 -1
- package/src/commands/report.js +9 -0
- package/src/commands/review-gate.js +69 -0
- package/src/config/defaults.js +3 -0
- package/src/config/schema.js +6 -0
- package/src/environment/playbook.js +76 -0
- package/src/git/automation.js +11 -1
- package/src/harden/hook-templates.js +10 -0
- package/src/orchestrator/config-init.js +8 -2
- package/src/orchestrator/drivers/init-context.js +14 -15
- package/src/orchestrator/drivers/iteration-loop.js +51 -2
- package/src/orchestrator/stages/coder-stage.js +7 -4
- package/src/rag/indexer.js +8 -1
- package/src/review/one-shot-review.js +85 -0
- package/src/review/verdict-store.js +87 -0
- package/src/roles/commiter-role.js +10 -1
- package/src/utils/git.js +5 -1
- package/src/utils/role-assigner.js +7 -1
package/README.md
CHANGED
|
@@ -23,10 +23,20 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **
|
|
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
|
+
"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
|
+
}
|
package/scripts/verify-pack.mjs
CHANGED
|
@@ -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
|
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/rag.js
CHANGED
|
@@ -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);
|
package/src/commands/report.js
CHANGED
|
@@ -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
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -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",
|
package/src/config/schema.js
CHANGED
|
@@ -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
|
+
}
|
package/src/git/automation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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 {
|
|
25
|
-
import {
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
ctx.rtkTracker =
|
|
129
|
-
logger.info(`RTK detected (${rtkResult.version}) —
|
|
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 —
|
|
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:
|
|
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 —
|
|
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}) —
|
|
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: "
|
|
404
|
+
return { action: "proceed" };
|
|
402
405
|
}
|
|
403
406
|
|
|
404
407
|
emitProgress(
|
package/src/rag/indexer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" },
|