karajan-code 3.10.2 → 3.12.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.10.2",
3
+ "version": "3.12.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",
@@ -192,6 +192,9 @@ const ragStubPlugin = {
192
192
  // reached from \`kj rag install-hooks\`, so it degrades like the rest.
193
193
  maybeAutoUpdate: async () => ({ skipped: true }),
194
194
  installPostMergeHook: notAvailable,
195
+ // KJC-BUG-0100 — the doctor rag-hooks check imports this; it throws
196
+ // here and the check swallows it (degrades to a benign info result).
197
+ resolveHooksDir: notAvailable,
195
198
  default: notAvailable,
196
199
  };
197
200
  `,
@@ -36,17 +36,8 @@ esac
36
36
  target="${os}-${arch}"
37
37
  case "$target" in
38
38
  linux-x64) ;;
39
- darwin-arm64)
40
- # The macOS standalone binary is not published yet (tracked in
41
- # KJC-BUG-0096: the darwin-arm64 SEA build crashes under smoke-test).
42
- # Degrade gracefully to the npm install path instead of trying to
43
- # download an asset that does not exist.
44
- echo "kj-install: the macOS standalone binary is not available yet." >&2
45
- echo "kj-install: install with npm instead (requires Node 18+):" >&2
46
- echo " npm install -g karajan-code" >&2
47
- exit 0
48
- ;;
49
- *) die "no prebuilt binary for '$target'. Available: linux-x64. On macOS use npm instead: npm install -g karajan-code" ;;
39
+ darwin-arm64) ;;
40
+ *) die "no prebuilt binary for '$target'. Available: linux-x64, darwin-arm64. Use npm instead: npm install -g karajan-code" ;;
50
41
  esac
51
42
 
52
43
  # --- Resolve the download URL for the requested version (or latest). ---
@@ -0,0 +1,54 @@
1
+ /**
2
+ * RAG post-merge hook wiring check (KJC-BUG-0100).
3
+ *
4
+ * `kj rag install-hooks` used to write the hook into `.git/hooks/post-merge`,
5
+ * but when a repo is hardened `git config core.hooksPath` points at
6
+ * `.karajan/hooks` and git ignores `.git/hooks/` entirely — so the RAG index
7
+ * silently never refreshes after a merge. This check resolves the *effective*
8
+ * hooks dir and reports whether the post-merge hook there reindexes the RAG.
9
+ * The pre-run drift check in `kj run` already covers the gap, so a missing
10
+ * hook is advisory (info), not a failure — it surfaces the state without
11
+ * nagging every repo that never ran `kj rag install-hooks`.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { STRATEGY } from "./types.js";
17
+ import { resolveHooksDir } from "../rag/auto-update.js";
18
+
19
+ export function createRagHooksCheck({ projectDir } = {}) {
20
+ return {
21
+ name: "rag-hooks",
22
+ label: "RAG post-merge refresh",
23
+ strategy: STRATEGY.MANUAL,
24
+ async detect() {
25
+ const dir = projectDir || process.cwd();
26
+ let hooks;
27
+ try {
28
+ hooks = await resolveHooksDir(dir);
29
+ } catch {
30
+ return { ok: true, severity: "info", detail: "not a git repository — no post-merge hook to wire" };
31
+ }
32
+ const target = join(hooks.dir, "post-merge");
33
+ const wired = existsSync(target) && /kj\s+rag\s+index/.test(readFileSync(target, "utf8"));
34
+ if (wired) {
35
+ return {
36
+ ok: true,
37
+ severity: "info",
38
+ detail: `post-merge reindexes the RAG (${hooks.hooksPath || ".git/hooks"})`,
39
+ };
40
+ }
41
+ const where = hooks.hooksPath ? ` — core.hooksPath=${hooks.hooksPath}` : "";
42
+ return {
43
+ ok: false,
44
+ severity: "info",
45
+ detail: `RAG index won't auto-refresh after merges${where}; pre-run drift check still covers \`kj run\``,
46
+ fix: "Wire the post-merge hook: `kj rag install-hooks`",
47
+ };
48
+ },
49
+ };
50
+ }
51
+
52
+ export function getRagHooksChecks({ projectDir } = {}) {
53
+ return [createRagHooksCheck({ projectDir })];
54
+ }
@@ -226,7 +226,7 @@ export function registerPipeline(program, { pkgVersion }) {
226
226
 
227
227
  program
228
228
  .command("install-tools")
229
- .description("Install external audit tools (semgrep, osv-scanner, lighthouse, docker, sonar) using the package manager available on your system")
229
+ .description("Install the tools kj needs (git, agent CLI: claude+codex) plus the external audit tools (semgrep, osv-scanner, lighthouse, docker, sonar) using the package manager available on your system")
230
230
  .option("--only <tools>", "Comma-separated subset (e.g. \"semgrep,osv-scanner\"). Bypasses stack-gating.")
231
231
  .option("-y, --yes", "Auto-accept all prompts (non-interactive)")
232
232
  .option("--dry-run", "Show what would be installed without running anything")
@@ -25,6 +25,7 @@ import { getRtkChecks } from "../checks/rtk.js";
25
25
  import { getSqueezrChecks } from "../checks/squeezr.js";
26
26
  import { getAiTrashChecks } from "../checks/ai-trash.js";
27
27
  import { getQmdChecks } from "../checks/qmd.js";
28
+ import { getRagHooksChecks } from "../checks/rag-hooks.js";
28
29
  import { getNodeChecks } from "../checks/node.js";
29
30
  import { getNativeBuildChecks } from "../checks/native-build.js";
30
31
  import { getPortChecks } from "../checks/ports.js";
@@ -72,6 +73,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
72
73
  ...getSqueezrChecks(),
73
74
  ...getAiTrashChecks(),
74
75
  ...getQmdChecks(),
76
+ ...getRagHooksChecks({ projectDir }),
75
77
  ...getProjectChecks({ projectDir }),
76
78
  ];
