karajan-code 2.34.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -36
- package/bin/kj-rag-mcp.js +75 -0
- package/docs/README.es.md +4 -4
- package/package.json +20 -16
- package/packages/hu-board/package.json +5 -2
- package/src/audit/deterministic-summary.js +20 -0
- package/src/audit/injection-findings.js +105 -0
- package/src/checks/hardware.js +150 -0
- package/src/checks/node.js +9 -10
- package/src/cli/register-meta.js +33 -0
- package/src/commands/doctor.js +2 -0
- package/src/commands/init.js +41 -1
- package/src/commands/rag-mcp.js +18 -0
- package/src/commands/telemetry.js +70 -0
- package/src/config/defaults.js +4 -1
- package/src/orchestrator/stages/coder-stage.js +3 -0
- package/src/orchestrator/stages/reviewer-stage.js +11 -0
- package/src/roles/audit-role.js +9 -1
- package/src/sonar/scanner.js +51 -16
- package/src/utils/quota-simulator.js +56 -0
- package/src/utils/telemetry.js +34 -12
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Advisory hardware checks: warn (not fail) so the pipeline keeps running.
|
|
2
|
+
// RAM < 4 GB silently OOM-kills the RAG embedder; disk < 5 GB corrupts
|
|
3
|
+
// Ollama model shards. GPU detection is best-effort and never throws.
|
|
4
|
+
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { statfs } from "node:fs/promises";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { STRATEGY } from "./types.js";
|
|
9
|
+
|
|
10
|
+
const RAM_WARN_GB = 4;
|
|
11
|
+
const RAM_RECOMMENDED_GB = 8;
|
|
12
|
+
const DISK_WARN_GB = 5;
|
|
13
|
+
const DISK_RECOMMENDED_GB = 20;
|
|
14
|
+
const GPU_DETECT_TIMEOUT_MS = 1500;
|
|
15
|
+
|
|
16
|
+
const bytesToGb = (bytes) => Math.round((bytes / 1024 ** 3) * 10) / 10;
|
|
17
|
+
const info = (detail, extra) => ({ ok: true, severity: "info", detail, extra });
|
|
18
|
+
const warn = (detail, fix, extra) => ({ ok: false, severity: "warn", detail, fix, extra });
|
|
19
|
+
|
|
20
|
+
function createRamCheck() {
|
|
21
|
+
return {
|
|
22
|
+
name: "hw-ram",
|
|
23
|
+
label: "System RAM",
|
|
24
|
+
strategy: STRATEGY.MANUAL,
|
|
25
|
+
async detect() {
|
|
26
|
+
const totalGb = bytesToGb(os.totalmem());
|
|
27
|
+
const freeGb = bytesToGb(os.freemem());
|
|
28
|
+
const base = `${totalGb} GB total, ${freeGb} GB free`;
|
|
29
|
+
if (totalGb < RAM_WARN_GB) {
|
|
30
|
+
return warn(
|
|
31
|
+
`${base} (RAG embedder needs >=${RAM_WARN_GB} GB)`,
|
|
32
|
+
"Disable local RAG (kj init --no-rag) or switch rag.embedder.provider to openai/voyage/cohere.",
|
|
33
|
+
{ totalGb, freeGb, threshold: RAM_WARN_GB }
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (totalGb < RAM_RECOMMENDED_GB) {
|
|
37
|
+
return info(`${base} (>=${RAM_RECOMMENDED_GB} GB recommended for parallel runs)`, { totalGb, freeGb });
|
|
38
|
+
}
|
|
39
|
+
return info(base, { totalGb, freeGb });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createCpuCheck() {
|
|
45
|
+
return {
|
|
46
|
+
name: "hw-cpu",
|
|
47
|
+
label: "CPU",
|
|
48
|
+
strategy: STRATEGY.NONE,
|
|
49
|
+
async detect() {
|
|
50
|
+
const cpus = os.cpus();
|
|
51
|
+
const model = cpus[0]?.model?.trim() || "unknown";
|
|
52
|
+
const cores = cpus.length;
|
|
53
|
+
const arch = os.arch();
|
|
54
|
+
return info(`${cores} cores, ${arch} (${model})`, { cores, arch, model });
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createDiskCheck() {
|
|
60
|
+
return {
|
|
61
|
+
name: "hw-disk",
|
|
62
|
+
label: "Disk space (cwd)",
|
|
63
|
+
strategy: STRATEGY.MANUAL,
|
|
64
|
+
async detect() {
|
|
65
|
+
try {
|
|
66
|
+
const stat = await statfs(process.cwd());
|
|
67
|
+
const freeGb = bytesToGb(stat.bsize * stat.bavail);
|
|
68
|
+
if (freeGb < DISK_WARN_GB) {
|
|
69
|
+
return warn(
|
|
70
|
+
`${freeGb} GB free (>=${DISK_WARN_GB} GB recommended)`,
|
|
71
|
+
"Free disk space. Ollama models alone take 3-5 GB; the RAG store grows ~50 MB per 1k LOC indexed.",
|
|
72
|
+
{ freeGb, threshold: DISK_WARN_GB }
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const note = freeGb < DISK_RECOMMENDED_GB ? ` (>=${DISK_RECOMMENDED_GB} GB recommended)` : "";
|
|
76
|
+
return info(`${freeGb} GB free${note}`, { freeGb });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return info(`Cannot read free disk space: ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runGpuCommand(cmd, args) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
let stdout = "";
|
|
87
|
+
let settled = false;
|
|
88
|
+
const finish = (value) => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
settled = true;
|
|
91
|
+
resolve(value);
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
try { child.kill("SIGKILL"); } catch { /* noop */ }
|
|
97
|
+
finish(null);
|
|
98
|
+
}, GPU_DETECT_TIMEOUT_MS);
|
|
99
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
|
|
100
|
+
child.on("error", () => { clearTimeout(timer); finish(null); });
|
|
101
|
+
child.on("close", (code) => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
finish(code === 0 ? stdout : null);
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
finish(null);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function detectGpu() {
|
|
112
|
+
const platform = process.platform;
|
|
113
|
+
if (platform === "linux" || platform === "win32") {
|
|
114
|
+
const out = await runGpuCommand("nvidia-smi", ["--query-gpu=name", "--format=csv,noheader"]);
|
|
115
|
+
const model = out?.split("\n").map((l) => l.trim()).find(Boolean);
|
|
116
|
+
if (model) return { vendor: "NVIDIA", model };
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (platform === "darwin") {
|
|
120
|
+
const out = await runGpuCommand("system_profiler", ["SPDisplaysDataType"]);
|
|
121
|
+
if (!out) return null;
|
|
122
|
+
const match = out.match(/Chipset Model:\s*(.+)/);
|
|
123
|
+
if (!match) return null;
|
|
124
|
+
const model = match[1].trim();
|
|
125
|
+
const vendor = /Apple/i.test(model) ? "Apple" : /AMD|Radeon/i.test(model) ? "AMD" : /Intel/i.test(model) ? "Intel" : "GPU";
|
|
126
|
+
return { vendor, model };
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createGpuCheck() {
|
|
132
|
+
return {
|
|
133
|
+
name: "hw-gpu",
|
|
134
|
+
label: "GPU",
|
|
135
|
+
strategy: STRATEGY.NONE,
|
|
136
|
+
async detect() {
|
|
137
|
+
const gpu = await detectGpu();
|
|
138
|
+
if (!gpu) {
|
|
139
|
+
return info("No discrete GPU detected (CPU-only)", { detected: false });
|
|
140
|
+
}
|
|
141
|
+
return info(`${gpu.vendor} ${gpu.model}`, { detected: true, vendor: gpu.vendor, model: gpu.model });
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getHardwareChecks() {
|
|
147
|
+
return [createRamCheck(), createCpuCheck(), createDiskCheck(), createGpuCheck()];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const __test = { RAM_WARN_GB, RAM_RECOMMENDED_GB, DISK_WARN_GB, DISK_RECOMMENDED_GB, GPU_DETECT_TIMEOUT_MS, bytesToGb, detectGpu };
|
package/src/checks/node.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Node.js runtime version check. Requires Node >=
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* runtime.
|
|
10
|
-
* hints instead of a silent runtime crash.
|
|
2
|
+
* Node.js runtime version check. Requires Node >= 22 — the Active LTS
|
|
3
|
+
* baseline since Karajan v3.0.0 (KJC-TSK-0500, 2026-06-03). Node 20 was
|
|
4
|
+
* dropped because (a) Node 20 reaches EOL on 2026-04-30 and (b) three
|
|
5
|
+
* dependencies forced the bump: lint-staged 17 needs ≥22.22.1, commander
|
|
6
|
+
* 15 needs ≥22.12.0, and better-sqlite3 v12.10+ dropped Node 20 prebuilds.
|
|
7
|
+
* Strategy: manual — we cannot auto-upgrade the runtime. Users on older
|
|
8
|
+
* Node get a clear upgrade instruction with nvm + brew hints instead of a
|
|
9
|
+
* silent runtime crash.
|
|
11
10
|
*/
|
|
12
11
|
|
|
13
12
|
import { STRATEGY } from "./types.js";
|
|
14
13
|
|
|
15
|
-
export const MIN_NODE_MAJOR =
|
|
14
|
+
export const MIN_NODE_MAJOR = 22;
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Accepts a version string in the form "v20.12.1" or "20.12.1" and returns
|
package/src/cli/register-meta.js
CHANGED
|
@@ -4,6 +4,7 @@ import { researcherCommand } from "../commands/researcher.js";
|
|
|
4
4
|
import { architectCommand } from "../commands/architect.js";
|
|
5
5
|
import { onboardCommand } from "../commands/onboard.js";
|
|
6
6
|
import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
|
|
7
|
+
import { ragMcpCommand } from "../commands/rag-mcp.js";
|
|
7
8
|
import { watchStartCommand, watchStopCommand, watchStatusCommand } from "../commands/watch.js";
|
|
8
9
|
import { auditCommand } from "../commands/audit.js";
|
|
9
10
|
import { resumeCommand } from "../commands/resume.js";
|
|
@@ -12,6 +13,7 @@ import { webperfCommand } from "../commands/webperf.js";
|
|
|
12
13
|
import { undoCommand } from "../commands/undo.js";
|
|
13
14
|
import { syncCommand } from "../commands/sync.js";
|
|
14
15
|
import { cleanCommand } from "../commands/clean.js";
|
|
16
|
+
import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/telemetry.js";
|
|
15
17
|
import { withConfig } from "./_shared.js";
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -160,6 +162,16 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
160
162
|
});
|
|
161
163
|
});
|
|
162
164
|
|
|
165
|
+
// KJC-TSK-0492 PR3 — `kj rag mcp` exposes the kj-rag-mcp binary under the
|
|
166
|
+
// familiar `kj rag <subcommand>` surface so users discover it without
|
|
167
|
+
// grepping bin/. The action dynamic-imports the binary, which takes over
|
|
168
|
+
// stdio for MCP transport; the CLI process never returns.
|
|
169
|
+
rag.command("mcp")
|
|
170
|
+
.description("Start the standalone RAG MCP server (kj_rag_query + kj_rag_index only). Same as running `kj-rag-mcp` directly")
|
|
171
|
+
.action(async () => {
|
|
172
|
+
await ragMcpCommand();
|
|
173
|
+
});
|
|
174
|
+
|
|
163
175
|
// KJC-TSK-0441 — `kj watch [start|stop|status]` for live RAG re-index.
|
|
164
176
|
const watch = program.command("watch").description("Live RAG re-index daemon");
|
|
165
177
|
watch.command("start").description("Start the watcher daemon").option("--foreground", "Run in foreground (do not detach)").option("--with-sources", "Watch project source files too").action(async (flags) => {
|
|
@@ -310,6 +322,27 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
310
322
|
});
|
|
311
323
|
});
|
|
312
324
|
|
|
325
|
+
// KJC-TSK-0496 — surface the exact telemetry payload without sending it,
|
|
326
|
+
// and report whether telemetry is currently enabled. Read-only inspection
|
|
327
|
+
// commands so users can audit the contract without grepping the source.
|
|
328
|
+
const telemetry = program.command("telemetry").description("Inspect what Karajan telemetry sends and whether it's enabled (never opens a socket)");
|
|
329
|
+
telemetry.command("preview")
|
|
330
|
+
.description("Print the exact JSON payload for every event type (install, cli_command, pipeline_complete) — no network call")
|
|
331
|
+
.option("--json", "Emit the full preview as JSON")
|
|
332
|
+
.action(async (flags) => {
|
|
333
|
+
await withConfig(pkgVersion, "telemetry:preview", flags, async ({ config, logger }) => {
|
|
334
|
+
await telemetryPreviewCommand({ config, logger, version: pkgVersion, json: Boolean(flags.json) });
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
telemetry.command("status")
|
|
338
|
+
.description("Report whether telemetry is enabled and where the endpoint lives")
|
|
339
|
+
.option("--json", "Emit the status as JSON")
|
|
340
|
+
.action(async (flags) => {
|
|
341
|
+
await withConfig(pkgVersion, "telemetry:status", flags, async ({ config, logger }) => {
|
|
342
|
+
await telemetryStatusCommand({ config, logger, json: Boolean(flags.json) });
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
313
346
|
program
|
|
314
347
|
.command("clean")
|
|
315
348
|
.description("Garbage-collect stale plans, sessions and HU batches (dry-run by default)")
|
package/src/commands/doctor.js
CHANGED
|
@@ -29,6 +29,7 @@ import { getMcpHealthChecks } from "../checks/mcp-health.js";
|
|
|
29
29
|
import { getSkillsChecks } from "../checks/skills.js";
|
|
30
30
|
import { getDirSetupChecks } from "../checks/dir-setup.js";
|
|
31
31
|
import { getProjectChecks } from "../checks/project-checks.js";
|
|
32
|
+
import { getHardwareChecks } from "../checks/hardware.js";
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Build the list of Check objects applicable to the current config.
|
|
@@ -50,6 +51,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
|
|
|
50
51
|
return [
|
|
51
52
|
...getSystemChecks(),
|
|
52
53
|
...getNodeChecks(),
|
|
54
|
+
...getHardwareChecks(),
|
|
53
55
|
...getDirSetupChecks(),
|
|
54
56
|
...getConfigFileChecks(),
|
|
55
57
|
...getBinaryChecks(),
|
package/src/commands/init.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createWizard, isTTY } from "../utils/wizard.js";
|
|
|
12
12
|
import { runCommand } from "../utils/process.js";
|
|
13
13
|
import { getInstallCommand } from "../utils/os-detect.js";
|
|
14
14
|
import { detectOsLocale, SUPPORTED_LANGUAGES } from "../utils/locale.js";
|
|
15
|
+
import { buildTelemetryPayload, sendTelemetryEvent } from "../utils/telemetry.js";
|
|
15
16
|
import { detectRtk } from "../utils/rtk-detect.js";
|
|
16
17
|
import { installRtk } from "../utils/rtk-install.js";
|
|
17
18
|
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
@@ -256,6 +257,42 @@ async function askBoardSecurity(wizard, config, logger) {
|
|
|
256
257
|
logger.info(` -> HU Board port: ${config.hu_board.port}`);
|
|
257
258
|
}
|
|
258
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Explicit, opt-in telemetry consent. We do NOT inherit a "Karajan-installed
|
|
262
|
+
* users want to help" assumption — silence and missing config keys both mean
|
|
263
|
+
* "no". The prompt is rendered in the OS locale (EN/ES today; falls back to
|
|
264
|
+
* EN for everything else) so users see it in their own language even before
|
|
265
|
+
* they've set `config.language`. See src/utils/telemetry.js and the
|
|
266
|
+
* "Privacy & Telemetry" section of README.md for what gets sent.
|
|
267
|
+
*/
|
|
268
|
+
async function askTelemetry(wizard, config, logger) {
|
|
269
|
+
const locale = detectOsLocale();
|
|
270
|
+
const prompts = {
|
|
271
|
+
es: {
|
|
272
|
+
question: "¿Quieres ayudar a mejorar Karajan enviando telemetría anónima al proyecto?",
|
|
273
|
+
detail: " (versión, OS, comandos, duración del pipeline — sin código, sin tareas, sin datos personales)",
|
|
274
|
+
previewIntro: " Ejemplo del payload exacto que se enviaría al ejecutar `kj run` (puedes inspeccionar más con `kj telemetry preview`):",
|
|
275
|
+
enabled: " -> Telemetría: activada (¡gracias!)",
|
|
276
|
+
disabled: " -> Telemetría: desactivada",
|
|
277
|
+
},
|
|
278
|
+
en: {
|
|
279
|
+
question: "Help improve Karajan by sending anonymous telemetry to the project?",
|
|
280
|
+
detail: " (version, OS, commands, pipeline duration — no code, no tasks, no personal data)",
|
|
281
|
+
previewIntro: " Example of the exact payload that would be sent when you run `kj run` (inspect more with `kj telemetry preview`):",
|
|
282
|
+
enabled: " -> Telemetry: enabled (thank you!)",
|
|
283
|
+
disabled: " -> Telemetry: disabled",
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
const t = prompts[locale] || prompts.en;
|
|
287
|
+
logger.info(t.detail);
|
|
288
|
+
const example = buildTelemetryPayload("cli_command", { version: "x.y.z", command: "run" });
|
|
289
|
+
logger.info(t.previewIntro);
|
|
290
|
+
logger.info(` ${JSON.stringify(example)}`);
|
|
291
|
+
const enabled = await wizard.confirm(t.question, false);
|
|
292
|
+
config.telemetry = enabled;
|
|
293
|
+
logger.info(enabled ? t.enabled : t.disabled);
|
|
294
|
+
}
|
|
295
|
+
|
|
259
296
|
async function runWizard(config, logger) {
|
|
260
297
|
const agents = await detectAvailableAgents();
|
|
261
298
|
const available = agents.filter((a) => a.available);
|
|
@@ -318,6 +355,10 @@ async function runWizard(config, logger) {
|
|
|
318
355
|
await askBoardSecurity(wizard, config, logger);
|
|
319
356
|
}
|
|
320
357
|
|
|
358
|
+
logger.info("");
|
|
359
|
+
logger.info("Privacy:");
|
|
360
|
+
await askTelemetry(wizard, config, logger);
|
|
361
|
+
|
|
321
362
|
logger.info("");
|
|
322
363
|
} finally {
|
|
323
364
|
wizard.close();
|
|
@@ -735,7 +776,6 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
735
776
|
await writeInitConfig(configPath, config);
|
|
736
777
|
|
|
737
778
|
// Telemetry: anonymous install event (non-blocking)
|
|
738
|
-
const { sendTelemetryEvent } = await import("../utils/telemetry.js");
|
|
739
779
|
const { readFileSync } = await import("node:fs");
|
|
740
780
|
const { fileURLToPath } = await import("node:url");
|
|
741
781
|
try {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `kj rag mcp` — spawn the standalone RAG MCP server inline.
|
|
7
|
+
*
|
|
8
|
+
* KJC-TSK-0492 PR3. Adds CLI discoverability for the `kj-rag-mcp` binary
|
|
9
|
+
* so external IDE users can find it via the same `kj rag <subcommand>`
|
|
10
|
+
* surface they already know. The implementation dynamic-imports the
|
|
11
|
+
* binary entry so behavior stays identical to running `kj-rag-mcp`
|
|
12
|
+
* directly — same handlers, same transport, same orphan guard.
|
|
13
|
+
*/
|
|
14
|
+
export async function ragMcpCommand() {
|
|
15
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const binPath = path.resolve(moduleDir, "../../bin/kj-rag-mcp.js");
|
|
17
|
+
await import(pathToFileURL(binPath).href);
|
|
18
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { TELEMETRY_ENDPOINT, buildTelemetryPayload, isTelemetryEnabled } from "../utils/telemetry.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Render the exact payload that would be POSTed for every event type.
|
|
5
|
+
*
|
|
6
|
+
* Read-only by design: this never opens a socket. It exists so users
|
|
7
|
+
* can inspect what telemetry sends before (or after) opting in,
|
|
8
|
+
* without grepping the source. The contract here MUST stay in sync
|
|
9
|
+
* with the producers in src/cli/_shared.js and the pipeline emitter
|
|
10
|
+
* in src/orchestrator/flow-runner.js — if a new field appears there,
|
|
11
|
+
* mirror it in the example below.
|
|
12
|
+
*/
|
|
13
|
+
function buildPreview(version) {
|
|
14
|
+
const v = version || "x.y.z";
|
|
15
|
+
return {
|
|
16
|
+
endpoint: TELEMETRY_ENDPOINT,
|
|
17
|
+
events: [
|
|
18
|
+
buildTelemetryPayload("install", { version: v }),
|
|
19
|
+
buildTelemetryPayload("cli_command", { version: v, command: "run" }),
|
|
20
|
+
buildTelemetryPayload("pipeline_complete", {
|
|
21
|
+
version: v,
|
|
22
|
+
mode: "standard",
|
|
23
|
+
agent: "claude",
|
|
24
|
+
duration_s: 42,
|
|
25
|
+
success: true,
|
|
26
|
+
taskType: "feature"
|
|
27
|
+
})
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function telemetryPreviewCommand({ config, logger, version, json }) {
|
|
33
|
+
const preview = buildPreview(version);
|
|
34
|
+
if (json) {
|
|
35
|
+
logger.info(JSON.stringify(preview, null, 2));
|
|
36
|
+
return preview;
|
|
37
|
+
}
|
|
38
|
+
const enabled = isTelemetryEnabled(config);
|
|
39
|
+
logger.info(`Telemetry endpoint: ${preview.endpoint}`);
|
|
40
|
+
logger.info(`Telemetry enabled: ${enabled ? "yes" : "no"}`);
|
|
41
|
+
logger.info("");
|
|
42
|
+
logger.info("Exact payloads that would be POSTed (one per event type):");
|
|
43
|
+
logger.info("");
|
|
44
|
+
for (const payload of preview.events) {
|
|
45
|
+
logger.info(` [${payload.event}]`);
|
|
46
|
+
logger.info(` ${JSON.stringify(payload)}`);
|
|
47
|
+
logger.info("");
|
|
48
|
+
}
|
|
49
|
+
logger.info("No data was sent — `kj telemetry preview` never opens a socket.");
|
|
50
|
+
return preview;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function telemetryStatusCommand({ config, logger, json }) {
|
|
54
|
+
const enabled = isTelemetryEnabled(config);
|
|
55
|
+
const report = {
|
|
56
|
+
enabled,
|
|
57
|
+
endpoint: TELEMETRY_ENDPOINT,
|
|
58
|
+
flipHint: enabled
|
|
59
|
+
? "Set `telemetry: false` in ~/.karajan/kj.config.yml (or re-run `kj init`) to turn it off."
|
|
60
|
+
: "Set `telemetry: true` in ~/.karajan/kj.config.yml (or re-run `kj init`) to turn it on."
|
|
61
|
+
};
|
|
62
|
+
if (json) {
|
|
63
|
+
logger.info(JSON.stringify(report, null, 2));
|
|
64
|
+
return report;
|
|
65
|
+
}
|
|
66
|
+
logger.info(`Telemetry: ${enabled ? "enabled" : "disabled"}`);
|
|
67
|
+
logger.info(`Endpoint: ${report.endpoint}`);
|
|
68
|
+
logger.info(report.flipHint);
|
|
69
|
+
return report;
|
|
70
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -196,7 +196,10 @@ const DEFAULTS = {
|
|
|
196
196
|
// hook. Both default ON; flip to false to opt out without uninstalling.
|
|
197
197
|
autoUpdate: { onCommit: true, onRun: true }
|
|
198
198
|
},
|
|
199
|
-
|
|
199
|
+
// Opt-in. Set by the `kj init` wizard (explicit consent prompt in the OS
|
|
200
|
+
// locale). When the key is absent or false, no events are sent. See
|
|
201
|
+
// src/utils/telemetry.js and the Privacy & Telemetry section of README.md.
|
|
202
|
+
telemetry: false,
|
|
200
203
|
guards: {
|
|
201
204
|
output: {
|
|
202
205
|
enabled: true,
|
|
@@ -19,6 +19,7 @@ import { invokeSolomon } from "../solomon-escalation.js";
|
|
|
19
19
|
import { detectRateLimit } from "../../utils/rate-limit-detector.js";
|
|
20
20
|
import { createStallDetector } from "../../utils/stall-detector.js";
|
|
21
21
|
import { buildStandbyState } from "../../brain/standby-store.js";
|
|
22
|
+
import { applyQuotaSimulation } from "../../utils/quota-simulator.js";
|
|
22
23
|
import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, verifyLeaksAgainstTranscript, detectTranscriptCdLeaks } from "../fs-leak-detector.js";
|
|
23
24
|
|
|
24
25
|
export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration, brainCtx, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null }) {
|
|
@@ -76,6 +77,7 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
76
77
|
coderStall.stop();
|
|
77
78
|
}
|
|
78
79
|
trackBudget({ role: "coder", provider: coderRole.provider, model: coderRole.model, result: coderExecResult.result, duration_ms: Date.now() - coderStart });
|
|
80
|
+
applyQuotaSimulation(coderExecResult, { iteration, agent: coderRole.provider });
|
|
79
81
|
|
|
80
82
|
if (!coderExecResult.ok) {
|
|
81
83
|
const details = coderExecResult.result?.error || coderExecResult.summary || "unknown error";
|
|
@@ -281,6 +283,7 @@ export async function runRefactorerStage({ refactorerRole, config, logger, emitt
|
|
|
281
283
|
refactorerStall.stop();
|
|
282
284
|
}
|
|
283
285
|
trackBudget({ role: "refactorer", provider: refactorerRole.provider, model: refactorerRole.model, result: refResult.result, duration_ms: Date.now() - refactorerStart });
|
|
286
|
+
applyQuotaSimulation(refResult, { iteration, agent: refactorerRole.provider });
|
|
284
287
|
if (!refResult.ok) {
|
|
285
288
|
const details = refResult.result?.error || refResult.summary || "unknown error";
|
|
286
289
|
// Quota cap → hibernate cleanly, same as the coder stage above.
|
|
@@ -13,6 +13,7 @@ import { runReviewerWithFallback } from "../reviewer-fallback.js";
|
|
|
13
13
|
import { invokeSolomon } from "../solomon-escalation.js";
|
|
14
14
|
import { detectRateLimit } from "../../utils/rate-limit-detector.js";
|
|
15
15
|
import { createStallDetector } from "../../utils/stall-detector.js";
|
|
16
|
+
import { applyQuotaSimulation } from "../../utils/quota-simulator.js";
|
|
16
17
|
|
|
17
18
|
function categorizeIssues(issues) {
|
|
18
19
|
const categories = { security: 0, correctness: 0, tests: 0, style: 0, other: 0 };
|
|
@@ -269,6 +270,16 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
|
|
|
269
270
|
reviewerStall.stop();
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
if (applyQuotaSimulation(reviewerExec.execResult, { iteration, agent: reviewerRole.provider })) {
|
|
274
|
+
// Reviewer wraps the agent call: detectRateLimit reads `lastAttempt.result.error`,
|
|
275
|
+
// so propagate the synthetic line into the most recent attempt too.
|
|
276
|
+
const last = reviewerExec.attempts?.at(-1);
|
|
277
|
+
if (last) {
|
|
278
|
+
if (!last.result || typeof last.result !== "object") last.result = {};
|
|
279
|
+
last.result.error = reviewerExec.execResult.result.error;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
272
283
|
if (!reviewerExec.execResult?.ok) {
|
|
273
284
|
const lastAttempt = reviewerExec.attempts.at(-1);
|
|
274
285
|
const details =
|
package/src/roles/audit-role.js
CHANGED
|
@@ -8,6 +8,7 @@ import { collectOsvFindings } from "../audit/osv-findings.js";
|
|
|
8
8
|
import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
|
|
9
9
|
import { collectCircularDeps } from "../audit/circular-deps.js";
|
|
10
10
|
import { collectDeadExports } from "../audit/dead-exports.js";
|
|
11
|
+
import { collectInjectionFindings } from "../audit/injection-findings.js";
|
|
11
12
|
|
|
12
13
|
function parseDimensions(dimensionsStr) {
|
|
13
14
|
if (!dimensionsStr || dimensionsStr === "all") return null;
|
|
@@ -51,6 +52,7 @@ export class AuditRole extends AgentRole {
|
|
|
51
52
|
const noSemgrep = typeof input === "object" ? Boolean(input?.noSemgrep) : false;
|
|
52
53
|
const noMadge = typeof input === "object" ? Boolean(input?.noMadge) : false;
|
|
53
54
|
const noKnip = typeof input === "object" ? Boolean(input?.noKnip) : false;
|
|
55
|
+
const noInjectionScan = typeof input === "object" ? Boolean(input?.noInjectionScan) : false;
|
|
54
56
|
const projectDir = this.config?.projectDir || process.cwd();
|
|
55
57
|
let basalCost = null;
|
|
56
58
|
let growthDelta = null;
|
|
@@ -61,6 +63,7 @@ export class AuditRole extends AgentRole {
|
|
|
61
63
|
let semgrepFindings = null;
|
|
62
64
|
let circularDeps = null;
|
|
63
65
|
let deadExports = null;
|
|
66
|
+
let injectionFindings = null;
|
|
64
67
|
try {
|
|
65
68
|
basalCost = await measureBasalCost(projectDir);
|
|
66
69
|
const previous = await loadPreviousAudit(projectDir);
|
|
@@ -108,7 +111,12 @@ export class AuditRole extends AgentRole {
|
|
|
108
111
|
deadExports = await collectDeadExports(projectDir, stack, this.config, this.logger);
|
|
109
112
|
} catch { /* knip is best-effort */ }
|
|
110
113
|
}
|
|
111
|
-
|
|
114
|
+
if (!noInjectionScan) {
|
|
115
|
+
try {
|
|
116
|
+
injectionFindings = await collectInjectionFindings(projectDir, this.logger);
|
|
117
|
+
} catch { /* injection scan is best-effort (KJC-TSK-0498) */ }
|
|
118
|
+
}
|
|
119
|
+
return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings };
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
/**
|
package/src/sonar/scanner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import fsPromises from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import { runCommand } from "../utils/process.js";
|
|
5
6
|
import { sonarUp } from "./manager.js";
|
|
6
7
|
import { resolveSonarProjectKey } from "./project-key.js";
|
|
@@ -10,6 +11,32 @@ import {
|
|
|
10
11
|
resolveSonarCredentials,
|
|
11
12
|
} from "./config-resolver.js";
|
|
12
13
|
|
|
14
|
+
export async function findNativeSonarScanner() {
|
|
15
|
+
const which = await runCommand("sh", ["-c", "command -v sonar-scanner"]);
|
|
16
|
+
if (which.exitCode === 0) {
|
|
17
|
+
const bin = String(which.stdout || "").trim();
|
|
18
|
+
if (bin) return bin;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function pickSonarScanner(scannerConfig = {}, deps = {}) {
|
|
24
|
+
const find = deps.findNativeSonarScanner || findNativeSonarScanner;
|
|
25
|
+
const arch = deps.arch || os.arch();
|
|
26
|
+
const forced = String(scannerConfig.command || "").trim().toLowerCase();
|
|
27
|
+
if (forced === "docker") return { type: "docker" };
|
|
28
|
+
if (forced === "native" || forced === "sonar-scanner" || forced.endsWith("/sonar-scanner")) {
|
|
29
|
+
const bin = (forced === "native" || forced === "sonar-scanner") ? await find() : forced;
|
|
30
|
+
if (bin) return { type: "native", bin };
|
|
31
|
+
return { type: "docker", reason: "native sonar-scanner not found on PATH" };
|
|
32
|
+
}
|
|
33
|
+
if (arch === "arm64") {
|
|
34
|
+
const bin = await find();
|
|
35
|
+
if (bin) return { type: "native", bin, reason: "ARM64 host — preferring native binary" };
|
|
36
|
+
}
|
|
37
|
+
return { type: "docker" };
|
|
38
|
+
}
|
|
39
|
+
|
|
13
40
|
export function buildScannerOpts(projectKey, scanner = {}) {
|
|
14
41
|
const opts = [`-Dsonar.projectKey=${projectKey}`];
|
|
15
42
|
if (scanner.sources) opts.push(`-Dsonar.sources=${scanner.sources}`);
|
|
@@ -236,31 +263,39 @@ export async function runSonarScan(config, projectKey = null) {
|
|
|
236
263
|
...coverage.scannerPatch
|
|
237
264
|
});
|
|
238
265
|
|
|
239
|
-
const
|
|
240
|
-
"run",
|
|
241
|
-
"--rm",
|
|
242
|
-
"-v",
|
|
243
|
-
`${process.cwd()}:/usr/src`,
|
|
244
|
-
...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
|
|
245
|
-
...(isLocalHost || isExternalSonar ? [] : ["--network", sonarNetwork]),
|
|
246
|
-
"-e", "SONAR_HOST_URL",
|
|
247
|
-
"-e", "SONAR_TOKEN",
|
|
248
|
-
"-e", "SONAR_SCANNER_OPTS",
|
|
249
|
-
"sonarsource/sonar-scanner-cli"
|
|
250
|
-
];
|
|
251
|
-
|
|
252
|
-
// Pass secrets via process env, not CLI args — invisible in `ps aux`
|
|
266
|
+
const pick = await pickSonarScanner(sonarConfig.scanner);
|
|
253
267
|
const env = {
|
|
254
268
|
...process.env,
|
|
255
|
-
SONAR_HOST_URL: host,
|
|
269
|
+
SONAR_HOST_URL: pick.type === "native" ? rawHost : host,
|
|
256
270
|
SONAR_TOKEN: token || "",
|
|
257
271
|
SONAR_SCANNER_OPTS: buildScannerOpts(effectiveProjectKey, scannerConfig)
|
|
258
272
|
};
|
|
259
273
|
|
|
260
|
-
|
|
274
|
+
let cmd, args;
|
|
275
|
+
if (pick.type === "native") {
|
|
276
|
+
cmd = pick.bin;
|
|
277
|
+
args = [];
|
|
278
|
+
} else {
|
|
279
|
+
cmd = "docker";
|
|
280
|
+
args = [
|
|
281
|
+
"run",
|
|
282
|
+
"--rm",
|
|
283
|
+
"-v",
|
|
284
|
+
`${process.cwd()}:/usr/src`,
|
|
285
|
+
...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
|
|
286
|
+
...(isLocalHost || isExternalSonar ? [] : ["--network", sonarNetwork]),
|
|
287
|
+
"-e", "SONAR_HOST_URL",
|
|
288
|
+
"-e", "SONAR_TOKEN",
|
|
289
|
+
"-e", "SONAR_SCANNER_OPTS",
|
|
290
|
+
"sonarsource/sonar-scanner-cli"
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const result = await runCommand(cmd, args, { timeout: scannerTimeout, env });
|
|
261
295
|
return {
|
|
262
296
|
ok: result.exitCode === 0,
|
|
263
297
|
projectKey: effectiveProjectKey,
|
|
298
|
+
scanner: pick.type,
|
|
264
299
|
stdout: result.stdout,
|
|
265
300
|
stderr: result.stderr,
|
|
266
301
|
exitCode: result.exitCode
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// KJ_SIMULATE_QUOTA — testing hook for the standby flow. See
|
|
2
|
+
// docs/quota-simulation.md. KJC-TSK-0493.
|
|
3
|
+
|
|
4
|
+
let _warned = false;
|
|
5
|
+
|
|
6
|
+
export function parseQuotaSimulationConfig(env = process.env) {
|
|
7
|
+
const trigger = env.KJ_SIMULATE_QUOTA;
|
|
8
|
+
if (!trigger) return null;
|
|
9
|
+
const m = /^after-iter-(\d+)$/i.exec(String(trigger).trim());
|
|
10
|
+
if (!m) {
|
|
11
|
+
if (!_warned) {
|
|
12
|
+
// eslint-disable-next-line no-console
|
|
13
|
+
console.warn(`[KJ_SIMULATE_QUOTA] Ignoring malformed value "${trigger}". Expected "after-iter-<N>".`);
|
|
14
|
+
_warned = true;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
afterIter: Number.parseInt(m[1], 10),
|
|
20
|
+
resetIso: env.KJ_SIMULATE_QUOTA_RESET || new Date(Date.now() + 3600_000).toISOString(),
|
|
21
|
+
agent: String(env.KJ_SIMULATE_QUOTA_AGENT || "claude").toLowerCase(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SYNTHETIC_MSG = {
|
|
26
|
+
claude: (iso) => `[KJ_SIMULATE_QUOTA] Claude Pro usage limit reached. weekly limit. resets at ${iso}`,
|
|
27
|
+
codex: () => `[KJ_SIMULATE_QUOTA] You have exceeded your current quota. retry after 60 seconds`,
|
|
28
|
+
gemini: () => `[KJ_SIMULATE_QUOTA] Resource exhausted: quota exceeded. retry after 60 seconds`,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function buildSyntheticQuotaMessage(agent, resetIso) {
|
|
32
|
+
return (SYNTHETIC_MSG[agent] || SYNTHETIC_MSG.claude)(resetIso);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function shouldSimulateQuota({ iteration, agent }, env = process.env) {
|
|
36
|
+
const cfg = parseQuotaSimulationConfig(env);
|
|
37
|
+
if (!cfg) return null;
|
|
38
|
+
if (Number(iteration) < cfg.afterIter) return null;
|
|
39
|
+
const a = String(agent || "").toLowerCase();
|
|
40
|
+
if (cfg.agent !== "any" && cfg.agent !== a) return null;
|
|
41
|
+
return { resetIso: cfg.resetIso, agent: a || cfg.agent };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Mutates execResult: forces ok=false and prepends the synthetic quota
|
|
45
|
+
// line to result.error so the existing detectRateLimit branch fires.
|
|
46
|
+
export function applyQuotaSimulation(execResult, { iteration, agent }, env = process.env) {
|
|
47
|
+
const fire = shouldSimulateQuota({ iteration, agent }, env);
|
|
48
|
+
if (!fire || !execResult || typeof execResult !== "object") return false;
|
|
49
|
+
if (!execResult.result || typeof execResult.result !== "object") execResult.result = {};
|
|
50
|
+
execResult.result.error = `${buildSyntheticQuotaMessage(fire.agent, fire.resetIso)}\n${execResult.result.error || ""}`;
|
|
51
|
+
execResult.ok = false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Test seam — reset the one-shot warning between unit tests.
|
|
56
|
+
export function _resetSimulatorWarning() { _warned = false; }
|