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 +11 -1
- package/package.json +1 -1
- 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/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/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/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
|
@@ -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);
|
|
@@ -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":
|
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) {
|