77
79
  }
@@ -14,15 +14,29 @@
14
14
  import readline from "node:readline";
15
15
  import { execFile } from "node:child_process";
16
16
  import { promisify } from "node:util";
17
+ import os from "node:os";
17
18
  import path from "node:path";
18
19
  import fs from "node:fs/promises";
19
20
  import { checkBinary } from "../utils/agent-detect.js";
20
- import { getInstallHint, detectPackageManagers, appliesToStack } from "../utils/install-hints.js";
21
+ import { getInstallHint, detectPackageManagers, appliesToStack, gitInstallPlan } from "../utils/install-hints.js";
21
22
  import { detectProjectStack } from "../utils/stack-detect.js";
23
+ import { resolveStandalone } from "../utils/binary-sources.js";
24
+ import { downloadBinary, binDir, runInstallCommand } from "../utils/tool-installer.js";
25
+ import { dockerInstallPlan } from "../utils/docker-install.js";
22
26
 
23
27
  const execFileAsync = promisify(execFile);
24
28
 
25
- const ALL_TOOLS = ["semgrep", "osv-scanner", "lighthouse", "docker", "sonar"];
29
+ // git and the agent CLI lead the list: both are `kj doctor` *required* tools,
30
+ // so a blank machine wants them before the optional audit tools.
31
+ const ALL_TOOLS = ["git", "agent-cli", "semgrep", "osv-scanner", "lighthouse", "docker", "sonar"];
32
+
33
+ // The default pipeline is coder=claude, reviewer=codex, so `agent-cli` installs
34
+ // exactly those two. gemini stays out of the default: it is a supported
35
+ // reviewer, not a required one. Both ship as global npm packages.
36
+ const AGENT_CLIS = [
37
+ { bin: "claude", pkg: "@anthropic-ai/claude-code", url: "https://docs.anthropic.com/en/docs/claude-code" },
38
+ { bin: "codex", pkg: "@openai/codex", url: "https://github.com/openai/codex" },
39
+ ];
26
40
 
