karajan-code 4.1.8 → 4.1.10

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": "4.1.8",
3
+ "version": "4.1.10",
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",
@@ -208,9 +208,27 @@ function showPromptModal(prompt) {
208
208
  if (activePromptId === prompt.promptId) return;
209
209
  activePromptId = prompt.promptId;
210
210
  const dlg = ensureDialog();
211
+ // KJC-BUG-0125 (issue #1275): findings render as a readable, open list —
212
+ // "4 findings at severity fail" alone gives the user nothing to act on.
213
+ // Each finding shows its message and the concrete suggestion.
214
+ const sevColor = (s) => (s === 'fail' ? '#f87171' : s === 'warn' ? '#fbbf24' : '#60a5fa');
215
+ const findings = prompt.context?.detail?.findings || prompt.context?.findings;
216
+ const findingsBlock = Array.isArray(findings) && findings.length
217
+ ? `<div style="margin-top:10px;display:flex;flex-direction:column;gap:8px;max-height:280px;overflow:auto">`
218
+ + findings.map((f) => `
219
+ <div style="border-left:3px solid ${sevColor(f.severity)};padding:6px 10px;background:var(--bg-primary);border-radius:var(--radius-sm)">
220
+ <div style="font-size:0.78rem;color:var(--text-muted)">
221
+ <strong style="color:${sevColor(f.severity)}">${esc(String(f.severity || '').toUpperCase())}</strong>
222
+ · ${esc(f.category || '')}${f.id ? ` · ${esc(f.id)}` : ''}
223
+ </div>
224
+ <div style="font-size:0.86rem;line-height:1.45;margin-top:2px">${esc(f.message || '')}</div>
225
+ ${f.suggestion ? `<div style="font-size:0.8rem;color:var(--text-muted);margin-top:2px">→ ${esc(f.suggestion)}</div>` : ''}
226
+ </div>`).join('')
227
+ + `</div>`
228
+ : '';
211
229
  const ctxBlock = prompt.context
212
230
  ? `<details style="margin-top:8px">
213
- <summary style="cursor:pointer;color:var(--text-muted);font-size:0.85rem">Context</summary>
231
+ <summary style="cursor:pointer;color:var(--text-muted);font-size:0.85rem">Raw context</summary>
214
232
  <pre style="white-space:pre-wrap;font-size:0.78rem;background:var(--bg-primary);padding:8px;border-radius:var(--radius-sm);overflow:auto;max-height:240px">${esc(JSON.stringify(prompt.context, null, 2))}</pre>
215
233
  </details>`
216
234
  : '';
@@ -221,6 +239,7 @@ function showPromptModal(prompt) {
221
239
  </div>
222
240
  <div style="padding:14px 18px">
223
241
  <div style="white-space:pre-wrap;font-size:0.95rem;line-height:1.55">${esc(prompt.question)}</div>
242
+ ${findingsBlock}
224
243
  ${ctxBlock}
225
244
  <textarea id="prompt-answer" rows="3"
226
245
  placeholder="Type your answer (or 'stop' to abort the run)"
@@ -19,10 +19,7 @@
19
19
  * out-of-control result list cannot blow the token budget.
20
20
  */
21
21
 
22
- import { execFile } from "node:child_process";
23
- import { promisify } from "node:util";
24
-
25
- const execFileAsync = promisify(execFile);
22
+ import { runGoverned } from "../utils/tool-governor.js";
26
23
 
27
24
  const SCAN_TIMEOUT_MS = 60_000;
28
25
  const SEVERITY_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "MODERATE"];
@@ -42,7 +39,10 @@ export async function collectOsvFindings(projectDir, logger = null) {
42
39
  }
43
40
  let stdout;
44
41
  try {
45
- const { stdout: out } = await execFileAsync(
42
+ // KJC-TSK-0668: governed run — single-flight across kj processes and a
43
+ // timeout that kills the whole process tree (osv-scanner is I/O-bound,
44
+ // no jobs flag to cap).
45
+ const { stdout: out } = await runGoverned(
46
46
  "osv-scanner",
47
47
  ["--format=json", "--recursive", projectDir || "."],
48
48
  { cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }
@@ -18,10 +18,7 @@
18
18
  * into the `security` dimension using the rule ID as the rule field.
19
19
  */
20
20
 
21
- import { execFile } from "node:child_process";
22
- import { promisify } from "node:util";
23
-
24
- const execFileAsync = promisify(execFile);
21
+ import { runGoverned, jobsCap } from "../utils/tool-governor.js";
25
22
 
26
23
  const SCAN_TIMEOUT_MS = 120_000;
27
24
  const SEVERITY_ORDER = ["ERROR", "WARNING", "INFO"];
@@ -44,9 +41,12 @@ export async function collectSemgrepFindings(projectDir, logger = null) {
44
41
  }
45
42
  let stdout;
46
43
  try {
47
- const { stdout: out } = await execFileAsync(
44
+ // KJC-TSK-0668: governed run — machine-wide single-flight, --jobs capped
45
+ // at half the CPUs (semgrep grabs 14 by default), nice +10, and a timeout
46
+ // that kills the whole process tree (no more orphaned semgrep-core).
47
+ const { stdout: out } = await runGoverned(
48
48
  "semgrep",
49
- ["--config", "auto", "--json", "--quiet", `--timeout=${Math.floor(SCAN_TIMEOUT_MS / 1000)}`, projectDir || "."],
49
+ ["--config", "auto", "--json", "--quiet", `--jobs=${jobsCap()}`, `--timeout=${Math.floor(SCAN_TIMEOUT_MS / 1000)}`, projectDir || "."],
50
50
  { cwd: projectDir, timeout: SCAN_TIMEOUT_MS + 5000, maxBuffer: 32 * 1024 * 1024 }
51
51
  );
52
52
  stdout = out;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Orphaned scanner detection for `kj doctor` (KJC-TSK-0668). After the CPU
3
+ * storm, timed-out scans left semgrep-core processes reparented to init /
4
+ * systemd, silently burning cores. This check finds processes of tools kj
5
+ * governs whose parent is init (pid 1) or a systemd reaper, reports them,
6
+ * and — strategy "prompt" — offers to kill them.
7
+ */
8
+
9
+ import { execFile } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ import { STRATEGY } from "./types.js";
12
+
13
+ const execFileAsync = promisify(execFile);
14
+ const GOVERNED_TOOLS = new Set(["semgrep", "semgrep-core", "osv-scanner", "lighthouse"]);
15
+
16
+ /**
17
+ * Scan the process table for governed tools whose parent is init or a
18
+ * systemd reaper (where the kernel sends orphans). `runPs` injectable.
19
+ */
20
+ export async function findOrphanToolProcesses(runPs = execFileAsync) {
21
+ if (process.platform === "win32") return { supported: false, orphans: [] };
22
+ let stdout;
23
+ try {
24
+ ({ stdout } = await runPs("ps", ["-eo", "pid=,ppid=,comm="]));
25
+ } catch {
26
+ return { supported: false, orphans: [] };
27
+ }
28
+ const rows = [];
29
+ const commByPid = new Map();
30
+ for (const line of String(stdout).split("\n")) {
31
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
32
+ if (!m) continue;
33
+ const row = { pid: Number(m[1]), ppid: Number(m[2]), comm: m[3].trim() };
34
+ rows.push(row);
35
+ commByPid.set(row.pid, row.comm);
36
+ }
37
+ const orphans = rows.filter((r) =>
38
+ GOVERNED_TOOLS.has(r.comm) && (r.ppid === 1 || commByPid.get(r.ppid) === "systemd")
39
+ );
40
+ return { supported: true, orphans };
41
+ }
42
+
43
+ function createOrphanCheck() {
44
+ return {
45
+ name: "tool-orphans",
46
+ label: "Orphaned scanner processes",
47
+ strategy: STRATEGY.PROMPT,
48
+ describe: "Kill orphaned scanner processes left behind by interrupted runs",
49
+ async detect({ runPs } = {}) {
50
+ const { supported, orphans } = await findOrphanToolProcesses(runPs || execFileAsync);
51
+ if (!supported) return { ok: true, severity: "info", detail: "orphan detection not supported on this platform" };
52
+ if (orphans.length === 0) return { ok: true, severity: "info", detail: "no orphaned scanner processes" };
53
+ const list = orphans.map((o) => `${o.comm}(${o.pid})`).join(", ");
54
+ return {
55
+ ok: false,
56
+ severity: "warn",
57
+ detail: `${orphans.length} orphaned scanner process(es) burning CPU: ${list}`,
58
+ fix: `kill -9 ${orphans.map((o) => o.pid).join(" ")}`,
59
+ extra: { orphans },
60
+ };
61
+ },
62
+ async remediate({ extra, kill = process.kill } = {}) {
63
+ let killed = 0;
64
+ for (const o of extra?.orphans || []) {
65
+ try {
66
+ kill(o.pid, "SIGKILL");
67
+ killed += 1;
68
+ } catch { /* already gone */ }
69
+ }
70
+ return { fixed: killed > 0, detail: `killed ${killed} orphaned scanner process(es)` };
71
+ },
72
+ };
73
+ }
74
+
75
+ export function getOrphanChecks() {
76
+ return [createOrphanCheck()];
77
+ }
@@ -35,6 +35,7 @@ import { getSkillsChecks } from "../checks/skills.js";
35
35
  import { getDirSetupChecks } from "../checks/dir-setup.js";
36
36
  import { getProjectChecks } from "../checks/project-checks.js";
37
37
  import { getHardwareChecks } from "../checks/hardware.js";
38
+ import { getOrphanChecks } from "../checks/orphans.js";
38
39
 
39
40
  /**
40
41
  * Build the list of Check objects applicable to the current config.
@@ -72,6 +73,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
72
73
  ...getRtkChecks(),
73
74
  ...getSqueezrChecks(),
74
75
  ...getAiTrashChecks(),
76
+ ...getOrphanChecks(),
75
77
  ...getQmdChecks(),
76
78
  ...getRagHooksChecks({ projectDir }),
77
79
  ...getProjectChecks({ projectDir }),
@@ -11,6 +11,16 @@ import { RISK } from "../autonomy/policy.js";
11
11
 
12
12
  const MAX_REFINE_ITERATIONS = 5;
13
13
 
14
+ // KJC-BUG-0125 (issue #1275): the prompt used to carry only counts and
15
+ // categories — the dashboard modal showed "4 findings at severity fail"
16
+ // with nothing to act on. The full findings travel with the prompt now
17
+ // (capped: a runaway reviewer must not blow up the prompt payload).
18
+ const MAX_FINDINGS_IN_PROMPT = 20;
19
+ const findingsForPrompt = (findings) => findings.slice(0, MAX_FINDINGS_IN_PROMPT).map((f) => ({
20
+ id: f.id, severity: f.severity, category: f.category,
21
+ message: f.message, suggestion: f.suggestion || null,
22
+ }));
23
+
14
24
  /**
15
25
  * Decide the gate action (`continue` | `refine`) autonomously: the Arbiter
16
26
  * picks PROCEED (proceed with the best reading) or RETRY_DIFFERENT_APPROACH
@@ -24,7 +34,7 @@ async function decideGateAutonomously(resolve, { severity, findings }) {
24
34
  { value: "PROCEED", label: "proceed with the best reading", conservative: true },
25
35
  { value: "RETRY_DIFFERENT_APPROACH", label: "auto-refine the spec" },
26
36
  ],
27
- context: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))] },
37
+ context: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], findings: findingsForPrompt(findings) },
28
38
  risk: severity === "fail" ? RISK.HIGH : RISK.LOW,
29
39
  });
30
40
  return choice === "RETRY_DIFFERENT_APPROACH" ? "refine" : "continue";
@@ -71,7 +81,7 @@ export async function runSpecReview({ spec, config, logger, askQuestion, flags =
71
81
  const answer = await askQuestion(
72
82
  `Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: continue)`,
73
83
  {
74
- detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1 },
84
+ detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1, findings: findingsForPrompt(findings) },
75
85
  // KJC-BUG-0109: mirrors the interactive default — --yes presses Enter.
76
86
  defaultAnswer: "continue",
77
87
  },
@@ -0,0 +1,116 @@
1
+ /**
2
+ * tool-governor — resource governance for external tools launched by kj
3
+ * (KJC-TSK-0668), born from a real semgrep CPU storm: single-flight across
4
+ * processes (pid-stamped lockfile, stale locks stolen), jobs cap (half the
5
+ * CPUs), nice +10 best-effort, and hard timeouts that kill the PROCESS TREE
6
+ * (own process group on POSIX). In-process cousin: async-lock.js.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ const DEFAULT_LOCKS_DIR = path.join(os.homedir(), ".karajan", "locks");
15
+ const NICENESS = 10;
16
+
17
+ /** Parallelism cap for scanner `--jobs` flags: half the CPUs, min 1. */
18
+ export function jobsCap(cpuCount = os.cpus().length) {
19
+ return Math.max(1, Math.floor(cpuCount / 2));
20
+ }
21
+
22
+ function isPidAlive(pid) {
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ } catch (err) {
27
+ return err.code === "EPERM"; // alive but owned by another user
28
+ }
29
+ }
30
+
31
+ /** Machine-wide lock for `tool` → {release}; ELOCKTIMEOUT if the live holder outlasts `timeoutMs`. */
32
+ export async function acquireToolLock(tool, { locksDir = DEFAULT_LOCKS_DIR, timeoutMs = 180_000, pollMs = 500 } = {}) {
33
+ fs.mkdirSync(locksDir, { recursive: true });
34
+ const lockPath = path.join(locksDir, `${tool}.lock`);
35
+ const deadline = Date.now() + timeoutMs;
36
+ for (;;) {
37
+ try {
38
+ // wx = atomic create-or-fail: the filesystem is the arbiter.
39
+ const fd = fs.openSync(lockPath, "wx");
40
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
41
+ fs.closeSync(fd);
42
+ return { release: () => fs.rmSync(lockPath, { force: true }) };
43
+ } catch (err) {
44
+ if (err.code !== "EEXIST") throw err;
45
+ let holder = null;
46
+ try {
47
+ holder = JSON.parse(fs.readFileSync(lockPath, "utf8"));
48
+ } catch { /* unreadable → stale */ }
49
+ if (!holder || !Number.isInteger(holder.pid) || !isPidAlive(holder.pid)) {
50
+ fs.rmSync(lockPath, { force: true });
51
+ continue;
52
+ }
53
+ if (Date.now() >= deadline) {
54
+ const e = new Error(`${tool} is already running (pid ${holder.pid}, since ${holder.startedAt}) — gave up waiting after ${timeoutMs}ms`);
55
+ e.code = "ELOCKTIMEOUT";
56
+ throw e;
57
+ }
58
+ await new Promise((r) => setTimeout(r, pollMs));
59
+ }
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Governed spawn: own process group (POSIX), lowered priority, timeout kills
65
+ * the whole tree. Rejections mirror execFile's contract (err.code /
66
+ * err.killed / err.stdout) so scanner collectors keep their error handling.
67
+ */
68
+ export function governedExecFile(bin, args, { cwd, timeout = 0, maxBuffer = 16 * 1024 * 1024, niceness = NICENESS } = {}) {
69
+ return new Promise((resolve, reject) => {
70
+ const posix = process.platform !== "win32";
71
+ const child = spawn(bin, args, { cwd, detached: posix, stdio: ["ignore", "pipe", "pipe"] });
72
+ let stdout = "";
73
+ let stderr = "";
74
+ let killedByGovernor = false, timer = null;
75
+ const killTree = () => {
76
+ killedByGovernor = true;
77
+ try {
78
+ if (posix) process.kill(-child.pid, "SIGKILL");
79
+ else child.kill("SIGKILL");
80
+ } catch { /* tree already gone */ }
81
+ };
82
+ if (timeout > 0) timer = setTimeout(killTree, timeout);
83
+ child.on("spawn", () => {
84
+ try {
85
+ os.setPriority(child.pid, niceness);
86
+ } catch { /* best-effort */ }
87
+ });
88
+ const bufferBlown = () => stdout.length + stderr.length > maxBuffer;
89
+ child.stdout.on("data", (d) => { stdout += d; if (bufferBlown()) killTree(); });
90
+ child.stderr.on("data", (d) => { stderr += d; if (bufferBlown()) killTree(); });
91
+ child.on("error", (err) => {
92
+ if (timer) clearTimeout(timer);
93
+ reject(Object.assign(err, { stdout, stderr }));
94
+ });
95
+ child.on("close", (code) => {
96
+ if (timer) clearTimeout(timer);
97
+ if (killedByGovernor) {
98
+ reject(Object.assign(new Error(`${bin} killed by governor after ${timeout}ms (process tree included)`), { killed: true, stdout, stderr }));
99
+ } else if (code === 0) {
100
+ resolve({ stdout, stderr });
101
+ } else {
102
+ reject(Object.assign(new Error(`${bin} exited with code ${code}`), { code, stdout, stderr }));
103
+ }
104
+ });
105
+ });
106
+ }
107
+
108
+ /** Lock + governed exec + guaranteed release. `opts.bin` overrides the executable. */
109
+ export async function runGoverned(tool, args, opts = {}) {
110
+ const lock = await acquireToolLock(tool, opts.lock);
111
+ try {
112
+ return await governedExecFile(opts.bin || tool, args, opts);
113
+ } finally {
114
+ lock.release();
115
+ }
116
+ }