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