27
41
  function parseOnlyList(raw) {
28
42
  if (!raw) return null;
@@ -52,11 +66,100 @@ async function isInstalled(tool) {
52
66
  // sonar is a Docker container, not a binary. We check it separately
53
67
  // by looking at running containers via `docker ps`.
54
68
  if (tool === "sonar") return checkSonarRunning();
69
+ // agent-cli is "installed" only when BOTH default-pipeline CLIs are present;
70
+ // a partial install (one of two) still routes to handleAgentCli.
71
+ if (tool === "agent-cli") {
72
+ const present = await Promise.all(AGENT_CLIS.map(async (c) => (await checkBinary(c.bin)).ok));
73
+ return present.every(Boolean);
74
+ }
55
75
  if (tool === "lighthouse") return (await checkBinary("lighthouse")).ok;
56
76
  if (tool === "docker") return (await checkBinary("docker")).ok;
57
77
  return (await checkBinary(tool)).ok;
58
78
  }
59
79
 
80
+ /**
81
+ * Agent-CLI handler. Installs whichever of the default-pipeline CLIs
82
+ * (coder=claude, reviewer=codex) are missing, via global npm. Opt-in with the
83
+ * exact command shown first. When npm itself is absent we surface the manual
84
+ * commands + URLs rather than failing silently.
85
+ */
86
+ async function handleAgentCli({ available, dryRun, yes, logger }) {
87
+ const missing = [];
88
+ for (const cli of AGENT_CLIS) {
89
+ if (!(await checkBinary(cli.bin)).ok) missing.push(cli);
90
+ }
91
+ if (missing.length === 0) return { tool: "agent-cli", action: "already-installed" };
92
+
93
+ if (!available.npm) {
94
+ const commands = missing.map((c) => `npm install -g ${c.pkg}`);
95
+ logger.warn?.(`✗ agent-cli: npm not found — install manually: ${commands.join(" ; ")}`);
96
+ return { tool: "agent-cli", action: "manual", reason: "npm not available", missing: missing.map((c) => c.bin), commands, manualUrls: missing.map((c) => c.url) };
97
+ }
98
+
99
+ const installed = [];
100
+ for (const cli of missing) {
101
+ const command = `npm install -g ${cli.pkg}`;
102
+ if (dryRun) {
103
+ logger.info?.(`▸ ${cli.bin}: would run \`${command}\``);
104
+ installed.push({ bin: cli.bin, action: "dry-run", command });
105
+ continue;
106
+ }
107
+ const proceed = yes || await promptYesNo(`Install ${cli.bin} with: ${command}?`);
108
+ if (!proceed) {
109
+ logger.info?.(`⊘ ${cli.bin}: declined`);
110
+ installed.push({ bin: cli.bin, action: "declined", command });
111
+ continue;
112
+ }
113
+ logger.info?.(`▸ ${cli.bin}: running \`${command}\`...`);
114
+ const r = await runInstallCommand(command, { interactive: false });
115
+ if (r.ok) { logger.info?.(`✓ ${cli.bin}: installed`); installed.push({ bin: cli.bin, action: "installed", command }); }
116
+ else { const err = r.error || `exit ${r.code}`; logger.warn?.(`✗ ${cli.bin}: install failed (${err})`); installed.push({ bin: cli.bin, action: "failed", command, error: err }); }
117
+ }
118
+
119
+ const action = dryRun ? "dry-run"
120
+ : installed.some((r) => r.action === "failed") ? "failed"
121
+ : installed.some((r) => r.action === "installed") ? "installed"
122
+ : "declined";
123
+ return { tool: "agent-cli", action, installed };
124
+ }
125
+
126
+ /**
127
+ * git handler. git installs through the OS package manager; on Linux that
128
+ * needs root, so those runs go through the interactive (tty-inherited) path
129
+ * where sudo prompts the user directly — kj never captures the password. The
130
+ * exact command is shown first and the install is opt-in, defaulting to NO for
131
+ * the privileged case. When no package manager matches, we surface the manual
132
+ * download URL instead of failing silently.
133
+ */
134
+ async function handleGit({ available, dryRun, yes, logger }) {
135
+ const plan = gitInstallPlan(available);
136
+ if (!plan.command) {
137
+ logger.warn?.(`✗ git: no supported package manager here — install manually: ${plan.manualUrl}`);
138
+ return { tool: "git", action: "manual", reason: "no supported package manager found", manualUrl: plan.manualUrl };
139
+ }
140
+ if (dryRun) {
141
+ logger.info?.(`▸ git: would run \`${plan.command}\` (manager: ${plan.manager}${plan.needsSudo ? ", needs sudo" : ""})`);
142
+ return { tool: "git", action: "dry-run", command: plan.command, manager: plan.manager };
143
+ }
144
+ if (plan.needsSudo) {
145
+ logger.warn?.("git: this step needs sudo — you'll be prompted for your password on your own terminal (kj never sees it).");
146
+ }
147
+ const proceed = yes || await promptYesNo(`Install git with: ${plan.command}?`, !plan.needsSudo);
148
+ if (!proceed) {
149
+ logger.info?.("⊘ git: declined");
150
+ return { tool: "git", action: "declined", command: plan.command };
151
+ }
152
+ logger.info?.(`▸ git: running \`${plan.command}\`...`);
153
+ const r = await runInstallCommand(plan.command, { interactive: plan.needsSudo });
154
+ if (r.ok) {
155
+ logger.info?.(`✓ git: installed via ${plan.manager}`);
156
+ return { tool: "git", action: "installed", manager: plan.manager };
157
+ }
158
+ const err = r.error || `exit ${r.code}`;
159
+ logger.warn?.(`✗ git: install failed (${err})`);
160
+ return { tool: "git", action: "failed", command: plan.command, error: err };
161
+ }
162
+
60
163
  async function checkSonarRunning() {
61
164
  try {
62
165
  const { stdout } = await execFileAsync("docker", ["ps", "--format", "{{.Names}}", "--filter", "name=sonarqube"], { timeout: 5000 });
@@ -85,7 +188,7 @@ async function handleSonar({ available, dryRun }) {
85
188
  return {
86
189
  tool: "sonar",
87
190
  action: "manual",
88
- reason: "docker not available — sonar needs docker. Install docker first.",
191
+ reason: "SonarQube runs as a Docker container — install Docker first (kj install-tools --only docker), then re-run.",
89
192
  manualUrl: "https://docs.docker.com/get-docker/",
90
193
  };
91
194
  }
@@ -104,17 +207,80 @@ async function handleSonar({ available, dryRun }) {
104
207
  }
105
208
 
106
209
  /**
107
- * Docker handler: never auto-install (platform-specific, invasive).
108
- * Always point at the official docs.
210
+ * Docker handler. On macOS/Windows we never auto-install — point at the docs.
211
+ * On Linux we offer the distro package manager (apt/dnf) or, failing that, the
212
+ * official convenience script downloaded to a file. The install runs through
213
+ * `sudo` on the user's own tty (kj never sees the password) and is opt-in,
214
+ * defaulting to NO, with the exact command shown first.
109
215
  */
110
- function handleDocker(available) {
111
- return {
112
- tool: "docker",
113
- action: "manual",
114
- reason: "Docker install is platform-specific — see official docs.",
115
- manualUrl: "https://docs.docker.com/get-docker/",
116
- suggested: available.brew ? "brew install --cask docker" : null,
117
- };
216
+ async function handleDocker({ available, dryRun, yes, logger }) {
217
+ const plan = dockerInstallPlan(process.platform, available);
218
+ if (plan.kind === "manual") {
219
+ logger.info?.(`▸ docker: ${plan.suggested || plan.manualUrl}`);
220
+ return { tool: "docker", action: "manual", reason: "Docker install is platform-specific — see official docs.", manualUrl: plan.manualUrl, suggested: plan.suggested };
221
+ }
222
+
223
+ const shownCommand = plan.kind === "package" ? plan.command : `sudo sh <get-docker.sh from ${plan.url}>`;
224
+ if (dryRun) {
225
+ logger.info?.(`▸ docker: would run \`${shownCommand}\` (invasive, needs sudo)`);
226
+ return { tool: "docker", action: "dry-run", command: shownCommand, via: plan.kind };
227
+ }
228
+
229
+ logger.warn?.("docker: this step is invasive and needs sudo — you'll be prompted for your password on your own terminal (kj never sees it).");
230
+ const proceed = yes || await promptYesNo(`Install Docker with: ${shownCommand}?`, false);
231
+ if (!proceed) {
232
+ logger.info?.("⊘ docker: declined");
233
+ return { tool: "docker", action: "declined", command: shownCommand };
234
+ }
235
+
236
+ if (plan.kind === "package") {
237
+ logger.info?.(`▸ docker: running \`${plan.command}\`...`);
238
+ const r = await runInstallCommand(plan.command, { interactive: true });
239
+ if (r.ok) { logger.info?.(`✓ docker: installed via ${plan.manager}`); return { tool: "docker", action: "installed", via: "package", manager: plan.manager }; }
240
+ const err = r.error || `exit ${r.code}`;
241
+ logger.warn?.(`✗ docker: install failed (${err})`);
242
+ return { tool: "docker", action: "failed", via: "package", error: err };
243
+ }
244
+
245
+ // script route: download to a temp file, then run with sudo — never `curl | sh`.
246
+ const dl = await downloadBinary({ url: plan.url, name: "get-docker.sh", dir: os.tmpdir() });
247
+ if (!dl.ok) { logger.warn?.(`✗ docker: script download failed (${dl.error})`); return { tool: "docker", action: "failed", via: "script", error: dl.error }; }
248
+ logger.info?.(`▸ docker: running \`sudo sh ${dl.dest}\`...`);
249
+ const r = await runInstallCommand(`sudo sh ${dl.dest}`, { interactive: true });
250
+ if (r.ok) { logger.info?.("✓ docker: installed via get.docker.com"); return { tool: "docker", action: "installed", via: "script", dest: dl.dest }; }
251
+ const err = r.error || `exit ${r.code}`;
252
+ logger.warn?.(`✗ docker: install failed (${err})`);
253
+ return { tool: "docker", action: "failed", via: "script", error: err };
254
+ }
255
+
256
+ /**
257
+ * Standalone route when no package manager matched: download a static binary
258
+ * (osv-scanner) or surface a concrete command to run (semgrep).
259
+ */
260
+ async function handleStandalone({ tool, standalone, manualUrl, dryRun, yes, logger }) {
261
+ if (standalone.kind === "command") {
262
+ logger.warn?.(`✗ ${tool}: no package manager here. Try: ${standalone.command} · \`kj doctor\` shows what's missing (docs: ${manualUrl})`);
263
+ return { tool, action: "manual", reason: "no package manager — suggested route below", suggested: standalone.command, via: standalone.via, manualUrl };
264
+ }
265
+ // kind === "binary"
266
+ const { url, name } = standalone;
267
+ if (dryRun) {
268
+ logger.info?.(`▸ ${tool}: would download ${url} → ${binDir()}`);
269
+ return { tool, action: "dry-run", command: `download ${url}`, via: "binary" };
270
+ }
271
+ const proceed = yes || await promptYesNo(`Download ${tool} from ${url} into ${binDir()}?`);
272
+ if (!proceed) {
273
+ logger.info?.(`⊘ ${tool}: declined`);
274
+ return { tool, action: "declined", command: `download ${url}` };
275
+ }
276
+ logger.info?.(`▸ ${tool}: downloading static binary...`);
277
+ const r = await downloadBinary({ url, name });
278
+ if (r.ok) {
279
+ logger.info?.(`✓ ${tool}: installed → ${r.dest} (ensure ${binDir()} is on PATH)`);
280
+ return { tool, action: "installed", via: "binary", dest: r.dest };
281
+ }
282
+ logger.warn?.(`✗ ${tool}: download failed (${r.error})`);
283
+ return { tool, action: "failed", via: "binary", error: r.error };
118
284
  }
119
285
 
120
286
  /**
@@ -156,7 +322,8 @@ export async function installToolsCommand(opts = {}) {
156
322
  if (tool === "sonar") {
157
323
  const result = await handleSonar({ available, dryRun });
158
324
  results.push(result);
159
- logger.info?.(`▸ sonar: ${result.command || result.manualUrl}`);
325
+ if (result.action === "manual") logger.warn?.(`✗ sonar: ${result.reason}`);
326
+ else logger.info?.(`▸ sonar: ${result.command}`);
160
327
  if (!dryRun && result.action === "ready") {
161
328
  const proceed = yes || await promptYesNo(`Start SonarQube with: ${result.command}?`);
162
329
  if (proceed) {
@@ -168,18 +335,37 @@ export async function installToolsCommand(opts = {}) {
168
335
  continue;
169
336
  }
170
337
 
338
+ if (tool === "git") {
339
+ const result = await handleGit({ available, dryRun, yes, logger });
340
+ results.push(result);
341
+ continue;
342
+ }
343
+
344
+ if (tool === "agent-cli") {
345
+ const result = await handleAgentCli({ available, dryRun, yes, logger });
346
+ results.push(result);
347
+ continue;
348
+ }
349
+
171
350
  if (tool === "docker") {
172
- const result = handleDocker(available);
351
+ const result = await handleDocker({ available, dryRun, yes, logger });
173
352
  results.push(result);
174
- logger.info?.(`▸ docker: ${result.suggested || result.manualUrl}`);
175
353
  continue;
176
354
  }
177
355
 
178
356
  // semgrep / osv-scanner / lighthouse — package-manager driven.
179
357
  const hint = await getInstallHint(tool, available);
180
358
  if (!hint.command || !hint.manager) {
359
+ // No package manager matched — try a standalone route (static binary
360
+ // for osv-scanner, a concrete command for semgrep) before giving up.
361
+ const standalone = resolveStandalone(tool, available);
362
+ if (standalone) {
363
+ const result = await handleStandalone({ tool, standalone, manualUrl: hint.manualUrl, dryRun, yes, logger });
364
+ results.push(result);
365
+ continue;
366
+ }
181
367
  results.push({ tool, action: "manual", reason: "no compatible package manager found", manualUrl: hint.manualUrl, suggested: hint.command });
182
- logger.warn?.(`✗ ${tool}: no compatible package manager. Manual: ${hint.manualUrl}`);
368
+ logger.warn?.(`✗ ${tool}: no automatic install route here. Run \`kj doctor\` for options, or install manually: ${hint.manualUrl}`);
183
369
  continue;
184
370
  }
185
371
  if (dryRun) {
@@ -64,9 +64,10 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
64
64
  // who never run it.
65
65
  export async function ragInstallHooksCommand({ config, logger, flags = {} }) {
66
66
  const projectDir = config?.projectDir || process.cwd();
67
- const res = installPostMergeHook({ projectDir, logger });
67
+ const res = await installPostMergeHook({ projectDir, logger });
68
68
  if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
69
- else if (res.installed) logger.info(`[rag] installed post-merge hook at ${res.target}`);
69
+ else if (res.installed) logger.info(`[rag] installed post-merge hook at ${res.target}${res.hooksPath ? ` (core.hooksPath=${res.hooksPath})` : ""}`);
70
+ else if (res.covered) logger.info(`[rag] ${res.target} already reindexes the RAG (kj-managed); nothing to do`);
70
71
  return res;
71
72
  }
72
73
 
@@ -2,7 +2,7 @@
2
2
  // Keeps the local RAG index aligned with HEAD so retrieval never serves
3
3
  // stale code without the user having to run `kj rag index` by hand.
4
4
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
- import { join, resolve } from "node:path";
5
+ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { execa } from "execa";
8
8
 
@@ -34,17 +34,39 @@ export async function maybeAutoUpdate({ projectDir, config, logger = console, fl
34
34
  } finally { db.close(); }
35
35
  }
36
36
 
37
- export function installPostMergeHook({ projectDir, logger = console } = {}) {
38
- const hooksDir = join(projectDir, ".git", "hooks");
37
+ // KJC-BUG-0100 — Resolve the hooks dir git actually honours. When a repo is
38
+ // hardened `git config core.hooksPath` points at `.karajan/hooks` and git
39
+ // ignores `.git/hooks/` entirely, so a hook written there never fires.
40
+ export async function resolveHooksDir(projectDir) {
39
41
  if (!existsSync(join(projectDir, ".git"))) throw new Error(`Not a git repository: ${projectDir}`);
42
+ let hooksPath = "";
43
+ try {
44
+ const r = await execa("git", ["-C", projectDir, "config", "--local", "core.hooksPath"]);
45
+ hooksPath = r.stdout.trim();
46
+ } catch { /* unset → git uses .git/hooks */ }
47
+ if (hooksPath) {
48
+ return { dir: isAbsolute(hooksPath) ? hooksPath : join(projectDir, hooksPath), hooksPath };
49
+ }
50
+ return { dir: join(projectDir, ".git", "hooks"), hooksPath: "" };
51
+ }
52
+
53
+ export async function installPostMergeHook({ projectDir, logger = console } = {}) {
54
+ const { dir: hooksDir, hooksPath } = await resolveHooksDir(projectDir);
40
55
  if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
41
56
  const target = join(hooksDir, "post-merge");
42
57
  const src = readFileSync(HOOK_SRC, "utf8");
43
- if (existsSync(target) && !readFileSync(target, "utf8").includes("KJC-TSK-0455")) {
44
- logger.warn?.(`[rag] ${target} already exists and was not installed by kj; leaving untouched`);
45
- return { skipped: true, target };
58
+ if (existsSync(target)) {
59
+ const current = readFileSync(target, "utf8");
60
+ // Another kj-managed hook (e.g. harden's post-merge) already reindexes.
61
+ if (!current.includes("KJC-TSK-0455") && /kj\s+rag\s+index/.test(current)) {
62
+ return { skipped: true, covered: true, target, hooksPath };
63
+ }
64
+ if (!current.includes("KJC-TSK-0455")) {
65
+ logger.warn?.(`[rag] ${target} already exists and was not installed by kj; leaving untouched`);
66
+ return { skipped: true, target, hooksPath };
67
+ }
46
68
  }
47
69
  writeFileSync(target, src);
48
70
  chmodSync(target, 0o755);
49
- return { installed: true, target };
71
+ return { installed: true, target, hooksPath };
50
72
  }
@@ -0,0 +1,64 @@
1
+ // Standalone install routes for tools with no package manager available
2
+ // (KJC-TSK-0607). On a machine with no pipx/brew/go, `kj install-tools`
3
+ // still needs a way to install osv-scanner and semgrep.
4
+ //
5
+ // · osv-scanner ships a single static binary per platform on its GitHub
6
+ // releases — we build the /latest/download URL for the current platform.
7
+ // · semgrep has no single static binary; we offer the most viable concrete
8
+ // command (Docker image if docker exists, otherwise bootstrapping pipx).
9
+
10
+ const OSV_BASE = "https://github.com/google/osv-scanner/releases/latest/download";
11
+
12
+ function osvOs(platform) {
13
+ if (platform === "linux") return "linux";
14
+ if (platform === "darwin") return "darwin";
15
+ if (platform === "win32") return "windows";
16
+ return null;
17
+ }
18
+
19
+ function osvArch(arch) {
20
+ if (arch === "x64") return "amd64";
21
+ if (arch === "arm64") return "arm64";
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Build the osv-scanner static-binary download source for a platform+arch,
27
+ * or null when the combination has no published asset.
28
+ *
29
+ * @returns {{ url: string, name: string }|null}
30
+ */
31
+ export function osvScannerSource(platform = process.platform, arch = process.arch) {
32
+ const os = osvOs(platform);
33
+ const a = osvArch(arch);
34
+ if (!os || !a) return null;
35
+ const ext = os === "windows" ? ".exe" : "";
36
+ return { url: `${OSV_BASE}/osv-scanner_${os}_${a}${ext}`, name: `osv-scanner${ext}` };
37
+ }
38
+
39
+ /**
40
+ * Most viable concrete route to install semgrep without a package manager.
41
+ * Returns a command the user sees before it runs — never a bare docs URL.
42
+ *
43
+ * @returns {{ via: "docker"|"pipx", command: string }}
44
+ */
45
+ export function semgrepFallback(available = {}) {
46
+ if (available.docker) return { via: "docker", command: "docker pull semgrep/semgrep" };
47
+ return { via: "pipx", command: "python3 -m pip install --user pipx && pipx install semgrep" };
48
+ }
49
+
50
+ /**
51
+ * Resolve the standalone route for a tool when no package manager matched.
52
+ * · binary → download a static binary into ~/.local/bin.
53
+ * · command → run/show a concrete install command.
54
+ *
55
+ * @returns {{ kind: "binary", url: string, name: string }|{ kind: "command", via: string, command: string }|null}
56
+ */
57
+ export function resolveStandalone(tool, available = {}, platform = process.platform, arch = process.arch) {
58
+ if (tool === "osv-scanner") {
59
+ const src = osvScannerSource(platform, arch);
60
+ return src ? { kind: "binary", ...src } : null;
61
+ }
62
+ if (tool === "semgrep") return { kind: "command", ...semgrepFallback(available) };
63
+ return null;
64
+ }
@@ -0,0 +1,34 @@
1
+ // How `kj install-tools` installs Docker, resolved per platform (KJC-TSK-0609,
2
+ // absorbing the sudo+apt/dnf primitive of KJC-TSK-0608).
3
+ //
4
+ // · Linux: prefer the distro package manager (apt → docker.io, dnf → docker)
5
+ // run through `sudo` on the user's own tty — kj never sees the password.
6
+ // When neither is present, fall back to Docker's official convenience
7
+ // script, which the caller downloads to a file and runs (never `curl | sh`).
8
+ // · macOS / Windows: never auto-install (platform-specific, invasive) — point
9
+ // at the official docs, suggesting the brew cask on macOS.
10
+
11
+ const GET_DOCKER_URL = "https://get.docker.com";
12
+ const DOCKER_DOCS_URL = "https://docs.docker.com/get-docker/";
13
+
14
+ /**
15
+ * Resolve the Docker install plan for a platform + available managers.
16
+ *
17
+ * @param {string} platform - process.platform value (linux/darwin/win32).
18
+ * @param {Record<string, boolean>} available - detected package managers.
19
+ * @returns {{ kind: "package", manager: string, command: string }
20
+ * | { kind: "script", url: string }
21
+ * | { kind: "manual", manualUrl: string, suggested: string|null }}
22
+ */
23
+ export function dockerInstallPlan(platform = process.platform, available = {}) {
24
+ if (platform !== "linux") {
25
+ return {
26
+ kind: "manual",
27
+ manualUrl: DOCKER_DOCS_URL,
28
+ suggested: available.brew ? "brew install --cask docker" : null,
29
+ };
30
+ }
31
+ if (available.apt) return { kind: "package", manager: "apt", command: "sudo apt-get install -y docker.io" };
32
+ if (available.dnf) return { kind: "package", manager: "dnf", command: "sudo dnf install -y docker" };
33
+ return { kind: "script", url: GET_DOCKER_URL };
34
+ }
@@ -116,8 +116,36 @@ const MANUAL_URLS = {
116
116
  "osv-scanner": "https://google.github.io/osv-scanner/installation/",
117
117
  lighthouse: "https://github.com/GoogleChrome/lighthouse#using-the-cli",
118
118
  docker: "https://docs.docker.com/get-docker/",
119
+ git: "https://git-scm.com/downloads",
119
120
  };
120
121
 
122
+ // git is a `kj doctor` *required* tool. Unlike the audit tools it is installed
123
+ // through the OS package manager, and on Linux that needs root — so the plan
124
+ // carries a `needsSudo` flag and the caller runs those through the interactive
125
+ // (tty-inherited) path, where sudo prompts the user directly and kj never sees
126
+ // the password. brew/scoop/choco install at user scope, no sudo.
127
+ const GIT_CANDIDATES = [
128
+ { manager: "brew", command: "brew install git", needsSudo: false },
129
+ { manager: "apt", command: "sudo apt-get install -y git", needsSudo: true },
130
+ { manager: "dnf", command: "sudo dnf install -y git", needsSudo: true },
131
+ { manager: "choco", command: "choco install git -y", needsSudo: false },
132
+ { manager: "scoop", command: "scoop install git", needsSudo: false },
133
+ ];
134
+
135
+ /**
136
+ * Pick a git install plan for the current machine. Returns a manual plan
137
+ * (command null + URL) when no supported package manager is present.
138
+ *
139
+ * @param {Record<PackageManager, boolean>} available
140
+ * @returns {{ command: string|null, manager: PackageManager|null, needsSudo: boolean, manualUrl: string }}
141
+ */
142
+ export function gitInstallPlan(available = {}) {
143
+ for (const { manager, command, needsSudo } of GIT_CANDIDATES) {
144
+ if (available[manager]) return { command, manager, needsSudo, manualUrl: MANUAL_URLS.git };
145
+ }
146
+ return { command: null, manager: null, needsSudo: false, manualUrl: MANUAL_URLS.git };
147
+ }
148
+
121
149
  /**
122
150
  * Tools whose hint depends on the project stack. lighthouse is only
123
151
  * relevant for frontend projects; suppressing the hint elsewhere keeps
@@ -0,0 +1,93 @@
1
+ // Executor primitives for `kj install-tools` (KJC-TSK-0606).
2
+ //
3
+ // Two ways to run an install step:
4
+ // · runInstallCommand(cmd) — captured: stdout/stderr collected,
5
+ // for non-privileged commands.
6
+ // · runInstallCommand(cmd, {interactive}) — tty inherited: the child owns the
7
+ // terminal so `sudo` prompts the user
8
+ // directly. kj NEVER sees the password.
9
+ //
10
+ // And a downloader for tools shipped as a single static binary (osv-scanner,
11
+ // semgrep) when no package manager is available: fetch → temp file → chmod +x →
12
+ // atomic rename into ~/.local/bin. On any failure it leaves no partial file.
13
+
14
+ import { execFile, spawn } from "node:child_process";
15
+ import { promisify } from "node:util";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import fs from "node:fs/promises";
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ /** Directory where downloaded standalone binaries are placed. */
23
+ export function binDir() {
24
+ return path.join(os.homedir(), ".local", "bin");
25
+ }
26
+
27
+ /**
28
+ * Run an install command. Commands here are simple (no nested quoting), so we
29
+ * tokenize on whitespace.
30
+ *
31
+ * @param {string} command
32
+ * @param {{ interactive?: boolean }} [opts]
33
+ * interactive → inherit the parent's stdio so sudo can prompt for the password
34
+ * on the user's own terminal; kj never captures or logs it.
35
+ */
36
+ export async function runInstallCommand(command, opts = {}) {
37
+ const [bin, ...args] = command.split(/\s+/);
38
+ if (opts.interactive) return runInherited(bin, args);
39
+ try {
40
+ const { stdout, stderr } = await execFileAsync(bin, args, {
41
+ timeout: 300_000,
42
+ maxBuffer: 32 * 1024 * 1024,
43
+ });
44
+ return { ok: true, stdout, stderr };
45
+ } catch (err) {
46
+ return { ok: false, error: err?.shortMessage || err?.message || String(err), stderr: err?.stderr };
47
+ }
48
+ }
49
+
50
+ function runInherited(bin, args) {
51
+ return new Promise((resolve) => {
52
+ let child;
53
+ try {
54
+ child = spawn(bin, args, { stdio: "inherit", timeout: 600_000 });
55
+ } catch (err) {
56
+ resolve({ ok: false, error: err?.message || String(err) });
57
+ return;
58
+ }
59
+ child.on("error", (err) => resolve({ ok: false, error: err?.message || String(err) }));
60
+ child.on("close", (code) => resolve({ ok: code === 0, code }));
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Download a single static binary and place it (executable) into `dir`.
66
+ * Writes to a temp file first and renames atomically; on any failure the temp
67
+ * file is removed so no partial artifact is left behind.
68
+ *
69
+ * @param {{ url: string, name: string, dir?: string }} args
70
+ */
71
+ export async function downloadBinary({ url, name, dir = binDir() }) {
72
+ let res;
73
+ try {
74
+ res = await fetch(url);
75
+ } catch (err) {
76
+ return { ok: false, error: `download failed: ${err?.message || String(err)}` };
77
+ }
78
+ if (!res.ok) return { ok: false, error: `download failed: HTTP ${res.status}` };
79
+
80
+ await fs.mkdir(dir, { recursive: true });
81
+ const dest = path.join(dir, name);
82
+ const tmp = path.join(dir, `.${name}.download`);
83
+ try {
84
+ const buf = Buffer.from(await res.arrayBuffer());
85
+ await fs.writeFile(tmp, buf);
86
+ await fs.chmod(tmp, 0o755);
87
+ await fs.rename(tmp, dest);
88
+ return { ok: true, dest };
89
+ } catch (err) {
90
+ await fs.rm(tmp, { force: true }).catch(() => {});
91
+ return { ok: false, error: `install failed: ${err?.message || String(err)}` };
92
+ }
93
+ }
@@ -8,10 +8,44 @@ You are the **Coder** in a multi-role AI pipeline. Your job is to write code and
8
8
  - Write tests BEFORE implementation when using TDD.
9
9
  - Keep changes minimal and focused on the task.
10
10
  - "Minimal" means no unnecessary changes — it does NOT mean avoiding new files. If the task requires creating new files (pages, components, modules, tests), you MUST create them. Updating references/links without creating the actual files is an incomplete implementation.
11
- - Do not modify code unrelated to the task.
11
+ - Change only the code the task requires; leave everything else as it is.
12
12
  - Before creating a new utility or helper, check if a similar one already exists in the codebase. Reuse existing code over creating duplicates.
13
13
  - Follow existing code conventions and patterns in the repository.
14
14
 
15
+ ## Constructive dissent (raise real blockers before you code)
16
+
17
+ You are an Active Partner, not a silent contractor. Before implementing, if you
18
+ detect a genuine problem with the task, surface it FIRST instead of quietly
19
+ coding around it:
20
+
21
+ - A contradiction between two requirements that cannot both hold.
22
+ - A false or impossible premise — the task assumes a fact, file, or API that
23
+ does not exist, or forces an outcome that cannot be correct.
24
+ - A concrete security or maintainability risk in what is being asked.
25
+
26
+ Report it concisely in `result.concerns` (an array of short strings). When a
27
+ blocker makes the task unworkable as stated, stop and report instead of guessing.
28
+
29
+ Keep the bar high. Proceed silently when the task is clear and coherent — do NOT
30
+ object to trivial or stylistic details, and do NOT manufacture doubts to look
31
+ thorough. Relevance is the test: raise only what would derail the result.
32
+
33
+ ## Check alignment (moderate or complex tasks only)
34
+
35
+ When the task has moderate or high scope — it spans several files, touches
36
+ architecture, security or testing, or leaves any real doubt about the goal —
37
+ state your understanding BEFORE writing code:
38
+
39
+ - One or two sentences on what you understood the task to be.
40
+ - Any open questions whose answer would change the implementation.
41
+
42
+ Report this in `result.alignment` (`{ "understood": "…", "open_questions": [] }`).
43
+ Surfacing a misread here is far cheaper than discovering it after the diff.
44
+
45
+ Do NOT add this step for trivial or small, unambiguous tasks — proceed straight
46
+ to the code. This gate is for tasks big enough that a wrong assumption is costly,
47
+ not a ritual for every change.
48
+
15
49
  ## PR atomicity (hard project rule)
16
50
 
17
51
  Karajan projects MAY enforce a CI gate that fails any PR whose net delta exceeds **200 lines added** (the karajan-code repo itself enforces this since 2026-05-08). Plan before writing:
@@ -35,7 +69,7 @@ Before reporting done, verify that ALL parts of the task are addressed:
35
69
  - If the task says "create X", create the complete working implementation, not a skeleton.
36
70
  - If tests exist, the implementation MUST make all tests pass.
37
71
  - If you write tests first (TDD), the implementation MUST make those tests pass.
38
- - Do NOT commit code that doesn't compile or doesn't pass tests.
72
+ - Commit only code that compiles and passes the full test suite.
39
73
 
40
74
  ## Test file location (MANDATORY convention)
41
75
 
@@ -105,7 +139,9 @@ Return a JSON object:
105
139
  "files_modified": ["path/to/file.js"],
106
140
  "files_created": ["path/to/new-file.js"],
107
141
  "tests_added": ["path/to/test.js"],
108
- "approach": "Brief description of what was done"
142
+ "approach": "Brief description of what was done",
143
+ "concerns": [],
144
+ "alignment": null
109
145
  },
110
146
  "summary": "Human-readable summary of changes"
111
147
  }
@@ -4,7 +4,7 @@ You are the **Reviewer** in a multi-role AI pipeline. Your job is to review code
4
4
 
5
5
  ## Scope constraint
6
6
 
7
- - **ONLY review files present in the diff.** Do not flag issues in files that were not changed.
7
+ - **Review only the files present in the diff.** Keep every finding scoped to what this change touched.
8
8
  - If you notice problems in untouched files, mention them as `non_blocking_suggestions` with a note that they are outside the current scope — never as `blocking_issues`.
9
9
  - Your job is to review THIS change, not audit the entire codebase.
10
10
 
@@ -21,7 +21,7 @@ You are the **Reviewer** in a multi-role AI pipeline. Your job is to review code
21
21
  - Focus on security, correctness, and tests first.
22
22
  - Only raise blocking issues for concrete production risks in the changed files.
23
23
  - Keep non-blocking suggestions separate.
24
- - Style preferences NEVER block approval.
24
+ - Let style preferences pass; reserve blocking for concrete production risks.
25
25
  - Confidence threshold: reject only if < 0.70.
26
26
 
27
27
  ## File overwrite detection (BLOCKING)
@@ -11,6 +11,11 @@ user's spec (prompt or `.md`) for deficiencies. You do NOT execute or plan.
11
11
  - **assumptions** — depends on unstated context.
12
12
  - **out_of_scope** — asks for things outside what Karajan can do.
13
13
 
14
+ A spec can also carry a **false premise**: it forces an answer that cannot be
15
+ correct (asks for N options when only one is valid, assumes a fact/API that does
16
+ not exist). Surface these as `contradiction` or `out_of_scope` — never rationalise
17
+ them into a plausible-looking answer.
18
+
14
19
  ## Severity
15
20
 
16
21
  `info` (nuance), `warn` (real gap), `fail` (unworkable). Top-level `severity`